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

feat(review): v9 scoring loop and unified reviser

Squashed from v9 branch feature commits (1433d00..44c23ab):

- Unified reviser prompt: LLM self-selects PATCHES or REVISED_CONTENT
  based on issue severity, no hardcoded mode routing
- Scoring loop: max 3 iterations, assess→revise→assess, pick best
  scoring version. Pass = auditResult.passed AND score >= 85 AND
  word count in softRange
- Spot-fix robustness: fuzzy whitespace matching, per-patch fallback
  instead of all-or-nothing, patch success rate >= 50% threshold
- Review gaps: isPassed checks auditResult.passed, state-validator
  fail-closed restored
- Pre-audit spot-fix removed: postWriteErrors fed as extraIssues
  into first assess() call, handled by scoring loop uniformly
Ma 7 месяцев назад
Родитель
Сommit
1751d78259

+ 121 - 101
packages/core/src/__tests__/chapter-review-cycle.test.ts

@@ -13,28 +13,51 @@ const LENGTH_SPEC: LengthSpec = {
   normalizeMode: "none",
 };
 
-const ZERO_USAGE = {
+const ZERO_USAGE: { promptTokens: number; completionTokens: number; totalTokens: number } = {
   promptTokens: 0,
   completionTokens: 0,
   totalTokens: 0,
-} as const;
+};
 
 function createAuditResult(overrides?: Partial<AuditResult>): AuditResult {
   return {
     passed: true,
     issues: [],
     summary: "clean",
+    overallScore: 90,
     ...overrides,
   };
 }
 
