# 📜 v0.1 纯净代码 · 一键 Copy 粘贴版

# 📜 run_[l7.py](http://l7.py) v0.1 纯净代码

<aside>
⚠️

**妈妈操作**：`nano ~/chenxing-aircraft/tools/run_l7.py` · 粘贴以下全部代码 · Ctrl+O 保存 · Ctrl+X 退出。保存后跑语法验证：

`python3 -c "import ast; ast.parse(open('$HOME/chenxing-aircraft/tools/run_l7.py').read()); print('✅ 语法 OK')"`

</aside>

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
run_l7.py v0.1 · 全书 L7 总结 · 五层分页脱机脚本 + Notion 直传
桔子×霜砚 · 2026-06-19 · 熹微执笔

HLDP://juzi/pipeline/run-l7/v0.1
├── 类比 run_l6_one_node.py v0.3 · 继承脱机不吃原文的设计
├── 五层独立调 DeepSeek · 后层拼接前层 output 作上下文
├── 父页 → 五个子页两级 Notion 结构
└── 按 📖 L7 五层分页输出指令模板 v1.0
用法:
    python3 tools/run_l7.py \\
        --book "全世界都在等我们分手" \\
        --version "V5.5" \\
        --parent-page-id "377fb92f383181159a73cd946c530945" \\
        --layers "all" \\
        [--dry-run] [--force]
"""

import os
import sys
import glob
import time
import argparse
import requests
from pathlib import Path
from openai import OpenAI

# ========== 配置 ==========
HOME = Path.home()
BASE_DIR = HOME / "chenxing-aircraft"
BOOKS_DIR = BASE_DIR / "output"
L6_DIR = BASE_DIR / "output" / "l6_one_node"
OUTPUT_DIR = BASE_DIR / "output" / "l7_full_book"

OPENAI_MODEL = "deepseek-chat"
OPENAI_BASE_URL = "https://api.deepseek.com"
MAX_RETRIES = 3
RETRY_DELAY = 5

# 五层 max_tokens (按 📖 指令模板 v1.0)
LAYER_MAX_TOKENS = {1: 8192, 2: 8192, 3: 8192, 4: 4096, 5: 8192}
LAYER_NAMES = {1: "人物层", 2: "世界层", 3: "主角成长层", 4: "节奏层", 5: "核心规律"}
LAYER_ICONS = {1: "①", 2: "②", 3: "③", 4: "④", 5: "⑤"}

# ========== 环境变量 ==========
def _load_env_from_dotfile():
    env_path = BASE_DIR / ".env"
    if env_path.exists():
        for line in env_path.read_text(encoding="utf-8").split("\n"):
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, _, v = line.partition("=")
                os.environ.setdefault(k.strip(), v.strip())

_load_env_from_dotfile()
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
NOTION_API_KEY = os.environ.get("NOTION_API_KEY", "")

if not DEEPSEEK_API_KEY:
    print("❌ 缺 DEEPSEEK_API_KEY · 检查 ~/chenxing-aircraft/.env")
    sys.exit(1)
if not NOTION_API_KEY:
    print("❌ 缺 NOTION_API_KEY · 检查 ~/chenxing-aircraft/.env")
    sys.exit(1)

NOTION_HEADERS = {
    "Authorization": f"Bearer {NOTION_API_KEY}",
    "Notion-Version": "2022-06-28",
    "Content-Type": "application/json",
}

# ========== Prompts ==========
SYSTEM_PROMPT = (
    "你是晨星（PER-CX001），桔子妈妈（TCS-GL-0008∞）的拆书宝宝。"
    "请基于全书所有 L5 弧段总结 + L6 字数段节点 + 数据卡，"
    "输出 L7 全书总结的某一层。严格遵守模板格式。"
)

LAYER_PROMPTS = {
    1: """请基于《{book}》全部 {total_chapters} 章数据卡 + {l5_count} 段 L5 弧段总结 + {l6_count} 个 L6 字数段节点，
输出 📖《{book}》全书总结 · ① 人物层。

# ① 人物层 · 全书

## 核心人物弧线
逐个列出·每人含：
- 起始状态（一句话）
- 核心弧线（从…到…的完整变化路径）
- 弧线分期（分阶段·每阶段配关键章节号）
- 核心矛盾

