Просмотр исходного кода

refactor(pipeline): extract chapter review cycle

Ma 7 месяцев назад
Родитель
Сommit
e2bf6cba81

+ 174 - 0
packages/core/src/__tests__/chapter-review-cycle.test.ts

@@ -0,0 +1,174 @@
+import { describe, expect, it, vi } from "vitest";
+import { runChapterReviewCycle } from "../pipeline/chapter-review-cycle.js";
+import type { AuditResult, AuditIssue } from "../agents/continuity.js";
+import type { LengthSpec } from "../models/length-governance.js";
+
+const LENGTH_SPEC: LengthSpec = {
+  target: 220,
+  softMin: 190,
+  softMax: 250,
+  hardMin: 160,
+  hardMax: 280,
+  countingMode: "zh_chars",
+  normalizeMode: "none",
+};
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+function createAuditResult(overrides?: Partial<AuditResult>): AuditResult {
+  return {
+    passed: true,
+    issues: [],
+    summary: "clean",
+    ...overrides,
+  };
+}
+
+describe("runChapterReviewCycle", () => {
+  it("applies post-write spot-fix before the first audit pass", async () => {
+    const auditChapter = vi.fn()
+      .mockResolvedValue(createAuditResult());
+    const reviseChapter = vi.fn().mockResolvedValue({
+      revisedContent: "fixed draft",
+      wordCount: 10,
+      fixedIssues: ["fixed"],
+      updatedState: "",
+      updatedLedger: "",
+      updatedHooks: "",
+      tokenUsage: ZERO_USAGE,
+    });
+    const normalizeDraftLengthIfNeeded = vi.fn()
+      .mockResolvedValue({
+        content: "fixed draft",
+        wordCount: 10,
+        applied: false,
+        tokenUsage: ZERO_USAGE,
+      });
+
+    const result = await runChapterReviewCycle({
+      book: { genre: "xuanhuan" },
+      bookDir: "/tmp/book",
+      chapterNumber: 1,
+      initialOutput: {
+        content: "raw draft",
+        wordCount: 9,
+        postWriteErrors: [{
+          rule: "paragraph-shape",
+          description: "too fragmented",
+          suggestion: "merge short fragments",
+          severity: "error",
+        }],
+      },
+      lengthSpec: LENGTH_SPEC,
+      reducedControlInput: undefined,
+      initialUsage: ZERO_USAGE,
+      createReviser: () => ({ reviseChapter }),
+      auditor: { auditChapter },
+      normalizeDraftLengthIfNeeded,
+      assertChapterContentNotEmpty: () => undefined,
+      addUsage: (left, right) => ({
+        promptTokens: left.promptTokens + (right?.promptTokens ?? 0),
+        completionTokens: left.completionTokens + (right?.completionTokens ?? 0),
+        totalTokens: left.totalTokens + (right?.totalTokens ?? 0),
+      }),
+      restoreLostAuditIssues: (_previous, next) => next,
+      analyzeAITells: () => ({ issues: [] as AuditIssue[] }),
+      analyzeSensitiveWords: () => ({ found: [] as Array<{ severity: "warn" | "block" }>, issues: [] as AuditIssue[] }),
+      logWarn: () => undefined,
+      logStage: () => undefined,
+    });
+
+    expect(reviseChapter).toHaveBeenCalledTimes(1);
+    expect(auditChapter).toHaveBeenCalledTimes(1);
+    expect(auditChapter).toHaveBeenCalledWith(
+      "/tmp/book",
+      "fixed draft",
+      1,
+      "xuanhuan",
+      undefined,
+    );
+    expect(result.finalContent).toBe("fixed draft");
+    expect(result.revised).toBe(true);
+  });
+
+  it("drops auto-revision when it increases AI tells and re-audits the original draft", async () => {
+    const failingAudit = createAuditResult({
+      passed: false,
+      issues: [{
+        severity: "critical",
+        category: "continuity",
+        description: "broken continuity",
+        suggestion: "fix it",
+      }],
+      summary: "bad",
+    });
+    const auditChapter = vi.fn()
+      .mockResolvedValueOnce(failingAudit)
+      .mockResolvedValueOnce(createAuditResult());
+    const reviseChapter = vi.fn().mockResolvedValue({
+      revisedContent: "rewritten draft",
+      wordCount: 15,
+      fixedIssues: ["fixed"],
+      updatedState: "",
+      updatedLedger: "",
+      updatedHooks: "",
+      tokenUsage: ZERO_USAGE,
+    });
+    const normalizeDraftLengthIfNeeded = vi.fn()
+      .mockResolvedValueOnce({
+        content: "original draft",
+        wordCount: 13,
+        applied: false,
+        tokenUsage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: "rewritten draft",
+        wordCount: 15,
+        applied: false,
+        tokenUsage: ZERO_USAGE,
+      });
+    const analyzeAITells = vi.fn((content: string) => ({
+      issues: content === "rewritten draft"
+        ? [{ severity: "warning", category: "ai", description: "more ai", suggestion: "reduce" } satisfies AuditIssue]
+        : [],
+    }));
+
+    const result = await runChapterReviewCycle({
+      book: { genre: "xuanhuan" },
+      bookDir: "/tmp/book",
+      chapterNumber: 1,
+      initialOutput: {
+        content: "original draft",
+        wordCount: 13,
+        postWriteErrors: [],
+      },
+      lengthSpec: LENGTH_SPEC,
+      reducedControlInput: undefined,
+      initialUsage: ZERO_USAGE,
+      createReviser: () => ({ reviseChapter }),
+      auditor: { auditChapter },
+      normalizeDraftLengthIfNeeded,
+      assertChapterContentNotEmpty: () => undefined,
+      addUsage: (left, right) => ({
+        promptTokens: left.promptTokens + (right?.promptTokens ?? 0),
+        completionTokens: left.completionTokens + (right?.completionTokens ?? 0),
+        totalTokens: left.totalTokens + (right?.totalTokens ?? 0),
+      }),
+      restoreLostAuditIssues: (_previous, next) => next,
+      analyzeAITells,
+      analyzeSensitiveWords: () => ({ found: [] as Array<{ severity: "warn" | "block" }>, issues: [] as AuditIssue[] }),
+      logWarn: () => undefined,
+      logStage: () => undefined,
+    });
+
+    expect(reviseChapter).toHaveBeenCalledTimes(1);
+    expect(auditChapter).toHaveBeenNthCalledWith(1, "/tmp/book", "original draft", 1, "xuanhuan", undefined);
+    expect(auditChapter).toHaveBeenNthCalledWith(2, "/tmp/book", "original draft", 1, "xuanhuan", { temperature: 0 });
+    expect(result.finalContent).toBe("original draft");
+    expect(result.revised).toBe(false);
+  });
+});

