Răsfoiți Sursa

refactor(v13): architect stays volume-level, planner reads brief directly

Architect was prescribing per-chapter tasks ("第17章让他回家") inside
volume_map, which made the planner a downstream executor rather than a
real chapter planner. When the architect's per-chapter prose failed to
match planner.findOutlineNode's line-start regex (e.g., chapters listed
inside a single prose paragraph instead of one per line), the planner
silently lost all chapter-level intent and cold-opened on its own. In
bench4 book 03 this caused 5 straight chapters with zero mention of
the brief's core setup (穿越/廉政/卧底/老鬼/劏房).

Clean up the division:
- Architect volume_map drops the "关键节点章 (散文级到章号)" section.
  It now only writes 5 volume-level sections (themes, cross-volume
  hooks, character stage goals, volume-end mandatory changes, rhythm
  principles). Chapter planning is the planner's job; the architect
  builds the skeleton.
- Brief is persisted to story/brief.md during initBook so the planner
  can read the user's original creative intent each chapter (not just
  the architect's abstraction of it).
- PlanningSeedMaterials gains a `brief` field; planner-context exposes
  readBrief(); the planner user prompt has a new authoritative
  "创作 brief (最高优先级)" block telling the LLM to honor brief's
  core setup before anything else.

Bench5 verification (3 books × 3 chapters): architect volume_map
contains zero "第X章" directives across all 3 books; planner now lands
the brief's ch1 mechanics on ch1 (港综 book went from 0 → 6/7 brief
keywords in ch1 — 劏房 x2, 老鬼 x3, 底薪, 廉政, 卧底, 东星 x6).
Unexpected win: the 仙侠 planner swapped its default classical 4-char
titles ("雷落药坡") for口语 心理 style ("今夜得挨一回雷") after
seeing the brief's sample titles, with no planner prompt change.

Tests: 665/665 pass after updating one architect-phase5 expectation
(from "写到章级 prose" to "只写到卷级 prose").
Ma 6 luni în urmă
părinte
comite
06d521d3a1

+ 3 - 2
packages/core/src/__tests__/architect-phase5.test.ts

@@ -308,7 +308,7 @@ describe("ArchitectAgent — Phase 5 prose output", () => {
     await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/story_frame/i);
   });
 