-describe("runChapterReviewCycle", () => {
-  it("applies post-write spot-fix before the first audit pass", async () => {
+const baseParams = {
+  book: { genre: "xuanhuan" },
+  bookDir: "/tmp/book",
+  chapterNumber: 1,
+  lengthSpec: LENGTH_SPEC,
+  reducedControlInput: undefined,
+  initialUsage: ZERO_USAGE,
+  assertChapterContentNotEmpty: () => undefined,
+  addUsage: (left: typeof ZERO_USAGE, right?: typeof ZERO_USAGE) => ({
+    promptTokens: left.promptTokens + (right?.promptTokens ?? 0),
+    completionTokens: left.completionTokens + (right?.completionTokens ?? 0),
+    totalTokens: left.totalTokens + (right?.totalTokens ?? 0),
+  }),
+  analyzeAITells: () => ({ issues: [] as AuditIssue[] }),
+  analyzeSensitiveWords: () => ({ found: [] as Array<{ severity: "warn" | "block" }>, issues: [] as AuditIssue[] }),
+  logWarn: () => undefined,
+  logStage: () => undefined,
+} as const;
+
+describe("runChapterReviewCycle v9", () => {
+  it("feeds postWriteErrors as extra issues into first assessment", async () => {
+    // postWriteErrors are critical → auditResult.passed forced false
+    // even though LLM says passed=true. This triggers the repair loop.
     const auditChapter = vi.fn()
-      .mockResolvedValue(createAuditResult());
+      .mockResolvedValueOnce(createAuditResult({ overallScore: 90, passed: true }))
+      .mockResolvedValueOnce(createAuditResult({ overallScore: 92, passed: true }));
     const reviseChapter = vi.fn().mockResolvedValue({
-      revisedContent: "fixed draft",
-      wordCount: 10,
+      revisedContent: "a".repeat(200),
+      wordCount: 200,
       fixedIssues: ["fixed"],
       updatedState: "",
       updatedLedger: "",
@@ -42,133 +65,130 @@ describe("runChapterReviewCycle", () => {
       tokenUsage: ZERO_USAGE,
     });
     const normalizeDraftLengthIfNeeded = vi.fn()
-      .mockResolvedValue({
-        content: "fixed draft",
-        wordCount: 10,
+      .mockImplementation(async (content: string) => ({
+        content,
+        wordCount: content.length,
         applied: false,
         tokenUsage: ZERO_USAGE,
-      });
+      }));
 
     const result = await runChapterReviewCycle({
-      book: { genre: "xuanhuan" },
-      bookDir: "/tmp/book",
-      chapterNumber: 1,
+      ...baseParams,
       initialOutput: {
-        content: "raw draft",
-        wordCount: 9,
+        content: "b".repeat(200),
+        wordCount: 200,
         postWriteErrors: [{
-          rule: "paragraph-shape",
-          description: "too fragmented",
-          suggestion: "merge short fragments",
+          rule: "chapter-number-reference",
+          description: "contains chapter ref",
+          suggestion: "remove it",
           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);
+    // postWriteErrors should appear in first audit issues
+    expect(result.auditResult.issues.some(i => i.category === "chapter-number-reference")).toBe(false);
+    // The loop should have run at least once to fix the critical postWriteError
+    expect(reviseChapter).toHaveBeenCalled();
+    expect(reviseChapter.mock.calls[0]?.[4]).toBe("auto");
   });
 
-  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",
-    });
+  it("runs repair loop when score is below threshold, picks best version", async () => {
     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(createAuditResult({
+        passed: false,
+        overallScore: 70,
+        issues: [{ severity: "critical", category: "continuity", description: "broken", suggestion: "fix" }],
+      }))
+      .mockResolvedValueOnce(createAuditResult({
+        passed: false,
+        overallScore: 80,
+        issues: [{ severity: "warning", category: "pacing", description: "slow", suggestion: "trim" }],
+      }))
+      .mockResolvedValueOnce(createAuditResult({
+        passed: false,
+        overallScore: 76,
+        issues: [{ severity: "warning", category: "pacing", description: "still slow", suggestion: "trim more" }],
+      }));
+
+    const reviseChapter = vi.fn()
       .mockResolvedValueOnce({
-        content: "original draft",
-        wordCount: 13,
-        applied: false,
+        revisedContent: "a".repeat(200),
+        wordCount: 200,
+        fixedIssues: ["fixed continuity"],
+        updatedState: "", updatedLedger: "", updatedHooks: "",
         tokenUsage: ZERO_USAGE,
       })
       .mockResolvedValueOnce({
-        content: "rewritten draft",
-        wordCount: 15,
-        applied: false,
+        revisedContent: "b".repeat(200),
+        wordCount: 200,
+        fixedIssues: ["trimmed pacing"],
+        updatedState: "", updatedLedger: "", updatedHooks: "",
         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 normalizeDraftLengthIfNeeded = vi.fn()
+      .mockImplementation(async (content: string) => ({
+        content,
+        wordCount: content.length,
+        applied: false,
+        tokenUsage: ZERO_USAGE,
+      }));
+
+    const result = await runChapterReviewCycle({
+      ...baseParams,
+      initialOutput: {
+        content: "c".repeat(200),
+        wordCount: 200,
+        postWriteErrors: [],
+      },
+      createReviser: () => ({ reviseChapter }),
+      auditor: { auditChapter },
+      normalizeDraftLengthIfNeeded,
+    });
+
+    // Should have attempted 2 revisions:
+    // iter 1: 70 → 80 (+10, net improvement)
+    // iter 2: 80 → 76 (no net improvement, stop)
+    expect(reviseChapter).toHaveBeenCalledTimes(2);
+    expect(reviseChapter.mock.calls[0]?.[4]).toBe("auto");
+
+    // Best version should be picked (score 80 from iter 1)
+    expect(result.auditResult.overallScore).toBe(80);
+    expect(result.finalContent).toBe("a".repeat(200));
+    expect(result.revised).toBe(true);
+  });
+
+  it("stops immediately when initial score passes threshold", async () => {
+    const auditChapter = vi.fn()
+      .mockResolvedValue(createAuditResult({ overallScore: 88 }));
+    const reviseChapter = vi.fn();
+    const normalizeDraftLengthIfNeeded = vi.fn()
+      .mockImplementation(async (content: string) => ({
+        content,
+        wordCount: content.length,
+        applied: false,
+        tokenUsage: ZERO_USAGE,
+      }));
 
     const result = await runChapterReviewCycle({
-      book: { genre: "xuanhuan" },
-      bookDir: "/tmp/book",
-      chapterNumber: 1,
+      ...baseParams,
       initialOutput: {
-        content: "original draft",
-        wordCount: 13,
+        content: "d".repeat(200),
+        wordCount: 200,
         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");
+    // No revision should have been called
+    expect(reviseChapter).not.toHaveBeenCalled();
+    expect(result.auditResult.overallScore).toBe(88);
     expect(result.revised).toBe(false);
   });
 });

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

@@ -256,6 +256,7 @@ describe("PipelineRunner structured-state memory sync", () => {
       passed: true,
       issues: [],
       summary: "clean",
+      overallScore: 90,
       tokenUsage: ZERO_USAGE,
     });
     vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({

+ 70 - 59
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -52,6 +52,7 @@ function createAuditResult(overrides: Partial<AuditResult>): AuditResult {
     passed: true,
     issues: [],
     summary: "ok",
+    overallScore: 90,
     tokenUsage: ZERO_USAGE,
     ...overrides,
   };
@@ -219,6 +220,16 @@ describe("PipelineRunner", () => {
       warnings: [],
       passed: true,
     });
+    // Default reviser mock: return input content unchanged so the review cycle's
+    // repair loop exits immediately when triggered by length-out-of-range content.
+    // Tests that need specific revision behavior override this mock explicitly.
+    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockImplementation(
+      async (_bookDir, chapterContent, _chapterNumber, _issues, _mode, _genre, _options) =>
+        createReviseOutput({
+          revisedContent: chapterContent,
+          wordCount: chapterContent.length,
+        }),
+    );
   });
 
   afterEach(() => {
@@ -1489,6 +1500,7 @@ describe("PipelineRunner", () => {
           passed: false,
           issues: [CRITICAL_ISSUE],
           summary: "needs revision",
+          overallScore: 40,
         }),
       )
       .mockResolvedValueOnce(
@@ -1496,6 +1508,7 @@ describe("PipelineRunner", () => {
           passed: true,
           issues: [],
           summary: "clean",
+          overallScore: 95,
         }),
       );
     vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
@@ -1530,71 +1543,49 @@ describe("PipelineRunner", () => {
 
   it("normalizes revised output once before re-audit when it leaves the target band", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
-    const writerDraft = "中段正文。".repeat(40);
-    const overlongRevision = "修订后正文。".repeat(60);
-    const normalizedRevision = "归一正文。".repeat(40);
+    const overlongDraft = "修订后正文。".repeat(60);
+    const normalizedDraft = "归一正文。".repeat(40);
 
     vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
-        content: writerDraft,
-        wordCount: writerDraft.length,
+        content: overlongDraft,
+        wordCount: overlongDraft.length,
       }),
     );
-    const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
-      .mockResolvedValueOnce(
-        createAuditResult({
-          passed: false,
-          issues: [CRITICAL_ISSUE],
-          summary: "needs revision",
-        }),
-      )
-      .mockResolvedValueOnce(
-        createAuditResult({
-          passed: true,
-          issues: [],
-          summary: "clean",
-        }),
-      );
-    const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
-      createReviseOutput({
-        revisedContent: overlongRevision,
-        wordCount: overlongRevision.length,
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
       }),
     );
     const normalizeChapter = vi.mocked(
       LengthNormalizerAgent.prototype.normalizeChapter,
     ).mockResolvedValue({
-      normalizedContent: normalizedRevision,
-      finalCount: normalizedRevision.length,
+      normalizedContent: normalizedDraft,
+      finalCount: normalizedDraft.length,
       applied: true,
       mode: "compress",
       tokenUsage: ZERO_USAGE,
     });
     vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
       createAnalyzedOutput({
-        content: normalizedRevision,
-        wordCount: normalizedRevision.length,
+        content: normalizedDraft,
+        wordCount: normalizedDraft.length,
       }),
     );
 
     try {
       await runner.writeNextChapter(bookId, 220);
 
-      expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
-        lengthSpec: expect.objectContaining({
-          target: 220,
-          softMin: 190,
-          softMax: 250,
-        }),
-      });
+      // v9: normalization happens once before the scoring loop, not after revision
       expect(normalizeChapter).toHaveBeenCalledTimes(1);
       expect(normalizeChapter.mock.calls[0]?.[0]).toMatchObject({
-        chapterContent: overlongRevision,
+        chapterContent: overlongDraft,
         lengthSpec: expect.objectContaining({
           target: 220,
         }),
       });
-      expect(auditChapter.mock.calls[1]?.[1]).toBe(normalizedRevision);
     } finally {
       await rm(root, { recursive: true, force: true });
     }
@@ -1859,7 +1850,7 @@ describe("PipelineRunner", () => {
     }
   });
 
-  it("uses the latest revised content as the input for follow-up spot-fix revisions", async () => {
+  it("feeds postWriteErrors into the scoring loop as extra issues", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
 
     vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
@@ -1876,39 +1867,40 @@ describe("PipelineRunner", () => {
         ],
       }),
     );
+    // First audit: postWriteErrors make it fail even though LLM says passed
+    // Second audit (after repair): clean
     vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
       .mockResolvedValueOnce(createAuditResult({
-        passed: false,
-        issues: [CRITICAL_ISSUE],
-        summary: "needs another revision",
+        passed: true,
+        overallScore: 88,
+        issues: [],
+        summary: "LLM thinks clean but postWriteErrors will override",
       }))
       .mockResolvedValueOnce(createAuditResult({
         passed: true,
+        overallScore: 92,
         issues: [],
-        summary: "clean",
+        summary: "clean after fix",
       }));
     const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter")
       .mockResolvedValueOnce(createReviseOutput({
-        revisedContent: "After first fix.",
-        wordCount: "After first fix.".length,
-      }))
-      .mockResolvedValueOnce(createReviseOutput({
-        revisedContent: "After second fix.",
-        wordCount: "After second fix.".length,
+        revisedContent: "After auto fix.",
+        wordCount: "After auto fix.".length,
       }));
     vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
     vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
     vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
       createAnalyzedOutput({
-        content: "After second fix.",
-        wordCount: "After second fix.".length,
+        content: "After auto fix.",
+        wordCount: "After auto fix.".length,
       }),
     );
 
     await runner.writeNextChapter(bookId);
 
-    expect(reviseChapter).toHaveBeenCalledTimes(2);
-    expect(reviseChapter.mock.calls[1]?.[1]).toBe("After first fix.");
+    // Reviser called via scoring loop (auto mode), not pre-audit spot-fix
+    expect(reviseChapter).toHaveBeenCalled();
+    expect(reviseChapter.mock.calls[0]?.[4]).toBe("auto");
 
     await rm(root, { recursive: true, force: true });
   });
@@ -1937,13 +1929,21 @@ describe("PipelineRunner", () => {
         ],
       }),
     );
-    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
-      createAuditResult({
+    // First audit: postWriteErrors force passed=false, score 40 triggers loop
+    // Second audit: after repair, passes
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(createAuditResult({
         passed: true,
+        overallScore: 40,
         issues: [],
-        summary: "clean",
-      }),
-    );
+        summary: "postWriteErrors will override passed",
+      }))
+      .mockResolvedValueOnce(createAuditResult({
+        passed: true,
+        overallScore: 95,
+        issues: [],
+        summary: "clean after fix",
+      }));
     vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
       createReviseOutput({
         revisedContent: "Final revised body.",
@@ -2510,6 +2510,13 @@ describe("PipelineRunner", () => {
         tokenUsage: ZERO_USAGE,
       }),
     );
+    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockImplementation(
+      async (_bookDir, chapterContent) =>
+        createReviseOutput({
+          revisedContent: chapterContent,
+          wordCount: chapterContent.length,
+        }),
+    );
 
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "legacy",
@@ -2585,6 +2592,7 @@ describe("PipelineRunner", () => {
           passed: false,
           issues: [CRITICAL_ISSUE],
           summary: "needs revision",
+          overallScore: 40,
         }),
       )
       .mockResolvedValueOnce(
@@ -2592,6 +2600,7 @@ describe("PipelineRunner", () => {
           passed: true,
           issues: [],
           summary: "clean",
+          overallScore: 95,
         }),
       );
     vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
@@ -3662,6 +3671,7 @@ describe("PipelineRunner", () => {
           passed: false,
           issues: [CRITICAL_ISSUE],
           summary: "needs revision",
+          overallScore: 40,
         }),
       )
       .mockResolvedValueOnce(
@@ -3669,6 +3679,7 @@ describe("PipelineRunner", () => {
           passed: true,
           issues: [],
           summary: "clean",
+          overallScore: 95,
         }),
       );
     vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
@@ -3825,7 +3836,7 @@ describe("PipelineRunner", () => {
     }
   });
 
-  it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => {
+  it("defaults manual reviseDraft to auto when mode is omitted", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");
     const chaptersDir = join(state.bookDir(bookId), "chapters");
@@ -3878,7 +3889,7 @@ describe("PipelineRunner", () => {
       await runner.reviseDraft(bookId, 1);
 
       expect(reviseChapter).toHaveBeenCalledTimes(1);
-      expect(reviseChapter.mock.calls[0]?.[4]).toBe("spot-fix");
+      expect(reviseChapter.mock.calls[0]?.[4]).toBe("auto");
     } finally {
       await rm(root, { recursive: true, force: true });
     }

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

@@ -88,7 +88,6 @@ describe("ReviserAgent", () => {
       const systemPrompt = messages?.[0]?.content ?? "";
 
       expect(systemPrompt).toContain("MUST be in English");
-      expect(systemPrompt).toContain("written entirely in English");
     } finally {
       await rm(root, { recursive: true, force: true });
     }

+ 30 - 24
packages/core/src/__tests__/spot-fix-patches.test.ts

@@ -47,6 +47,8 @@ describe("spot-fix patches", () => {
     ]);
 
     expect(result.applied).toBe(true);
+    expect(result.appliedPatchCount).toBe(1);
+    expect(result.skippedPatchCount).toBe(0);
     expect(result.revisedContent).toBe([
       "门轴轻轻响了一下。",
       "林越先停在门槛外,侧耳听了一息。",
@@ -57,42 +59,46 @@ describe("spot-fix patches", () => {
     ].join("\n"));
   });
 
-  it("rejects patches whose target text is not unique", () => {
-    const original = "他停了一下。\n门里的人也停了一下。";
+  it("skips non-unique patches instead of rejecting all", () => {
+    const original = "他停了一下。\n门里的人也停了一下。\n窗外很静。";
 
     const result = applySpotFixPatches(original, [
-      {
-        targetText: "停了一下",
-        replacementText: "顿了顿",
-      },
+      { targetText: "停了一下", replacementText: "顿了顿" },
+      { targetText: "窗外很静。", replacementText: "窗外传来虫鸣。" },
     ]);
 
-    expect(result.applied).toBe(false);
-    expect(result.revisedContent).toBe(original);
-    expect(result.rejectedReason).toContain("exactly once");
+    expect(result.applied).toBe(true);
+    expect(result.appliedPatchCount).toBe(1);
+    expect(result.skippedPatchCount).toBe(1);
+    expect(result.revisedContent).toContain("窗外传来虫鸣。");
+    expect(result.revisedContent).toContain("停了一下"); // unchanged — patch was skipped
   });
 
-  it("rejects oversized patch sets that touch too much of the chapter", () => {
-    const original = [
-      "第一段很长,需要保留原样。",
-      "第二段也很长,需要保留原样。",
-      "第三段也很长,需要保留原样。",
-      "第四段也很长,需要保留原样。",
-    ].join("\n");
+  it("applies patches via fuzzy match when whitespace differs", () => {
+    const original = "他慢慢站起来,   看了一眼\n远处的山。";
 
     const result = applySpotFixPatches(original, [
       {
-        targetText: [
-          "第一段很长,需要保留原样。",
-          "第二段也很长,需要保留原样。",
-          "第三段也很长,需要保留原样。",
-        ].join("\n"),
-        replacementText: "这里被大段重写了。",
+        targetText: "他慢慢站起来, 看了一眼 远处的山。",
+        replacementText: "他猛地起身,盯着远山。",
       },
     ]);
 
+    expect(result.applied).toBe(true);
+    expect(result.appliedPatchCount).toBe(1);
+    expect(result.revisedContent).toBe("他猛地起身,盯着远山。");
+  });
+
+  it("reports all skipped when no patches can be matched", () => {
+    const original = "完全不相关的内容。";
+
+    const result = applySpotFixPatches(original, [
+      { targetText: "这段不存在", replacementText: "替换" },
+    ]);
+
     expect(result.applied).toBe(false);
-    expect(result.revisedContent).toBe(original);
-    expect(result.rejectedReason).toContain("touch");
+    expect(result.appliedPatchCount).toBe(0);
+    expect(result.skippedPatchCount).toBe(1);
+    expect(result.rejectedReason).toContain("No patches could be matched");
   });
 });

+ 27 - 2
packages/core/src/agents/continuity.ts

@@ -14,6 +14,8 @@ export interface AuditResult {
   readonly passed: boolean;
   readonly issues: ReadonlyArray<AuditIssue>;
   readonly summary: string;
+  /** 0-100 overall quality score. Present when the auditor supports scoring. */
+  readonly overallScore?: number;
   readonly tokenUsage?: {
     readonly promptTokens: number;
     readonly completionTokens: number;
@@ -400,6 +402,7 @@ ${dimList}
 Output format MUST be JSON:
 {
   "passed": true/false,
+  "overall_score": 0-100,
   "issues": [
     {
       "severity": "critical|warning|info",
@@ -411,7 +414,15 @@ Output format MUST be JSON:
   "summary": "one-sentence audit conclusion"
 }
 
-passed is false ONLY when critical-severity issues exist.`
+passed is false ONLY when critical-severity issues exist.
+
+overall_score calibration:
+- 95-100: Publishable as-is, no noticeable issues
+- 85-94: Minor blemishes but smooth reading, the reader won't break immersion
+- 75-84: Noticeable problems but the story backbone holds, needs revision but not urgent
+- 65-74: Multiple issues hurt the reading experience, pacing or continuity has gaps
+- < 65: Structural breakdown, needs major rewrite
+Score holistically — do not let a single minor issue tank the score.`
       : `你是一位严格的${gp.name}网络小说审稿编辑。你的任务是对章节进行连续性、一致性和质量审查。${protagonistBlock}${searchNote}
 
 审查维度:
@@ -420,6 +431,7 @@ ${dimList}
 输出格式必须为 JSON:
 {
   "passed": true/false,
+  "overall_score": 0-100,
   "issues": [
     {
       "severity": "critical|warning|info",
@@ -431,7 +443,15 @@ ${dimList}
   "summary": "一句话总结审查结论"
 }
 
-只有当存在 critical 级别问题时,passed 才为 false。`;
+只有当存在 critical 级别问题时,passed 才为 false。
+
+overall_score 评分校准:
+- 95-100:可直接发布,无明显问题
+- 85-94:有小瑕疵但整体流畅可读,读者不会出戏
+- 75-84:有明显问题但故事主干完整,需要修但不紧急
+- 65-74:多处影响阅读体验的问题,节奏或连续性有断裂
+- < 65:结构性问题,需要大幅重写
+综合评分,不要因为单一小问题大幅拉低分数。`;
 
     const ledgerBlock = gp.numericalSystem
       ? isEnglish
@@ -678,6 +698,10 @@ ${overrides}\n`;
     try {
       const parsed = JSON.parse(json);
       if (typeof parsed.passed !== "boolean" && parsed.passed !== undefined) return null;
+      const rawScore = parsed.overall_score ?? parsed.overallScore;
+      const overallScore = typeof rawScore === "number" && Number.isFinite(rawScore)
+        ? Math.round(Math.max(0, Math.min(100, rawScore)))
+        : undefined;
       return {
         passed: Boolean(parsed.passed ?? false),
         issues: Array.isArray(parsed.issues)
@@ -689,6 +713,7 @@ ${overrides}\n`;
             }))
           : [],
         summary: String(parsed.summary ?? ""),
+        overallScore,
       };
     } catch {
       return null;

+ 207 - 84
packages/core/src/agents/reviser.ts

@@ -17,9 +17,9 @@ import { applySpotFixPatches, parseSpotFixPatches } from "../utils/spot-fix-patc
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 
-export type ReviseMode = "polish" | "rewrite" | "rework" | "anti-detect" | "spot-fix";
+export type ReviseMode = "auto" | "polish" | "rewrite" | "rework" | "anti-detect" | "spot-fix";
 
-export const DEFAULT_REVISE_MODE: ReviseMode = "spot-fix";
+export const DEFAULT_REVISE_MODE: ReviseMode = "auto";
 
 export interface ReviseOutput {
   readonly revisedContent: string;
@@ -36,6 +36,7 @@ export interface ReviseOutput {
 }
 
 const MODE_DESCRIPTIONS: Record<ReviseMode, string> = {
+  auto: "", // auto mode uses buildAutoSystemPrompt instead
   polish: "润色:只改表达、节奏、段落呼吸,不改事实与剧情结论。禁止:增删段落、改变人名/地名/物品名、增加新情节或新对话、改变因果关系。只允许:替换用词、调整句序、修改标点节奏",
   rewrite: "改写:允许重组问题段落、调整画面和叙述力度,但优先保留原文的绝大部分句段。除非问题跨越整章,否则禁止整章推倒重写;只能围绕问题段落及其直接上下文改写,同时保留核心事实与人物动机",
   rework: "重写:可重构场景推进和冲突组织,但不改主设定和大事件结果",
@@ -100,27 +101,31 @@ export class ReviserAgent extends BaseAgent {
       ? styleGuideRaw
       : (parsedRules?.body ?? "(无文风指南)");
 
+    const isEnglish = (bookLanguage ?? gp.language) === "en";
+    const resolvedLanguage = isEnglish ? "en" : "zh";
+    const suggestionLabel = isEnglish ? "Suggestion" : "建议";
+
     const issueList = issues
-      .map((i) => `- [${i.severity}] ${i.category}: ${i.description}\n  建议: ${i.suggestion}`)
+      .map((i) => `- [${i.severity}] ${i.category}: ${i.description}\n  ${suggestionLabel}: ${i.suggestion}`)
       .join("\n");
 
-    const modeDesc = MODE_DESCRIPTIONS[mode];
     const numericalRule = gp.numericalSystem
-      ? "\n3. 数值错误必须精确修正,前后对账"
+      ? (isEnglish
+          ? "\n3. Numerical errors must be fixed precisely — cross-check before and after"
+          : "\n3. 数值错误必须精确修正,前后对账")
       : "";
     const protagonistBlock = bookRules?.protagonist
-      ? `\n\n主角人设锁定:${bookRules.protagonist.name},${bookRules.protagonist.personalityLock.join("、")}。修改不得违反人设。`
+      ? (isEnglish
+          ? `\n\nProtagonist lock: ${bookRules.protagonist.name} — ${bookRules.protagonist.personalityLock.join(", ")}. Revisions must not violate the protagonist profile.`
+          : `\n\n主角人设锁定:${bookRules.protagonist.name},${bookRules.protagonist.personalityLock.join("、")}。修改不得违反人设。`)
       : "";
     const lengthGuardrail = options?.lengthSpec
-      ? `\n8. 保持章节字数在目标区间内;只有在修复关键问题确实需要时才允许轻微偏离`
+      ? (isEnglish
+          ? "\n8. Keep the chapter word count within the target range; only allow minor deviation when fixing critical issues truly requires it"
+          : "\n8. 保持章节字数在目标区间内;只有在修复关键问题确实需要时才允许轻微偏离")
       : "";
-
-    const isEnglish = (bookLanguage ?? gp.language) === "en";
-    const resolvedLanguage = isEnglish ? "en" : "zh";
     const langPrefix = isEnglish
-      ? mode === "spot-fix"
-        ? `【LANGUAGE OVERRIDE】ALL output (FIXED_ISSUES, PATCHES, UPDATED_STATE, UPDATED_HOOKS) MUST be in English. Every TARGET_TEXT and REPLACEMENT_TEXT must be written entirely in English.\n\n`
-        : `【LANGUAGE OVERRIDE】ALL output (FIXED_ISSUES, REVISED_CONTENT, UPDATED_STATE, UPDATED_HOOKS) MUST be in English. The revised chapter content must be written entirely in English.\n\n`
+      ? `【LANGUAGE OVERRIDE】ALL output (FIXED_ISSUES, PATCHES, REVISED_CONTENT, UPDATED_STATE, UPDATED_HOOKS) MUST be in English.\n\n`
       : "";
     const governedMode = Boolean(options?.chapterIntent && options?.contextPackage && options?.ruleStack);
     const hooksWorkingSet = governedMode && options?.contextPackage
@@ -143,53 +148,9 @@ export class ReviserAgent extends BaseAgent {
         })
       : characterMatrix;
 
-    const outputFormat = mode === "spot-fix"
-      ? `=== FIXED_ISSUES ===
-(逐条说明修正了什么,一行一条;如果无法安全定点修复,也在这里说明)
-
-=== PATCHES ===
-(只输出需要替换的局部补丁,不得输出整章重写。格式如下,可重复多个 PATCH 区块)
---- PATCH 1 ---
-TARGET_TEXT:
-(必须从原文中精确复制、且能唯一命中的原句或原段)
-REPLACEMENT_TEXT:
-(替换后的局部文本)
---- END PATCH ---
-
-=== UPDATED_STATE ===
-(更新后的完整状态卡)
-${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
-=== UPDATED_HOOKS ===
-(更新后的完整伏笔池)`
-      : `=== FIXED_ISSUES ===
-(逐条说明修正了什么,一行一条)
-
-=== REVISED_CONTENT ===
-(修正后的完整正文)
-
-=== UPDATED_STATE ===
-(更新后的完整状态卡)
-${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
-=== UPDATED_HOOKS ===
-(更新后的完整伏笔池)`;
-
-    const systemPrompt = `${langPrefix}你是一位专业的${gp.name}网络小说修稿编辑。你的任务是根据审稿意见对章节进行修正。${protagonistBlock}
-
-修稿模式:${modeDesc}
-
-修稿原则:
-1. 按模式控制修改幅度
-2. 修根因,不做表面润色${numericalRule}
-4. 伏笔状态必须与伏笔池同步
-5. 不改变剧情走向和核心冲突
-6. 保持原文的语言风格和节奏
-7. 修改后同步更新状态卡${gp.numericalSystem ? "、账本" : ""}、伏笔池
-${lengthGuardrail}
-${mode === "spot-fix" ? "\n9. spot-fix 只能输出局部补丁,禁止输出整章改写;TARGET_TEXT 必须能在原文中唯一命中\n10. 如果需要大面积改写,说明无法安全 spot-fix,并让 PATCHES 留空" : ""}
-
-输出格式:
-
-${outputFormat}`;
+    const systemPrompt = mode === "auto"
+      ? this.buildAutoSystemPrompt({ langPrefix, gp, protagonistBlock, numericalRule, lengthGuardrail, resolvedLanguage })
+      : this.buildLegacySystemPrompt({ langPrefix, gp, protagonistBlock, numericalRule, lengthGuardrail, mode, resolvedLanguage });
 
     const ledgerBlock = gp.numericalSystem
       ? `\n## 资源账本\n${ledger}`
@@ -248,14 +209,12 @@ ${hookDebtBlock}${hooksBlock}${volumeSummariesBlock}${reducedControlBlock || out
 ## 待修正章节
 ${chapterContent}`;
 
-    const maxTokens = mode === "spot-fix" ? 8192 : 16384;
-
     const response = await this.chat(
       [
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
       ],
-      { temperature: 0.3, maxTokens },
+      { temperature: 0.3, maxTokens: 16384 },
     );
 
     const output = this.parseOutput(response.content, gp, mode, chapterContent);
@@ -291,34 +250,198 @@ ${chapterContent}`;
       .map((l) => l.trim())
       .filter((l) => l.length > 0);
 
-    if (mode === "spot-fix") {
-      const patches = parseSpotFixPatches(extract("PATCHES"));
-      const patchResult = applySpotFixPatches(originalChapter, patches);
-
-      return {
-        revisedContent: patchResult.revisedContent,
-        wordCount: patchResult.revisedContent.length,
-        fixedIssues: patchResult.applied ? fixedIssues : [],
-        updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
-        updatedLedger: gp.numericalSystem
-          ? (extract("UPDATED_LEDGER") || "(账本未更新)")
-          : "",
-        updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
-      };
-    }
-
-    const revisedContent = extract("REVISED_CONTENT");
-
-    return {
+    const makeResult = (revisedContent: string, applied: boolean): ReviseOutput => ({
       revisedContent,
       wordCount: revisedContent.length,
-      fixedIssues,
+      fixedIssues: applied ? fixedIssues : [],
       updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
       updatedLedger: gp.numericalSystem
         ? (extract("UPDATED_LEDGER") || "(账本未更新)")
         : "",
       updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
-    };
+    });
+
+    // Auto mode: try PATCHES first, fall back to REVISED_CONTENT
+    if (mode === "auto") {
+      const patchesRaw = extract("PATCHES");
+      if (patchesRaw) {
+        const patches = parseSpotFixPatches(patchesRaw);
+        if (patches.length > 0) {
+          const patchResult = applySpotFixPatches(originalChapter, patches);
+          // Accept patches only if majority applied (≥ 50% success rate)
+          const successRate = patches.length > 0
+            ? patchResult.appliedPatchCount / patches.length
+            : 0;
+          if (patchResult.applied && successRate >= 0.5) {
+            return makeResult(patchResult.revisedContent, true);
+          }
+          // Low success rate or no patches applied — fall through to REVISED_CONTENT
+        }
+      }
+      const revisedContent = extract("REVISED_CONTENT");
+      if (revisedContent) {
+        return makeResult(revisedContent, true);
+      }
+      // Both empty — no fix
+      return makeResult(originalChapter, false);
+    }
+
+    // Legacy spot-fix mode: patches only
+    if (mode === "spot-fix") {
+      const patches = parseSpotFixPatches(extract("PATCHES"));
+      const patchResult = applySpotFixPatches(originalChapter, patches);
+      return makeResult(patchResult.revisedContent, patchResult.applied);
+    }
+
+    // Legacy rewrite/polish/rework/anti-detect: full content
+    const revisedContent = extract("REVISED_CONTENT");
+    return makeResult(revisedContent || originalChapter, revisedContent.length > 0);
+  }
+
+  private buildAutoSystemPrompt(params: {
+    langPrefix: string;
+    gp: GenreProfile;
+    protagonistBlock: string;
+    numericalRule: string;
+    lengthGuardrail: string;
+    resolvedLanguage: "zh" | "en";
+  }): string {
+    const { langPrefix, gp, protagonistBlock, numericalRule, lengthGuardrail, resolvedLanguage } = params;
+    const en = resolvedLanguage === "en";
+    const ledgerSection = gp.numericalSystem
+      ? (en ? "\n=== UPDATED_LEDGER ===\n(Full updated resource ledger)" : "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)")
+      : "";
+
+    return en
+      ? `${langPrefix}You are a professional ${gp.name} web-fiction revision editor. Fix the chapter according to the review notes.${protagonistBlock}
+
+Revision principles:
+1. For local issues (wording, small continuity errors, AI-tell phrases), output PATCHES — surgical replacements that leave the rest of the chapter untouched
+2. For structural issues (pacing collapse, major timeline errors, severe length deviation), output REVISED_CONTENT — a full rewrite of the chapter
+3. Prefer PATCHES whenever possible. Only use REVISED_CONTENT when local patches cannot fix the problem
+4. Fix root causes — do not apply superficial polish${numericalRule}
+5. Hook status must stay in sync with the hooks board. If hook debt briefs are provided, preserve hook payoff scenes
+6. Do not alter the plot direction or core conflicts
+7. Preserve the original language style, rhythm, and pacing — do not compress transitional scenes or remove breathing room${lengthGuardrail}
+
+Each PATCH's TARGET_TEXT should quote the passage you want to change — it can be a sentence, a paragraph, or multiple paragraphs, whatever the problem requires. Do not artificially constrain patch scope.
+
+Output format:
+
+=== FIXED_ISSUES ===
+(List each fix on its own line; if a safe local fix is not possible, explain here)
+
+=== PATCHES ===
+(Output local patches if applicable. Omit this section entirely if using REVISED_CONTENT)
+--- PATCH 1 ---
+TARGET_TEXT:
+(Exact quote from the original that identifies the passage to change)
+REPLACEMENT_TEXT:
+(Replacement text for this passage)
+--- END PATCH ---
+
+=== REVISED_CONTENT ===
+(Full revised chapter content — only when PATCHES cannot solve the problem. Omit this section if using PATCHES)
+
+=== UPDATED_STATE ===
+(Full updated state card)
+${ledgerSection}
+=== UPDATED_HOOKS ===
+(Full updated hooks board)`
+      : `${langPrefix}你是一位专业的${gp.name}网络小说修稿编辑。你的任务是根据审稿意见对章节进行修正。${protagonistBlock}
+
+修稿原则:
+1. 局部问题(措辞、小的连续性错误、AI痕迹用词),输出 PATCHES——定点替换,不碰无关段落
+2. 结构性问题(节奏坍塌、严重时间线错误、字数严重偏离),输出 REVISED_CONTENT——整章重写
+3. 优先使用 PATCHES。只有局部修复确实无法解决问题时才用 REVISED_CONTENT
+4. 修根因,不做表面润色${numericalRule}
+5. 伏笔状态必须与伏笔池同步。如果提供了 Hook Debt 简报,必须保留伏笔兑现段落
+6. 不改变剧情走向和核心冲突
+7. 保持原文的语言风格、节奏和呼吸——不要压缩过渡段、不要删掉减速段${lengthGuardrail}
+
+每个 PATCH 的 TARGET_TEXT 引用你要修改的段落——可以是一句话、一段、或多段,由问题范围决定。不要人为限制修改范围。
+
+输出格式:
+
+=== FIXED_ISSUES ===
+(逐条说明修正了什么,一行一条;如果无法安全局部修复,也在这里说明)
+
+=== PATCHES ===
+(输出局部补丁。如果使用 REVISED_CONTENT 则完全省略此区块)
+--- PATCH 1 ---
+TARGET_TEXT:
+(从原文中精确引用要修改的段落)
+REPLACEMENT_TEXT:
+(替换后的文本)
+--- END PATCH ---
+
+=== REVISED_CONTENT ===
+(修正后的完整正文——仅在 PATCHES 无法解决问题时使用。如果使用 PATCHES 则完全省略此区块)
+
+=== UPDATED_STATE ===
+(更新后的完整状态卡)
+${ledgerSection}
+=== UPDATED_HOOKS ===
+(更新后的完整伏笔池)`;
+  }
+
+  private buildLegacySystemPrompt(params: {
+    langPrefix: string;
+    gp: GenreProfile;
+    protagonistBlock: string;
+    numericalRule: string;
+    lengthGuardrail: string;
+    mode: ReviseMode;
+    resolvedLanguage: "zh" | "en";
+  }): string {
+    const { langPrefix, gp, protagonistBlock, numericalRule, lengthGuardrail, mode } = params;
+    const modeDesc = MODE_DESCRIPTIONS[mode];
+    const outputFormat = mode === "spot-fix"
+      ? `=== FIXED_ISSUES ===
+(逐条说明修正了什么,一行一条;如果无法安全定点修复,也在这里说明)
+
+=== PATCHES ===
+--- PATCH 1 ---
+TARGET_TEXT:
+(必须从原文中精确复制、且能唯一命中的原句或原段)
+REPLACEMENT_TEXT:
+(替换后的局部文本)
+--- END PATCH ---
+
+=== UPDATED_STATE ===
+(更新后的完整状态卡)
+${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
+=== UPDATED_HOOKS ===
+(更新后的完整伏笔池)`
+      : `=== FIXED_ISSUES ===
+(逐条说明修正了什么,一行一条)
+
+=== REVISED_CONTENT ===
+(修正后的完整正文)
+
+=== UPDATED_STATE ===
+(更新后的完整状态卡)
+${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
+=== UPDATED_HOOKS ===
+(更新后的完整伏笔池)`;
+
+    return `${langPrefix}你是一位专业的${gp.name}网络小说修稿编辑。你的任务是根据审稿意见对章节进行修正。${protagonistBlock}
+
+修稿模式:${modeDesc}
+
+修稿原则:
+1. 按模式控制修改幅度
+2. 修根因,不做表面润色${numericalRule}
+4. 伏笔状态必须与伏笔池同步
+5. 不改变剧情走向和核心冲突
+6. 保持原文的语言风格和节奏
+7. 修改后同步更新状态卡${gp.numericalSystem ? "、账本" : ""}、伏笔池
+${lengthGuardrail}
+${mode === "spot-fix" ? "\n9. spot-fix 只能输出局部补丁,禁止输出整章改写;TARGET_TEXT 必须能在原文中唯一命中\n10. 如果需要大面积改写,说明无法安全 spot-fix,并让 PATCHES 留空" : ""}
+
+输出格式:
+
+${outputFormat}`;
   }
 
   private async readFileSafe(path: string): Promise<string> {

+ 182 - 96
packages/core/src/pipeline/chapter-review-cycle.ts

@@ -1,8 +1,9 @@
 import type { AuditIssue, AuditResult } from "../agents/continuity.js";
-import type { ReviseOutput } from "../agents/reviser.js";
+import type { ReviseMode, 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";
+import { countChapterLength, isOutsideSoftRange } from "../utils/length-metrics.js";
 
 export interface ChapterReviewCycleUsage {
   readonly promptTokens: number;
@@ -27,6 +28,17 @@ export interface ChapterReviewCycleResult {
   readonly normalizeApplied: boolean;
 }
 
+const MAX_REVIEW_ITERATIONS = 3;
+const PASS_SCORE_THRESHOLD = 85;
+const NET_IMPROVEMENT_EPSILON = 3;
+
+interface ReviewSnapshot {
+  readonly content: string;
+  readonly wordCount: number;
+  readonly auditResult: AuditResult;
+  readonly score: number;
+}
+
 export async function runChapterReviewCycle(params: {
   readonly book: Pick<{ genre: string }, "genre">;
   readonly bookDir: string;
@@ -41,7 +53,7 @@ export async function runChapterReviewCycle(params: {
       chapterContent: string,
       chapterNumber: number,
       issues: ReadonlyArray<AuditIssue>,
-      mode: "spot-fix",
+      mode?: ReviseMode,
       genre?: string,
       options?: {
         chapterIntent?: string;
@@ -76,7 +88,6 @@ export async function runChapterReviewCycle(params: {
     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 }>;
@@ -86,44 +97,23 @@ export async function runChapterReviewCycle(params: {
   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;
-    }
-  }
+  // Convert postWriteErrors into AuditIssues to feed into the first assessment.
+  // These are deterministic structural violations (chapter-ref, paragraph shape, etc.)
+  // that the LLM auditor might not catch on its own.
+  const postWriteExtraIssues: ReadonlyArray<AuditIssue> = params.initialOutput.postWriteErrors.map((violation) => ({
+    severity: "critical" as const,
+    category: violation.rule,
+    description: violation.description,
+    suggestion: violation.suggestion,
+  }));
 
+  // ---------------------------------------------------------------------------
+  // Initial length normalization (once, before entering the loop)
+  // ---------------------------------------------------------------------------
   const normalizedBeforeAudit = await params.normalizeDraftLengthIfNeeded(finalContent);
   totalUsage = params.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage);
   finalContent = normalizedBeforeAudit.content;
@@ -131,86 +121,182 @@ export async function runChapterReviewCycle(params: {
   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,
+  // ---------------------------------------------------------------------------
+  // Helper: assess a chapter (audit + deterministic checks + length + score)
+  // ---------------------------------------------------------------------------
+  const assess = async (
+    content: string,
+    options?: { temperature?: number; extraIssues?: ReadonlyArray<AuditIssue> },
+  ): Promise<{ auditResult: AuditResult; score: number; lengthInRange: boolean }> => {
+    const llmAudit = await params.auditor.auditChapter(
+      params.bookDir,
+      content,
+      params.chapterNumber,
+      params.book.genre,
+      params.reducedControlInput
+        ? { ...params.reducedControlInput, ...(options ?? {}) }
+        : options,
+    );
+    totalUsage = params.addUsage(totalUsage, llmAudit.tokenUsage);
+    const aiTellsResult = params.analyzeAITells(content);
+    const sensitiveResult = params.analyzeSensitiveWords(content);
+    const hasBlockedWords = sensitiveResult.found.some((item) => item.severity === "block");
+    const wordCount = countChapterLength(content, params.lengthSpec.countingMode);
+    const lengthInRange = !isOutsideSoftRange(wordCount, params.lengthSpec);
+
+    const allIssues: AuditIssue[] = [
+      ...llmAudit.issues,
+      ...aiTellsResult.issues,
+      ...sensitiveResult.issues,
+      ...(options?.extraIssues ?? []),
+    ];
+
+    // Add length issue if out of range
+    if (!lengthInRange) {
+      const direction = wordCount < params.lengthSpec.softMin ? "short" : "long";
+      allIssues.push({
+        severity: "warning",
+        category: "length",
+        description: `Chapter word count ${wordCount} is outside target range ${params.lengthSpec.softMin}-${params.lengthSpec.softMax} (too ${direction}).`,
+        suggestion: direction === "long"
+          ? "Trim redundant explanations and weak-information sentences."
+          : "Expand key scenes with more sensory detail or dialogue.",
+      });
+    }
+
+    const hasExtraCritical = (options?.extraIssues ?? []).some((i) => i.severity === "critical");
+    const auditResult: AuditResult = {
+      passed: (hasBlockedWords || hasExtraCritical) ? false : llmAudit.passed,
+      issues: allIssues,
+      summary: llmAudit.summary,
+      overallScore: llmAudit.overallScore,
+    };
+
+    const score = llmAudit.overallScore ?? 0;
+
+    return { auditResult, score, lengthInRange };
   };
 
-  if (!auditResult.passed) {
-    const criticalIssues = auditResult.issues.filter((issue) => issue.severity === "critical");
-    if (criticalIssues.length > 0) {
+  const isPassed = (assessment: { auditResult: AuditResult; score: number; lengthInRange: boolean }): boolean =>
+    assessment.auditResult.passed && assessment.score >= PASS_SCORE_THRESHOLD && assessment.lengthInRange;
+
+  // ---------------------------------------------------------------------------
+  // Scoring loop: assess → revise → assess, max 3 iterations, pick best
+  // ---------------------------------------------------------------------------
+  params.logStage({ zh: "审计草稿", en: "auditing draft" });
+  const initial = await assess(finalContent, {
+    extraIssues: postWriteExtraIssues.length > 0 ? postWriteExtraIssues : undefined,
+  });
+
+  const snapshots: ReviewSnapshot[] = [{
+    content: finalContent,
+    wordCount: finalWordCount,
+    auditResult: initial.auditResult,
+    score: initial.score,
+  }];
+
+  let currentAudit = initial;
+  let postReviseCount = 0;
+
+  if (!isPassed(initial)) {
+    for (let iteration = 0; iteration < MAX_REVIEW_ITERATIONS; iteration++) {
+      params.logStage({
+        zh: `修复轮次 ${iteration + 1}/${MAX_REVIEW_ITERATIONS}(当前 ${currentAudit.score} 分)`,
+        en: `repair iteration ${iteration + 1}/${MAX_REVIEW_ITERATIONS} (current score: ${currentAudit.score})`,
+      });
+
       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",
+        currentAudit.auditResult.issues,
+        "auto",
         params.book.genre,
-        {
-          ...params.reducedControlInput,
-          lengthSpec: params.lengthSpec,
-        },
+        { ...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,
+      if (reviseOutput.revisedContent.length === 0 || reviseOutput.revisedContent === finalContent) {
+        params.logWarn({
+          zh: `修复轮次 ${iteration + 1} 未产出新内容,退出循环`,
+          en: `repair iteration ${iteration + 1} produced no new content, exiting loop`,
+        });
+        break;
+      }
+
+      params.assertChapterContentNotEmpty(reviseOutput.revisedContent, `repair iteration ${iteration + 1}`);
+      const revisedWordCount = countChapterLength(reviseOutput.revisedContent, params.lengthSpec.countingMode);
+
+      // Re-assess the revised content
+      const nextAssessment = await assess(reviseOutput.revisedContent, { temperature: 0 });
+
+      snapshots.push({
+        content: reviseOutput.revisedContent,
+        wordCount: revisedWordCount,
+        auditResult: nextAssessment.auditResult,
+        score: nextAssessment.score,
+      });
+
+      // Check if passed
+      if (isPassed(nextAssessment)) {
+        params.logStage({
+          zh: `修复后达到通过线(${nextAssessment.score} 分),退出循环`,
+          en: `repair reached pass threshold (${nextAssessment.score}), exiting loop`,
+        });
+        finalContent = reviseOutput.revisedContent;
+        finalWordCount = revisedWordCount;
+        postReviseCount = revisedWordCount;
+        currentAudit = nextAssessment;
+        break;
+      }
+
+      // Check net improvement
+      if (nextAssessment.score > currentAudit.score + NET_IMPROVEMENT_EPSILON) {
+        finalContent = reviseOutput.revisedContent;
+        finalWordCount = revisedWordCount;
+        postReviseCount = revisedWordCount;
+        currentAudit = nextAssessment;
+        // Continue to next iteration
+      } else {
+        params.logWarn({
+          zh: `修复轮次 ${iteration + 1} 未净提升(${currentAudit.score} → ${nextAssessment.score}),退出循环`,
+          en: `repair iteration ${iteration + 1} no net improvement (${currentAudit.score} → ${nextAssessment.score}), exiting loop`,
         });
+        break;
       }
     }
   }
 
+  // ---------------------------------------------------------------------------
+  // Pick the best scoring snapshot for final output
+  // ---------------------------------------------------------------------------
+  const bestSnapshot = snapshots.reduce((best, snap) =>
+    snap.score > best.score + NET_IMPROVEMENT_EPSILON ? snap : best,
+  );
+
+  // If best snapshot differs from current content (repair made things worse
+  // but an earlier version was better), roll back to the best version.
+  if (bestSnapshot.content !== finalContent && bestSnapshot.score > currentAudit.score + NET_IMPROVEMENT_EPSILON) {
+    params.logWarn({
+      zh: `回退到最高分版本(${bestSnapshot.score} 分 vs 当前 ${currentAudit.score} 分)`,
+      en: `rolling back to highest-scoring version (${bestSnapshot.score} vs current ${currentAudit.score})`,
+    });
+    finalContent = bestSnapshot.content;
+    finalWordCount = bestSnapshot.wordCount;
+    currentAudit = {
+      auditResult: bestSnapshot.auditResult,
+      score: bestSnapshot.score,
+      lengthInRange: !isOutsideSoftRange(bestSnapshot.wordCount, params.lengthSpec),
+    };
+  }
+
   return {
     finalContent,
     finalWordCount,
     preAuditNormalizedWordCount: normalizedBeforeAudit.wordCount,
-    revised,
-    auditResult,
+    revised: snapshots.length > 1 && finalContent !== params.initialOutput.content,
+    auditResult: currentAudit.auditResult,
     totalUsage,
     postReviseCount,
     normalizeApplied,

+ 2 - 3
packages/core/src/pipeline/runner.ts

@@ -1215,9 +1215,8 @@ export class PipelineRunner {
       assertChapterContentNotEmpty: (content, stage) =>
         this.assertChapterContentNotEmpty(content, chapterNumber, stage),
       addUsage: PipelineRunner.addUsage,
-      restoreLostAuditIssues: (previous, next) => this.restoreLostAuditIssues(previous, next),
-      analyzeAITells,
-      analyzeSensitiveWords,
+      analyzeAITells: (content) => analyzeAITells(content, pipelineLang),
+      analyzeSensitiveWords: (content) => analyzeSensitiveWords(content, undefined, pipelineLang),
       logWarn: (message) => this.logWarn(pipelineLang, message),
       logStage: (message) => this.logStage(stageLanguage, message),
     });

+ 128 - 41
packages/core/src/utils/spot-fix-patches.ts

@@ -8,11 +8,10 @@ export interface SpotFixPatchApplyResult {
   readonly revisedContent: string;
   readonly rejectedReason?: string;
   readonly appliedPatchCount: number;
+  readonly skippedPatchCount: number;
   readonly touchedChars: number;
 }
 
-const MAX_SPOT_FIX_TOUCHED_RATIO = 0.25;
-
 export function parseSpotFixPatches(raw: string): SpotFixPatch[] {
   const normalized = raw.includes("=== PATCHES ===")
     ? raw.slice(raw.indexOf("=== PATCHES ===") + "=== PATCHES ===".length)
@@ -32,6 +31,12 @@ export function parseSpotFixPatches(raw: string): SpotFixPatch[] {
   return patches.filter((patch) => patch.targetText.length > 0);
 }
 
+/**
+ * Apply patches to original content. Uses best-effort per-patch strategy:
+ * - Try exact match first
+ * - Fall back to fuzzy match (whitespace-normalized) if exact fails
+ * - Skip individual patches that can't be matched instead of rejecting all
+ */
 export function applySpotFixPatches(
   original: string,
   patches: ReadonlyArray<SpotFixPatch>,
@@ -42,61 +47,143 @@ export function applySpotFixPatches(
       revisedContent: original,
       rejectedReason: "No valid patches returned.",
       appliedPatchCount: 0,
+      skippedPatchCount: 0,
       touchedChars: 0,
     };
   }
 
-  const touchedChars = patches.reduce((sum, patch) => sum + patch.targetText.length, 0);
-  if (original.length > 0 && touchedChars / original.length > MAX_SPOT_FIX_TOUCHED_RATIO) {
-    return {
-      applied: false,
-      revisedContent: original,
-      rejectedReason: "Patch set would touch too much of the chapter.",
-      appliedPatchCount: 0,
-      touchedChars,
-    };
-  }
-
   let current = original;
+  let appliedPatchCount = 0;
+  let skippedPatchCount = 0;
+  let touchedChars = 0;
 
   for (const patch of patches) {
-    const start = current.indexOf(patch.targetText);
-    if (start === -1) {
-      return {
-        applied: false,
-        revisedContent: original,
-        rejectedReason: "Each TARGET_TEXT must match the chapter exactly once.",
-        appliedPatchCount: 0,
-        touchedChars,
-      };
+    const result = tryApplyPatch(current, patch);
+    if (result) {
+      current = result.content;
+      touchedChars += patch.targetText.length;
+      appliedPatchCount++;
+    } else {
+      skippedPatchCount++;
     }
-
-    const another = current.indexOf(patch.targetText, start + patch.targetText.length);
-    if (another !== -1) {
-      return {
-        applied: false,
-        revisedContent: original,
-        rejectedReason: "Each TARGET_TEXT must match the chapter exactly once.",
-        appliedPatchCount: 0,
-        touchedChars,
-      };
-    }
-
-    current = [
-      current.slice(0, start),
-      patch.replacementText,
-      current.slice(start + patch.targetText.length),
-    ].join("");
   }
 
   return {
-    applied: current !== original,
+    applied: appliedPatchCount > 0 && current !== original,
     revisedContent: current,
-    appliedPatchCount: patches.length,
+    appliedPatchCount,
+    skippedPatchCount,
     touchedChars,
+    rejectedReason: appliedPatchCount === 0
+      ? "No patches could be matched to the chapter content."
+      : undefined,
   };
 }
 
+function tryApplyPatch(
+  content: string,
+  patch: SpotFixPatch,
+): { content: string } | null {
+  // 1. Try exact match
+  const exactResult = tryExactMatch(content, patch.targetText);
+  if (exactResult) {
+    return {
+      content: content.slice(0, exactResult.start) +
+        patch.replacementText +
+        content.slice(exactResult.start + patch.targetText.length),
+    };
+  }
+
+  // 2. Try fuzzy match (normalize whitespace for comparison)
+  const fuzzyResult = tryFuzzyMatch(content, patch.targetText);
+  if (fuzzyResult) {
+    return {
+      content: content.slice(0, fuzzyResult.start) +
+        patch.replacementText +
+        content.slice(fuzzyResult.end),
+    };
+  }
+
+  return null;
+}
+
+function tryExactMatch(
+  content: string,
+  target: string,
+): { start: number } | null {
+  const start = content.indexOf(target);
+  if (start === -1) return null;
+
+  // Ensure unique match
+  const another = content.indexOf(target, start + target.length);
+  if (another !== -1) return null;
+
+  return { start };
+}
+
+function tryFuzzyMatch(
+  content: string,
+  target: string,
+): { start: number; end: number } | null {
+  const normalizedTarget = normalizeWhitespace(target);
+  if (normalizedTarget.length < 10) return null; // Too short to fuzzy match safely
+
+  // Build a sliding window over the content using normalized comparison.
+  // Strategy: normalize both sides, find the normalized target in the
+  // normalized content, then map back to original positions.
+  const contentChars = [...content];
+  const normalizedContent = normalizeWhitespace(content);
+
+  const matchStart = normalizedContent.indexOf(normalizedTarget);
+  if (matchStart === -1) return null;
+
+  // Ensure unique
+  const anotherMatch = normalizedContent.indexOf(normalizedTarget, matchStart + normalizedTarget.length);
+  if (anotherMatch !== -1) return null;
+
+  // Map normalized position back to original position
+  const originalStart = mapNormalizedToOriginal(content, matchStart);
+  const originalEnd = mapNormalizedToOriginal(content, matchStart + normalizedTarget.length);
+
+  if (originalStart === -1 || originalEnd === -1) return null;
+
+  return { start: originalStart, end: originalEnd };
+}
+
+function normalizeWhitespace(text: string): string {
+  return text.replace(/\s+/g, " ").trim();
+}
+
+/**
+ * Map a position in normalized (whitespace-collapsed) text back to
+ * the corresponding position in the original text.
+ */
+function mapNormalizedToOriginal(original: string, normalizedPos: number): number {
+  let ni = 0; // position in normalized text
+  let inWhitespace = false;
+  let leadingSkipped = false;
+
+  // Skip leading whitespace in original (matches trim())
+  let oi = 0;
+  while (oi < original.length && /\s/.test(original[oi]!)) oi++;
+
+  for (; oi <= original.length && ni < normalizedPos; oi++) {
+    if (oi === original.length) break;
+    const ch = original[oi]!;
+    if (/\s/.test(ch)) {
+      if (!inWhitespace) {
+        ni++; // one space in normalized
+        inWhitespace = true;
+      }
+    } else {
+      ni++;
+      inWhitespace = false;
+    }
+  }
+
+  return oi <= original.length ? oi : -1;
+}
+
 function trimField(value: string): string {
   return value.replace(/^\s*\n/, "").replace(/\n\s*$/, "").trim();
 }