reviser.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  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 { ReviserAgent } from "../agents/reviser.js";
  6. import { buildLengthSpec } from "../utils/length-metrics.js";
  7. import type { AuditIssue } from "../agents/continuity.js";
  8. const ZERO_USAGE = {
  9. promptTokens: 0,
  10. completionTokens: 0,
  11. totalTokens: 0,
  12. } as const;
  13. const CRITICAL_ISSUE: AuditIssue = {
  14. severity: "critical",
  15. category: "continuity",
  16. description: "Fix the broken continuity",
  17. suggestion: "Repair the contradiction",
  18. };
  19. describe("ReviserAgent", () => {
  20. afterEach(() => {
  21. vi.restoreAllMocks();
  22. });
  23. it("prefers book language override when building revision prompts", async () => {
  24. const root = await mkdtemp(join(tmpdir(), "inkos-reviser-lang-test-"));
  25. const bookDir = join(root, "book");
  26. await mkdir(join(bookDir, "story"), { recursive: true });
  27. await writeFile(
  28. join(bookDir, "book.json"),
  29. JSON.stringify({
  30. id: "english-book",
  31. title: "English Book",
  32. genre: "xuanhuan",
  33. platform: "royalroad",
  34. chapterWordCount: 800,
  35. targetChapters: 60,
  36. status: "active",
  37. language: "en",
  38. createdAt: "2026-03-23T00:00:00.000Z",
  39. updatedAt: "2026-03-23T00:00:00.000Z",
  40. }, null, 2),
  41. "utf-8",
  42. );
  43. const agent = new ReviserAgent({
  44. client: {
  45. provider: "openai",
  46. apiFormat: "chat",
  47. stream: false,
  48. defaults: {
  49. temperature: 0.7,
  50. maxTokens: 4096,
  51. thinkingBudget: 0,
  52. extra: {},
  53. },
  54. },
  55. model: "test-model",
  56. projectRoot: root,
  57. });
  58. const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
  59. content: [
  60. "=== FIXED_ISSUES ===",
  61. "- repaired",
  62. "",
  63. "=== REVISED_CONTENT ===",
  64. "Revised chapter content.",
  65. "",
  66. "=== UPDATED_STATE ===",
  67. "State card",
  68. "",
  69. "=== UPDATED_HOOKS ===",
  70. "Hooks board",
  71. ].join("\n"),
  72. usage: ZERO_USAGE,
  73. });
  74. try {
  75. await agent.reviseChapter(bookDir, "Original chapter content.", 1, [CRITICAL_ISSUE], "rewrite", "xuanhuan");
  76. const messages = chatSpy.mock.calls[0]?.[0] as
  77. | ReadonlyArray<{ content: string }>
  78. | undefined;
  79. const systemPrompt = messages?.[0]?.content ?? "";
  80. expect(systemPrompt).toContain("MUST be in English");
  81. expect(systemPrompt).toContain("written entirely in English");
  82. } finally {
  83. await rm(root, { recursive: true, force: true });
  84. }
  85. });
  86. it("keeps rewrite mode local-first instead of encouraging full-chapter replacement", async () => {
  87. const root = await mkdtemp(join(tmpdir(), "inkos-reviser-rewrite-guardrail-test-"));
  88. const bookDir = join(root, "book");
  89. await mkdir(join(bookDir, "story"), { recursive: true });
  90. const agent = new ReviserAgent({
  91. client: {
  92. provider: "openai",
  93. apiFormat: "chat",
  94. stream: false,
  95. defaults: {
  96. temperature: 0.7,
  97. maxTokens: 4096,
  98. thinkingBudget: 0,
  99. extra: {},
  100. },
  101. },
  102. model: "test-model",
  103. projectRoot: root,
  104. });
  105. const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
  106. content: [
  107. "=== FIXED_ISSUES ===",
  108. "- repaired",
  109. "",
  110. "=== PATCHES ===",
  111. "--- PATCH 1 ---",
  112. "TARGET_TEXT:",
  113. "原始正文。",
  114. "REPLACEMENT_TEXT:",
  115. "修订后的正文。",
  116. "--- END PATCH ---",
  117. "",
  118. "=== UPDATED_STATE ===",
  119. "状态卡",
  120. "",
  121. "=== UPDATED_HOOKS ===",
  122. "伏笔池",
  123. ].join("\n"),
  124. usage: ZERO_USAGE,
  125. });
  126. try {
  127. await agent.reviseChapter(bookDir, "原始正文。", 1, [CRITICAL_ISSUE], "rewrite", "xuanhuan");
  128. const messages = chatSpy.mock.calls[0]?.[0] as
  129. | ReadonlyArray<{ content: string }>
  130. | undefined;
  131. const systemPrompt = messages?.[0]?.content ?? "";
  132. expect(systemPrompt).toContain("优先保留原文的绝大部分句段");
  133. expect(systemPrompt).toContain("除非问题跨越整章");
  134. } finally {
  135. await rm(root, { recursive: true, force: true });
  136. }
  137. });
  138. it("tells the model to preserve the target range when a length spec is provided", async () => {
  139. const root = await mkdtemp(join(tmpdir(), "inkos-reviser-test-"));
  140. const bookDir = join(root, "book");
  141. await mkdir(join(bookDir, "story"), { recursive: true });
  142. const agent = new ReviserAgent({
  143. client: {
  144. provider: "openai",
  145. apiFormat: "chat",
  146. stream: false,
  147. defaults: {
  148. temperature: 0.7,
  149. maxTokens: 4096,
  150. thinkingBudget: 0,
  151. extra: {},
  152. },
  153. },
  154. model: "test-model",
  155. projectRoot: root,
  156. });
  157. const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
  158. content: [
  159. "=== FIXED_ISSUES ===",
  160. "- repaired",
  161. "",
  162. "=== PATCHES ===",
  163. "--- PATCH 1 ---",
  164. "TARGET_TEXT:",
  165. "原始正文。",
  166. "REPLACEMENT_TEXT:",
  167. "修订后的正文。",
  168. "--- END PATCH ---",
  169. "",
  170. "=== UPDATED_STATE ===",
  171. "状态卡",
  172. "",
  173. "=== UPDATED_HOOKS ===",
  174. "伏笔池",
  175. ].join("\n"),
  176. usage: ZERO_USAGE,
  177. });
  178. try {
  179. await agent.reviseChapter(
  180. bookDir,
  181. "原始正文。",
  182. 1,
  183. [CRITICAL_ISSUE],
  184. "spot-fix",
  185. "xuanhuan",
  186. {
  187. lengthSpec: buildLengthSpec(220, "zh"),
  188. },
  189. );
  190. const messages = chatSpy.mock.calls[0]?.[0] as
  191. | ReadonlyArray<{ content: string }>
  192. | undefined;
  193. const systemPrompt = messages?.[0]?.content ?? "";
  194. const userPrompt = messages?.[1]?.content ?? "";
  195. expect(systemPrompt).toContain("保持章节字数在目标区间内");
  196. expect(systemPrompt).toContain("=== PATCHES ===");
  197. expect(systemPrompt).not.toContain("=== REVISED_CONTENT ===");
  198. expect(userPrompt).toContain("目标字数:220");
  199. expect(userPrompt).toContain("允许区间:190-250");
  200. } finally {
  201. await rm(root, { recursive: true, force: true });
  202. }
  203. });
  204. it("reconstructs revised content from spot-fix patches and preserves untouched text", async () => {
  205. const root = await mkdtemp(join(tmpdir(), "inkos-reviser-spotfix-patch-test-"));
  206. const bookDir = join(root, "book");
  207. await mkdir(join(bookDir, "story"), { recursive: true });
  208. const agent = new ReviserAgent({
  209. client: {
  210. provider: "openai",
  211. apiFormat: "chat",
  212. stream: false,
  213. defaults: {
  214. temperature: 0.7,
  215. maxTokens: 4096,
  216. thinkingBudget: 0,
  217. extra: {},
  218. },
  219. },
  220. model: "test-model",
  221. projectRoot: root,
  222. });
  223. vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
  224. content: [
  225. "=== FIXED_ISSUES ===",
  226. "- 收紧了开头动作句。",
  227. "",
  228. "=== PATCHES ===",
  229. "--- PATCH 1 ---",
  230. "TARGET_TEXT:",
  231. "林越没有立刻进去。",
  232. "REPLACEMENT_TEXT:",
  233. "林越先停在门槛外,侧耳听了一息。",
  234. "--- END PATCH ---",
  235. "",
  236. "=== UPDATED_STATE ===",
  237. "状态卡",
  238. "",
  239. "=== UPDATED_HOOKS ===",
  240. "伏笔池",
  241. ].join("\n"),
  242. usage: ZERO_USAGE,
  243. });
  244. const original = [
  245. "门轴轻轻响了一下。",
  246. "林越没有立刻进去。",
  247. "",
  248. "巷子尽头的风还在吹。",
  249. "他把手按在潮冷的门框上,没有出声。",
  250. "更远处传来极轻的脚步回响,又很快断掉。",
  251. ].join("\n");
  252. try {
  253. const result = await agent.reviseChapter(
  254. bookDir,
  255. original,
  256. 1,
  257. [CRITICAL_ISSUE],
  258. "spot-fix",
  259. "xuanhuan",
  260. );
  261. expect(result.revisedContent).toBe([
  262. "门轴轻轻响了一下。",
  263. "林越先停在门槛外,侧耳听了一息。",
  264. "",
  265. "巷子尽头的风还在吹。",
  266. "他把手按在潮冷的门框上,没有出声。",
  267. "更远处传来极轻的脚步回响,又很快断掉。",
  268. ].join("\n"));
  269. expect(result.fixedIssues).toEqual(["- 收紧了开头动作句。"]);
  270. } finally {
  271. await rm(root, { recursive: true, force: true });
  272. }
  273. });
  274. it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
  275. const root = await mkdtemp(join(tmpdir(), "inkos-reviser-governed-test-"));
  276. const bookDir = join(root, "book");
  277. const storyDir = join(bookDir, "story");
  278. await mkdir(storyDir, { recursive: true });
  279. await Promise.all([
  280. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  281. writeFile(
  282. join(storyDir, "pending_hooks.md"),
  283. [
  284. "# Pending Hooks",
  285. "",
  286. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  287. "| --- | --- | --- | --- | --- | --- | --- |",
  288. "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
  289. "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
  290. "",
  291. ].join("\n"),
  292. "utf-8",
  293. ),
  294. writeFile(
  295. join(storyDir, "chapter_summaries.md"),
  296. [
  297. "# Chapter Summaries",
  298. "",
  299. "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
  300. "| 99 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  301. "",
  302. ].join("\n"),
  303. "utf-8",
  304. ),
  305. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
  306. writeFile(
  307. join(storyDir, "story_bible.md"),
  308. [
  309. "# Story Bible",
  310. "",
  311. "- The jade seal cannot be destroyed.",
  312. "- Guildmaster Ren secretly forged the harbor roster in chapter 140.",
  313. "",
  314. ].join("\n"),
  315. "utf-8",
  316. ),
  317. writeFile(
  318. join(storyDir, "character_matrix.md"),
  319. [
  320. "# 角色交互矩阵",
  321. "",
  322. "### 角色档案",
  323. "| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |",
  324. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  325. "| Lin Yue | oath | restraint | clipped | stubborn | self | repay debt | find mentor |",
  326. "| Guildmaster Ren | guild | swagger | loud | opportunistic | rival | stall Mara | seize seal |",
  327. "",
  328. ].join("\n"),
  329. "utf-8",
  330. ),
  331. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  332. ]);
  333. const agent = new ReviserAgent({
  334. client: {
  335. provider: "openai",
  336. apiFormat: "chat",
  337. stream: false,
  338. defaults: {
  339. temperature: 0.7,
  340. maxTokens: 4096,
  341. thinkingBudget: 0,
  342. extra: {},
  343. },
  344. },
  345. model: "test-model",
  346. projectRoot: root,
  347. });
  348. const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
  349. content: [
  350. "=== FIXED_ISSUES ===",
  351. "- repaired",
  352. "",
  353. "=== PATCHES ===",
  354. "--- PATCH 1 ---",
  355. "TARGET_TEXT:",
  356. "原始正文。",
  357. "REPLACEMENT_TEXT:",
  358. "修订后的正文。",
  359. "--- END PATCH ---",
  360. "",
  361. "=== UPDATED_STATE ===",
  362. "状态卡",
  363. "",
  364. "=== UPDATED_HOOKS ===",
  365. "伏笔池",
  366. ].join("\n"),
  367. usage: ZERO_USAGE,
  368. });
  369. try {
  370. await agent.reviseChapter(
  371. bookDir,
  372. "原始正文。",
  373. 100,
  374. [CRITICAL_ISSUE],
  375. "spot-fix",
  376. "xuanhuan",
  377. {
  378. chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
  379. contextPackage: {
  380. chapter: 100,
  381. selectedContext: [
  382. {
  383. source: "story/story_bible.md",
  384. reason: "Preserve canon constraints referenced by mustKeep.",
  385. excerpt: "The jade seal cannot be destroyed.",
  386. },
  387. {
  388. source: "story/volume_outline.md",
  389. reason: "Anchor the default planning node for this chapter.",
  390. excerpt: "Track the mentor oath fallout.",
  391. },
  392. {
  393. source: "story/chapter_summaries.md#99",
  394. reason: "Relevant episodic memory.",
  395. excerpt: "Trial Echo | Mentor left without explanation | mentor-oath advanced",
  396. },
  397. {
  398. source: "story/pending_hooks.md#mentor-oath",
  399. reason: "Carry forward unresolved hook.",
  400. excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
  401. },
  402. ],
  403. },
  404. ruleStack: {
  405. layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
  406. sections: {
  407. hard: ["current_state"],
  408. soft: ["current_focus"],
  409. diagnostic: ["continuity_audit"],
  410. },
  411. overrideEdges: [],
  412. activeOverrides: [],
  413. },
  414. lengthSpec: buildLengthSpec(220, "zh"),
  415. },
  416. );
  417. const messages = chatSpy.mock.calls[0]?.[0] as
  418. | ReadonlyArray<{ content: string }>
  419. | undefined;
  420. const userPrompt = messages?.[1]?.content ?? "";
  421. expect(userPrompt).toContain("story/chapter_summaries.md#99");
  422. expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
  423. expect(userPrompt).toContain("story/story_bible.md");
  424. expect(userPrompt).toContain("story/volume_outline.md");
  425. expect(userPrompt).not.toContain("| 1 | Guild Trail |");
  426. expect(userPrompt).not.toContain("guild-route | 1 | mystery");
  427. expect(userPrompt).not.toContain("Guildmaster Ren secretly forged the harbor roster in chapter 140.");
  428. expect(userPrompt).not.toContain("| Guildmaster Ren | guild | swagger | loud | opportunistic | rival | stall Mara | seize seal |");
  429. } finally {
  430. await rm(root, { recursive: true, force: true });
  431. }
  432. });
  433. });