planner.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
  3. import { tmpdir } from "node:os";
  4. import { join } from "node:path";
  5. import { PlannerAgent } from "../agents/planner.js";
  6. import * as llmProvider from "../llm/provider.js";
  7. import type { LLMClient } from "../llm/provider.js";
  8. import type { BookConfig } from "../models/book.js";
  9. import { PlannerParseError } from "../utils/chapter-memo-parser.js";
  10. const VALID_BODY = `
  11. ## 当前任务
  12. 主角进入七号门现场,比对锁芯刮痕与监控时间线,把"被动过手脚"从猜测钉成实证。
  13. ## 读者此刻在等什么
  14. 1) 读者在等七号门是否有异常实锤
  15. 2) 本章完全兑现,钉成现场实证
  16. ## 该兑现的 / 暂不掀的
  17. - 该兑现:七号门异常 → 钉成现场实证
  18. - 暂不掀:幕后主使 → 压到第 20 章
  19. ## 日常/过渡承担什么任务
  20. 不适用 - 本章为高压实证章,无日常过渡段。
  21. ## 关键抉择过三连问
  22. - 主角本章最关键的一次选择:
  23. - 为什么这么做?线索只剩这一条
  24. - 符合当前利益吗?符合
  25. - 符合他的人设吗?符合
  26. - 对手/配角本章最关键的一次选择:
  27. - 为什么这么做?掩盖踪迹
  28. - 符合当前利益吗?符合
  29. - 符合他的人设吗?符合
  30. ## 章尾必须发生的改变
  31. - 信息改变:主角掌握实证,可以面对幕后主使前先压住对手的退路
  32. ## 本章 hook 账
  33. advance:
  34. - H03 "七号门异常" → 从 pressured → near_payoff(本章钉成实证)
  35. resolve:
  36. - S004 "锁芯刮痕" → 核验完毕,本章结清
  37. defer:
  38. - H07 "幕后主使" → 第 20 章再动
  39. ## 不要做
  40. - 不要让对手突然降智
  41. - 不要直接点破幕后主使
  42. `.trim();
  43. function validMemoRaw(chapter: number): string {
  44. return `---\nchapter: ${chapter}\ngoal: 把七号门被动过手脚钉成现场实证\nisGoldenOpening: false\nthreadRefs:\n - H03\n - S004\n---\n${VALID_BODY}\n`;
  45. }
  46. const ZERO_USAGE = {
  47. promptTokens: 0,
  48. completionTokens: 0,
  49. totalTokens: 0,
  50. } as const;
  51. const STUB_CLIENT: LLMClient = {
  52. provider: "openai",
  53. apiFormat: "chat",
  54. stream: false,
  55. defaults: { temperature: 0.7, maxTokens: 2048, thinkingBudget: 0, maxTokensCap: null, extra: {} },
  56. };
  57. function makeBook(): BookConfig {
  58. return {
  59. id: "book-plan-1",
  60. title: "Test Book",
  61. genre: "urban",
  62. platform: "qidian",
  63. status: "active",
  64. language: "zh",
  65. targetChapters: 120,
  66. chapterWordCount: 3000,
  67. createdAt: "2026-04-01T00:00:00.000Z",
  68. updatedAt: "2026-04-01T00:00:00.000Z",
  69. };
  70. }
  71. async function seedStoryFiles(bookDir: string): Promise<void> {
  72. const storyDir = join(bookDir, "story");
  73. await mkdir(storyDir, { recursive: true });
  74. await Promise.all([
  75. writeFile(join(storyDir, "author_intent.md"), "# Intent\n- Tell a taut mystery.", "utf-8"),
  76. writeFile(join(storyDir, "current_focus.md"), "# Focus\n- Keep pressure on the seventh gate.", "utf-8"),
  77. writeFile(join(storyDir, "story_bible.md"), "# Bible\n- Protagonist: 阿泽", "utf-8"),
  78. writeFile(join(storyDir, "volume_outline.md"), "# Outline\n- 第 1 章:开场", "utf-8"),
  79. writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n", "utf-8"),
  80. writeFile(join(storyDir, "book_rules.md"), "# Rules\n- 禁止反派降智", "utf-8"),
  81. writeFile(join(storyDir, "current_state.md"), "# State\n- 主角在七号门附近", "utf-8"),
  82. writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n", "utf-8"),
  83. writeFile(join(storyDir, "subplot_board.md"), "# Subplot\n", "utf-8"),
  84. writeFile(join(storyDir, "emotional_arcs.md"), "# Arcs\n", "utf-8"),
  85. writeFile(join(storyDir, "character_matrix.md"), "# Matrix\n", "utf-8"),
  86. ]);
  87. }
  88. describe("PlannerAgent.planChapter memo generation", () => {
  89. let root: string;
  90. let bookDir: string;
  91. beforeEach(async () => {
  92. root = await mkdtemp(join(tmpdir(), "planner-memo-"));
  93. bookDir = join(root, "book");
  94. await seedStoryFiles(bookDir);
  95. });
  96. afterEach(async () => {
  97. vi.restoreAllMocks();
  98. await rm(root, { recursive: true, force: true });
  99. });
  100. function makePlanner(): PlannerAgent {
  101. return new PlannerAgent({
  102. client: STUB_CLIENT,
  103. model: "test-model",
  104. projectRoot: root,
  105. bookId: "book-plan-1",
  106. });
  107. }
  108. it("produces a valid ChapterMemo when the LLM returns well-formed output", async () => {
  109. const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
  110. content: validMemoRaw(1),
  111. usage: ZERO_USAGE,
  112. } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
  113. const result = await makePlanner().planChapter({
  114. book: makeBook(),
  115. bookDir,
  116. chapterNumber: 1,
  117. });
  118. expect(chatSpy).toHaveBeenCalledTimes(1);
  119. expect(result.memo.chapter).toBe(1);
  120. expect(result.memo.isGoldenOpening).toBe(true); // ch1 zh → golden opening, authoritative over LLM
  121. expect(result.memo.goal).toBe("把七号门被动过手脚钉成现场实证");
  122. expect(result.memo.threadRefs).toEqual(["H03", "S004"]);
  123. expect(result.memo.body).toContain("## 当前任务");
  124. });
  125. it("retries when the first response is malformed and succeeds on retry", async () => {
  126. const chatSpy = vi.spyOn(llmProvider, "chatCompletion")
  127. .mockResolvedValueOnce({
  128. content: "no frontmatter here",
  129. usage: ZERO_USAGE,
  130. } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>)
  131. .mockResolvedValueOnce({
  132. content: "still no frontmatter",
  133. usage: ZERO_USAGE,
  134. } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>)
  135. .mockResolvedValueOnce({
  136. content: validMemoRaw(4),
  137. usage: ZERO_USAGE,
  138. } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
  139. const result = await makePlanner().planChapter({
  140. book: makeBook(),
  141. bookDir,
  142. chapterNumber: 4,
  143. });
  144. expect(chatSpy).toHaveBeenCalledTimes(3);
  145. expect(result.memo.chapter).toBe(4);
  146. expect(result.memo.isGoldenOpening).toBe(false);
  147. // Retry prompts must include the failure feedback
  148. const secondCallArgs = chatSpy.mock.calls[1]!;
  149. const secondMessages = secondCallArgs[2] as ReadonlyArray<{ role: string; content: string }>;
  150. const userMsg = secondMessages.find((m) => m.role === "user");
  151. expect(userMsg?.content).toContain("上次输出的错误");
  152. });
  153. // Phase hotfix 4: English books must receive English system + user prompts
  154. // and English golden-opening guidance for chapters ≤ 3.
  155. it("uses English prompts end-to-end when book.language is en", async () => {
  156. const VALID_EN_BODY = `
  157. ## Current task
  158. Pin the Door 7 tampering from suspicion to live evidence.
  159. ## What the reader is waiting for right now
  160. 1) Reader expects to learn whether Door 7 is really compromised.
  161. 2) This chapter pays it off in full — live evidence on stage.
  162. ## To pay off / to keep buried
  163. - Pay off: Door 7 anomaly → live evidence
  164. - Keep buried: the mastermind → push to chapter 20
  165. ## What the slow / transitional beats carry
  166. n/a — pressure chapter, no transitional beats.
  167. ## Three-question check on the key choice
  168. - Protagonist's most important choice this chapter:
  169. - Why this choice? It is the only remaining lead.
  170. - Does it match current interest? Yes.
  171. - Does it match their persona? Yes.
  172. - Antagonist / supporting cast's most important choice this chapter:
  173. - Why this choice? To cover their tracks.
  174. - Does it match current interest? Yes.
  175. - Does it match their persona? Yes.
  176. ## Required end-of-chapter change
  177. - Information change: protagonist holds live evidence.
  178. ## Hook ledger for this chapter
  179. advance:
  180. - H03 "Door 7 anomaly" → pressured → near_payoff (pinned as live evidence this chapter)
  181. defer:
  182. - H07 "the mastermind" → hold until chapter 20
  183. ## Do not
  184. - Do not let the antagonist suddenly turn dumb.
  185. - Do not directly name the mastermind.
  186. `.trim();
  187. const validEnRaw = `---\nchapter: 1\ngoal: Pin Door 7 tampering as live evidence\nisGoldenOpening: false\nthreadRefs:\n - H03\n---\n${VALID_EN_BODY}\n`;
  188. const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
  189. content: validEnRaw,
  190. usage: ZERO_USAGE,
  191. } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
  192. const enBook = { ...makeBook(), language: "en" as const };
  193. const result = await makePlanner().planChapter({
  194. book: enBook,
  195. bookDir,
  196. chapterNumber: 1,
  197. });
  198. expect(chatSpy).toHaveBeenCalledTimes(1);
  199. expect(result.memo.chapter).toBe(1);
  200. expect(result.memo.isGoldenOpening).toBe(true); // ch1 en → also golden (≤5)
  201. // System prompt must be the English variant
  202. const callArgs = chatSpy.mock.calls[0]!;
  203. const messages = callArgs[2] as ReadonlyArray<{ role: string; content: string }>;
  204. const systemMsg = messages.find((m) => m.role === "system");
  205. const userMsg = messages.find((m) => m.role === "user");
  206. // English system prompt markers
  207. expect(systemMsg?.content).toContain("editor-in-chief");
  208. expect(systemMsg?.content).toContain("Output format (strict)");
  209. expect(systemMsg?.content).not.toContain("你是这本小说的创作总编");
  210. // English user template markers
  211. expect(userMsg?.content).toContain("# Chapter 1 memo request");
  212. expect(userMsg?.content).toContain("Last screen of previous chapter");
  213. expect(userMsg?.content).toContain("Golden opening chapter: yes");
  214. expect(userMsg?.content).not.toContain("# 第 1 章 memo 请求");
  215. // English golden-opening guidance appended for ch ≤ 3
  216. expect(userMsg?.content).toContain("Golden Opening Guidance");
  217. expect(userMsg?.content).toContain("Chapter 1");
  218. expect(userMsg?.content).not.toContain("黄金三章规划指引");
  219. });
  220. it("throws PlannerParseError when all 3 attempts fail", async () => {
  221. vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
  222. content: "permanently broken",
  223. usage: ZERO_USAGE,
  224. } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
  225. await expect(
  226. makePlanner().planChapter({
  227. book: makeBook(),
  228. bookDir,
  229. chapterNumber: 2,
  230. }),
  231. ).rejects.toBeInstanceOf(PlannerParseError);
  232. });
  233. // Phase hotfix 5: planner.intent.mustAvoid must come from the Phase 5
  234. // authoritative loader (story_frame frontmatter), not from raw
  235. // book_rules.md — for new-layout books the legacy file is just a shim.
  236. it("derives intent.mustAvoid from outline/story_frame.md frontmatter (new layout)", async () => {
  237. // Replace book_rules.md with a Phase 5 compat shim (no YAML, just pointer)
  238. // and put the authoritative YAML on outline/story_frame.md.
  239. const storyDir = join(bookDir, "story");
  240. await mkdir(join(storyDir, "outline"), { recursive: true });
  241. await writeFile(
  242. join(storyDir, "outline/story_frame.md"),
  243. [
  244. "---",
  245. "version: \"1.0\"",
  246. "protagonist:",
  247. " name: 阿泽",
  248. " personalityLock: []",
  249. " behavioralConstraints: []",
  250. "prohibitions:",
  251. " - 禁止主角降智",
  252. " - 禁止神化反派",
  253. "---",
  254. "",
  255. "## 主题与基调",
  256. "调查与压制。",
  257. ].join("\n"),
  258. "utf-8",
  259. );
  260. await writeFile(
  261. join(storyDir, "book_rules.md"),
  262. "# 本书规则(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。",
  263. "utf-8",
  264. );
  265. vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
  266. content: validMemoRaw(2),
  267. usage: ZERO_USAGE,
  268. } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
  269. const result = await makePlanner().planChapter({
  270. book: makeBook(),
  271. bookDir,
  272. chapterNumber: 2,
  273. });
  274. expect(result.intent.mustAvoid).toContain("禁止主角降智");
  275. expect(result.intent.mustAvoid).toContain("禁止神化反派");
  276. });
  277. });