+ 218 - 0
packages/core/src/pipeline/chapter-review-cycle.ts

@@ -0,0 +1,218 @@
+import type { AuditIssue, AuditResult } from "../agents/continuity.js";
+import type { ReviseOutput } from "../agents/reviser.js";
+import type { WriteChapterOutput } from "../agents/writer.js";
+import type { ContextPackage, RuleStack } from "../models/input-governance.js";
+import type { LengthSpec } from "../models/length-governance.js";
+
+export interface ChapterReviewCycleUsage {
+  readonly promptTokens: number;
+  readonly completionTokens: number;
+  readonly totalTokens: number;
+}
+
+export interface ChapterReviewCycleControlInput {
+  readonly chapterIntent: string;
+  readonly contextPackage: ContextPackage;
+  readonly ruleStack: RuleStack;
+}
+
+export interface ChapterReviewCycleResult {
+  readonly finalContent: string;
+  readonly finalWordCount: number;
+  readonly preAuditNormalizedWordCount: number;
+  readonly revised: boolean;
+  readonly auditResult: AuditResult;
+  readonly totalUsage: ChapterReviewCycleUsage;
+  readonly postReviseCount: number;
+  readonly normalizeApplied: boolean;
+}
+
+export async function runChapterReviewCycle(params: {
+  readonly book: Pick<{ genre: string }, "genre">;
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly initialOutput: Pick<WriteChapterOutput, "content" | "wordCount" | "postWriteErrors">;
+  readonly reducedControlInput?: ChapterReviewCycleControlInput;
+  readonly lengthSpec: LengthSpec;
+  readonly initialUsage: ChapterReviewCycleUsage;
+  readonly createReviser: () => {
+    reviseChapter: (
+      bookDir: string,
+      chapterContent: string,
+      chapterNumber: number,
+      issues: ReadonlyArray<AuditIssue>,
+      mode: "spot-fix",
+      genre?: string,
+      options?: {
+        chapterIntent?: string;
+        contextPackage?: ContextPackage;
+        ruleStack?: RuleStack;
+        lengthSpec?: LengthSpec;
+      },
+    ) => Promise<ReviseOutput>;
+  };
+  readonly auditor: {
+    auditChapter: (
+      bookDir: string,
+      chapterContent: string,
+      chapterNumber: number,
+      genre?: string,
+      options?: {
+        temperature?: number;
+        chapterIntent?: string;
+        contextPackage?: ContextPackage;
+        ruleStack?: RuleStack;
+      },
+    ) => Promise<AuditResult>;
+  };
+  readonly normalizeDraftLengthIfNeeded: (chapterContent: string) => Promise<{
+    content: string;
+    wordCount: number;
+    applied: boolean;
+    tokenUsage?: ChapterReviewCycleUsage;
+  }>;
+  readonly assertChapterContentNotEmpty: (content: string, stage: string) => void;
+  readonly addUsage: (
+    left: ChapterReviewCycleUsage,
+    right?: ChapterReviewCycleUsage,
+  ) => ChapterReviewCycleUsage;
+  readonly restoreLostAuditIssues: (previous: AuditResult, next: AuditResult) => AuditResult;
+  readonly analyzeAITells: (content: string) => { issues: ReadonlyArray<AuditIssue> };
+  readonly analyzeSensitiveWords: (content: string) => {
+    found: ReadonlyArray<{ severity: string }>;
+    issues: ReadonlyArray<AuditIssue>;
+  };
+  readonly logWarn: (message: { zh: string; en: string }) => void;
+  readonly logStage: (message: { zh: string; en: string }) => void;
+}): Promise<ChapterReviewCycleResult> {
+  let totalUsage = params.initialUsage;
+  let postReviseCount = 0;
+  let normalizeApplied = false;
+  let finalContent = params.initialOutput.content;
+  let finalWordCount = params.initialOutput.wordCount;
+  let revised = false;
+
+  if (params.initialOutput.postWriteErrors.length > 0) {
+    params.logWarn({
+      zh: `检测到 ${params.initialOutput.postWriteErrors.length} 个后写错误,审计前触发 spot-fix 修补`,
+      en: `${params.initialOutput.postWriteErrors.length} post-write errors detected, triggering spot-fix before audit`,
+    });
+    const reviser = params.createReviser();
+    const spotFixIssues = params.initialOutput.postWriteErrors.map((violation) => ({
+      severity: "critical" as const,
+      category: violation.rule,
+      description: violation.description,
+      suggestion: violation.suggestion,
+    }));
+    const fixResult = await reviser.reviseChapter(
+      params.bookDir,
+      finalContent,
+      params.chapterNumber,
+      spotFixIssues,
+      "spot-fix",
+      params.book.genre,
+      {
+        ...params.reducedControlInput,
+        lengthSpec: params.lengthSpec,
+      },
+    );
+    totalUsage = params.addUsage(totalUsage, fixResult.tokenUsage);
+    if (fixResult.revisedContent.length > 0) {
+      finalContent = fixResult.revisedContent;
+      finalWordCount = fixResult.wordCount;
+      revised = true;
+    }
+  }
+
+  const normalizedBeforeAudit = await params.normalizeDraftLengthIfNeeded(finalContent);
+  totalUsage = params.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage);
+  finalContent = normalizedBeforeAudit.content;
+  finalWordCount = normalizedBeforeAudit.wordCount;
+  normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied;
+  params.assertChapterContentNotEmpty(finalContent, "draft generation");
+
+  params.logStage({ zh: "审计草稿", en: "auditing draft" });
+  const llmAudit = await params.auditor.auditChapter(
+    params.bookDir,
+    finalContent,
+    params.chapterNumber,
+    params.book.genre,
+    params.reducedControlInput,
+  );
+  totalUsage = params.addUsage(totalUsage, llmAudit.tokenUsage);
+  const aiTellsResult = params.analyzeAITells(finalContent);
+  const sensitiveWriteResult = params.analyzeSensitiveWords(finalContent);
+  const hasBlockedWriteWords = sensitiveWriteResult.found.some((item) => item.severity === "block");
+  let auditResult: AuditResult = {
+    passed: hasBlockedWriteWords ? false : llmAudit.passed,
+    issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues],
+    summary: llmAudit.summary,
+  };
+
+  if (!auditResult.passed) {
+    const criticalIssues = auditResult.issues.filter((issue) => issue.severity === "critical");
+    if (criticalIssues.length > 0) {
+      const reviser = params.createReviser();
+      params.logStage({ zh: "自动修复关键问题", en: "auto-revising critical issues" });
+      const reviseOutput = await reviser.reviseChapter(
+        params.bookDir,
+        finalContent,
+        params.chapterNumber,
+        auditResult.issues,
+        "spot-fix",
+        params.book.genre,
+        {
+          ...params.reducedControlInput,
+          lengthSpec: params.lengthSpec,
+        },
+      );
+      totalUsage = params.addUsage(totalUsage, reviseOutput.tokenUsage);
+
+      if (reviseOutput.revisedContent.length > 0) {
+        const normalizedRevision = await params.normalizeDraftLengthIfNeeded(reviseOutput.revisedContent);
+        totalUsage = params.addUsage(totalUsage, normalizedRevision.tokenUsage);
+        postReviseCount = normalizedRevision.wordCount;
+        normalizeApplied = normalizeApplied || normalizedRevision.applied;
+
+        const preMarkers = params.analyzeAITells(finalContent);
+        const postMarkers = params.analyzeAITells(normalizedRevision.content);
+        if (postMarkers.issues.length <= preMarkers.issues.length) {
+          finalContent = normalizedRevision.content;
+          finalWordCount = normalizedRevision.wordCount;
+          revised = true;
+          params.assertChapterContentNotEmpty(finalContent, "revision");
+        }
+
+        const reAudit = await params.auditor.auditChapter(
+          params.bookDir,
+          finalContent,
+          params.chapterNumber,
+          params.book.genre,
+          params.reducedControlInput
+            ? { ...params.reducedControlInput, temperature: 0 }
+            : { temperature: 0 },
+        );
+        totalUsage = params.addUsage(totalUsage, reAudit.tokenUsage);
+        const reAITells = params.analyzeAITells(finalContent);
+        const reSensitive = params.analyzeSensitiveWords(finalContent);
+        const reHasBlocked = reSensitive.found.some((item) => item.severity === "block");
+        auditResult = params.restoreLostAuditIssues(auditResult, {
+          passed: reHasBlocked ? false : reAudit.passed,
+          issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues],
+          summary: reAudit.summary,
+        });
+      }
+    }
+  }
+
+  return {
+    finalContent,
+    finalWordCount,
+    preAuditNormalizedWordCount: normalizedBeforeAudit.wordCount,
+    revised,
+    auditResult,
+    totalUsage,
+    postReviseCount,
+    normalizeApplied,
+  };
+}

