Browse Source

feat(v13): align writer prompt and PRE_WRITE_CHECK to 7-section chapter_memo

Ma 6 months ago
parent
commit
062d4aca00

+ 54 - 4
packages/core/src/agents/writer-prompts.ts

@@ -44,6 +44,7 @@ export function buildWriterSystemPrompt(
         buildEnglishGenreIntro(book, genreProfile),
         buildEnglishCoreRules(book),
         buildGovernedInputContract("en", governed),
+        buildChapterMemoContract("en", governed),
         buildLengthGuidance(resolvedLengthSpec, "en"),
         buildWritingCraftCard("en"),
         buildGenreRules(genreProfile, genreBody),
@@ -61,6 +62,7 @@ export function buildWriterSystemPrompt(
         buildGenreIntro(book, genreProfile),
         buildCoreRules(resolvedLengthSpec),
         buildGovernedInputContract("zh", governed),
+        buildChapterMemoContract("zh", governed),
         buildLengthGuidance(resolvedLengthSpec, "zh"),
         buildWritingCraftCard("zh"),
         buildGoldenChaptersRules(chapterNumber, isEnglish ? "en" : "zh"),
@@ -118,6 +120,44 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
 - 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`;
 }
 
+// ---------------------------------------------------------------------------
+// Chapter memo alignment — 7 sections from new.txt methodology
+// ---------------------------------------------------------------------------
+
+function buildChapterMemoContract(language: "zh" | "en", governed: boolean): string {
+  if (!governed) return "";
+
+  if (language === "en") {
+    return `## Chapter Memo Alignment
+
+You will receive a chapter_memo composed of 7 markdown sections:
+
+- ## 当前任务 → the concrete action this chapter must complete; stay aligned with it throughout
+- ## 读者此刻在等什么 → controls how emotional gaps are created / delayed / paid off
+- ## 该兑现的 / 暂不掀的 → payoffs that must land this chapter + cards you must NOT reveal
+- ## 日常/过渡承担什么任务 → function map for non-conflict passages ([passage location] → [function])
+- ## 关键抉择过三连问 → three-question check every key character choice must pass
+- ## 章尾必须发生的改变 → 1-3 concrete changes the ending must deliver (info / relation / physical / power)
+- ## 不要做 → hard prohibitions for this chapter
+
+Address each section in order when drafting the chapter. Every section must leave a visible trace in the prose — if a section is not reflected, the chapter is incomplete.`;
+  }
+
+  return `## 章节备忘对齐
+
+你将收到本章的 chapter_memo,由 7 段 markdown 组成:
+
+- ## 当前任务 → 本章必须完成的具体动作,写作时始终对齐这条
+- ## 读者此刻在等什么 → 控制情绪缺口的制造/延迟/兑现程度
+- ## 该兑现的 / 暂不掀的 → 本章必须兑现的伏笔清单 + 必须压住不掀的底牌
+- ## 日常/过渡承担什么任务 → 非冲突段落的功能映射([段落位置] → [承担功能])
+- ## 关键抉择过三连问 → 关键人物选择必须过的检查
+- ## 章尾必须发生的改变 → 结尾落地的 1-3 条具体改变(信息/关系/物理/权力)
+- ## 不要做 → 硬约束红线
+
+写作时按段落顺序落实,每一段都要在正文里有对应的兑现痕迹。如果某一段没有体现到正文里,本章不算完成。`;
+}
+
 function buildLengthGuidance(lengthSpec: LengthSpec, language: "zh" | "en"): string {
   if (language === "en") {
     return `## Length Guidance
@@ -629,10 +669,15 @@ function buildCreativeOutputFormat(book: BookConfig, gp: GenreProfile, lengthSpe
     : "";
 
   const preWriteTable = `=== PRE_WRITE_CHECK ===
-(必须输出Markdown表格)
+(必须输出Markdown表格,全部检查项对齐 chapter_memo 七段,而不是卷纲)
 | 检查项 | 本章记录 | 备注 |
 |--------|----------|------|
-| 大纲锚定 | 当前卷名/阶段 + 本章应推进的具体节点 | 严禁跳过节点或提前消耗后续剧情 |
+| 当前任务 | 复述 chapter_memo 的「当前任务」并写出本章执行动作 | 必须具体,不能抽象 |
+| 读者在等什么 | 本章如何处理「读者此刻在等什么」—制造/延迟/兑现 | 与 memo 一致 |
+| 该兑现的 / 暂不掀的 | 本章确认要兑现的伏笔 + 必须压住不掀的底牌 | 引用 memo 原文 |
+| 日常/过渡承担任务 | 若有日常/过渡段落,说明各自承担的功能 | 对齐 memo 映射表 |
+| 章尾必须发生的改变 | 列出 memo「章尾必须发生的改变」中 1-3 条具体改变 | 必须落地 |
+| 不要做 | 复述 memo「不要做」清单 | 正文不得触碰 |
 | 上下文范围 | 第X章至第Y章 / 状态卡 / 设定文件 | |
 | 当前锚点 | 地点 / 对手 / 收益目标 | 锚点必须具体 |
 ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | 与伏笔池一致 |
@@ -664,10 +709,15 @@ function buildOutputFormat(book: BookConfig, gp: GenreProfile, lengthSpec: Lengt
     : "";
 
   const preWriteTable = `=== PRE_WRITE_CHECK ===
-(必须输出Markdown表格)
+(必须输出Markdown表格,全部检查项对齐 chapter_memo 七段,而不是卷纲)
 | 检查项 | 本章记录 | 备注 |
 |--------|----------|------|
-| 大纲锚定 | 当前卷名/阶段 + 本章应推进的具体节点 | 严禁跳过节点或提前消耗后续剧情 |
+| 当前任务 | 复述 chapter_memo 的「当前任务」并写出本章执行动作 | 必须具体,不能抽象 |
+| 读者在等什么 | 本章如何处理「读者此刻在等什么」—制造/延迟/兑现 | 与 memo 一致 |
+| 该兑现的 / 暂不掀的 | 本章确认要兑现的伏笔 + 必须压住不掀的底牌 | 引用 memo 原文 |
+| 日常/过渡承担任务 | 若有日常/过渡段落,说明各自承担的功能 | 对齐 memo 映射表 |
+| 章尾必须发生的改变 | 列出 memo「章尾必须发生的改变」中 1-3 条具体改变 | 必须落地 |
+| 不要做 | 复述 memo「不要做」清单 | 正文不得触碰 |
 | 上下文范围 | 第X章至第Y章 / 状态卡 / 设定文件 | |
 | 当前锚点 | 地点 / 对手 / 收益目标 | 锚点必须具体 |
 ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | 与伏笔池一致 |

+ 41 - 0
packages/core/src/agents/writer.ts

@@ -262,6 +262,13 @@ export class WriterAgent extends BaseAgent {
 
     const creative = parseCreativeOutput(chapterNumber, creativeResponse.content, resolvedLengthSpec.countingMode);
 
+    // Phase 4: soft-check that PRE_WRITE_CHECK aligns with the chapter memo.
+    // Memo was already parse-validated in the planner, so this only warns —
+    // the LLM self-check may have skipped or abbreviated a row.
+    if (input.chapterMemo) {
+      this.verifyPreWriteCheckAlignsWithMemo(creative.preWriteCheck, chapterNumber, resolvedLanguage);
+    }
+
     // ── Phase 2: State settlement (temperature 0.3) ──
     this.logInfo(resolvedLanguage, {
       zh: `阶段 2:状态结算(第${chapterNumber}章,${creative.wordCount}字)`,
@@ -925,6 +932,40 @@ ${selectedContext || "- none"}
 ${overrides}\n`;
   }
 
+  /**
+   * Soft-check that the LLM's PRE_WRITE_CHECK output references the three
+   * non-negotiable memo sections: 当前任务, 不要做, 章尾必须发生的改变.
+   *
+   * This is NOT a hard gate — the memo was already parse-validated in the
+   * planner, and the writer prompt already tells the LLM to align to memo.
+   * We only warn when the LLM skipped a section, so the chapter still ships.
+   */
+  private verifyPreWriteCheckAlignsWithMemo(
+    preWriteCheck: string,
+    chapterNumber: number,
+    language: "zh" | "en",
+  ): void {
+    if (!preWriteCheck || preWriteCheck.trim().length === 0) {
+      this.logWarn(language, {
+        zh: `第${chapterNumber}章 PRE_WRITE_CHECK 为空,无法对齐 chapter_memo`,
+        en: `Chapter ${chapterNumber} PRE_WRITE_CHECK is empty; cannot verify memo alignment`,
+      });
+      return;
+    }
+
+    const missing: string[] = [];
+    if (!preWriteCheck.includes("当前任务")) missing.push("当前任务");
+    if (!preWriteCheck.includes("不要做")) missing.push("不要做");
+    if (!preWriteCheck.includes("章尾")) missing.push("章尾必须发生的改变");
+
+    if (missing.length > 0) {
+      this.logWarn(language, {
+        zh: `第${chapterNumber}章 PRE_WRITE_CHECK 缺少 memo 章节检查:${missing.join("、")}`,
+        en: `Chapter ${chapterNumber} PRE_WRITE_CHECK missing memo sections: ${missing.join(", ")}`,
+      });
+    }
+  }
+
   private buildLengthRequirementBlock(lengthSpec: LengthSpec, language: "zh" | "en"): string {
     if (language === "en") {
       return `Requirements:

+ 11 - 6
packages/core/src/utils/narrative-control.ts

@@ -41,8 +41,12 @@ export function sanitizeNarrativeControlText(
 
 /**
  * Render a ChapterMemo + optional ChapterIntent into a sanitized narrative
- * control block for the writer / reviser prompt. Phase 1 transitional: memo
- * body is typically empty until Phase 3 fills it with new.txt 7-section prose.
+ * control block for the writer / reviser prompt.
+ *
+ * Phase 4: the memo body already contains the 7 required section headings
+ * (当前任务 / 读者此刻在等什么 / 该兑现的 / 日常过渡 / 关键抉择 / 章尾 / 不要做)
+ * produced by the planner LLM. We emit them at top level so the writer sees
+ * each section as its own task-unit instead of one flattened "memo" block.
  */
 export function renderMemoAsNarrativeBlock(
   memo: ChapterMemo,
@@ -59,10 +63,6 @@ export function renderMemoAsNarrativeBlock(
     sections.push(`## ${isEn ? "Arc Context" : "弧线背景"}\n- ${s(intent.arcContext)}`);
   }
 
-  if (memo.body.trim().length > 0) {
-    sections.push(`## ${isEn ? "Memo" : "本章备忘"}\n${s(memo.body)}`);
-  }
-
   if (memo.threadRefs.length > 0) {
     const threads = memo.threadRefs.map((id) => `- ${id}`).join("\n");
     sections.push(`## ${isEn ? "Thread Refs" : "关联线索"}\n${threads}`);
@@ -74,6 +74,11 @@ export function renderMemoAsNarrativeBlock(
     );
   }
 
+  // Emit the 7-section memo body at top level so each heading is a task.
+  if (memo.body.trim().length > 0) {
+    sections.push(s(memo.body));
+  }
+
   return sections.join("\n\n");
 }