Jelajahi Sumber

refactor(pipeline): extract chapter truth validation

Ma 7 bulan lalu
induk
melakukan
16cbf271f7

+ 205 - 0
packages/core/src/__tests__/chapter-truth-validation.test.ts

@@ -0,0 +1,205 @@
+import { describe, expect, it, vi } from "vitest";
+import type { AuditIssue, AuditResult } from "../agents/continuity.js";
+import type { ValidationResult } from "../agents/state-validator.js";
+import type { WriteChapterOutput } from "../agents/writer.js";
+import type { BookConfig } from "../models/book.js";
+import { validateChapterTruthPersistence } from "../pipeline/chapter-truth-validation.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+function createAuditResult(overrides?: Partial<AuditResult>): AuditResult {
+  return {
+    passed: true,
+    issues: [],
+    summary: "clean",
+    tokenUsage: ZERO_USAGE,
+    ...overrides,
+  };
+}
+
+function createValidationResult(overrides?: Partial<ValidationResult>): ValidationResult {
+  return {
+    passed: true,
+    warnings: [],
+    ...overrides,
+  };
+}
+
+function createWriterOutput(overrides: Partial<WriteChapterOutput> = {}): WriteChapterOutput {
+  return {
+    chapterNumber: 1,
+    title: "Test Chapter",
+    content: "Healthy chapter body with the copper token in his coat.",
+    wordCount: "Healthy chapter body with the copper token in his coat.".length,
+    preWriteCheck: "check",
+    postSettlement: "settled",
+    updatedState: "writer state",
+    updatedLedger: "writer ledger",
+    updatedHooks: "writer hooks",
+    chapterSummary: "| 1 | Original summary |",
+    updatedSubplots: "writer subplots",
+    updatedEmotionalArcs: "writer emotions",
+    updatedCharacterMatrix: "writer matrix",
+    postWriteErrors: [],
+    postWriteWarnings: [],
+    tokenUsage: ZERO_USAGE,
+    ...overrides,
+  };
+}
+
+const BOOK: BookConfig = {
+  id: "book-1",
+  title: "Book",
+  platform: "other",
+  genre: "xuanhuan",
+  status: "active",
+  targetChapters: 10,
+  chapterWordCount: 2000,
+  createdAt: "2026-04-01T00:00:00.000Z",
+  updatedAt: "2026-04-01T00:00:00.000Z",
+};
+
+describe("validateChapterTruthPersistence", () => {
+  it("uses recovered settlement output when retry succeeds", async () => {
+    const validator = {
+      validate: vi.fn()
+        .mockResolvedValueOnce(createValidationResult({
+          passed: false,
+          warnings: [{
+            category: "unsupported_change",
+            description: "正文写铜牌在怀里,但 state 说未携带。",
+          }],
+        }))
+        .mockResolvedValueOnce(createValidationResult()),
+    };
+    const writer = {
+      settleChapterState: vi.fn().mockResolvedValue(
+        createWriterOutput({
+          updatedState: "fixed state",
+          updatedHooks: "fixed hooks",
+          updatedLedger: "fixed ledger",
+        }),
+      ),
+    };
+    const logWarn = vi.fn();
+    const logger = { warn: vi.fn() };
+
+    const result = await validateChapterTruthPersistence({
+      writer,
+      validator,
+      book: BOOK,
+      bookDir: "/tmp/book",
+      chapterNumber: 3,
+      title: "Test Chapter",
+      content: "Healthy chapter body with the copper token in his coat.",
+      persistenceOutput: createWriterOutput({
+        updatedState: "broken state",
+        updatedHooks: "broken hooks",
+        updatedLedger: "broken ledger",
+      }),
+      auditResult: createAuditResult(),
+      previousTruth: {
+        oldState: "stable state",
+        oldHooks: "stable hooks",
+        oldLedger: "stable ledger",
+      },
+      language: "zh",
+      logWarn,
+      logger,
+    });
+
+    expect(writer.settleChapterState).toHaveBeenCalledTimes(1);
+    expect(writer.settleChapterState).toHaveBeenCalledWith(expect.objectContaining({
+      chapterNumber: 3,
+      title: "Test Chapter",
+      validationFeedback: expect.stringContaining("铜牌在怀里"),
+    }));
+    expect(result.chapterStatus).toBeNull();
+    expect(result.persistenceOutput.updatedState).toBe("fixed state");
+    expect(result.persistenceOutput.updatedHooks).toBe("fixed hooks");
+    expect(result.auditResult.issues).toEqual([]);
+    expect(logger.warn).toHaveBeenCalledWith("  [unsupported_change] 正文写铜牌在怀里,但 state 说未携带。");
+  });
+
+  it("degrades persistence output and appends audit issues when retry still fails", async () => {
+    const validator = {
+      validate: vi.fn()
+        .mockResolvedValueOnce(createValidationResult({
+          passed: false,
+          warnings: [{
+            category: "unsupported_change",
+            description: "第一次校验失败。",
+          }],
+        }))
+        .mockResolvedValueOnce(createValidationResult({
+          passed: false,
+          warnings: [{
+            category: "unsupported_change",
+            description: "重试后仍然失败。",
+          }],
+        })),
+    };
+    const writer = {
+      settleChapterState: vi.fn().mockResolvedValue(
+        createWriterOutput({
+          updatedState: "still broken state",
+          updatedHooks: "still broken hooks",
+          updatedLedger: "still broken ledger",
+        }),
+      ),
+    };
+    const baseIssue: AuditIssue = {
+      severity: "warning",
+      category: "title-dedup",
+      description: "title adjusted",
+      suggestion: "check title",
+    };
+
+    const result = await validateChapterTruthPersistence({
+      writer,
+      validator,
+      book: BOOK,
+      bookDir: "/tmp/book",
+      chapterNumber: 4,
+      title: "Test Chapter",
+      content: "Healthy chapter body with the copper token in his coat.",
+      persistenceOutput: createWriterOutput({
+        updatedState: "broken state",
+        updatedHooks: "broken hooks",
+        updatedLedger: "broken ledger",
+      }),
+      auditResult: createAuditResult({ issues: [baseIssue] }),
+      previousTruth: {
+        oldState: "stable state",
+        oldHooks: "stable hooks",
+        oldLedger: "stable ledger",
+      },
+      language: "zh",
+      logWarn: vi.fn(),
+      logger: { warn: vi.fn() },
+    });
+
+    expect(result.chapterStatus).toBe("state-degraded");
+    expect(result.degradedIssues).toEqual([
+      expect.objectContaining({
+        severity: "warning",
+        category: "state-validation",
+        description: "重试后仍然失败。",
+      }),
+    ]);
+    expect(result.persistenceOutput.updatedState).toBe("stable state");
+    expect(result.persistenceOutput.updatedHooks).toBe("stable hooks");
+    expect(result.persistenceOutput.updatedLedger).toBe("stable ledger");
+    expect(result.auditResult.issues).toEqual([
+      baseIssue,
+      expect.objectContaining({
+        category: "state-validation",
+        description: "重试后仍然失败。",
+      }),
+    ]);
+  });
+});