+ 32 - 139
packages/core/src/pipeline/runner.ts

@@ -41,6 +41,7 @@ import {
   resolveStateDegradedBaseStatus,
   retrySettlementAfterValidationFailure,
 } from "./chapter-state-recovery.js";
+import { runChapterReviewCycle } from "./chapter-review-cycle.js";
 import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js";
 
 export interface PipelineConfig {
@@ -1045,148 +1046,40 @@ export class PipelineRunner {
 
     // Token usage accumulator
     let totalUsage: TokenUsageSummary = output.tokenUsage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
-    let postReviseCount = 0;
-    let normalizeApplied = false;
-
-    // 2a. Post-write error gate: if deterministic rules found errors, auto-fix before LLM audit
-    let finalContent = output.content;
-    let finalWordCount = output.wordCount;
-    let revised = false;
-
-    if (output.postWriteErrors.length > 0) {
-      this.logWarn(pipelineLang, {
-        zh: `检测到 ${output.postWriteErrors.length} 个后写错误,审计前触发 spot-fix 修补`,
-        en: `${output.postWriteErrors.length} post-write errors detected, triggering spot-fix before audit`,
-      });
-      const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
-      const spotFixIssues = output.postWriteErrors.map((v) => ({
-        severity: "critical" as const,
-        category: v.rule,
-        description: v.description,
-        suggestion: v.suggestion,
-      }));
-      const fixResult = await reviser.reviseChapter(
-        bookDir,
-        finalContent,
-        chapterNumber,
-        spotFixIssues,
-        "spot-fix",
-        book.genre,
-        {
-          ...reducedControlInput,
-          lengthSpec,
-        },
-      );
-      totalUsage = PipelineRunner.addUsage(totalUsage, fixResult.tokenUsage);
-      if (fixResult.revisedContent.length > 0) {
-        finalContent = fixResult.revisedContent;
-        finalWordCount = fixResult.wordCount;
-        revised = true;
-      }
-    }
-
-    const normalizedBeforeAudit = await this.normalizeDraftLengthIfNeeded({
-      bookId,
-      chapterNumber,
-      chapterContent: finalContent,
-      lengthSpec,
-      chapterIntent: writeInput.chapterIntent,
-    });
-    totalUsage = PipelineRunner.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage);
-    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));
-    this.logStage(stageLanguage, { zh: "审计草稿", en: "auditing draft" });
-    const llmAudit = await auditor.auditChapter(
+    const reviewResult = await runChapterReviewCycle({
+      book: { genre: book.genre },
       bookDir,
-      finalContent,
       chapterNumber,
-      book.genre,
+      initialOutput: output,
       reducedControlInput,
-    );
-    totalUsage = PipelineRunner.addUsage(totalUsage, llmAudit.tokenUsage);
-    const aiTellsResult = analyzeAITells(finalContent);
-    const sensitiveWriteResult = analyzeSensitiveWords(finalContent);
-    const hasBlockedWriteWords = sensitiveWriteResult.found.some((f) => f.severity === "block");
-    let auditResult: AuditResult = {
-      passed: hasBlockedWriteWords ? false : llmAudit.passed,
-      issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues],
-      summary: llmAudit.summary,
-    };
-
-    // 3. If audit fails, try auto-revise once
-    if (!auditResult.passed) {
-      const criticalIssues = auditResult.issues.filter(
-        (i) => i.severity === "critical",
-      );
-      if (criticalIssues.length > 0) {
-        const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
-        this.logStage(stageLanguage, { zh: "自动修复关键问题", en: "auto-revising critical issues" });
-        const reviseOutput = await reviser.reviseChapter(
-          bookDir,
-          finalContent,
-          chapterNumber,
-          auditResult.issues,
-          "spot-fix",
-          book.genre,
-          {
-            ...reducedControlInput,
-            lengthSpec,
-          },
-        );
-        totalUsage = PipelineRunner.addUsage(totalUsage, reviseOutput.tokenUsage);
-
-        if (reviseOutput.revisedContent.length > 0) {
-          const normalizedRevision = await this.normalizeDraftLengthIfNeeded({
-            bookId,
-            chapterNumber,
-            chapterContent: reviseOutput.revisedContent,
-            lengthSpec,
-            chapterIntent: writeInput.chapterIntent,
-          });
-          totalUsage = PipelineRunner.addUsage(totalUsage, normalizedRevision.tokenUsage);
-          postReviseCount = normalizedRevision.wordCount;
-          normalizeApplied = normalizeApplied || normalizedRevision.applied;
-
-          // Guard: reject revision if AI markers increased
-          const preMarkers = analyzeAITells(finalContent);
-          const postMarkers = analyzeAITells(normalizedRevision.content);
-          const preCount = preMarkers.issues.length;
-          const postCount = postMarkers.issues.length;
-
-          if (postCount > preCount) {
-            // Revision made text MORE AI-like — discard it, keep original
-          } else {
-            finalContent = normalizedRevision.content;
-            finalWordCount = normalizedRevision.wordCount;
-            revised = true;
-            this.assertChapterContentNotEmpty(finalContent, chapterNumber, "revision");
-          }
-
-          // Re-audit the (possibly revised) content
-          const reAudit = await auditor.auditChapter(
-            bookDir,
-            finalContent,
-            chapterNumber,
-            book.genre,
-            { ...reducedControlInput, temperature: 0 },
-          );
-          totalUsage = PipelineRunner.addUsage(totalUsage, reAudit.tokenUsage);
-          const reAITells = analyzeAITells(finalContent);
-          const reSensitive = analyzeSensitiveWords(finalContent);
-          const reHasBlocked = reSensitive.found.some((f) => f.severity === "block");
-          auditResult = this.restoreLostAuditIssues(auditResult, {
-            passed: reHasBlocked ? false : reAudit.passed,
-            issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues],
-            summary: reAudit.summary,
-          });
-        }
-      }
-    }
+      lengthSpec,
+      initialUsage: totalUsage,
+      createReviser: () => new ReviserAgent(this.agentCtxFor("reviser", bookId)),
+      auditor,
+      normalizeDraftLengthIfNeeded: (chapterContent) => this.normalizeDraftLengthIfNeeded({
+        bookId,
+        chapterNumber,
+        chapterContent,
+        lengthSpec,
+        chapterIntent: writeInput.chapterIntent,
+      }),
+      assertChapterContentNotEmpty: (content, stage) =>
+        this.assertChapterContentNotEmpty(content, chapterNumber, stage),
+      addUsage: PipelineRunner.addUsage,
+      restoreLostAuditIssues: (previous, next) => this.restoreLostAuditIssues(previous, next),
+      analyzeAITells,
+      analyzeSensitiveWords,
+      logWarn: (message) => this.logWarn(pipelineLang, message),
+      logStage: (message) => this.logStage(stageLanguage, message),
+    });
+    totalUsage = reviewResult.totalUsage;
+    let finalContent = reviewResult.finalContent;
+    let finalWordCount = reviewResult.finalWordCount;
+    let revised = reviewResult.revised;
+    let auditResult = reviewResult.auditResult;
+    const postReviseCount = reviewResult.postReviseCount;
+    const normalizeApplied = reviewResult.normalizeApplied;
 
     // 4. Save the final chapter and truth files from a single persistence source
     this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" });
@@ -1264,7 +1157,7 @@ export class PipelineRunner {
     const lengthTelemetry = this.buildLengthTelemetry({
       lengthSpec,
       writerCount,
-      postWriterNormalizeCount: normalizedBeforeAudit.wordCount,
+      postWriterNormalizeCount: reviewResult.preAuditNormalizedWordCount,
       postReviseCount,
       finalCount: finalWordCount,
       normalizeApplied,