Просмотр исходного кода

fix(v13): parseMemo rejects empty sections [phase hotfix 7]

The validator only checked for the presence of each section heading
(body.includes(heading)). A memo with all 7 headings but a blank/whitespace
payload between them passed cleanly and flowed downstream. Reviewer would
then accept what looks structurally like a memo but carries zero planning
substance.

Fix:
- chapter-memo-parser.ts: extract the content between each heading and the
  next ## heading, then enforce a per-section minimum length.
  - Most sections: ≥ 20 chars (one short sentence — long enough to catch
    obvious empties / "TODO" / placeholder, short enough to honor the
    Phase 6 sparse-memo principle for breath chapters).
  - "## 不要做" / "## Do not": ≥ 1 char (relaxed) so "无", "N/A",
    "none", or "—" remain valid for chapters with no extra prohibitions.
- Error message reports each empty section with its required threshold
  ("## 当前任务 (need ≥ 20 chars)") so the LLM-feedback retry loop knows
  exactly what to fix.

Test fixtures in planner.test.ts and persisted-governed-plan.test.ts were
slightly extended where their original sample sections fell just below
the new threshold — preserving the spirit of the test (sparse-but-real
memo) while complying with the stricter validator.
Ma 6 месяцев назад
Родитель
Сommit
b3ff2f08ca

+ 7 - 8
packages/core/src/__tests__/persisted-governed-plan.test.ts

@@ -9,24 +9,23 @@ import {
 import type { PlanChapterOutput } from "../agents/planner.js";
 import type { PlanChapterOutput } from "../agents/planner.js";
 
 
 const MEMO_BODY = `## 当前任务
 const MEMO_BODY = `## 当前任务
-林越必须取回母亲留下的账册并离开旧港。
+林越必须趁守夜人换班的空档,从母亲遗物里取回账册并悄悄离开旧港。
 
 
 ## 读者此刻在等什么
 ## 读者此刻在等什么
-读者想看账册内容会不会撕开上一章的悬念。
+读者想看账册内容会不会撕开上一章关于母亲身份的悬念,并对账册的下落给出实证。
 
 
 ## 该兑现的 / 暂不掀的
 ## 该兑现的 / 暂不掀的
-- 兑现:账册现身
-- 压住:母亲真实身份
+- 兑现:账册现身,与上一章的暗示对上
+- 压住:母亲真实身份,等到第 6 章揭
 
 
 ## 日常/过渡承担什么任务
 ## 日常/过渡承担什么任务
-- [开场茶馆段] → 建立旧港潮湿氛围与对手视线
+- [开场茶馆段] → 建立旧港潮湿氛围与对手视线,同时铺一个反派眼线的伏笔
 
 
 ## 关键抉择过三连问
 ## 关键抉择过三连问
-林越拿起账册时必须过:为什么现在?符合人设吗?读者会觉得突兀吗?
+林越拿起账册时必须过:为什么是现在?这符合他的当前利益吗?符合他的人设吗?
 
 
 ## 章尾必须发生的改变
 ## 章尾必须发生的改变
-- 账册从母亲遗物变为主角私有
-- 主角与旧港江湖关系破裂
+- 账册从母亲遗物变为主角私有,主角与旧港江湖关系出现实质裂痕
 
 
 ## 不要做
 ## 不要做
 - 不要出现母亲真实姓名
 - 不要出现母亲真实姓名

+ 130 - 5
packages/core/src/__tests__/planner.test.ts

@@ -10,18 +10,18 @@ import { PlannerParseError } from "../utils/chapter-memo-parser.js";
 
 
 const VALID_BODY = `
 const VALID_BODY = `
 ## 当前任务
 ## 当前任务
-主角进入七号门查看被动过手脚的痕迹。
+主角进入七号门现场,比对锁芯刮痕与监控时间线,把"被动过手脚"从猜测钉成实证。
 
 
 ## 读者此刻在等什么
 ## 读者此刻在等什么
-1) 读者在等七号门是否有异常
-2) 本章完全兑现
+1) 读者在等七号门是否有异常实锤
+2) 本章完全兑现,钉成现场实证
 
 
 ## 该兑现的 / 暂不掀的
 ## 该兑现的 / 暂不掀的
 - 该兑现:七号门异常 → 钉成现场实证
 - 该兑现:七号门异常 → 钉成现场实证
 - 暂不掀:幕后主使 → 压到第 20 章
 - 暂不掀:幕后主使 → 压到第 20 章
 
 
 ## 日常/过渡承担什么任务
 ## 日常/过渡承担什么任务