-  it("system prompt emphasises chapter-level prose for volume_map and contrast-detail for roles", async () => {
+  it("system prompt emphasises volume-level prose for volume_map and contrast-detail for roles", async () => {
     const agent = buildAgent();
     const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
@@ -318,7 +318,8 @@ describe("ArchitectAgent — Phase 5 prose output", () => {
     const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
     const system = messages[0]?.content ?? "";
     expect(system).toContain("散文密度");
-    expect(system).toContain("写到章级 prose");
+    // Post-refactor: architect stays at volume level; chapter-level planning is planner's job.
+    expect(system).toContain("只写到卷级 prose");
     expect(system).toContain("反差细节");
     expect(system).toContain("节奏原则");
     expect(system).toContain("=== SECTION: story_frame ===");

+ 14 - 20
packages/core/src/agents/architect.ts

@@ -202,24 +202,21 @@ ${eraBlock}
 
 === SECTION: volume_map ===
 
-这是分卷散文地图,**5 段主体 + 1 段节奏原则尾段**。**关键要求:写到章级 prose**("第 17 章让他回家"、"第 32-35 章把师父的秘密揭开"级别的布局)。这是 Phase 3 planner LLM 能从稀疏 memo 里读出动作的源头。不写到章级就废了。
+这是分卷散文地图,**5 段主体 + 1 段节奏原则尾段**。**关键要求:只写到卷级 prose**——写清楚每卷的主题、情绪曲线、卷间钩子、角色阶段目标、卷尾不可逆事件。**禁止指定具体章号任务**(不要写"第 17 章让他回家"这种章级布局)。章级规划是 Phase 3 planner 的职责,架构师只搭骨架、不编章目。
 
 ### 段 1:各卷主题与情绪曲线
 有几卷?每卷的主题一句话,每卷的情绪曲线一段(哪里压、哪里爽、哪里冷、哪里暖)。不要机械的"第一卷打小怪第二卷打大怪",写情绪的流动。
 
-### 段 2:关键节点章(散文级到章号)
-列出本书的关键章——高潮章(第 X 章、第 Y 章)、重大转折章、呼吸章/温情章。每个节点一句话说"这一章发生什么、为什么放在这个位置"。例:"第 17 章让他回家——母亲已经病了三卷没见,这一章的重量在回家路上那段沉默"。
+### 段 2:卷间钩子与回收承诺
+第 1 卷埋什么钩子、在哪一卷回收;第 2 卷埋什么、在哪一卷回收。散文写,不要表格。**只写卷级**(如"第 1 卷埋的身世之谜在第 3 卷回收"),不要写具体章号。
 
-### 段 3:卷间钩子与回收承诺
-第 1 卷埋什么钩子、哪一章回收;第 2 卷埋什么、哪一章回收。散文写,不要表格。必须写具体"这个钩子在第 N 章回"而不是"后期回收"。
-
-### 段 4:角色阶段性目标
+### 段 3:角色阶段性目标
 主角在第 1 卷末要到什么状态?第 2 卷末?每一卷结束时主角的身份/关系/能力/心境应该是什么。次要角色的阶段性变化也要点到(师父在第 2 卷会死、对手在第 3 卷会黑化等)。写阶段性,不写完整弧线(完整弧线在 roles)。
 
-### 段 5:卷尾必须发生的改变
-每一卷最后一章必须发生什么不可逆的事——权力结构改变、关系破裂、秘密暴露、主角身份重定位。写散文,一卷一段。
+### 段 4:卷尾必须发生的改变
+每一卷最后一章必须发生什么不可逆的事——权力结构改变、关系破裂、秘密暴露、主角身份重定位。写散文,一卷一段。**只写"必须发生什么",不指定是第几章**。
 
-### 段 6:节奏原则(具体化 + 通用)
+### 段 5:节奏原则(具体化 + 通用)
 **这是节奏原则的唯一归宿,不再有独立 rhythm_principles section。** 本段输出 6 条节奏原则。**至少 3 条必须具体化到本书**(例:"前 30 章每 5 章一个小爽点"),其余可保留通用原则(例:"拒绝机械降神"、"高潮前 3-5 章埋伏笔")。具体化 + 通用混合是合法的。反面例子:"节奏要张弛有度"(废话)。正面例子:"前 30 章每 5 章一个小爽点,且小爽点必须落在章末 300 字内"。6 条各写 2-3 句,覆盖(顺序不强制、可替换同权重议题):
 1. 高潮间距——本书大高潮之间最长多少章?(具体化优先)
 2. 喘息频率——高压段多长必须插一章喘息?喘息章承担什么任务?
@@ -386,24 +383,21 @@ What the last chapter roughly feels like. The final shot: where, doing what, aro
 
 === SECTION: volume_map ===
 
-Prose volume map, **5 sections + 1 closing rhythm paragraph**. **Critical requirement: write to chapter-level prose** ("chapter 17 sends him home", "chapters 32-35 reveal the master's secret"). Without chapter-level detail the sparse-memo planner has nothing to read.
+Prose volume map, **5 sections + 1 closing rhythm paragraph**. **Critical requirement: stay at volume-level prose only** — specify each volume's theme, emotional curve, cross-volume hooks, character stage goals, and volume-end irreversible changes. **Do NOT prescribe chapter-level tasks** (no "chapter 17 sends him home"). Chapter planning is the Phase 3 planner's job; the architect builds the skeleton, not the chapter list.
 
 ## 01_Volume_Themes_and_Emotional_Curves
 How many volumes? Each volume's theme in one sentence; each volume's emotional curve as a paragraph (where pressured, where rewarding, where cold, where warm). Not mechanical rotation.
 
-## 02_Key_Node_Chapters (prose-level, with chapter numbers)
-Climax chapters (ch X, ch Y), turning-point chapters, breath/tender chapters. One line per node: "ch 17 sends him home — the mother has been ill three volumes unseen; the weight of the chapter lives in the silence of the walk home".
-
-## 03_Cross_Volume_Hooks_and_Payoff_Promises
-Volume 1 plants hook A, paid off in ch N; volume 2 plants hook B, paid off in ch M. Prose, not tables. Concrete chapter numbers, not "late game payoff".
+## 02_Cross_Volume_Hooks_and_Payoff_Promises
+Volume 1 plants hook A, paid off in volume N; volume 2 plants hook B, paid off in volume M. Prose, not tables. **Stay at volume-level** (e.g., "the origin mystery planted in volume 1 pays off in volume 3"); do not specify chapter numbers.
 
-## 04_Character_Stage_Goals
+## 03_Character_Stage_Goals
 Protagonist's state at end of vol 1, vol 2, ... Supporting characters' stage changes (master dies end of vol 2, opponent breaks bad in vol 3). Stage goals only — full arc lives in roles.
 
-## 05_Volume_End_Mandatory_Changes
-Each volume's last chapter must contain an irreversible event. Prose, one paragraph per volume.
+## 04_Volume_End_Mandatory_Changes
+Each volume's last chapter must contain an irreversible event. Prose, one paragraph per volume. **Write what must happen, not which chapter**.
 
-## 06_Rhythm_Principles (concrete + universal)
+## 05_Rhythm_Principles (concrete + universal)
 **This is the single home for rhythm principles — no separate rhythm_principles section exists.** Output 6 rhythm principles. **At least 3 must be concretized for this book** (e.g., "every 5 chapters in the first 30, hit one small payoff"); the rest may stay as universal rules (e.g., "no deus ex machina", "plant the foreshadow 3-5 chapters before the climax"). A mix of concrete + universal is valid. Bad: "rhythm must balance tension and release". Good: "every 5 chapters in the first 30 carries a small payoff landing in the last 300 chars of the chapter". Cover (order flexible, substitutions of equal weight are allowed): (1) climax spacing, (2) breath frequency, (3) hook density, (4) information release pacing, (5) payoff rhythm, (6) relationship advancement — each 2-3 sentences.
 
 === SECTION: roles ===

+ 4 - 0
packages/core/src/agents/planner-context.ts

@@ -34,6 +34,10 @@ export async function readPendingHooks(storyDir: string): Promise<string> {
   return readOrEmpty(join(storyDir, "pending_hooks.md"));
 }
 
+export async function readBrief(storyDir: string): Promise<string> {
+  return readOrEmpty(join(storyDir, "brief.md"));
+}
+
 /**
  * Render the structured book rules (protagonist / prohibitions / genreLock /
  * behavioral constraints) as a compact markdown block for the planner prompt.

+ 30 - 0
packages/core/src/agents/planner-prompts.ts

@@ -159,6 +159,8 @@ if this is a pressure / conflict chapter, write "n/a — pressure chapter, no tr
 
 export const PLANNER_MEMO_USER_TEMPLATE_EN = `# Chapter {{chapterNumber}} memo request
 
+{{brief_block}}
+
 ## Last screen of previous chapter (excerpt)
 {{previous_chapter_ending_excerpt}}
 
@@ -202,6 +204,8 @@ export function getPlannerMemoUserTemplate(language: "zh" | "en" = "zh"): string
 
 export const PLANNER_MEMO_USER_TEMPLATE = `# 第 {{chapterNumber}} 章 memo 请求
 
+{{brief_block}}
+
 ## 上一章最后一屏(原文节选)
 {{previous_chapter_ending_excerpt}}
 
@@ -241,6 +245,7 @@ export interface PlannerUserMessageInput {
   readonly relevantThreads: string;
   readonly isGoldenOpening: boolean;
   readonly bookRulesRelevant: string;
+  readonly brief?: string;
   readonly language?: "zh" | "en";
 }
 
@@ -250,8 +255,11 @@ export function buildPlannerUserMessage(input: PlannerUserMessageInput): string
   const yesText = language === "en" ? "yes" : "是";
   const noText = language === "en" ? "no" : "否";
 
+  const briefBlock = buildBriefBlock(input.brief ?? "", language);
+
   const filled = template
     .replaceAll("{{chapterNumber}}", String(input.chapterNumber))
+    .replaceAll("{{brief_block}}", briefBlock)
     .replaceAll("{{previous_chapter_ending_excerpt}}", input.previousChapterEndingExcerpt)
     .replaceAll("{{recent_summaries}}", input.recentSummaries)
     .replaceAll("{{current_arc_prose}}", input.currentArcProse)
@@ -266,6 +274,28 @@ export function buildPlannerUserMessage(input: PlannerUserMessageInput): string
   return golden ? `${filled}\n\n${golden}` : filled;
 }
 
+/**
+ * Brief is the user's original creative document. It's the highest authority
+ * source for "what this book is". story_frame/volume_map are the architect's
+ * abstraction of brief; chapter memos must honor brief first.
+ *
+ * Returns "" when no brief exists (legacy books without brief.md).
+ */
+function buildBriefBlock(brief: string, language: "zh" | "en"): string {
+  const trimmed = brief.trim();
+  if (!trimmed) return "";
+  if (language === "en") {
+    return `## Creative brief (user's original intent — authoritative)
+${trimmed}
+
+The brief is the user's direct instruction. When planning this chapter, honor the brief's core setup (protagonist concept, world premise, opening mechanics, sample chapter hooks if any) before anything else. Do NOT defer the brief's core setup to later chapters; land it early.`;
+  }
+  return `## 用户创作 brief(原始意图——最高优先级)
+${trimmed}
+
+brief 是用户的直接指令。本章规划时,必须优先兑现 brief 里写明的核心设定(主角设定、世界前提、开场机制、样本章回钩子等)。**不要把 brief 里的核心设定推迟到后面的章节**——该在前几章落地的必须落地。`;
+}
+
 // ---------------------------------------------------------------------------
 // 黄金三章 prose guidance — Phase 6.5
 // Single conditional append (chapterNumber <= 3). No new schema, no new

+ 3 - 0
packages/core/src/agents/planner.ts

@@ -136,6 +136,7 @@ export class PlannerAgent extends BaseAgent {
       fallbackGoal: goal,
       chapterSummariesRaw: seedMaterials.chapterSummariesRaw,
       previousEndingExcerpt: seedMaterials.previousEndingExcerpt,
+      brief: seedMaterials.brief,
       // Phase hotfix 4: thread book language through so the planner uses
       // English prompts (system + user template + golden opening guidance)
       // for English books instead of always-Chinese.
@@ -180,6 +181,7 @@ export class PlannerAgent extends BaseAgent {
     readonly fallbackGoal: string;
     readonly chapterSummariesRaw: string;
     readonly previousEndingExcerpt?: string;
+    readonly brief?: string;
     readonly language?: "zh" | "en";
   }): Promise<ChapterMemo> {
     const [characterMatrix, subplotBoard, emotionalArcs, pendingHooks, bookRulesRaw] = await Promise.all([
@@ -217,6 +219,7 @@ export class PlannerAgent extends BaseAgent {
       relevantThreads: extractRelevantThreads(pendingHooks, subplotBoard),
       isGoldenOpening: input.isGoldenOpening,
       bookRulesRelevant: bookRulesRaw.trim().length > 0 ? bookRulesRaw.trim() : noBookRules,
+      brief: input.brief ?? "",
       language,
     });
 

+ 6 - 0
packages/core/src/pipeline/runner.ts

@@ -473,6 +473,12 @@ export class PipelineRunner {
         book.language ?? gp.language,
       );
 
+      if (this.config.externalContext && this.config.externalContext.trim().length > 0) {
+        const storyDir = join(stagingBookDir, "story");
+        await mkdir(storyDir, { recursive: true });
+        await writeFile(join(storyDir, "brief.md"), this.config.externalContext, "utf-8");
+      }
+
       this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
       await this.state.ensureControlDocumentsAt(
         stagingBookDir,

+ 13 - 0
packages/core/src/utils/planning-materials.ts

@@ -21,6 +21,7 @@ export interface PlanningSeedMaterials {
   readonly bookRulesRaw: string;
   readonly currentState: string;
   readonly chapterSummariesRaw: string;
+  readonly brief: string;
   readonly outlineNode?: string;
   readonly recentSummaries: ReadonlyArray<StoredSummary>;
   readonly previousEndingHook?: string;
@@ -41,6 +42,14 @@ async function readFileOrDefault(path: string): Promise<string> {
   }
 }
 
+async function readBriefFile(path: string): Promise<string> {
+  try {
+    return await readFile(path, "utf-8");
+  } catch {
+    return "";
+  }
+}
+
 async function readPreviousEndingExcerpt(
   bookDir: string,
   chapterNumber: number,
@@ -84,6 +93,7 @@ export async function loadPlanningSeedMaterials(params: {
     chapterSummaries: join(storyDir, "chapter_summaries.md"),
     bookRules: join(storyDir, "book_rules.md"),
     currentState: join(storyDir, "current_state.md"),
+    brief: join(storyDir, "brief.md"),
   } as const;
 
   // Phase 5: prefer the new prose outline files (outline/story_frame.md +
@@ -99,6 +109,7 @@ export async function loadPlanningSeedMaterials(params: {
     bookRulesRaw,
     currentState,
     previousEndingExcerpt,
+    brief,
   ] = await Promise.all([
     readFileOrDefault(sourcePaths.authorIntent),
     readFileOrDefault(sourcePaths.currentFocus),
@@ -110,6 +121,7 @@ export async function loadPlanningSeedMaterials(params: {
     // seed rows when current_state.md is still just the architect's placeholder.
     readCurrentStateWithFallback(params.bookDir, placeholder),
     readPreviousEndingExcerpt(params.bookDir, params.chapterNumber),
+    readBriefFile(sourcePaths.brief),
   ]);
 
   const chapterSummaries = parseChapterSummariesMarkdown(chapterSummariesRaw)
@@ -125,6 +137,7 @@ export async function loadPlanningSeedMaterials(params: {
     bookRulesRaw,
     currentState,
     chapterSummariesRaw,
+    brief,
     recentSummaries: chapterSummaries.slice(0, 4).sort((left, right) => left.chapter - right.chapter),
     previousEndingHook: chapterSummaries[0]?.hookActivity || undefined,
     previousEndingExcerpt,