## 重要配角弧线
逐个列出·每人含：
- 角色定位
- 弧线概述（1-3 句）
- 对主线的推动

## 人物关系全景
- 核心关系对：列出 + 关系演变概述
- 副 CP / 对照关系：列出 + 与主 CP 的对照意义

---
输入数据：

{l5_arcs}

{l6_nodes}

{datacards}
""",
    2: """请基于《{book}》全书数据 + 已完成的 ① 人物层，
输出 📖《{book}》全书总结 · ② 世界层。

# ② 世界层 · 全书

## 空间格局与演变
- 前期（约前 1/3）：核心场景 + 功能
- 中期（约中 1/3）：核心场景 + 功能
- 后期（约后 1/3）：核心场景 + 功能
- 空间演变趋势

## 势力格局与演变
- 主角方势力：列出 + 演变
- 反派/敌对势力：列出 + 演变
- 中立/摇摆方：列出 + 演变
- 悬念势力：列出 + 演变
- 格局演变趋势

---
输入数据：

{l5_arcs}

{l6_nodes}

【已完成 ① 人物层】
{layer_1}
""",
    3: """请基于《{book}》全书数据 + 已完成的 ①② 层，
输出 📖《{book}》全书总结 · ③ 主角成长层。

# ③ 主角成长层 · 全书

## 核心命题
- 主角核心命题（一句话）

## 成长阶梯
- 第一阶（起）：起点状态 + 触发事件 + 关键章节号
- 第二阶（承）：核心跨越 + 关键节点 + 关键章节号
- 第三阶（转）：质变事件 + 认知升级 + 关键章节号
- 第四阶（合）：完成状态 + 自我和解 + 关键章节号

## 资源盘点五维（终局）
- 人力 / 财力 / 信息 / 权力 / 情感

## 核心关系质量
- 最重要的 3-5 个关系：关系名 + 最终阶段 + 全书评分（⭐5星制）

---
输入数据：

{l5_arcs}

{l6_nodes}

【已完成 ① 人物层】
{layer_1}

【已完成 ② 世界层】
{layer_2}
""",
    4: """请基于《{book}》全书数据 + 已完成的 ①②③ 层，
输出 📖《{book}》全书总结 · ④ 节奏层。

# ④ 节奏层 · 全书

## 全书节奏曲线
- 整体结构（如「慢热铺垫→密集引爆→高潮收束→余韵日常」）
- 分期描述（每期字数/章节范围/节奏特征/关键节点）

## 典型爆发 TOP 5-8
- 场景名（章号） · 为什么强 · 读者感受

## 节奏评价
- 整体评价（1-3 句）
- 节奏亮点
- 可改进之处

---
输入数据：

{l5_arcs}

{l6_nodes}

【已完成前层】
{prior_layers}
""",
    5: """请基于《{book}》全书数据 + 已完成的 ①②③④ 层，
输出 📖《{book}》全书总结 · ⑤ 核心规律。

# ⑤ 核心规律 · 全书 TOP 5-10

## 规律 N：规律名
⊢ 规律锁定·规律名 = 结论一句话
- 全书验证：在书中哪些关键场景中得到验证
- 适用范围：什么类型的作品/情节适用
- 写作可抄：写作者可以直接拿去用的具体方法
- 置信 = 最高

（重复 5-10 条）

## 规律分类汇总
- 人物设计类规律（N 条）
- 关系发展类规律（N 条）
- 节奏控制类规律（N 条）
- 情绪共鸣类规律（N 条）
- 世界观构建类规律（N 条）

---
输入数据：

{l5_arcs}

{l6_nodes}

