ソースを参照

test(v13): cover memo drift audit, reviser routing, polisher scope, writer prose [phase 6/8 part 6]

- continuity.test.ts: new test asserts the auditor system prompt carries the
  structural-only scope clause ('审稿边界', '你不审文笔') and the sparse-memo
  legality clause, and that the user prompt injects the chapter memo under
  '## 章节备忘(用于 memo 偏离检测)' with no legacy '## 卷纲' block.
- reviser.test.ts: two routing tests. Structural issue (章节备忘偏离 critical)
  forces rewrite-only — parser rejects stray PATCHES and leaves chapter
  unchanged. Local-only issue (高疲劳词 warning) forces patch-only — parser
  rejects stray REVISED_CONTENT. Both confirm the '分流指令' directive lands
  in the system prompt.
- polisher.test.ts: new file. Covers scope-boundary clauses (zh + en),
  the six prose 雷点 subset, the [polisher-note] escape hatch, memo
  injection, changed/unchanged output flags, fenced-code-block wrapper
  stripping.
- writer-prompts.test.ts: two new tests verify Creative Constitution and
  Six Pillars of Immersion appear as prose sections (zh + en) with the
  expected load-bearing phrases, and that pillars are NOT rendered as a
  numbered/bulleted checklist.

Total tests: 524 -> 536 (12 new).
Ma 6 ヶ月 前
親
コミット
006d3378f1

+ 95 - 0
packages/core/src/__tests__/continuity.test.ts

@@ -290,4 +290,99 @@ describe("ContinuityAuditor", () => {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }
   });
   });
+
+  it("injects the chapter memo into the audit prompt for memo-drift checking", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-auditor-memo-drift-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+      writeFile(join(storyDir, "subplot_board.md"), "# 支线\n", "utf-8"),
+      writeFile(join(storyDir, "emotional_arcs.md"), "# 情感\n", "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), "# 矩阵\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style\n", "utf-8"),
+    ]);
+
+    const auditor = new ContinuityAuditor({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockResolvedValue({
+      content: JSON.stringify({ passed: true, issues: [], summary: "ok" }),
+      usage: ZERO_USAGE,
+    });
+
+    const memoBody = [
+      "## 当前任务",
+      "陆焚在小巷抢回残刃并离开。",
+      "",
+      "## 读者此刻在等什么",
+      "读者想看他怎么脱身。",
+      "",
+      "## 该兑现的 / 暂不掀的",
+      "兑现:残刃归手;暂不掀:身世。",
+      "",
+      "## 日常/过渡承担什么任务",
+      "开篇小巷场景 → 情绪代入 + 信息植入。",
+      "",
+      "## 关键抉择过三连问",
+      "陆焚选择独自动手的理由是什么?",
+      "",
+      "## 章尾必须发生的改变",
+      "陆焚拿回残刃,被人目击。",
+      "",
+      "## 不要做",
+      "不要写成大段打斗。",
+    ].join("\n");
+
+    try {
+      await auditor.auditChapter(bookDir, "Chapter body.", 42, "xuanhuan", {
+        chapterMemo: {
+          chapter: 42,
+          goal: "陆焚抢回残刃并离开",
+          isGoldenOpening: false,
+          body: memoBody,
+          threadRefs: [],
+        },
+      });
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+      const userPrompt = messages?.[1]?.content ?? "";
+
+      // Prompt declares structure-only scope and sparse-memo legality.
+      expect(systemPrompt).toContain("审稿边界");
+      expect(systemPrompt).toContain("你不审文笔");
+      expect(systemPrompt).toContain("稀疏 memo 是合法状态");
+      expect(systemPrompt).toContain("章节备忘偏离");
+      expect(systemPrompt).not.toContain("大纲偏离检测");
+
+      // User prompt injects the memo for drift-checking.
+      expect(userPrompt).toContain("## 章节备忘(用于 memo 偏离检测)");
+      expect(userPrompt).toContain("goal:陆焚抢回残刃并离开");
+      expect(userPrompt).toContain("## 章尾必须发生的改变");
+      // Legacy volume-outline block is gone.
+      expect(userPrompt).not.toContain("## 卷纲");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
 });
 });