+ 117 - 0
packages/core/src/pipeline/chapter-truth-validation.ts

@@ -0,0 +1,117 @@
+import type { AuditIssue, AuditResult } from "../agents/continuity.js";
+import type { ValidationResult, StateValidatorAgent } from "../agents/state-validator.js";
+import type { WriteChapterOutput, WriterAgent } from "../agents/writer.js";
+import type { BookConfig } from "../models/book.js";
+import type { ContextPackage, RuleStack } from "../models/input-governance.js";
+import type { Logger } from "../utils/logger.js";
+import type { LengthLanguage } from "../utils/length-metrics.js";
+import {
+  buildStateDegradedPersistenceOutput,
+  retrySettlementAfterValidationFailure,
+} from "./chapter-state-recovery.js";
+
+export async function validateChapterTruthPersistence(params: {
+  readonly writer: Pick<WriterAgent, "settleChapterState">;
+  readonly validator: Pick<StateValidatorAgent, "validate">;
+  readonly book: BookConfig;
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly title: string;
+  readonly content: string;
+  readonly persistenceOutput: WriteChapterOutput;
+  readonly auditResult: AuditResult;
+  readonly previousTruth: {
+    readonly oldState: string;
+    readonly oldHooks: string;
+    readonly oldLedger: string;
+  };
+  readonly reducedControlInput?: {
+    chapterIntent: string;
+    contextPackage: ContextPackage;
+    ruleStack: RuleStack;
+  };
+  readonly language: LengthLanguage;
+  readonly logWarn: (message: { zh: string; en: string }) => void;
+  readonly logger?: Pick<Logger, "warn">;
+}): Promise<{
+  readonly validation: ValidationResult;
+  readonly chapterStatus: "state-degraded" | null;
+  readonly degradedIssues: ReadonlyArray<AuditIssue>;
+  readonly persistenceOutput: WriteChapterOutput;
+  readonly auditResult: AuditResult;
+}> {
+  let validation: ValidationResult;
+  let chapterStatus: "state-degraded" | null = null;
+  let degradedIssues: ReadonlyArray<AuditIssue> = [];
+  let persistenceOutput = params.persistenceOutput;
+  let auditResult = params.auditResult;
+
+  try {
+    validation = await params.validator.validate(
+      params.content,
+      params.chapterNumber,
+      params.previousTruth.oldState,
+      persistenceOutput.updatedState,
+      params.previousTruth.oldHooks,
+      persistenceOutput.updatedHooks,
+      params.language,
+    );
+  } catch (error) {
+    throw new Error(`State validation failed for chapter ${params.chapterNumber}: ${String(error)}`);
+  }
+
+  if (validation.warnings.length > 0) {
+    params.logWarn({
+      zh: `状态校验:第${params.chapterNumber}章发现 ${validation.warnings.length} 条警告`,
+      en: `State validation: ${validation.warnings.length} warning(s) for chapter ${params.chapterNumber}`,
+    });
+    for (const warning of validation.warnings) {
+      params.logger?.warn(`  [${warning.category}] ${warning.description}`);
+    }
+  }
+
+  if (!validation.passed) {
+    const recovery = await retrySettlementAfterValidationFailure({
+      writer: params.writer,
+      validator: params.validator,
+      book: params.book,
+      bookDir: params.bookDir,
+      chapterNumber: params.chapterNumber,
+      title: params.title,
+      content: params.content,
+      reducedControlInput: params.reducedControlInput,
+      oldState: params.previousTruth.oldState,
+      oldHooks: params.previousTruth.oldHooks,
+      originalValidation: validation,
+      language: params.language,
+      logWarn: params.logWarn,
+      logger: params.logger,
+    });
+
+    if (recovery.kind === "recovered") {
+      persistenceOutput = recovery.output;
+      validation = recovery.validation;
+    } else {
+      chapterStatus = "state-degraded";
+      degradedIssues = recovery.issues;
+      persistenceOutput = buildStateDegradedPersistenceOutput({
+        output: persistenceOutput,
+        oldState: params.previousTruth.oldState,
+        oldHooks: params.previousTruth.oldHooks,
+        oldLedger: params.previousTruth.oldLedger,
+      });
+      auditResult = {
+        ...auditResult,
+        issues: [...auditResult.issues, ...recovery.issues],
+      };
+    }
+  }
+
+  return {
+    validation,
+    chapterStatus,
+    degradedIssues,
+    persistenceOutput,
+    auditResult,
+  };
+}

