瀏覽代碼

fix: preserve final chapter content during truth rebuild

Ma 7 月之前
父節點
當前提交
b2a0c776fc

+ 83 - 0
packages/core/src/__tests__/chapter-analyzer.test.ts

@@ -313,4 +313,87 @@ describe("ChapterAnalyzerAgent", () => {
       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 });
+    }
+  });
 });

+ 5 - 0
packages/core/src/__tests__/pipeline-runner-memory-sync.test.ts

@@ -142,6 +142,7 @@ describe("PipelineRunner structured-state memory sync", () => {
     const { StateManager } = await import("../state/manager.js");
     const { WriterAgent } = await import("../agents/writer.js");
     const { ContinuityAuditor } = await import("../agents/continuity.js");
+    const { StateValidatorAgent } = await import("../agents/state-validator.js");
 
     root = await mkdtemp(join(tmpdir(), "inkos-runner-memory-sync-"));
     const state = new StateManager(root);
@@ -257,6 +258,10 @@ describe("PipelineRunner structured-state memory sync", () => {
       summary: "clean",
       tokenUsage: ZERO_USAGE,
     });
+    vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({
+      warnings: [],
+      passed: true,
+    });
     vi.spyOn(WriterAgent.prototype, "saveChapter").mockImplementation(async function (
       this: InstanceType<typeof WriterAgent>,
       bookDirArg,

+ 90 - 1
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -1,6 +1,6 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { createRequire } from "node:module";
-import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { PipelineRunner } from "../pipeline/runner.js";
@@ -13,6 +13,7 @@ import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
 import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/continuity.js";
 import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
+import { StateValidatorAgent } from "../agents/state-validator.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import { MemoryDB } from "../state/memory-db.js";
@@ -206,6 +207,10 @@ describe("PipelineRunner", () => {
         tokenUsage: ZERO_USAGE,
       }),
     );
+    vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({
+      warnings: [],
+      passed: true,
+    });
   });
 
   afterEach(() => {
@@ -2002,6 +2007,90 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
   });
 
+  it("does not persist chapter files or index entries when state validation errors before save", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: "Healthy chapter body.",
+        wordCount: "Healthy chapter body.".length,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+    vi.spyOn(StateValidatorAgent.prototype, "validate").mockRejectedValue(
+      new Error("LLM returned empty response"),
+    );
+
+    await expect(runner.writeNextChapter(bookId)).rejects.toThrow("LLM returned empty response");
+    await expect(readdir(chaptersDir)).resolves.toEqual([]);
+    await expect(state.loadChapterIndex(bookId)).resolves.toEqual([]);
+
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("preserves the revised chapter content when final truth rebuild omits CHAPTER_CONTENT", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const revisedBody = "Final revised body that should never be replaced by an empty chapter.";
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: "Original draft body.",
+        wordCount: "Original draft body.".length,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [CRITICAL_ISSUE],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: true,
+          issues: [],
+          summary: "clean",
+        }),
+      );
+    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: revisedBody,
+        wordCount: revisedBody.length,
+      }),
+    );
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        content: "",
+        wordCount: 0,
+      }),
+    );
+
+    const result = await runner.writeNextChapter(bookId);
+    const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
+    const savedIndex = await state.loadChapterIndex(bookId);
+    const expectedCount = countChapterLength(revisedBody, "zh_chars");
+
+    expect(result.wordCount).toBe(expectedCount);
+    expect(savedChapter).toContain(revisedBody);
+    expect(savedIndex[0]?.wordCount).toBe(expectedCount);
+    expect(savedIndex[0]?.status).toBe("ready-for-review");
+
+    await rm(root, { recursive: true, force: true });
+  });
+
   it("reports only resumed chapters in import results", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const now = "2026-03-19T00:00:00.000Z";

+ 49 - 0
packages/core/src/__tests__/state-validator-agent.test.ts

@@ -0,0 +1,49 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { StateValidatorAgent } from "../agents/state-validator.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+describe("StateValidatorAgent", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("throws when the validator model returns an empty response", async () => {
+    const agent = new StateValidatorAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: "",
+        usage: ZERO_USAGE,
+      });
+
+    await expect(agent.validate(
+      "Chapter body.",
+      3,
+      "old state",
+      "new state",
+      "old hooks",
+      "new hooks",
+      "en",
+    )).rejects.toThrow(/empty response/i);
+  });
+});