+ 189 - 0
packages/core/src/__tests__/polisher.test.ts

@@ -0,0 +1,189 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { PolisherAgent } from "../agents/polisher.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+function makeAgent(): PolisherAgent {
+  return new PolisherAgent({
+    client: {
+      provider: "openai",
+      apiFormat: "chat",
+      stream: false,
+      defaults: {
+        temperature: 0.7,
+        maxTokens: 4096,
+        thinkingBudget: 0, maxTokensCap: null,
+        extra: {},
+      },
+    },
+    model: "test-model",
+    projectRoot: "/tmp/irrelevant",
+  });
+}
+
+describe("PolisherAgent", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("encodes file-layer scope boundary and six prose 雷点 in the zh system prompt", async () => {
+    const agent = makeAgent();
+    const chatSpy = vi.spyOn(PolisherAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: "润色后的正文。",
+      usage: ZERO_USAGE,
+    });
+
+    await agent.polishChapter({
+      chapterContent: "原始正文。",
+      chapterNumber: 7,
+      language: "zh",
+    });
+
+    const messages = chatSpy.mock.calls[0]?.[0] as
+      | ReadonlyArray<{ content: string }>
+      | undefined;
+    const systemPrompt = messages?.[0]?.content ?? "";
+
+    // Hard scope boundary.
+    expect(systemPrompt).toContain("润色边界");
+    expect(systemPrompt).toContain("禁止增删情节");
+    expect(systemPrompt).toContain("结构的事归 Reviewer");
+    // File-layer 雷点 subset.
+    expect(systemPrompt).toContain("描写无效");
+    expect(systemPrompt).toContain("文笔华丽过度");
+    expect(systemPrompt).toContain("排版不规范");
+    // Hard text-layer rules.
+    expect(systemPrompt).toContain("3-5 行/段");
+    expect(systemPrompt).toContain("五感代入");
+    expect(systemPrompt).toContain("对话自然度");
+  });
+
+  it("routes plot/structure findings to [polisher-note] lines instead of rewriting", async () => {
+    const agent = makeAgent();
+    const chatSpy = vi.spyOn(PolisherAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: "润色后的正文。",
+      usage: ZERO_USAGE,
+    });
+
+    await agent.polishChapter({
+      chapterContent: "原始正文。",
+      chapterNumber: 7,
+      language: "zh",
+    });
+
+    const messages = chatSpy.mock.calls[0]?.[0] as
+      | ReadonlyArray<{ content: string }>
+      | undefined;
+    const systemPrompt = messages?.[0]?.content ?? "";
+
+    expect(systemPrompt).toContain("[polisher-note]");
+  });
+
+  it("injects the chapter memo so polish stays anchored to the memo goal", async () => {
+    const agent = makeAgent();
+    const chatSpy = vi.spyOn(PolisherAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: "润色后的正文。",
+      usage: ZERO_USAGE,
+    });
+
+    await agent.polishChapter({
+      chapterContent: "原始正文。",
+      chapterNumber: 7,
+      language: "zh",
+      chapterMemo: {
+        chapter: 7,
+        goal: "陆焚拿回残刃",
+        isGoldenOpening: false,
+        body: "## 当前任务\n陆焚拿回残刃。",
+        threadRefs: [],
+      },
+    });
+
+    const messages = chatSpy.mock.calls[0]?.[0] as
+      | ReadonlyArray<{ content: string }>
+      | undefined;
+    const userPrompt = messages?.[1]?.content ?? "";
+
+    expect(userPrompt).toContain("## 章节备忘(润色不得偏离此目标)");
+    expect(userPrompt).toContain("goal:陆焚拿回残刃");
+  });
+
+  it("returns polished content and flags 'changed' when output differs", async () => {
+    const agent = makeAgent();
+    vi.spyOn(PolisherAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: "润色后的正文。",
+      usage: ZERO_USAGE,
+    });
+
+    const out = await agent.polishChapter({
+      chapterContent: "原始正文。",
+      chapterNumber: 1,
+      language: "zh",
+    });
+
+    expect(out.polishedContent).toBe("润色后的正文。");
+    expect(out.changed).toBe(true);
+  });
+
+  it("preserves the original chapter when the model returns empty content", async () => {
+    const agent = makeAgent();
+    vi.spyOn(PolisherAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: "",
+      usage: ZERO_USAGE,
+    });
+
+    const out = await agent.polishChapter({
+      chapterContent: "原始正文。",
+      chapterNumber: 1,
+      language: "zh",
+    });
+
+    expect(out.polishedContent).toBe("原始正文。");
+    expect(out.changed).toBe(false);
+  });
+
+  it("strips a surrounding fenced-code-block wrapper if the model adds one", async () => {
+    const agent = makeAgent();
+    vi.spyOn(PolisherAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: "```markdown\n润色后的正文。\n```",
+      usage: ZERO_USAGE,
+    });
+
+    const out = await agent.polishChapter({
+      chapterContent: "原始正文。",
+      chapterNumber: 1,
+      language: "zh",
+    });
+
+    expect(out.polishedContent).toBe("润色后的正文。");
+    expect(out.changed).toBe(true);
+  });
+
+  it("builds the English system prompt when language is en", async () => {
+    const agent = makeAgent();
+    const chatSpy = vi.spyOn(PolisherAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: "Polished chapter body.",
+      usage: ZERO_USAGE,
+    });
+
+    await agent.polishChapter({
+      chapterContent: "Original chapter body.",
+      chapterNumber: 3,
+      language: "en",
+    });
+
+    const messages = chatSpy.mock.calls[0]?.[0] as
+      | ReadonlyArray<{ content: string }>
+      | undefined;
+    const systemPrompt = messages?.[0]?.content ?? "";
+
+    expect(systemPrompt).toContain("Polisher Scope");
+    expect(systemPrompt).toContain("FORBIDDEN from adding or removing plot beats");
+    expect(systemPrompt).toContain("Ineffective description");
+    expect(systemPrompt).toContain("Dialogue naturalness");
+  });
+});

