| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- import { afterEach, 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 { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
- import type { BookConfig } from "../models/book.js";
- import { countChapterLength } from "../utils/length-metrics.js";
- const ZERO_USAGE = {
- promptTokens: 0,
- completionTokens: 0,
- totalTokens: 0,
- } as const;
- describe("ChapterAnalyzerAgent", () => {
- afterEach(() => {
- vi.restoreAllMocks();
- });
- it("counts English chapter content using words instead of characters", async () => {
- const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-"));
- const englishContent = "He looked at the sky and waited.";
- const agent = new ChapterAnalyzerAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "english-book",
- title: "English Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- };
- vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== CHAPTER_TITLE ===",
- "A Quiet Sky",
- "",
- "=== CHAPTER_CONTENT ===",
- englishContent,
- "",
- "=== PRE_WRITE_CHECK ===",
- "",
- "=== POST_SETTLEMENT ===",
- "",
- "=== UPDATED_STATE ===",
- "| Field | Value |",
- "| --- | --- |",
- "| Chapter | 1 |",
- "",
- "=== UPDATED_LEDGER ===",
- "",
- "=== UPDATED_HOOKS ===",
- "| hook_id | status |",
- "| --- | --- |",
- "| h1 | open |",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 1 | A Quiet Sky |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- const output = await agent.analyzeChapter({
- book,
- bookDir,
- chapterNumber: 1,
- chapterContent: englishContent,
- });
- expect(output.wordCount).toBe(countChapterLength(englishContent, "en_words"));
- expect(output.wordCount).toBe(7);
- } finally {
- await rm(bookDir, { recursive: true, force: true });
- }
- });
- it("uses English prompts when analyzing imported English chapters", async () => {
- const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-en-"));
- const englishContent = "He looked at the sky and waited.";
- const agent = new ChapterAnalyzerAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "english-book",
- title: "English Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- };
- const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== CHAPTER_TITLE ===",
- "A Quiet Sky",
- "",
- "=== CHAPTER_CONTENT ===",
- englishContent,
- "",
- "=== PRE_WRITE_CHECK ===",
- "",
- "=== POST_SETTLEMENT ===",
- "",
- "=== UPDATED_STATE ===",
- "| Field | Value |",
- "| --- | --- |",
- "| Current Chapter | 1 |",
- "",
- "=== UPDATED_LEDGER ===",
- "",
- "=== UPDATED_HOOKS ===",
- "| hook_id | status |",
- "| --- | --- |",
- "| h1 | open |",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 1 | A Quiet Sky |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- await agent.analyzeChapter({
- book,
- bookDir,
- chapterNumber: 1,
- chapterContent: englishContent,
- chapterTitle: "A Quiet Sky",
- });
- const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
- expect(messages[0]?.content).toContain("ALL output MUST be in English");
- expect(messages[1]?.content).toContain("Analyze chapter 1");
- expect(messages[1]?.content).toContain("## Chapter Content");
- expect(messages[1]?.content).toContain("## Current State");
- expect(messages[1]?.content).not.toContain("请分析第1章正文");
- } finally {
- await rm(bookDir, { recursive: true, force: true });
- }
- });
- it("uses a retrieved summary snapshot instead of full long-history chapter summaries", async () => {
- const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-memory-"));
- const storyDir = join(bookDir, "story");
- await mkdir(storyDir, { recursive: true });
- await Promise.all([
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 1 | Guild Trail | Lin Yue | Merchant guild flees west | Route clues only | guild-route seeded | tense | action |",
- "| 99 | Mentor Oath | Lin Yue, Mentor Shen | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "pending_hooks.md"),
- [
- "# Pending Hooks",
- "",
- "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
- "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still carries the oath token.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nReturn to the mentor oath conflict.\n", "utf-8"),
- ]);
- const agent = new ChapterAnalyzerAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== CHAPTER_TITLE ===",
- "Mentor Oath Returns",
- "",
- "=== CHAPTER_CONTENT ===",
- "Lin Yue returned to the mentor oath and the missing explanation.",
- "",
- "=== PRE_WRITE_CHECK ===",
- "",
- "=== POST_SETTLEMENT ===",
- "",
- "=== UPDATED_STATE ===",
- "| Field | Value |",
- "| --- | --- |",
- "| Current Chapter | 100 |",
- "",
- "=== UPDATED_LEDGER ===",
- "",
- "=== UPDATED_HOOKS ===",
- "| hook_id | status |",
- "| --- | --- |",
- "| h1 | open |",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 100 | Mentor Oath Returns |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- const book: BookConfig = {
- id: "english-book",
- title: "English Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 120,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- };
- try {
- await agent.analyzeChapter({
- book,
- bookDir,
- chapterNumber: 100,
- chapterTitle: "Mentor Oath Returns",
- chapterContent: "Lin Yue returned to the mentor oath and the missing explanation.",
- });
- const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
- const userPrompt = messages[1]?.content ?? "";
- expect(userPrompt).toContain("| 99 | Mentor Oath |");
- expect(userPrompt).not.toContain("| 1 | Guild Trail |");
- } finally {
- await rm(bookDir, { recursive: true, force: true });
- }
- });
- it("preserves the supplied chapter content when the model omits CHAPTER_CONTENT", async () => {
- const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-fallback-"));
- const chapterContent = "Lin Yue stepped into the archive and kept the real ledger hidden inside his sleeve.";
- const agent = new ChapterAnalyzerAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "english-book",
- title: "English Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- };
- vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== CHAPTER_TITLE ===",
- "Archive Entry",
- "",
- "=== PRE_WRITE_CHECK ===",
- "",
- "=== POST_SETTLEMENT ===",
- "",
- "=== UPDATED_STATE ===",
- "| Field | Value |",
- "| --- | --- |",
- "| Current Chapter | 1 |",
- "",
- "=== UPDATED_LEDGER ===",
- "",
- "=== UPDATED_HOOKS ===",
- "| hook_id | status |",
- "| --- | --- |",
- "| h1 | open |",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 1 | Archive Entry |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- const output = await agent.analyzeChapter({
- book,
- bookDir,
- chapterNumber: 1,
- chapterTitle: "Archive Entry",
- chapterContent,
- });
- expect(output.content).toBe(chapterContent);
- expect(output.wordCount).toBe(countChapterLength(chapterContent, "en_words"));
- } finally {
- await rm(bookDir, { recursive: true, force: true });
- }
- });
- it("uses governed control inputs instead of old broad truth-file blocks when provided", async () => {
- const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-governed-"));
- const storyDir = join(bookDir, "story");
- await mkdir(storyDir, { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Full bible should stay out of governed analyzer prompts.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nReturn to the mentor oath conflict.\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still carries the oath token.\n", "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), [
- "# Pending Hooks",
- "",
- "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
- "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "subplot_board.md"), [
- "# Subplot Board",
- "",
- "| subplot | status | last_update | notes |",
- "| --- | --- | --- | --- |",
- "| Guild trail | open | 99 | Still active |",
- "| Harbor tax | resolved | 40 | Closed long ago |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "emotional_arcs.md"), [
- "# Emotional Arcs",
- "",
- "| chapter | character | emotion | trigger | direction |",
- "| --- | --- | --- | --- | --- |",
- "| 95 | Lin Yue | grief | mentor silence | down |",
- "| 100 | Lin Yue | resolve | oath token | up |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "character_matrix.md"), [
- "# Character Matrix",
- "",
- "### Character Profiles",
- "| character | role | status | notes |",
- "| --- | --- | --- | --- |",
- "| Lin Yue | protagonist | active | carries oath token |",
- "| Mentor Shen | mentor | missing | tied to oath debt |",
- "| Harbor Clerk | clerk | inactive | old tax subplot |",
- "",
- ].join("\n"), "utf-8"),
- ]);
- const agent = new ChapterAnalyzerAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== CHAPTER_TITLE ===",
- "Mentor Oath Returns",
- "",
- "=== CHAPTER_CONTENT ===",
- "Lin Yue returned to the mentor oath and the missing explanation.",
- "",
- "=== PRE_WRITE_CHECK ===",
- "",
- "=== POST_SETTLEMENT ===",
- "",
- "=== UPDATED_STATE ===",
- "| Field | Value |",
- "| --- | --- |",
- "| Current Chapter | 100 |",
- "",
- "=== UPDATED_LEDGER ===",
- "",
- "=== UPDATED_HOOKS ===",
- "| hook_id | status |",
- "| --- | --- |",
- "| h1 | open |",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 100 | Mentor Oath Returns |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- const book: BookConfig = {
- id: "english-book",
- title: "English Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 120,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- };
- try {
- await agent.analyzeChapter({
- book,
- bookDir,
- chapterNumber: 100,
- chapterTitle: "Mentor Oath Returns",
- chapterContent: "Lin Yue returned to the mentor oath and the missing explanation.",
- chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
- contextPackage: {
- chapter: 100,
- selectedContext: [
- {
- source: "story/pending_hooks.md#mentor-oath",
- reason: "Primary hook for this chapter",
- excerpt: "mentor-oath remains unresolved",
- },
- {
- source: "story/chapter_summaries.md#99",
- reason: "Closest relevant summary",
- excerpt: "Mentor oath debt sharpened",
- },
- ],
- },
- ruleStack: {
- layers: [
- { id: "L1", name: "Global", precedence: 1, scope: "global" },
- { id: "L2", name: "Book", precedence: 2, scope: "book" },
- ],
- sections: {
- hard: ["story_bible"],
- soft: ["author_intent"],
- diagnostic: ["anti_ai_checks"],
- },
- overrideEdges: [],
- activeOverrides: [
- {
- from: "brief",
- to: "current_focus",
- reason: "Keep the chapter on the oath debt",
- target: "focus",
- },
- ],
- },
- });
- const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
- const userPrompt = messages[1]?.content ?? "";
- expect(userPrompt).toContain("## Chapter Control Inputs (compiled by Planner/Composer)");
- expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
- expect(userPrompt).toContain("Selected Hook Evidence");
- expect(userPrompt).not.toContain("## Story Bible");
- expect(userPrompt).not.toContain("Full bible should stay out of governed analyzer prompts");
- expect(userPrompt).not.toContain("guild-route");
- } finally {
- await rm(bookDir, { recursive: true, force: true });
- }
- });
- });
|