-不适用 - 本章无日常过渡
+不适用 - 本章为高压实证章,无日常过渡段。
 
 
 ## 关键抉择过三连问
 ## 关键抉择过三连问
 - 主角本章最关键的一次选择:
 - 主角本章最关键的一次选择:
@@ -34,7 +34,7 @@ const VALID_BODY = `
   - 符合他的人设吗?符合
   - 符合他的人设吗?符合
 
 
 ## 章尾必须发生的改变
 ## 章尾必须发生的改变
-- 信息改变:主角掌握实证
+- 信息改变:主角掌握实证,可以面对幕后主使前先压住对手的退路
 
 
 ## 不要做
 ## 不要做
 - 不要让对手突然降智
 - 不要让对手突然降智
@@ -168,6 +168,83 @@ describe("PlannerAgent.planChapter memo generation", () => {
     expect(userMsg?.content).toContain("上次输出的错误");
     expect(userMsg?.content).toContain("上次输出的错误");
   });
   });
 
 
+  // Phase hotfix 4: English books must receive English system + user prompts
+  // and English golden-opening guidance for chapters ≤ 3.
+  it("uses English prompts end-to-end when book.language is en", async () => {
+    const VALID_EN_BODY = `
+## Current task
+Pin the Door 7 tampering from suspicion to live evidence.
+
+## What the reader is waiting for right now
+1) Reader expects to learn whether Door 7 is really compromised.
+2) This chapter pays it off in full — live evidence on stage.
+
+## To pay off / to keep buried
+- Pay off: Door 7 anomaly → live evidence
+- Keep buried: the mastermind → push to chapter 20
+
+## What the slow / transitional beats carry
+n/a — pressure chapter, no transitional beats.
+
+## Three-question check on the key choice
+- Protagonist's most important choice this chapter:
+  - Why this choice? It is the only remaining lead.
+  - Does it match current interest? Yes.
+  - Does it match their persona? Yes.
+- Antagonist / supporting cast's most important choice this chapter:
+  - Why this choice? To cover their tracks.
+  - Does it match current interest? Yes.
+  - Does it match their persona? Yes.
+
+## Required end-of-chapter change
+- Information change: protagonist holds live evidence.
+
+## Do not
+- Do not let the antagonist suddenly turn dumb.
+- Do not directly name the mastermind.
+`.trim();
+
+    const validEnRaw = `---\nchapter: 1\ngoal: Pin Door 7 tampering as live evidence\nisGoldenOpening: false\nthreadRefs:\n  - H03\n---\n${VALID_EN_BODY}\n`;
+
+    const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
+      content: validEnRaw,
+      usage: ZERO_USAGE,
+    } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
+
+    const enBook = { ...makeBook(), language: "en" as const };
+    const result = await makePlanner().planChapter({
+      book: enBook,
+      bookDir,
+      chapterNumber: 1,
+    });
+
+    expect(chatSpy).toHaveBeenCalledTimes(1);
+    expect(result.memo.chapter).toBe(1);
+    expect(result.memo.isGoldenOpening).toBe(true); // ch1 en → also golden (≤5)
+
+    // System prompt must be the English variant
+    const callArgs = chatSpy.mock.calls[0]!;
+    const messages = callArgs[2] as ReadonlyArray<{ role: string; content: string }>;
+    const systemMsg = messages.find((m) => m.role === "system");
+    const userMsg = messages.find((m) => m.role === "user");
+
+    // English system prompt markers
+    expect(systemMsg?.content).toContain("editor-in-chief");
+    expect(systemMsg?.content).toContain("Output format (strict)");
+    expect(systemMsg?.content).not.toContain("你是这本小说的创作总编");
+
+    // English user template markers
+    expect(userMsg?.content).toContain("# Chapter 1 memo request");
+    expect(userMsg?.content).toContain("Last screen of previous chapter");
+    expect(userMsg?.content).toContain("Golden opening chapter: yes");
+    expect(userMsg?.content).not.toContain("# 第 1 章 memo 请求");
+
+    // English golden-opening guidance appended for ch ≤ 3
+    expect(userMsg?.content).toContain("Golden Opening Guidance");
+    expect(userMsg?.content).toContain("Chapter 1");
+    expect(userMsg?.content).not.toContain("黄金三章规划指引");
+  });
+
   it("throws PlannerParseError when all 3 attempts fail", async () => {
   it("throws PlannerParseError when all 3 attempts fail", async () => {
     vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
     vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
       content: "permanently broken",
       content: "permanently broken",
@@ -182,4 +259,52 @@ describe("PlannerAgent.planChapter memo generation", () => {
       }),
       }),
     ).rejects.toBeInstanceOf(PlannerParseError);
     ).rejects.toBeInstanceOf(PlannerParseError);
   });
   });
+
+  // Phase hotfix 5: planner.intent.mustAvoid must come from the Phase 5
+  // authoritative loader (story_frame frontmatter), not from raw
+  // book_rules.md — for new-layout books the legacy file is just a shim.
+  it("derives intent.mustAvoid from outline/story_frame.md frontmatter (new layout)", async () => {
+    // Replace book_rules.md with a Phase 5 compat shim (no YAML, just pointer)
+    // and put the authoritative YAML on outline/story_frame.md.
+    const storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "outline"), { recursive: true });
+    await writeFile(
+      join(storyDir, "outline/story_frame.md"),
+      [
+        "---",
+        "version: \"1.0\"",
+        "protagonist:",
+        "  name: 阿泽",
+        "  personalityLock: []",
+        "  behavioralConstraints: []",
+        "prohibitions:",
+        "  - 禁止主角降智",
+        "  - 禁止神化反派",
+        "---",
+        "",
+        "## 主题与基调",
+        "调查与压制。",
+      ].join("\n"),
+      "utf-8",
+    );
+    await writeFile(
+      join(storyDir, "book_rules.md"),
+      "# 本书规则(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。",
+      "utf-8",
+    );
+
+    vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
+      content: validMemoRaw(2),
+      usage: ZERO_USAGE,
+    } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
+
+    const result = await makePlanner().planChapter({
+      book: makeBook(),
+      bookDir,
+      chapterNumber: 2,
+    });
+
+    expect(result.intent.mustAvoid).toContain("禁止主角降智");
+    expect(result.intent.mustAvoid).toContain("禁止神化反派");
+  });
 });
 });

+ 60 - 9
packages/core/src/utils/chapter-memo-parser.ts

@@ -11,19 +11,53 @@ export class PlannerParseError extends Error {
 // Phase hotfix 4: each required section is a (zh, en) heading pair.
 // Phase hotfix 4: each required section is a (zh, en) heading pair.
 // The English headings come from PLANNER_MEMO_SYSTEM_PROMPT_EN — we accept
 // The English headings come from PLANNER_MEMO_SYSTEM_PROMPT_EN — we accept
 // EITHER language at parse time so the same parser works for both.
 // EITHER language at parse time so the same parser works for both.
-const REQUIRED_SECTIONS: ReadonlyArray<{
+//
+// Phase hotfix 7: minContentChars enforces non-emptiness per section so
+// "all 7 headings + blank payload" no longer slips through. The "do not"
+// section uses a relaxed threshold because "无 / N/A / none." is legitimate
+// for chapters with no extra prohibitions.
+//
+// Threshold rationale:
+// - 20 chars: long enough to catch obvious empty sections (whitespace,
+//   "(略)", "TODO") but short enough to accept genuinely sparse memos for
+//   breath/transition chapters (Phase 6 sparse-memo principle).
+// - 1 char for "## 不要做" / "## Do not" because "无" / "N/A" / "none" /
+//   "—" are all legitimate for a chapter with no extra prohibitions; we
+//   only need to ensure the section is not whitespace-only.
+interface RequiredSection {
   readonly zh: string;
   readonly zh: string;
   readonly en: string;
   readonly en: string;
-}> = [
-  { zh: "## 当前任务", en: "## Current task" },
-  { zh: "## 读者此刻在等什么", en: "## What the reader is waiting for right now" },
-  { zh: "## 该兑现的 / 暂不掀的", en: "## To pay off / to keep buried" },
-  { zh: "## 日常/过渡承担什么任务", en: "## What the slow / transitional beats carry" },
-  { zh: "## 关键抉择过三连问", en: "## Three-question check on the key choice" },
-  { zh: "## 章尾必须发生的改变", en: "## Required end-of-chapter change" },
-  { zh: "## 不要做", en: "## Do not" },
+  readonly minContentChars: number;
+}
+
+const REQUIRED_SECTIONS: ReadonlyArray<RequiredSection> = [
+  { zh: "## 当前任务", en: "## Current task", minContentChars: 20 },
+  { zh: "## 读者此刻在等什么", en: "## What the reader is waiting for right now", minContentChars: 20 },
+  { zh: "## 该兑现的 / 暂不掀的", en: "## To pay off / to keep buried", minContentChars: 20 },
+  { zh: "## 日常/过渡承担什么任务", en: "## What the slow / transitional beats carry", minContentChars: 20 },
+  { zh: "## 关键抉择过三连问", en: "## Three-question check on the key choice", minContentChars: 20 },
+  { zh: "## 章尾必须发生的改变", en: "## Required end-of-chapter change", minContentChars: 20 },
+  { zh: "## 不要做", en: "## Do not", minContentChars: 1 },
 ];
 ];
 
 
+/**
+ * Extract the content between `heading` and the next `## ...` heading (or
+ * end-of-body). Strips whitespace and returns "" if the section payload is
+ * absent. The heading itself is NOT included.
+ */
+function extractSectionContent(body: string, heading: string): string {
+  const startIndex = body.indexOf(heading);
+  if (startIndex < 0) return "";
+  const after = body.slice(startIndex + heading.length);
+  // Find the next H2 heading on its own line. The leading newline + ## guards
+  // against false matches inside the current section's prose.
+  const nextHeadingMatch = after.match(/\n##\s/);
+  const sectionRaw = nextHeadingMatch
+    ? after.slice(0, nextHeadingMatch.index)
+    : after;
+  return sectionRaw.replace(/\s+/g, " ").trim();
+}
+
 /**
 /**
  * Parse a planner memo produced by the LLM.
  * Parse a planner memo produced by the LLM.
  *
  *
@@ -91,6 +125,23 @@ export function parseMemo(
     );
     );
   }
   }
 
 
+  // Phase hotfix 7: each section's payload must be non-empty (≥ minContentChars).
+  // Headings present + blank payload was previously accepted, allowing useless
+  // "shell" memos to flow downstream. Threshold differs per section: most need
+  // 20 chars (one short sentence) while "## 不要做" / "## Do not" allows 5
+  // (e.g. "无", "N/A") since "no extra prohibitions" is a legitimate state.
+  const empty = REQUIRED_SECTIONS.filter((section) => {
+    const heading = body.includes(section.zh) ? section.zh : section.en;
+    const content = extractSectionContent(body, heading);
+    return content.length < section.minContentChars;
+  });
+  if (empty.length > 0) {
+    const detail = empty
+      .map((s) => `${s.zh} (need ≥ ${s.minContentChars} chars)`)
+      .join(", ");
+    throw new PlannerParseError(`empty sections: ${detail}`);
+  }
+
   const threadRefs = Array.isArray(f.threadRefs)
   const threadRefs = Array.isArray(f.threadRefs)
     ? f.threadRefs.filter((value): value is string => typeof value === "string")
     ? f.threadRefs.filter((value): value is string => typeof value === "string")
     : [];
     : [];