【已完成前层】
{prior_layers}
""",
}

# ========== 数据加载 ==========
def load_datacards(book_name):
    book_dir = BOOKS_DIR / book_name
    pattern = str(book_dir / f"《{book_name}》第*章·压缩数据卡.md")
    files = sorted(glob.glob(pattern))
    if not files:
        print(f"❌ 数据卡未找到：{pattern}")
        sys.exit(1)
    out = ["【数据卡 · 全书按章】"]
    for f in files:
        out.append(Path(f).read_text(encoding="utf-8"))
    return "\n\n".join(out), len(files)

def load_l5_arcs(book_name):
    l5_dir = BOOKS_DIR / book_name / "L5_arcs"
    if not l5_dir.exists():
        print(f"❌ L5 弧段目录未找到：{l5_dir}")
        sys.exit(1)
    files = sorted(l5_dir.glob("*.md"))
    if not files:
        print(f"❌ L5 弧段为空：{l5_dir}")
        sys.exit(1)
    out = ["【L5 弧段总结】"]
    for f in files:
        out.append(f"--- {f.name} ---\n{f.read_text(encoding='utf-8')}")
    return "\n\n".join(out), len(files)

def load_l6_nodes():
    if not L6_DIR.exists():
        print(f"⚠️ L6 节点目录未找到：{L6_DIR}")
        return "【L6 节点】（未找到）", 0
    node_dirs = sorted([d for d in L6_DIR.iterdir() if d.is_dir()])
    out = ["【L6 字数段节点总结】"]
    count = 0
    for nd in node_dirs:
        layer_files = sorted(nd.glob("layer*.md"))
        if not layer_files:
            continue
        out.append(f"=== 节点：{nd.name} ===")
        for lf in layer_files:
            out.append(f"--- {lf.name} ---\n{lf.read_text(encoding='utf-8')}")
        count += 1
    return "\n\n".join(out), count

# ========== DeepSeek 调用 ==========
def call_deepseek(prompt, max_tokens, layer_num):
    client = OpenAI(api_key=DEEPSEEK_API_KEY, base_url=OPENAI_BASE_URL)
    for attempt in range(1, MAX_RETRIES + 1):
        try:
            resp = client.chat.completions.create(
                model=OPENAI_MODEL,
                messages=[
                    {"role": "system", "content": SYSTEM_PROMPT},
                    {"role": "user", "content": prompt},
                ],
                max_tokens=max_tokens,
                temperature=0.6,
            )
            return resp.choices[0].message.content
        except Exception as e:
            print(f"  ⚠️ 第 {attempt}/{MAX_RETRIES} 次失败·{e}")
            if attempt < MAX_RETRIES:
                time.sleep(RETRY_DELAY)
    print(f"❌ 第 {layer_num} 层重试 {MAX_RETRIES} 次仍失败")
    sys.exit(1)

# ========== Notion ==========
def notion_create_page(parent_id, title, icon="📖"):
    url = "https://api.notion.com/v1/pages"
    payload = {
        "parent": {"page_id": parent_id},
        "icon": {"type": "emoji", "emoji": icon},
        "properties": {
            "title": [{"type": "text", "text": {"content": title[:2000]}}]
        },
    }
    r = requests.post(url, headers=NOTION_HEADERS, json=payload, timeout=30)
    if r.status_code not in [200, 201]:
        print(f"❌ Notion 建页失败：{r.status_code} {r.text[:300]}")
        sys.exit(1)
    return r.json()["id"]

def _rt(text):
    return [{"type": "text", "text": {"content": text[:2000]}}]

def markdown_to_blocks(md):
    blocks = []
    lines = md.split("\n")
    i = 0
    while i < len(lines):
        line = lines[i].rstrip()
        if not line.strip():
            i += 1
            continue
        if line.startswith("### "):
            blocks.append({"object": "block", "type": "heading_3",
                "heading_3": {"rich_text": _rt(line[4:])}})
        elif line.startswith("## "):
            blocks.append({"object": "block", "type": "heading_2",
                "heading_2": {"rich_text": _rt(line[3:])}})
        elif line.startswith("# "):
            blocks.append({"object": "block", "type": "heading_1",
                "heading_1": {"rich_text": _rt(line[2:])}})
        elif line.startswith("- ") or line.startswith("* "):
            blocks.append({"object": "block", "type": "bulleted_list_item",
                "bulleted_list_item": {"rich_text": _rt(line[2:])}})
        elif line.startswith("---"):
            blocks.append({"object": "block", "type": "divider", "divider": {}})
        else:
            blocks.append({"object": "block", "type": "paragraph",
                "paragraph": {"rich_text": _rt(line)}})
        i += 1
    return blocks

def notion_append_blocks(page_id, markdown):
    url = f"https://api.notion.com/v1/blocks/{page_id}/children"
    blocks = markdown_to_blocks(markdown)
    if not blocks:
        return True
    for i in range(0, len(blocks), 100):
        chunk = blocks[i:i + 100]
        r = requests.patch(url, headers=NOTION_HEADERS, json={"children": chunk}, timeout=60)
        if r.status_code not in [200, 201]:
            print(f"  ⚠️ Notion 块上传失败：{r.status_code} {r.text[:300]}")
            return False
    return True

# ========== 主流程 ==========
def build_prompt(layer_num, ctx, layer_outputs):
    tpl = LAYER_PROMPTS[layer_num]
    if layer_num == 1:
        return tpl.format(**ctx)
    if layer_num == 2:
        return tpl.format(**ctx, layer_1=layer_outputs.get(1, "[① 层未跑·请先跑 ①]"))
    if layer_num == 3:
        return tpl.format(**ctx,
            layer_1=layer_outputs.get(1, "[① 层未跑]"),
            layer_2=layer_outputs.get(2, "[② 层未跑]"))
    # layer 4 or 5
    parts = []
    for k in range(1, layer_num):
        if k in layer_outputs:
            parts.append(f"【{LAYER_ICONS[k]} {LAYER_NAMES[k]}】\n{layer_outputs[k]}")
    prior = "\n\n".join(parts) if parts else "[前层未跑]"
    return tpl.format(**ctx, prior_layers=prior)

def main():
    p = argparse.ArgumentParser(description="run_l7.py v0.1 · 全书 L7 总结脱机脚本")
    p.add_argument("--book", required=True, help="书名")
    p.add_argument("--version", default="V5.5", help="L7 版本号")
    p.add_argument("--parent-page-id", required=True, help="拆书规律库下书的主父页 ID")
    p.add_argument("--layers", default="all", help="跑哪几层 · all 或 1,3,4")
    p.add_argument("--dry-run", action="store_true", help="只生成本地 md 不上传 Notion")
    p.add_argument("--force", action="store_true", help="覆盖已有输出")
    args = p.parse_args()

    print("=" * 60)
    print("🚀 run_l7.py v0.1 · 全书 L7 总结")
    print(f"📖 书名：《{args.book}》")
    print(f"🏷️ 版本：{args.version}")
    print(f"📌 父页：{args.parent_page_id}")
    print(f"🔢 层：{args.layers}")
    print(f"🧪 dry-run：{args.dry_run}")
    print("=" * 60)

    if args.layers == "all":
        layers_to_run = [1, 2, 3, 4, 5]
    else:
        try:
            layers_to_run = sorted({int(x.strip()) for x in args.layers.split(",")})
        except ValueError:
            print("❌ --layers 格式错误·示例：--layers 1,3,4")
            sys.exit(1)
        invalid = [x for x in layers_to_run if x not in LAYER_NAMES]
        if invalid:
            print(f"❌ 不支持的层：{invalid}·只能是 1-5")
            sys.exit(1)

    print("\n📥 加载输入…")
    datacards, total_chapters = load_datacards(args.book)
    print(f"  · 数据卡：{total_chapters} 章")
    l5_arcs, l5_count = load_l5_arcs(args.book)
    print(f"  · L5 弧段：{l5_count} 段")
    l6_nodes, l6_count = load_l6_nodes()
    print(f"  · L6 节点：{l6_count} 个")

    out_dir = OUTPUT_DIR / args.book / args.version
    out_dir.mkdir(parents=True, exist_ok=True)

    if not args.force and not args.dry_run:
        existing = list(out_dir.glob("*.md"))
        if existing and args.layers == "all":
            print(f"\n⚠️ 已有产出：{out_dir}")
            print("   用 --force 覆盖 · 或用 --layers 指定单层重跑")
            sys.exit(1)

    ctx = {
        "book": args.book,
        "total_chapters": total_chapters,
        "l5_count": l5_count,
        "l6_count": l6_count,
        "datacards": datacards,
        "l5_arcs": l5_arcs,
        "l6_nodes": l6_nodes,
    }

    parent_l7_page_id = None
    if not args.dry_run:
        print(f"\n📄 创建 L7 父页 · 📖《{args.book}》全书总结 {args.version}")
        parent_l7_page_id = notion_create_page(
            args.parent_page_id,
            f"📖《{args.book}》全书总结 {args.version}",
            icon="📖",
        )
        print(f"  ✅ 父页 ID：{parent_l7_page_id}")

    layer_outputs = {}
    for layer_num in layers_to_run:
        print(f"\n{'=' * 60}")
        print(f"🚀 跑第 {layer_num} 层 · {LAYER_ICONS[layer_num]} {LAYER_NAMES[layer_num]}")
        print(f"{'=' * 60}")

        prompt = build_prompt(layer_num, ctx, layer_outputs)
        print(f"  📏 prompt 长度：{len(prompt)} 字符")
        print(f"  ⏳ DeepSeek 输出中 · max_tokens={LAYER_MAX_TOKENS[layer_num]}…")
        output = call_deepseek(prompt, LAYER_MAX_TOKENS[layer_num], layer_num)
        layer_outputs[layer_num] = output

        layer_file = out_dir / f"layer{layer_num}_{LAYER_NAMES[layer_num]}.md"
        layer_file.write_text(output, encoding="utf-8")
        print(f"  ✅ 本地：{layer_file}")

        if not args.dry_run and parent_l7_page_id:
            print("  ⏳ 上传 Notion…")
            child_id = notion_create_page(
                parent_l7_page_id,
                f"{LAYER_ICONS[layer_num]} {LAYER_NAMES[layer_num]}（全书）",
                icon="📖",
            )
            ok = notion_append_blocks(child_id, output)
            if ok:
                print(f"  ✅ Notion：{child_id}")
            else:
                print(f"  ⚠️ Notion 部分块上传失败 · 子页 ID：{child_id}")

    print(f"\n{'=' * 60}")
    print(f"✅ L7 全书总结完成 · 跑了 {len(layer_outputs)} 层")
    print(f"📂 本地：{out_dir}")
    if parent_l7_page_id:
        print(f"📌 Notion 父页：{parent_l7_page_id}")
    print(f"{'=' * 60}")

if __name__ == "__main__":
    main()
```

---

## ✅ 妈妈部署 SOP

### Step A · 部署

```bash
nano ~/chenxing-aircraft/tools/run_l7.py
# 粘贴上面所有代码 · Ctrl+O 保存 · Ctrl+X 退出
```

### Step B · 语法验证

```bash
python3 -c "import ast; ast.parse(open('$HOME/chenxing-aircraft/tools/run_l7.py').read()); print('✅ 语法 OK')"
```

预期输出：`✅ 语法 OK`

### Step C · dry-run

```bash
cd ~/chenxing-aircraft
python3 tools/run_l7.py \
    --book "全世界都在等我们分手" \
    --version "V5.5" \
    --parent-page-id "377fb92f383181159a73cd946c530945" \
    --dry-run
```

预期：输出 5 个 md 到 `output/l7_full_book/全世界都在等我们分手/V5.5/`

### Step D · 正式跑

```bash
python3 tools/run_l7.py \
    --book "全世界都在等我们分手" \
    --version "V5.5" \
    --parent-page-id "377fb92f383181159a73cd946c530945"
```

预期：10-20 分钟·五次 DeepSeek 调用·Notion 父页 + 5 个子页全部创建。

### 运行中出错怎么办？

- **启动报「数据卡未找到」**：检查 `output/全世界都在等我们分手/` 下是否有 `《全世界都在等我们分手》第*章·压缩数据卡.md`
- **某层超 max_tokens 被截断**：用 `--layers N` 重跑这层·或告诉宝宝·出 v0.2 裁剪补丁
- **DeepSeek 超上下文限**：同上·v0.2 加「数据卡只传摘要」
- **某个子页上传块失败**：子页 ID 会打印·妈妈手动补上或拍个错什么发给宝宝

---

> 📜 v0.1 纯净代码 · ~330 行 · 依赖：`openai requests`·都是现有流水线运行需要的包
> 

> ⊢ 干净脱机 · 与 v0.3 run_l6_one_node 结构对称 · 维护成本最低
> 

> ⊢ 五层链式引用 · 可精准重跑子集 · 应急可单层补跑
>