+ 150 - 0
packages/core/src/__tests__/reviser.test.ts

@@ -706,4 +706,154 @@ describe("ReviserAgent", () => {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }
   });
   });
+
+  it("routes structural issues to REVISED_CONTENT (rewrite-only) and rejects stray PATCHES", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-route-structural-"));
+    const bookDir = join(root, "book");
+    await mkdir(join(bookDir, "story"), { recursive: true });
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    // Model returns PATCHES when reviewer asked for REVISED_CONTENT — parser
+    // must reject the patches and leave the chapter unchanged.
+    const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- tried to patch but problem is structural",
+        "",
+        "=== PATCHES ===",
+        "--- PATCH 1 ---",
+        "TARGET_TEXT:",
+        "原文。",
+        "REPLACEMENT_TEXT:",
+        "局部替换。",
+        "--- END PATCH ---",
+        "",
+        "=== UPDATED_STATE ===",
+        "状态卡",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "伏笔池",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      const out = await agent.reviseChapter(
+        bookDir,
+        "原文。",
+        1,
+        [
+          {
+            severity: "critical",
+            category: "章节备忘偏离",
+            description: "未兑现 memo 的 goal",
+            suggestion: "重写全章",
+          },
+        ],
+        "auto",
+        "xuanhuan",
+      );
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+
+      // System prompt directs model to REVISED_CONTENT for structural issues.
+      expect(systemPrompt).toContain("分流指令");
+      expect(systemPrompt).toContain("必须输出 REVISED_CONTENT");
+      // Parser rejects stray PATCHES in rewrite-only mode.
+      expect(out.revisedContent).toBe("原文。");
+      expect(out.fixedIssues).toEqual([]);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("routes local issues to PATCHES (patch-only) and rejects stray REVISED_CONTENT", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-route-local-"));
+    const bookDir = join(root, "book");
+    await mkdir(join(bookDir, "story"), { recursive: true });
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    // Model returns REVISED_CONTENT when reviewer asked for PATCHES — parser
+    // must reject the rewrite (patch-only mode) and leave the chapter unchanged.
+    const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- rewrote whole chapter",
+        "",
+        "=== REVISED_CONTENT ===",
+        "整章重写的正文。",
+        "",
+        "=== UPDATED_STATE ===",
+        "状态卡",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "伏笔池",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      const out = await agent.reviseChapter(
+        bookDir,
+        "原文。",
+        1,
+        [
+          {
+            severity: "warning",
+            category: "高疲劳词",
+            description: "'不禁' 密度过高",
+            suggestion: "替换成具体动作",
+          },
+        ],
+        "auto",
+        "xuanhuan",
+      );
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+
+      expect(systemPrompt).toContain("分流指令");
+      expect(systemPrompt).toContain("必须只输出 PATCHES");
+      // Parser rejects REVISED_CONTENT in patch-only mode.
+      expect(out.revisedContent).toBe("原文。");
+      expect(out.fixedIssues).toEqual([]);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
 });
 });