+ 14 - 3
packages/core/src/agents/chapter-analyzer.ts

@@ -3,7 +3,7 @@ import type { BookConfig } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { parseWriterOutput, type ParsedWriterOutput } from "./writer-parser.js";
-import { resolveLengthCountingMode } from "../utils/length-metrics.js";
+import { countChapterLength, resolveLengthCountingMode } from "../utils/length-metrics.js";
 import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
@@ -91,6 +91,8 @@ export class ChapterAnalyzerAgent extends BaseAgent {
 
     const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
     const output = parseWriterOutput(chapterNumber, response.content, genreProfile, countingMode);
+    const canonicalContent = chapterContent;
+    const canonicalWordCount = countChapterLength(canonicalContent, countingMode);
 
     // If LLM didn't return a title, use the one from input or derive from chapter number
     if (
@@ -100,10 +102,19 @@ export class ChapterAnalyzerAgent extends BaseAgent {
         || output.title === `第${chapterNumber}章`
       )
     ) {
-      return { ...output, title: chapterTitle };
+      return {
+        ...output,
+        title: chapterTitle,
+        content: canonicalContent,
+        wordCount: canonicalWordCount,
+      };
     }
 
-    return output;
+    return {
+      ...output,
+      content: canonicalContent,
+      wordCount: canonicalWordCount,
+    };
   }
 
   private buildSystemPrompt(

+ 29 - 11
packages/core/src/agents/state-validator.ts

@@ -13,7 +13,7 @@ export interface ValidationResult {
 /**
  * Validates Settler output by comparing old and new truth files via LLM.
  * Catches contradictions, missing state changes, and temporal inconsistencies.
- * Non-blocking: returns warnings but does not prevent truth file writes.
+ * Fail-closed: validator execution/format failures must surface to the pipeline.
  */
 export class StateValidatorAgent extends BaseAgent {
   get name(): string {
@@ -84,10 +84,9 @@ ${chapterContent.slice(0, 6000)}`;
       );
 
       return this.parseResult(response.content);
-    } catch (e) {
-      // Validation failure should never block the pipeline
-      this.log?.warn(`State validation failed: ${e}`);
-      return { warnings: [], passed: true };
+    } catch (error) {
+      this.log?.warn(`State validation failed: ${error}`);
+      throw error;
     }
   }
 
@@ -109,19 +108,38 @@ ${chapterContent.slice(0, 6000)}`;
   }
 
   private parseResult(content: string): ValidationResult {
+    const trimmed = content.trim();
+    if (!trimmed) {
+      throw new Error("LLM returned empty response");
+    }
+
+    const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
+    if (!jsonMatch) {
+      throw new Error("State validator returned invalid JSON");
+    }
+
     try {
-      const jsonMatch = content.match(/\{[\s\S]*\}/);
-      if (!jsonMatch) return { warnings: [], passed: true };
-      const parsed = JSON.parse(jsonMatch[0]) as { warnings?: Array<{ category?: string; description?: string }>; passed?: boolean };
+      const parsed = JSON.parse(jsonMatch[0]) as {
+        warnings?: Array<{ category?: string; description?: string }>;
+        passed?: boolean;
+      };
+      if (typeof parsed.passed !== "boolean") {
+        throw new Error("missing boolean 'passed' field");
+      }
+
+      if (parsed.warnings !== undefined && !Array.isArray(parsed.warnings)) {
+        throw new Error("'warnings' must be an array");
+      }
+
       return {
         warnings: (parsed.warnings ?? []).map((w) => ({
           category: w.category ?? "unknown",
           description: w.description ?? "",
         })),
-        passed: parsed.passed !== false,
+        passed: parsed.passed,
       };
-    } catch {
-      return { warnings: [], passed: true };
+    } catch (error) {
+      throw new Error(`State validator returned invalid response: ${String(error)}`);
     }
   }
 }

+ 37 - 24
packages/core/src/pipeline/runner.ts

@@ -33,7 +33,7 @@ import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
 import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js";
 import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
 import { readFile, readdir, writeFile, mkdir, rm } from "node:fs/promises";
-import { join } from "node:path";
+import { join, relative } from "node:path";
 
 export interface PipelineConfig {
   readonly client: LLMClient;
@@ -1005,6 +1005,7 @@ export class PipelineRunner {
     finalContent = normalizedBeforeAudit.content;
     finalWordCount = normalizedBeforeAudit.wordCount;
     normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied;
+    this.assertChapterContentNotEmpty(finalContent, chapterNumber, "draft generation");
 
     // 2b. LLM audit
     const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
@@ -1072,6 +1073,7 @@ export class PipelineRunner {
             finalContent = normalizedRevision.content;
             finalWordCount = normalizedRevision.wordCount;
             revised = true;
+            this.assertChapterContentNotEmpty(finalContent, chapterNumber, "revision");
           }
 
           // Re-audit the (possibly revised) content
@@ -1105,6 +1107,7 @@ export class PipelineRunner {
       chapterNumber,
       output,
       finalContent,
+      lengthSpec.countingMode,
     );
     const longSpanFatigue = await analyzeLongSpanFatigue({
       bookDir,
@@ -1138,35 +1141,38 @@ export class PipelineRunner {
     });
     this.logLengthWarnings(lengthWarnings);
 
-    // 4.1 Validate settler output before writing (non-blocking)
+    // 4.1 Validate settler output before writing
+    this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
+    const storyDir = join(bookDir, "story");
+    const [oldState, oldHooks] = await Promise.all([
+      readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
+      readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
+    ]);
+    const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
+    let validation;
     try {
-      this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
-      const storyDir = join(bookDir, "story");
-      const [oldState, oldHooks] = await Promise.all([
-        readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
-        readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
-      ]);
-      const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
-      const validation = await validator.validate(
+      validation = await validator.validate(
         finalContent, chapterNumber,
         oldState, persistenceOutput.updatedState,
         oldHooks, persistenceOutput.updatedHooks,
         pipelineLang,
       );
-      if (validation.warnings.length > 0) {
-        this.logWarn(pipelineLang, {
-          zh: `状态校验:第${chapterNumber}章发现 ${validation.warnings.length} 条警告`,
-          en: `State validation: ${validation.warnings.length} warning(s) for chapter ${chapterNumber}`,
-        });
-        for (const w of validation.warnings) {
-          this.config.logger?.warn(`  [${w.category}] ${w.description}`);
-        }
-      }
-    } catch (e) {
+    } catch (error) {
+      throw new Error(`State validation failed for chapter ${chapterNumber}: ${String(error)}`);
+    }
+
+    if (validation.warnings.length > 0) {
       this.logWarn(pipelineLang, {
-        zh: `状态校验已跳过:${String(e)}`,
-        en: `State validation skipped: ${String(e)}`,
+        zh: `状态校验:第${chapterNumber}章发现 ${validation.warnings.length} 条警告`,
+        en: `State validation: ${validation.warnings.length} warning(s) for chapter ${chapterNumber}`,
       });
+      for (const w of validation.warnings) {
+        this.config.logger?.warn(`  [${w.category}] ${w.description}`);
+      }
+    }
+    if (!validation.passed) {
+      const reason = validation.warnings[0]?.description ?? "validator reported contradictions";
+      throw new Error(`State validation failed for chapter ${chapterNumber}: ${reason}`);
     }
 
     await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang);
@@ -1641,6 +1647,7 @@ ${matrix}`,
     chapterNumber: number,
     output: WriteChapterOutput,
     finalContent: string,
+    countingMode: Parameters<typeof countChapterLength>[1],
   ): Promise<WriteChapterOutput> {
     if (finalContent === output.content) {
       return output;
@@ -1657,6 +1664,8 @@ ${matrix}`,
 
     return {
       ...analyzed,
+      content: finalContent,
+      wordCount: countChapterLength(finalContent, countingMode),
       postWriteErrors: [],
       postWriteWarnings: [],
       hookHealthIssues: output.hookHealthIssues,
@@ -1839,6 +1848,11 @@ ${matrix}`,
     };
   }
 
+  private assertChapterContentNotEmpty(content: string, chapterNumber: number, stage: string): void {
+    if (content.trim().length > 0) return;
+    throw new Error(`Chapter ${chapterNumber} has empty chapter content after ${stage}`);
+  }
+
   private async syncCurrentStateFactHistory(bookId: string, uptoChapter: number): Promise<void> {
     const bookDir = this.state.bookDir(bookId);
     try {
@@ -2387,8 +2401,7 @@ ${matrix}`,
   }
 
   private relativeToBookDir(bookDir: string, absolutePath: string): string {
-    const prefix = `${bookDir}/`;
-    return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
+    return relative(bookDir, absolutePath).replaceAll("\\", "/");
   }
 
   private async emitWebhook(