+ 23 - 58
packages/core/src/pipeline/runner.ts

@@ -35,13 +35,13 @@ import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js"
 import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises";
 import { join } from "node:path";
 import {
-  buildStateDegradedPersistenceOutput,
   parseStateDegradedReviewNote,
   resolveStateDegradedBaseStatus,
   retrySettlementAfterValidationFailure,
 } from "./chapter-state-recovery.js";
 import { persistChapterArtifacts } from "./chapter-persistence.js";
 import { runChapterReviewCycle } from "./chapter-review-cycle.js";
+import { validateChapterTruthPersistence } from "./chapter-truth-validation.js";
 import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js";
 
 export interface PipelineConfig {
@@ -1174,65 +1174,30 @@ export class PipelineRunner {
       readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""),
     ]);
     const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
-    let validation: ValidationResult;
-    let chapterStatus: ChapterPipelineResult["status"] | null = null;
-    let degradedIssues: ReadonlyArray<AuditIssue> = [];
-    try {
-      validation = await validator.validate(
-        finalContent, chapterNumber,
-        oldState, persistenceOutput.updatedState,
-        oldHooks, persistenceOutput.updatedHooks,
-        pipelineLang,
-      );
-    } catch (error) {
-      throw new Error(`State validation failed for chapter ${chapterNumber}: ${String(error)}`);
-    }
-
-    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}`);
-      }
-    }
-    if (!validation.passed) {
-      const recovery = await retrySettlementAfterValidationFailure({
-        writer,
-        validator,
-        book,
-        bookDir,
-        chapterNumber,
-        title: persistenceOutput.title,
-        content: finalContent,
-        reducedControlInput,
+    const truthValidation = await validateChapterTruthPersistence({
+      writer,
+      validator,
+      book,
+      bookDir,
+      chapterNumber,
+      title: persistenceOutput.title,
+      content: finalContent,
+      persistenceOutput,
+      auditResult,
+      previousTruth: {
         oldState,
         oldHooks,
-        originalValidation: validation,
-        language: pipelineLang,
-        logWarn: (message) => this.logWarn(pipelineLang, message),
-        logger: this.config.logger,
-      });
-
-      if (recovery.kind === "recovered") {
-        persistenceOutput = recovery.output;
-        validation = recovery.validation;
-      } else {
-        chapterStatus = "state-degraded";
-        degradedIssues = recovery.issues;
-        persistenceOutput = buildStateDegradedPersistenceOutput({
-          output: persistenceOutput,
-          oldState,
-          oldHooks,
-          oldLedger,
-        });
-        auditResult = {
-          ...auditResult,
-          issues: [...auditResult.issues, ...recovery.issues],
-        };
-      }
-    }
+        oldLedger,
+      },
+      reducedControlInput,
+      language: pipelineLang,
+      logWarn: (message) => this.logWarn(pipelineLang, message),
+      logger: this.config.logger,
+    });
+    let chapterStatus: ChapterPipelineResult["status"] | null = truthValidation.chapterStatus;
+    let degradedIssues: ReadonlyArray<AuditIssue> = truthValidation.degradedIssues;
+    persistenceOutput = truthValidation.persistenceOutput;
+    auditResult = truthValidation.auditResult;
 
     // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise)
     {