chapter-analyzer.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. import { afterEach, 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 { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
  6. import type { BookConfig } from "../models/book.js";
  7. import { countChapterLength } from "../utils/length-metrics.js";
  8. const ZERO_USAGE = {
  9. promptTokens: 0,
  10. completionTokens: 0,
  11. totalTokens: 0,
  12. } as const;
  13. describe("ChapterAnalyzerAgent", () => {
  14. afterEach(() => {
  15. vi.restoreAllMocks();
  16. });
  17. it("counts English chapter content using words instead of characters", async () => {
  18. const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-"));
  19. const englishContent = "He looked at the sky and waited.";
  20. const agent = new ChapterAnalyzerAgent({
  21. client: {
  22. provider: "openai",
  23. apiFormat: "chat",
  24. stream: false,
  25. defaults: {
  26. temperature: 0.7,
  27. maxTokens: 4096,
  28. thinkingBudget: 0, maxTokensCap: null,
  29. extra: {},
  30. },
  31. },
  32. model: "test-model",
  33. projectRoot: process.cwd(),
  34. });
  35. const book: BookConfig = {
  36. id: "english-book",
  37. title: "English Book",
  38. platform: "other",
  39. genre: "other",
  40. status: "active",
  41. targetChapters: 10,
  42. chapterWordCount: 2200,
  43. language: "en",
  44. createdAt: "2026-03-22T00:00:00.000Z",
  45. updatedAt: "2026-03-22T00:00:00.000Z",
  46. };
  47. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  48. .mockResolvedValue({
  49. content: [
  50. "=== CHAPTER_TITLE ===",
  51. "A Quiet Sky",
  52. "",
  53. "=== CHAPTER_CONTENT ===",
  54. englishContent,
  55. "",
  56. "=== PRE_WRITE_CHECK ===",
  57. "",
  58. "=== POST_SETTLEMENT ===",
  59. "",
  60. "=== UPDATED_STATE ===",
  61. "| Field | Value |",
  62. "| --- | --- |",
  63. "| Chapter | 1 |",
  64. "",
  65. "=== UPDATED_LEDGER ===",
  66. "",
  67. "=== UPDATED_HOOKS ===",
  68. "| hook_id | status |",
  69. "| --- | --- |",
  70. "| h1 | open |",
  71. "",
  72. "=== CHAPTER_SUMMARY ===",
  73. "| 1 | A Quiet Sky |",
  74. "",
  75. "=== UPDATED_SUBPLOTS ===",
  76. "",
  77. "=== UPDATED_EMOTIONAL_ARCS ===",
  78. "",
  79. "=== UPDATED_CHARACTER_MATRIX ===",
  80. "",
  81. ].join("\n"),
  82. usage: ZERO_USAGE,
  83. });
  84. try {
  85. const output = await agent.analyzeChapter({
  86. book,
  87. bookDir,
  88. chapterNumber: 1,
  89. chapterContent: englishContent,
  90. });
  91. expect(output.wordCount).toBe(countChapterLength(englishContent, "en_words"));
  92. expect(output.wordCount).toBe(7);
  93. } finally {
  94. await rm(bookDir, { recursive: true, force: true });
  95. }
  96. });
  97. it("uses English prompts when analyzing imported English chapters", async () => {
  98. const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-en-"));
  99. const englishContent = "He looked at the sky and waited.";
  100. const agent = new ChapterAnalyzerAgent({
  101. client: {
  102. provider: "openai",
  103. apiFormat: "chat",
  104. stream: false,
  105. defaults: {
  106. temperature: 0.7,
  107. maxTokens: 4096,
  108. thinkingBudget: 0, maxTokensCap: null,
  109. extra: {},
  110. },
  111. },
  112. model: "test-model",
  113. projectRoot: process.cwd(),
  114. });
  115. const book: BookConfig = {
  116. id: "english-book",
  117. title: "English Book",
  118. platform: "other",
  119. genre: "other",
  120. status: "active",
  121. targetChapters: 10,
  122. chapterWordCount: 2200,
  123. language: "en",
  124. createdAt: "2026-03-22T00:00:00.000Z",
  125. updatedAt: "2026-03-22T00:00:00.000Z",
  126. };
  127. const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  128. .mockResolvedValue({
  129. content: [
  130. "=== CHAPTER_TITLE ===",
  131. "A Quiet Sky",
  132. "",
  133. "=== CHAPTER_CONTENT ===",
  134. englishContent,
  135. "",
  136. "=== PRE_WRITE_CHECK ===",
  137. "",
  138. "=== POST_SETTLEMENT ===",
  139. "",
  140. "=== UPDATED_STATE ===",
  141. "| Field | Value |",
  142. "| --- | --- |",
  143. "| Current Chapter | 1 |",
  144. "",
  145. "=== UPDATED_LEDGER ===",
  146. "",
  147. "=== UPDATED_HOOKS ===",
  148. "| hook_id | status |",
  149. "| --- | --- |",
  150. "| h1 | open |",
  151. "",
  152. "=== CHAPTER_SUMMARY ===",
  153. "| 1 | A Quiet Sky |",
  154. "",
  155. "=== UPDATED_SUBPLOTS ===",
  156. "",
  157. "=== UPDATED_EMOTIONAL_ARCS ===",
  158. "",
  159. "=== UPDATED_CHARACTER_MATRIX ===",
  160. "",
  161. ].join("\n"),
  162. usage: ZERO_USAGE,
  163. });
  164. try {
  165. await agent.analyzeChapter({
  166. book,
  167. bookDir,
  168. chapterNumber: 1,
  169. chapterContent: englishContent,
  170. chapterTitle: "A Quiet Sky",
  171. });
  172. const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
  173. expect(messages[0]?.content).toContain("ALL output MUST be in English");
  174. expect(messages[1]?.content).toContain("Analyze chapter 1");
  175. expect(messages[1]?.content).toContain("## Chapter Content");
  176. expect(messages[1]?.content).toContain("## Current State");
  177. expect(messages[1]?.content).not.toContain("请分析第1章正文");
  178. } finally {
  179. await rm(bookDir, { recursive: true, force: true });
  180. }
  181. });
  182. it("uses a retrieved summary snapshot instead of full long-history chapter summaries", async () => {
  183. const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-memory-"));
  184. const storyDir = join(bookDir, "story");
  185. await mkdir(storyDir, { recursive: true });
  186. await Promise.all([
  187. writeFile(
  188. join(storyDir, "chapter_summaries.md"),
  189. [
  190. "# Chapter Summaries",
  191. "",
  192. "| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |",
  193. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  194. "| 1 | Guild Trail | Lin Yue | Merchant guild flees west | Route clues only | guild-route seeded | tense | action |",
  195. "| 99 | Mentor Oath | Lin Yue, Mentor Shen | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  196. "",
  197. ].join("\n"),
  198. "utf-8",
  199. ),
  200. writeFile(
  201. join(storyDir, "pending_hooks.md"),
  202. [
  203. "# Pending Hooks",
  204. "",
  205. "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
  206. "| --- | --- | --- | --- | --- | --- | --- |",
  207. "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
  208. "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt |",
  209. "",
  210. ].join("\n"),
  211. "utf-8",
  212. ),
  213. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still carries the oath token.\n", "utf-8"),
  214. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nReturn to the mentor oath conflict.\n", "utf-8"),
  215. ]);
  216. const agent = new ChapterAnalyzerAgent({
  217. client: {
  218. provider: "openai",
  219. apiFormat: "chat",
  220. stream: false,
  221. defaults: {
  222. temperature: 0.7,
  223. maxTokens: 4096,
  224. thinkingBudget: 0, maxTokensCap: null,
  225. extra: {},
  226. },
  227. },
  228. model: "test-model",
  229. projectRoot: process.cwd(),
  230. });
  231. const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  232. .mockResolvedValue({
  233. content: [
  234. "=== CHAPTER_TITLE ===",
  235. "Mentor Oath Returns",
  236. "",
  237. "=== CHAPTER_CONTENT ===",
  238. "Lin Yue returned to the mentor oath and the missing explanation.",
  239. "",
  240. "=== PRE_WRITE_CHECK ===",
  241. "",
  242. "=== POST_SETTLEMENT ===",
  243. "",
  244. "=== UPDATED_STATE ===",
  245. "| Field | Value |",
  246. "| --- | --- |",
  247. "| Current Chapter | 100 |",
  248. "",
  249. "=== UPDATED_LEDGER ===",
  250. "",
  251. "=== UPDATED_HOOKS ===",
  252. "| hook_id | status |",
  253. "| --- | --- |",
  254. "| h1 | open |",
  255. "",
  256. "=== CHAPTER_SUMMARY ===",
  257. "| 100 | Mentor Oath Returns |",
  258. "",
  259. "=== UPDATED_SUBPLOTS ===",
  260. "",
  261. "=== UPDATED_EMOTIONAL_ARCS ===",
  262. "",
  263. "=== UPDATED_CHARACTER_MATRIX ===",
  264. "",
  265. ].join("\n"),
  266. usage: ZERO_USAGE,
  267. });
  268. const book: BookConfig = {
  269. id: "english-book",
  270. title: "English Book",
  271. platform: "other",
  272. genre: "other",
  273. status: "active",
  274. targetChapters: 120,
  275. chapterWordCount: 2200,
  276. language: "en",
  277. createdAt: "2026-03-22T00:00:00.000Z",
  278. updatedAt: "2026-03-22T00:00:00.000Z",
  279. };
  280. try {
  281. await agent.analyzeChapter({
  282. book,
  283. bookDir,
  284. chapterNumber: 100,
  285. chapterTitle: "Mentor Oath Returns",
  286. chapterContent: "Lin Yue returned to the mentor oath and the missing explanation.",
  287. });
  288. const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
  289. const userPrompt = messages[1]?.content ?? "";
  290. expect(userPrompt).toContain("| 99 | Mentor Oath |");
  291. expect(userPrompt).not.toContain("| 1 | Guild Trail |");
  292. } finally {
  293. await rm(bookDir, { recursive: true, force: true });
  294. }
  295. });
  296. it("preserves the supplied chapter content when the model omits CHAPTER_CONTENT", async () => {
  297. const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-fallback-"));
  298. const chapterContent = "Lin Yue stepped into the archive and kept the real ledger hidden inside his sleeve.";
  299. const agent = new ChapterAnalyzerAgent({
  300. client: {
  301. provider: "openai",
  302. apiFormat: "chat",
  303. stream: false,
  304. defaults: {
  305. temperature: 0.7,
  306. maxTokens: 4096,
  307. thinkingBudget: 0, maxTokensCap: null,
  308. extra: {},
  309. },
  310. },
  311. model: "test-model",
  312. projectRoot: process.cwd(),
  313. });
  314. const book: BookConfig = {
  315. id: "english-book",
  316. title: "English Book",
  317. platform: "other",
  318. genre: "other",
  319. status: "active",
  320. targetChapters: 10,
  321. chapterWordCount: 2200,
  322. language: "en",
  323. createdAt: "2026-03-22T00:00:00.000Z",
  324. updatedAt: "2026-03-22T00:00:00.000Z",
  325. };
  326. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  327. .mockResolvedValue({
  328. content: [
  329. "=== CHAPTER_TITLE ===",
  330. "Archive Entry",
  331. "",
  332. "=== PRE_WRITE_CHECK ===",
  333. "",
  334. "=== POST_SETTLEMENT ===",
  335. "",
  336. "=== UPDATED_STATE ===",
  337. "| Field | Value |",
  338. "| --- | --- |",
  339. "| Current Chapter | 1 |",
  340. "",
  341. "=== UPDATED_LEDGER ===",
  342. "",
  343. "=== UPDATED_HOOKS ===",
  344. "| hook_id | status |",
  345. "| --- | --- |",
  346. "| h1 | open |",
  347. "",
  348. "=== CHAPTER_SUMMARY ===",
  349. "| 1 | Archive Entry |",
  350. "",
  351. "=== UPDATED_SUBPLOTS ===",
  352. "",
  353. "=== UPDATED_EMOTIONAL_ARCS ===",
  354. "",
  355. "=== UPDATED_CHARACTER_MATRIX ===",
  356. "",
  357. ].join("\n"),
  358. usage: ZERO_USAGE,
  359. });
  360. try {
  361. const output = await agent.analyzeChapter({
  362. book,
  363. bookDir,
  364. chapterNumber: 1,
  365. chapterTitle: "Archive Entry",
  366. chapterContent,
  367. });
  368. expect(output.content).toBe(chapterContent);
  369. expect(output.wordCount).toBe(countChapterLength(chapterContent, "en_words"));
  370. } finally {
  371. await rm(bookDir, { recursive: true, force: true });
  372. }
  373. });
  374. it("uses governed control inputs instead of old broad truth-file blocks when provided", async () => {
  375. const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-governed-"));
  376. const storyDir = join(bookDir, "story");
  377. await mkdir(storyDir, { recursive: true });
  378. await Promise.all([
  379. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Full bible should stay out of governed analyzer prompts.\n", "utf-8"),
  380. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nReturn to the mentor oath conflict.\n", "utf-8"),
  381. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still carries the oath token.\n", "utf-8"),
  382. writeFile(join(storyDir, "pending_hooks.md"), [
  383. "# Pending Hooks",
  384. "",
  385. "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
  386. "| --- | --- | --- | --- | --- | --- | --- |",
  387. "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
  388. "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt |",
  389. "",
  390. ].join("\n"), "utf-8"),
  391. writeFile(join(storyDir, "subplot_board.md"), [
  392. "# Subplot Board",
  393. "",
  394. "| subplot | status | last_update | notes |",
  395. "| --- | --- | --- | --- |",
  396. "| Guild trail | open | 99 | Still active |",
  397. "| Harbor tax | resolved | 40 | Closed long ago |",
  398. "",
  399. ].join("\n"), "utf-8"),
  400. writeFile(join(storyDir, "emotional_arcs.md"), [
  401. "# Emotional Arcs",
  402. "",
  403. "| chapter | character | emotion | trigger | direction |",
  404. "| --- | --- | --- | --- | --- |",
  405. "| 95 | Lin Yue | grief | mentor silence | down |",
  406. "| 100 | Lin Yue | resolve | oath token | up |",
  407. "",
  408. ].join("\n"), "utf-8"),
  409. writeFile(join(storyDir, "character_matrix.md"), [
  410. "# Character Matrix",
  411. "",
  412. "### Character Profiles",
  413. "| character | role | status | notes |",
  414. "| --- | --- | --- | --- |",
  415. "| Lin Yue | protagonist | active | carries oath token |",
  416. "| Mentor Shen | mentor | missing | tied to oath debt |",
  417. "| Harbor Clerk | clerk | inactive | old tax subplot |",
  418. "",
  419. ].join("\n"), "utf-8"),
  420. ]);
  421. const agent = new ChapterAnalyzerAgent({
  422. client: {
  423. provider: "openai",
  424. apiFormat: "chat",
  425. stream: false,
  426. defaults: {
  427. temperature: 0.7,
  428. maxTokens: 4096,
  429. thinkingBudget: 0, maxTokensCap: null,
  430. extra: {},
  431. },
  432. },
  433. model: "test-model",
  434. projectRoot: process.cwd(),
  435. });
  436. const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  437. .mockResolvedValue({
  438. content: [
  439. "=== CHAPTER_TITLE ===",
  440. "Mentor Oath Returns",
  441. "",
  442. "=== CHAPTER_CONTENT ===",
  443. "Lin Yue returned to the mentor oath and the missing explanation.",
  444. "",
  445. "=== PRE_WRITE_CHECK ===",
  446. "",
  447. "=== POST_SETTLEMENT ===",
  448. "",
  449. "=== UPDATED_STATE ===",
  450. "| Field | Value |",
  451. "| --- | --- |",
  452. "| Current Chapter | 100 |",
  453. "",
  454. "=== UPDATED_LEDGER ===",
  455. "",
  456. "=== UPDATED_HOOKS ===",
  457. "| hook_id | status |",
  458. "| --- | --- |",
  459. "| h1 | open |",
  460. "",
  461. "=== CHAPTER_SUMMARY ===",
  462. "| 100 | Mentor Oath Returns |",
  463. "",
  464. "=== UPDATED_SUBPLOTS ===",
  465. "",
  466. "=== UPDATED_EMOTIONAL_ARCS ===",
  467. "",
  468. "=== UPDATED_CHARACTER_MATRIX ===",
  469. "",
  470. ].join("\n"),
  471. usage: ZERO_USAGE,
  472. });
  473. const book: BookConfig = {
  474. id: "english-book",
  475. title: "English Book",
  476. platform: "other",
  477. genre: "other",
  478. status: "active",
  479. targetChapters: 120,
  480. chapterWordCount: 2200,
  481. language: "en",
  482. createdAt: "2026-03-22T00:00:00.000Z",
  483. updatedAt: "2026-03-22T00:00:00.000Z",
  484. };
  485. try {
  486. await agent.analyzeChapter({
  487. book,
  488. bookDir,
  489. chapterNumber: 100,
  490. chapterTitle: "Mentor Oath Returns",
  491. chapterContent: "Lin Yue returned to the mentor oath and the missing explanation.",
  492. chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
  493. contextPackage: {
  494. chapter: 100,
  495. selectedContext: [
  496. {
  497. source: "story/pending_hooks.md#mentor-oath",
  498. reason: "Primary hook for this chapter",
  499. excerpt: "mentor-oath remains unresolved",
  500. },
  501. {
  502. source: "story/chapter_summaries.md#99",
  503. reason: "Closest relevant summary",
  504. excerpt: "Mentor oath debt sharpened",
  505. },
  506. ],
  507. },
  508. ruleStack: {
  509. layers: [
  510. { id: "L1", name: "Global", precedence: 1, scope: "global" },
  511. { id: "L2", name: "Book", precedence: 2, scope: "book" },
  512. ],
  513. sections: {
  514. hard: ["story_bible"],
  515. soft: ["author_intent"],
  516. diagnostic: ["anti_ai_checks"],
  517. },
  518. overrideEdges: [],
  519. activeOverrides: [
  520. {
  521. from: "brief",
  522. to: "current_focus",
  523. reason: "Keep the chapter on the oath debt",
  524. target: "focus",
  525. },
  526. ],
  527. },
  528. });
  529. const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
  530. const userPrompt = messages[1]?.content ?? "";
  531. expect(userPrompt).toContain("## Chapter Control Inputs (compiled by Planner/Composer)");
  532. expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
  533. expect(userPrompt).toContain("Selected Hook Evidence");
  534. expect(userPrompt).not.toContain("## Story Bible");
  535. expect(userPrompt).not.toContain("Full bible should stay out of governed analyzer prompts");
  536. expect(userPrompt).not.toContain("guild-route");
  537. } finally {
  538. await rm(bookDir, { recursive: true, force: true });
  539. }
  540. });
  541. });