Browse Source

fix(review): postWrite checks every round + REVISED_CONTENT priority

Issue 2: postWriteErrors (chapter-ref, paragraph shape) now re-run
every assess() round via runPostWriteChecks callback, not just as
one-time extraIssues on the first round. If the reviser doesn't fix
a chapter-ref, subsequent assessments will still flag it.

Issue 3: Auto mode now checks REVISED_CONTENT first, PATCHES second.
If the model outputs both, REVISED_CONTENT wins (it's the more
comprehensive fix for whole-chapter issues). PATCHES only used when
REVISED_CONTENT is absent.

All 516 tests pass.
Ma 7 months ago
parent
commit
2a09be3f03

+ 6 - 1
packages/core/src/__tests__/chapter-review-cycle.test.ts

@@ -87,9 +87,14 @@ describe("runChapterReviewCycle v9", () => {
       createReviser: () => ({ reviseChapter }),
       auditor: { auditChapter },
       normalizeDraftLengthIfNeeded,
+      // Simulates: the reviser fixed the chapter-ref, so re-check returns empty
+      runPostWriteChecks: (content) =>
+        content === "b".repeat(200)
+          ? [{ severity: "critical" as const, category: "chapter-number-reference", description: "contains chapter ref", suggestion: "remove it" }]
+          : [],
     });
 
-    // postWriteErrors should appear in first audit issues
+    // After repair, postWriteChecks on the revised content returns empty → issue gone
     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();

+ 7 - 11
packages/core/src/agents/reviser.ts

@@ -304,27 +304,23 @@ ${chapterContent}`;
       updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
     });
 
-    // Auto mode: try PATCHES first, fall back to REVISED_CONTENT
+    // Auto mode: REVISED_CONTENT takes priority (whole-chapter fix),
+    // PATCHES only used when REVISED_CONTENT is absent (local-only fix).
     if (mode === "auto") {
+      const revisedContent = extract("REVISED_CONTENT");
+      if (revisedContent) {
+        return makeResult(revisedContent, true);
+      }
       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) {
+          if (patchResult.applied && patchResult.appliedPatchCount / patches.length >= 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);
     }

+ 15 - 11
packages/core/src/pipeline/chapter-review-cycle.ts

@@ -93,6 +93,8 @@ export async function runChapterReviewCycle(params: {
     found: ReadonlyArray<{ severity: string }>;
     issues: ReadonlyArray<AuditIssue>;
   };
+  /** Re-run deterministic post-write checks (chapter-ref, paragraph shape, etc.) on any content. */
+  readonly runPostWriteChecks?: (content: string) => ReadonlyArray<AuditIssue>;
   readonly logWarn: (message: { zh: string; en: string }) => void;
   readonly logStage: (message: { zh: string; en: string }) => void;
 }): Promise<ChapterReviewCycleResult> {
@@ -101,10 +103,8 @@ export async function runChapterReviewCycle(params: {
   let finalContent = params.initialOutput.content;
   let finalWordCount = params.initialOutput.wordCount;
 
-  // 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) => ({
+  // Convert initial postWriteErrors into AuditIssues as fallback when runPostWriteChecks isn't provided.
+  const initialPostWriteIssues: ReadonlyArray<AuditIssue> = params.initialOutput.postWriteErrors.map((violation) => ({
     severity: "critical" as const,
     category: violation.rule,
     description: violation.description,
@@ -147,7 +147,7 @@ export async function runChapterReviewCycle(params: {
   // ---------------------------------------------------------------------------
   const assess = async (
     content: string,
-    options?: { temperature?: number; extraIssues?: ReadonlyArray<AuditIssue> },
+    options?: { temperature?: number },
   ): Promise<{ auditResult: AuditResult; score: number; lengthInRange: boolean }> => {
     const llmAudit = await params.auditor.auditChapter(
       params.bookDir,
@@ -165,19 +165,25 @@ export async function runChapterReviewCycle(params: {
     const wordCount = countChapterLength(content, params.lengthSpec.countingMode);
     const lengthInRange = !isOutsideSoftRange(wordCount, params.lengthSpec);
 
+    // Deterministic post-write checks: run every round, not just the first.
+    // If runPostWriteChecks is provided, use it; otherwise fall back to initial postWriteErrors.
+    const postWriteIssues = params.runPostWriteChecks
+      ? params.runPostWriteChecks(content)
+      : initialPostWriteIssues;
+
     const allIssues: AuditIssue[] = [
       ...llmAudit.issues,
       ...aiTellsResult.issues,
       ...sensitiveResult.issues,
-      ...(options?.extraIssues ?? []),
+      ...postWriteIssues,
     ];
 
     // Length is NOT added to reviser issues — normalize handles it as a dedicated step.
     // lengthInRange is only used in isPassed() as a hard gate.
 
-    const hasExtraCritical = (options?.extraIssues ?? []).some((i) => i.severity === "critical");
+    const hasPostWriteCritical = postWriteIssues.some((i) => i.severity === "critical");
     const auditResult: AuditResult = {
-      passed: (hasBlockedWords || hasExtraCritical) ? false : llmAudit.passed,
+      passed: (hasBlockedWords || hasPostWriteCritical) ? false : llmAudit.passed,
       issues: allIssues,
       summary: llmAudit.summary,
       overallScore: llmAudit.overallScore,
@@ -195,9 +201,7 @@ export async function runChapterReviewCycle(params: {
   // 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 initial = await assess(finalContent);
 
   const snapshots: ReviewSnapshot[] = [{
     content: finalContent,

+ 13 - 0
packages/core/src/pipeline/runner.ts

@@ -1177,6 +1177,9 @@ export class PipelineRunner {
       wordCount ?? book.chapterWordCount,
       pipelineLang,
     );
+    const { validatePostWrite: postWriteValidate } = await import("../agents/post-write-validator.js");
+    const { readBookRules } = await import("../agents/rules-reader.js");
+    const parsedBookRules = (await readBookRules(bookDir))?.rules ?? null;
 
     // 1. Write chapter
     const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
@@ -1217,6 +1220,16 @@ export class PipelineRunner {
       addUsage: PipelineRunner.addUsage,
       analyzeAITells: (content) => analyzeAITells(content, pipelineLang),
       analyzeSensitiveWords: (content) => analyzeSensitiveWords(content, undefined, pipelineLang),
+      runPostWriteChecks: (content) => {
+        return postWriteValidate(content, gp, parsedBookRules, pipelineLang)
+          .filter((v) => v.severity === "error")
+          .map((v) => ({
+            severity: "critical" as const,
+            category: v.rule,
+            description: v.description,
+            suggestion: v.suggestion,
+          }));
+      },
       logWarn: (message) => this.logWarn(pipelineLang, message),
       logStage: (message) => this.logStage(stageLanguage, message),
     });