Bladeren bron

test(v13): cover chapter-memo parser, planner prompts, and planner retries [phase 3/8 part 3]

Add unit + integration tests for the Phase 3 planner:

- chapter-memo-parser.test.ts (12 cases)
  - happy path: valid frontmatter + 7 sections
  - missing frontmatter delimiters throws
  - goal > 50 chars throws
  - chapter mismatch throws
  - each of the 7 required sections missing → throws
  - non-array threadRefs silently becomes []
  - caller-authoritative isGoldenOpening ignores LLM frontmatter
  - non-object YAML throws
  - non-integer chapter throws
  - malformed YAML throws

- planner-prompts.test.ts (5 cases)
  - system prompt contains key methodology phrases (1 主线 + 1 支线,
    三连问, YAML frontmatter, goal 50 字, ## 当前任务, ## 不要做)
  - system prompt not empty (> 500 chars)
  - user template contains every placeholder
  - buildPlannerUserMessage fills all placeholders correctly
  - isGoldenOpening renders as 是 / 否 per the template

- planner.test.ts (3 cases)
  - happy path: mock chatCompletion returns valid memo → planChapter
    produces a well-formed ChapterMemo and respects caller-authoritative
    isGoldenOpening (ch1 zh = true even though mock says false)
  - retry path: two malformed responses then a valid one → 3 total
    chat calls; retry prompts include "上次输出的错误" feedback block
  - exhausted retries: all 3 attempts malformed → PlannerParseError
    surfaces to the caller, no silent fallback
Ma 6 maanden geleden
bovenliggende
commit
8db58fa578

+ 140 - 0
packages/core/src/__tests__/chapter-memo-parser.test.ts

@@ -0,0 +1,140 @@
+import { describe, it, expect } from "vitest";
+import { parseMemo, PlannerParseError } from "../utils/chapter-memo-parser.js";
+
+const SECTIONS = `
+## 当前任务
+主角进入七号门查看被动过手脚的痕迹。
+
+## 读者此刻在等什么
+1) 读者在等七号门异常是否实锤
+2) 本章完全兑现——钉成现场实证
+
+## 该兑现的 / 暂不掀的
+- 该兑现:七号门异常 → 钉成现场实证
+- 暂不掀:幕后主使 → 压到第 20 章
+
+## 日常/过渡承担什么任务
+不适用 - 本章无日常过渡
+
+## 关键抉择过三连问
+- 主角本章最关键的一次选择:
+  - 为什么这么做?因为线索只剩这一条
+  - 符合当前利益吗?符合
+  - 符合他的人设吗?符合
+- 对手/配角本章最关键的一次选择:
+  - 为什么这么做?为了掩盖踪迹
+  - 符合当前利益吗?符合
+  - 符合他的人设吗?符合
+
+## 章尾必须发生的改变
+- 信息改变:主角掌握实证
+- 关系改变:主角对阿泽产生戒心
+
+## 不要做
+- 不要让对手突然降智
+- 不要直接点破幕后主使
+`.trim();
+
+function makeRaw(
+  opts: {
+    chapter?: number | string;
+    goal?: string;
+    isGoldenOpening?: boolean | string;
+    threadRefs?: ReadonlyArray<string> | null | unknown;
+    body?: string;
+    dropFrontmatter?: boolean;
+  } = {},
+): string {
+  if (opts.dropFrontmatter) {
+    return opts.body ?? SECTIONS;
+  }
+
+  const threadRefsText = Array.isArray(opts.threadRefs)
+    ? `threadRefs:\n${opts.threadRefs.map((id) => `  - ${id}`).join("\n")}`
+    : opts.threadRefs === null
+      ? "threadRefs: null"
+      : opts.threadRefs === undefined
+        ? "threadRefs: []"
+        : `threadRefs: ${String(opts.threadRefs)}`;
+
+  const frontmatter = [
+    `chapter: ${opts.chapter ?? 12}`,
+    `goal: ${opts.goal ?? "把七号门被动过手脚钉成现场实证"}`,
+    `isGoldenOpening: ${opts.isGoldenOpening ?? false}`,
+    threadRefsText,
+  ].join("\n");
+
+  return `---\n${frontmatter}\n---\n${opts.body ?? SECTIONS}\n`;
+}
+
+describe("parseMemo", () => {
+  it("parses a valid frontmatter + 7 sections", () => {
+    const memo = parseMemo(makeRaw({ threadRefs: ["H03", "S004"] }), 12, false);
+    expect(memo.chapter).toBe(12);
+    expect(memo.goal).toBe("把七号门被动过手脚钉成现场实证");
+    expect(memo.isGoldenOpening).toBe(false);
+    expect(memo.threadRefs).toEqual(["H03", "S004"]);
+    expect(memo.body).toContain("## 当前任务");
+    expect(memo.body).toContain("## 不要做");
+  });
+
+  it("throws when frontmatter delimiters are missing", () => {
+    expect(() => parseMemo(SECTIONS, 12, false)).toThrow(PlannerParseError);
+    expect(() => parseMemo(SECTIONS, 12, false)).toThrow(/frontmatter/);
+  });
+
+  it("throws when goal exceeds 50 chars", () => {
+    const longGoal = "把异常钉成实证".repeat(10);
+    expect(() => parseMemo(makeRaw({ goal: longGoal }), 12, false)).toThrow(/goal too long/);
+  });
+
+  it("throws when chapter mismatches expected", () => {
+    expect(() => parseMemo(makeRaw({ chapter: 99 }), 12, false)).toThrow(/chapter mismatch/);
+  });
+
+  it.each([
+    "## 当前任务",
+    "## 读者此刻在等什么",
+    "## 该兑现的 / 暂不掀的",
+    "## 日常/过渡承担什么任务",
+    "## 关键抉择过三连问",
+    "## 章尾必须发生的改变",
+    "## 不要做",
+  ])("throws when body is missing section %s", (heading) => {
+    const body = SECTIONS.replace(heading, "## SECTION-REMOVED");
+    expect(() => parseMemo(makeRaw({ body }), 12, false)).toThrow(/missing sections/);
+  });
+
+  it("silently coerces non-array threadRefs to empty array", () => {
+    const raw = makeRaw({ threadRefs: null });
+    const memo = parseMemo(raw, 12, false);
+    expect(memo.threadRefs).toEqual([]);
+  });
+
+  it("uses caller-provided isGoldenOpening, not LLM frontmatter value", () => {
+    // LLM claims true but caller says false — caller wins
+    const raw = makeRaw({ isGoldenOpening: true });
+    const memo = parseMemo(raw, 12, false);
+    expect(memo.isGoldenOpening).toBe(false);
+
+    // LLM claims false but caller says true — caller wins
+    const raw2 = makeRaw({ isGoldenOpening: false });
+    const memo2 = parseMemo(raw2, 12, true);
+    expect(memo2.isGoldenOpening).toBe(true);
+  });
+
+  it("throws when YAML frontmatter is not an object", () => {
+    const raw = `---\n- just\n- a\n- list\n---\n${SECTIONS}\n`;
+    expect(() => parseMemo(raw, 12, false)).toThrow(/frontmatter is not an object/);
+  });
+
+  it("throws when chapter is not an integer", () => {
+    const raw = `---\nchapter: 12.5\ngoal: x\nisGoldenOpening: false\nthreadRefs: []\n---\n${SECTIONS}\n`;
+    expect(() => parseMemo(raw, 12, false)).toThrow(/chapter must be an integer/);
+  });
+
+  it("throws on invalid YAML", () => {
+    const raw = `---\nchapter: 12\n  bad indent: : :\n---\n${SECTIONS}\n`;
+    expect(() => parseMemo(raw, 12, false)).toThrow(/invalid YAML/);
+  });
+});

+ 86 - 0
packages/core/src/__tests__/planner-prompts.test.ts

@@ -0,0 +1,86 @@
+import { describe, it, expect } from "vitest";
+import {
+  PLANNER_MEMO_SYSTEM_PROMPT,
+  PLANNER_MEMO_USER_TEMPLATE,
+  buildPlannerUserMessage,
+} from "../agents/planner-prompts.js";
+
+describe("PLANNER_MEMO_SYSTEM_PROMPT", () => {
+  it("contains key methodology phrases from new.txt", () => {
+    expect(PLANNER_MEMO_SYSTEM_PROMPT).toContain("1 主线 + 1 支线");
+    expect(PLANNER_MEMO_SYSTEM_PROMPT).toContain("三连问");
+    expect(PLANNER_MEMO_SYSTEM_PROMPT).toContain("YAML frontmatter");
+    expect(PLANNER_MEMO_SYSTEM_PROMPT).toContain("goal 字段不超过 50 字");
+    expect(PLANNER_MEMO_SYSTEM_PROMPT).toContain("## 当前任务");
+    expect(PLANNER_MEMO_SYSTEM_PROMPT).toContain("## 不要做");
+  });
+
+  it("is not accidentally empty", () => {
+    expect(PLANNER_MEMO_SYSTEM_PROMPT.length).toBeGreaterThan(500);
+  });
+});
+
+describe("PLANNER_MEMO_USER_TEMPLATE", () => {
+  it("contains all placeholders", () => {
+    const placeholders = [
+      "{{chapterNumber}}",
+      "{{previous_chapter_ending_excerpt}}",
+      "{{recent_summaries}}",
+      "{{current_arc_prose}}",
+      "{{protagonist_matrix_row}}",
+      "{{opponent_rows}}",
+      "{{collaborator_rows}}",
+      "{{relevant_threads}}",
+      "{{isGoldenOpening}}",
+      "{{book_rules_relevant}}",
+    ];
+    for (const ph of placeholders) {
+      expect(PLANNER_MEMO_USER_TEMPLATE).toContain(ph);
+    }
+  });
+});
+
+describe("buildPlannerUserMessage", () => {
+  it("fills placeholders in order", () => {
+    const out = buildPlannerUserMessage({
+      chapterNumber: 12,
+      previousChapterEndingExcerpt: "上一屏结尾原文",
+      recentSummaries: "| ch9 | ... |",
+      currentArcProse: "主线推进七号门",
+      protagonistMatrixRow: "| 阿泽 | 主角 | ... |",
+      opponentRows: "| 老李 | 对手 | ... |",
+      collaboratorRows: "| 小白 | 盟友 | ... |",
+      relevantThreads: "- H03: 未解码信\n- S004: 七号门异常",
+      isGoldenOpening: false,
+      bookRulesRelevant: "- 禁止主角降智",
+    });
+
+    expect(out).toContain("# 第 12 章 memo 请求");
+    expect(out).toContain("上一屏结尾原文");
+    expect(out).toContain("| ch9 | ... |");
+    expect(out).toContain("主线推进七号门");
+    expect(out).toContain("| 阿泽 | 主角 | ... |");
+    expect(out).toContain("| 老李 | 对手 | ... |");
+    expect(out).toContain("| 小白 | 盟友 | ... |");
+    expect(out).toContain("- H03: 未解码信");
+    expect(out).toContain("是否黄金三章:否");
+    expect(out).toContain("- 禁止主角降智");
+    expect(out).not.toContain("{{");
+  });
+
+  it("translates isGoldenOpening true to 是", () => {
+    const out = buildPlannerUserMessage({
+      chapterNumber: 1,
+      previousChapterEndingExcerpt: "",
+      recentSummaries: "",
+      currentArcProse: "",
+      protagonistMatrixRow: "",
+      opponentRows: "",
+      collaboratorRows: "",
+      relevantThreads: "",
+      isGoldenOpening: true,
+      bookRulesRelevant: "",
+    });
+    expect(out).toContain("是否黄金三章:是");
+  });
+});

+ 185 - 0
packages/core/src/__tests__/planner.test.ts

@@ -0,0 +1,185 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { PlannerAgent } from "../agents/planner.js";
+import * as llmProvider from "../llm/provider.js";
+import type { LLMClient } from "../llm/provider.js";
+import type { BookConfig } from "../models/book.js";
+import { PlannerParseError } from "../utils/chapter-memo-parser.js";
+
+const VALID_BODY = `
+## 当前任务
+主角进入七号门查看被动过手脚的痕迹。
+
+## 读者此刻在等什么
+1) 读者在等七号门是否有异常
+2) 本章完全兑现
+
+## 该兑现的 / 暂不掀的
+- 该兑现:七号门异常 → 钉成现场实证
+- 暂不掀:幕后主使 → 压到第 20 章
+
+## 日常/过渡承担什么任务
+不适用 - 本章无日常过渡
+
+## 关键抉择过三连问
+- 主角本章最关键的一次选择:
+  - 为什么这么做?线索只剩这一条
+  - 符合当前利益吗?符合
+  - 符合他的人设吗?符合
+- 对手/配角本章最关键的一次选择:
+  - 为什么这么做?掩盖踪迹
+  - 符合当前利益吗?符合
+  - 符合他的人设吗?符合
+
+## 章尾必须发生的改变
+- 信息改变:主角掌握实证
+
+## 不要做
+- 不要让对手突然降智
+- 不要直接点破幕后主使
+`.trim();
+
+function validMemoRaw(chapter: number): string {
+  return `---\nchapter: ${chapter}\ngoal: 把七号门被动过手脚钉成现场实证\nisGoldenOpening: false\nthreadRefs:\n  - H03\n  - S004\n---\n${VALID_BODY}\n`;
+}
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+const STUB_CLIENT: LLMClient = {
+  provider: "openai",
+  apiFormat: "chat",
+  stream: false,
+  _openai: {} as never,
+  defaults: { temperature: 0.7, maxTokens: 2048, thinkingBudget: 0, maxTokensCap: null, extra: {} },
+};
+
+function makeBook(): BookConfig {
+  return {
+    id: "book-plan-1",
+    title: "Test Book",
+    genre: "urban",
+    platform: "qidian",
+    status: "active",
+    language: "zh",
+    targetChapters: 120,
+    chapterWordCount: 3000,
+    createdAt: "2026-04-01T00:00:00.000Z",
+    updatedAt: "2026-04-01T00:00:00.000Z",
+  };
+}
+
+async function seedStoryFiles(bookDir: string): Promise<void> {
+  const storyDir = join(bookDir, "story");
+  await mkdir(storyDir, { recursive: true });
+  await Promise.all([
+    writeFile(join(storyDir, "author_intent.md"), "# Intent\n- Tell a taut mystery.", "utf-8"),
+    writeFile(join(storyDir, "current_focus.md"), "# Focus\n- Keep pressure on the seventh gate.", "utf-8"),
+    writeFile(join(storyDir, "story_bible.md"), "# Bible\n- Protagonist: 阿泽", "utf-8"),
+    writeFile(join(storyDir, "volume_outline.md"), "# Outline\n- 第 1 章:开场", "utf-8"),
+    writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n", "utf-8"),
+    writeFile(join(storyDir, "book_rules.md"), "# Rules\n- 禁止反派降智", "utf-8"),
+    writeFile(join(storyDir, "current_state.md"), "# State\n- 主角在七号门附近", "utf-8"),
+    writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n", "utf-8"),
+    writeFile(join(storyDir, "subplot_board.md"), "# Subplot\n", "utf-8"),
+    writeFile(join(storyDir, "emotional_arcs.md"), "# Arcs\n", "utf-8"),
+    writeFile(join(storyDir, "character_matrix.md"), "# Matrix\n", "utf-8"),
+  ]);
+}
+
+describe("PlannerAgent.planChapter memo generation", () => {
+  let root: string;
+  let bookDir: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "planner-memo-"));
+    bookDir = join(root, "book");
+    await seedStoryFiles(bookDir);
+  });
+
+  afterEach(async () => {
+    vi.restoreAllMocks();
+    await rm(root, { recursive: true, force: true });
+  });
+
+  function makePlanner(): PlannerAgent {
+    return new PlannerAgent({
+      client: STUB_CLIENT,
+      model: "test-model",
+      projectRoot: root,
+      bookId: "book-plan-1",
+    });
+  }
+
+  it("produces a valid ChapterMemo when the LLM returns well-formed output", async () => {
+    const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
+      content: validMemoRaw(1),
+      usage: ZERO_USAGE,
+    } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
+
+    const result = await makePlanner().planChapter({
+      book: makeBook(),
+      bookDir,
+      chapterNumber: 1,
+    });
+
+    expect(chatSpy).toHaveBeenCalledTimes(1);
+    expect(result.memo.chapter).toBe(1);
+    expect(result.memo.isGoldenOpening).toBe(true); // ch1 zh → golden opening, authoritative over LLM
+    expect(result.memo.goal).toBe("把七号门被动过手脚钉成现场实证");
+    expect(result.memo.threadRefs).toEqual(["H03", "S004"]);
+    expect(result.memo.body).toContain("## 当前任务");
+  });
+
+  it("retries when the first response is malformed and succeeds on retry", async () => {
+    const chatSpy = vi.spyOn(llmProvider, "chatCompletion")
+      .mockResolvedValueOnce({
+        content: "no frontmatter here",
+        usage: ZERO_USAGE,
+      } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>)
+      .mockResolvedValueOnce({
+        content: "still no frontmatter",
+        usage: ZERO_USAGE,
+      } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>)
+      .mockResolvedValueOnce({
+        content: validMemoRaw(4),
+        usage: ZERO_USAGE,
+      } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
+
+    const result = await makePlanner().planChapter({
+      book: makeBook(),
+      bookDir,
+      chapterNumber: 4,
+    });
+
+    expect(chatSpy).toHaveBeenCalledTimes(3);
+    expect(result.memo.chapter).toBe(4);
+    expect(result.memo.isGoldenOpening).toBe(false);
+
+    // Retry prompts must include the failure feedback
+    const secondCallArgs = chatSpy.mock.calls[1]!;
+    const secondMessages = secondCallArgs[2] as ReadonlyArray<{ role: string; content: string }>;
+    const userMsg = secondMessages.find((m) => m.role === "user");
+    expect(userMsg?.content).toContain("上次输出的错误");
+  });
+
+  it("throws PlannerParseError when all 3 attempts fail", async () => {
+    vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
+      content: "permanently broken",
+      usage: ZERO_USAGE,
+    } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
+
+    await expect(
+      makePlanner().planChapter({
+        book: makeBook(),
+        bookDir,
+        chapterNumber: 2,
+      }),
+    ).rejects.toBeInstanceOf(PlannerParseError);
+  });
+});