+ 55 - 0
packages/core/src/__tests__/writer-prompts.test.ts

@@ -109,6 +109,61 @@ describe("buildWriterSystemPrompt", () => {
     expect(prompt).toContain("Keep the prose restrained");
     expect(prompt).toContain("Keep the prose restrained");
   });
   });
 
 
+  it("injects the creative constitution and six pillars of immersion as prose (zh)", () => {
+    const prompt = buildWriterSystemPrompt(
+      BOOK,
+      GENRE,
+      null,
+      "# Book Rules",
+      "# Genre Body",
+      "# Style Guide",
+      undefined,
+      3,
+      "creative",
+      undefined,
+      "zh",
+      "governed",
+    );
+
+    // Constitution and pillars appear as prose section headings.
+    expect(prompt).toContain("## 创作宪法");
+    expect(prompt).toContain("## 代入感六支柱");
+    // Constitution prose beats — verify a few load-bearing phrases ship.
+    expect(prompt).toContain("盐溶于汤");
+    expect(prompt).toContain("全员智商在线");
+    expect(prompt).toContain("拒绝流水账");
+    // Pillar prose beats — ensure six-pillar content is present.
+    expect(prompt).toContain("基础信息标签化");
+    expect(prompt).toContain("可视化熟悉感");
+    expect(prompt).toContain("五感钩子");
+    // Must NOT be rendered as a numbered checklist — writer must internalise.
+    expect(prompt).not.toContain("1. 基础信息标签化");
+    expect(prompt).not.toContain("- 基础信息标签化");
+  });
+
+  it("injects the creative constitution and six pillars of immersion as prose (en)", () => {
+    const prompt = buildWriterSystemPrompt(
+      { ...BOOK, language: "en" },
+      { ...GENRE, language: "en", name: "General" },
+      null,
+      "# Book Rules",
+      "# Genre Body",
+      "# Style Guide",
+      undefined,
+      3,
+      "creative",
+      undefined,
+      "en",
+      "governed",
+    );
+
+    expect(prompt).toContain("## Creative Constitution");
+    expect(prompt).toContain("## Six Pillars of Immersion");
+    expect(prompt).toContain("salt in soup");
+    expect(prompt).toContain("Refuse chronicle drift");
+    expect(prompt).toContain("core tag plus one contrasting detail");
+  });
+
   it("tells governed English prompts to obey variance briefs and include resistance-bearing exchanges", () => {
   it("tells governed English prompts to obey variance briefs and include resistance-bearing exchanges", () => {
     const prompt = buildWriterSystemPrompt(
     const prompt = buildWriterSystemPrompt(
       {
       {