architect-phase5.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
  3. import { tmpdir } from "node:os";
  4. import { join } from "node:path";
  5. import { ArchitectAgent } from "../agents/architect.js";
  6. import type { BookConfig } from "../models/book.js";
  7. const ZERO_USAGE = {
  8. promptTokens: 0,
  9. completionTokens: 0,
  10. totalTokens: 0,
  11. } as const;
  12. function buildAgent(): ArchitectAgent {
  13. return new ArchitectAgent({
  14. client: {
  15. provider: "openai",
  16. apiFormat: "chat",
  17. stream: false,
  18. defaults: {
  19. temperature: 0.7,
  20. maxTokens: 4096,
  21. thinkingBudget: 0,
  22. maxTokensCap: null,
  23. extra: {},
  24. },
  25. },
  26. model: "test-model",
  27. projectRoot: process.cwd(),
  28. });
  29. }
  30. function baseBook(): BookConfig {
  31. return {
  32. id: "phase5-book",
  33. title: "Phase5测试书",
  34. platform: "other",
  35. genre: "urban",
  36. status: "active",
  37. targetChapters: 60,
  38. chapterWordCount: 2200,
  39. language: "zh",
  40. createdAt: "2026-04-15T00:00:00.000Z",
  41. updatedAt: "2026-04-15T00:00:00.000Z",
  42. };
  43. }
  44. const SAMPLE_RESPONSE = [
  45. "=== SECTION: story_frame ===",
  46. "## 主题与基调",
  47. "这本书讲的是一个被时代按在泥里的人如何选择不被改写。",
  48. "## 主角弧线",
  49. "主角从沉默的旁观者走向沉默的见证人——代价是离开故乡。",
  50. "## 核心冲突与对手",
  51. "主角相信记录真相有价值,体制相信记录真相会扰乱秩序。",
  52. "## 世界观底色",
  53. "这是一个湿冷的沿海城市,铁律:凡被记录的名字都会消失。",
  54. "## 终局方向",
  55. "最后一个镜头是主角在码头上把笔记本烧了又重新写。",
  56. "",
  57. "=== SECTION: volume_map ===",
  58. "## 各卷主题与情绪曲线",
  59. "共三卷,第一卷压,第二卷放,第三卷压回。",
  60. "## 关键节点章",
  61. "第 17 章让他回家——母亲病重。第 32 章揭开秘密。第 55 章最终对峙。",
  62. "## 卷间钩子与回收",
  63. "第一卷埋下笔记本钩子,第 32 章回收。",
  64. "## 角色阶段性目标",
  65. "第一卷末:主角决定留下。",
  66. "## 卷尾必须发生的改变",
  67. "第一卷末:身份暴露。",
  68. "## 节奏意图",
  69. "前 10 章高压引人。",
  70. "",
  71. "=== SECTION: rhythm_principles ===",
  72. "## 原则 1:高潮间距",
  73. "每 8-10 章一个大高潮。",
  74. "## 原则 2:喘息频率",
  75. "每 3 章高压后必须 1 章喘息。",
  76. "## 原则 3:钩子密度",
  77. "每章章末 1 个主钩,最多悬 5 章。",
  78. "## 原则 4:信息释放节奏",
  79. "前 1/3 释放 30%,中段 40%,后段 30%。",
  80. "## 原则 5:爽点节奏",
  81. "每 5 章一个智商碾压式爽点。",
  82. "## 原则 6:情感节点递进",
  83. "每 6 章情感关系必须实质推进一次。",
  84. "",
  85. "=== SECTION: roles ===",
  86. "---ROLE---",
  87. "tier: major",
  88. "name: 林辞",
  89. "---CONTENT---",
  90. "## 核心标签",
  91. "沉默、执拗、过度理性",
  92. "## 反差细节",
  93. "会在加油站给流浪狗留罐头",
  94. "## 人物小传",
  95. "十五岁时失去父亲,从此习惯把情绪写下来。",
  96. "## 当前现状",
  97. "在码头边上的旧书店做账房。",
  98. "## 关系网络",
  99. "与沈默是旧日同窗。",
  100. "## 内在驱动",
  101. "想知道父亲死前那一夜发生了什么。",
  102. "## 成长弧光",
  103. "从独自追查到学会把真相交给别人。",
  104. "---ROLE---",
  105. "tier: major",
  106. "name: 沈默",
  107. "---CONTENT---",
  108. "## 核心标签",
  109. "精致、疏离、惯于算计",
  110. "## 反差细节",
  111. "唯独对林辞从不说谎",
  112. "## 人物小传",
  113. "出身体制内家庭。",
  114. "## 当前现状",
  115. "新任区域办公室副职。",
  116. "## 关系网络",
  117. "与林辞关系复杂。",
  118. "## 内在驱动",
  119. "想在规则内做到最好。",
  120. "## 成长弧光",
  121. "被迫在规则与情义间选择。",
  122. "---ROLE---",
  123. "tier: minor",
  124. "name: 老张",
  125. "---CONTENT---",
  126. "## 核心标签",
  127. "油滑、念旧",
  128. "## 反差细节",
  129. "每年清明会独自去扫一个无名坟",
  130. "## 当前现状",
  131. "旧书店老板。",
  132. "## 与主角关系",
  133. "代父执辈。",
  134. "",
  135. "=== SECTION: book_rules ===",
  136. "---",
  137. "version: \"1.0\"",
  138. "protagonist:",
  139. " name: 林辞",
  140. " personalityLock: [沉默, 执拗, 理性]",
  141. " behavioralConstraints: [不对长辈失礼]",
  142. "genreLock:",
  143. " primary: urban",
  144. " forbidden: [玄幻色彩]",
  145. "prohibitions:",
  146. " - 不得美化体制暴力",
  147. "chapterTypesOverride: []",
  148. "fatigueWordsOverride: []",
  149. "additionalAuditDimensions: []",
  150. "enableFullCastTracking: false",
  151. "---",
  152. "## 叙事视角",
  153. "第三人称单一视角,贴着主角。",
  154. "## 核心冲突驱动",
  155. "参见 outline/story_frame.md 段 3。",
  156. "",
  157. "=== SECTION: current_state ===",
  158. "| 字段 | 值 |",
  159. "| --- | --- |",
  160. "| 当前章节 | 0 |",
  161. "| 当前位置 | 码头旧书店 |",
  162. "| 主角状态 | 刚回乡 |",
  163. "| 当前目标 | 查清父亲死因 |",
  164. "| 当前限制 | 没有线索 |",
  165. "| 当前敌我 | 尚未出现 |",
  166. "| 当前冲突 | 自我怀疑 |",
  167. "",
  168. "=== SECTION: pending_hooks ===",
  169. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
  170. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  171. "| H01 | 1 | 主线 | 未开启 | 0 | 32章 | 中程 | 父亲的笔记本 |",
  172. ].join("\n");
  173. describe("ArchitectAgent — Phase 5 prose output", () => {
  174. let bookDir: string;
  175. beforeEach(async () => {
  176. bookDir = await mkdtemp(join(tmpdir(), "inkos-phase5-arch-"));
  177. });
  178. afterEach(async () => {
  179. await rm(bookDir, { recursive: true, force: true });
  180. vi.restoreAllMocks();
  181. });
  182. it("parses storyFrame / volumeMap / rhythmPrinciples / roles from the response", async () => {
  183. const agent = buildAgent();
  184. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  185. .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
  186. const result = await agent.generateFoundation(baseBook());
  187. expect(result.storyFrame).toContain("主题与基调");
  188. expect(result.volumeMap).toContain("第 17 章让他回家");
  189. expect(result.rhythmPrinciples).toContain("高潮间距");
  190. expect(result.roles).toBeDefined();
  191. expect(result.roles).toHaveLength(3);
  192. const majors = (result.roles ?? []).filter((role) => role.tier === "major");
  193. const minors = (result.roles ?? []).filter((role) => role.tier === "minor");
  194. expect(majors.map((role) => role.name)).toEqual(["林辞", "沈默"]);
  195. expect(minors.map((role) => role.name)).toEqual(["老张"]);
  196. expect(majors[0]?.content).toContain("核心标签");
  197. expect(majors[0]?.content).toContain("反差细节");
  198. });
  199. it("writes outline/* prose files, roles/*, and compat shims for story_bible/character_matrix", async () => {
  200. const agent = buildAgent();
  201. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  202. .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
  203. const result = await agent.generateFoundation(baseBook());
  204. await agent.writeFoundationFiles(bookDir, result, false, "zh");
  205. const storyDir = join(bookDir, "story");
  206. const storyFrame = await readFile(join(storyDir, "outline/story_frame.md"), "utf-8");
  207. expect(storyFrame).toContain("主题与基调");
  208. const volumeMap = await readFile(join(storyDir, "outline/volume_map.md"), "utf-8");
  209. expect(volumeMap).toContain("第 17 章让他回家");
  210. const rhythm = await readFile(join(storyDir, "outline/节奏原则.md"), "utf-8");
  211. expect(rhythm).toContain("高潮间距");
  212. // Role files — one per character, grouped by tier
  213. const majorFiles = await readdir(join(storyDir, "roles", "主要角色"));
  214. expect(majorFiles.sort()).toEqual(["林辞.md", "沈默.md"]);
  215. const minorFiles = await readdir(join(storyDir, "roles", "次要角色"));
  216. expect(minorFiles).toEqual(["老张.md"]);
  217. // Compat shim: story_bible.md must exist and point at outline/story_frame.md
  218. const storyBibleShim = await readFile(join(storyDir, "story_bible.md"), "utf-8");
  219. expect(storyBibleShim).toContain("兼容指针");
  220. expect(storyBibleShim).toContain("outline/story_frame.md");
  221. // Compat shim: character_matrix.md points at roles/ directory
  222. const matrixShim = await readFile(join(storyDir, "character_matrix.md"), "utf-8");
  223. expect(matrixShim).toContain("兼容指针");
  224. expect(matrixShim).toContain("roles/主要角色/林辞.md");
  225. expect(matrixShim).toContain("roles/次要角色/老张.md");
  226. // Runtime state files still produced
  227. const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
  228. expect(currentState).toContain("当前章节");
  229. const pendingHooks = await readFile(join(storyDir, "pending_hooks.md"), "utf-8");
  230. expect(pendingHooks).toContain("H01");
  231. // Cleanup #1: volume_outline.md mirror is NOT written anymore. All
  232. // readers flow through readVolumeMap() which falls back to the legacy
  233. // path only for pre-Phase-5 books that still have the file on disk.
  234. await expect(readFile(join(storyDir, "volume_outline.md"), "utf-8")).rejects.toThrow();
  235. });
  236. it("still requires book_rules / roles / current_state / pending_hooks to be present", async () => {
  237. const agent = buildAgent();
  238. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  239. .mockResolvedValue({
  240. content: [
  241. "=== SECTION: story_frame ===",
  242. "# frame",
  243. "=== SECTION: volume_map ===",
  244. "# map",
  245. "=== SECTION: pending_hooks ===",
  246. "# hooks",
  247. ].join("\n"),
  248. usage: ZERO_USAGE,
  249. });
  250. // book_rules + roles + current_state all missing — the error lists them.
  251. await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/book_rules/i);
  252. await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/roles/i);
  253. await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/current_state/i);
  254. });
  255. it("requires at least one of story_frame or legacy story_bible", async () => {
  256. const agent = buildAgent();
  257. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  258. .mockResolvedValue({
  259. content: [
  260. "=== SECTION: volume_map ===",
  261. "# map",
  262. "=== SECTION: roles ===",
  263. "---ROLE---",
  264. "tier: major",
  265. "name: X",
  266. "---CONTENT---",
  267. "## 核心标签",
  268. "只是占位",
  269. "=== SECTION: book_rules ===",
  270. "---\nversion: \"1.0\"\n---",
  271. "=== SECTION: pending_hooks ===",
  272. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
  273. ].join("\n"),
  274. usage: ZERO_USAGE,
  275. });
  276. await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/story_frame/i);
  277. });
  278. it("system prompt emphasises chapter-level prose for volume_map and contrast-detail for roles", async () => {
  279. const agent = buildAgent();
  280. const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  281. .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
  282. await agent.generateFoundation(baseBook());
  283. const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
  284. const system = messages[0]?.content ?? "";
  285. expect(system).toContain("散文密度");
  286. expect(system).toContain("写到章级 prose");
  287. expect(system).toContain("反差细节");
  288. expect(system).toContain("节奏原则");
  289. expect(system).toContain("=== SECTION: story_frame ===");
  290. expect(system).toContain("=== SECTION: volume_map ===");
  291. // Phase 5 consolidation: rhythm_principles is merged into volume_map's
  292. // closing paragraph and remains NOT a standalone SECTION header.
  293. expect(system).not.toContain("=== SECTION: rhythm_principles ===");
  294. // Post-consolidation-fix: current_state restored as a narrow 6th section
  295. // (500-800 chars, env/era/macro prose only).
  296. expect(system).toContain("=== SECTION: current_state ===");
  297. expect(system).toContain("=== SECTION: roles ===");
  298. expect(system).toContain("=== SECTION: book_rules ===");
  299. expect(system).toContain("=== SECTION: pending_hooks ===");
  300. });
  301. });