Răsfoiți Sursa

fix(pipeline): isolate audit drift guidance from state

Ma 7 luni în urmă
părinte
comite
2638f22927

+ 34 - 0
packages/core/src/__tests__/composer.test.ts

@@ -460,6 +460,40 @@ describe("ComposerAgent", () => {
     expect(fanficCanonEntry?.excerpt).toContain("oath debt logic");
   });
 
+  it("includes dedicated audit drift guidance instead of relying on current_state pollution", async () => {
+    await writeFile(
+      join(storyDir, "audit_drift.md"),
+      [
+        "# Audit Drift",
+        "",
+        "## 审计纠偏(自动生成,下一章写作前参照)",
+        "",
+        "> - [warning] 节奏单调: 最近4章章节类型持续停留在“调查章”。",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const composer = new ComposerAgent({
+      client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await composer.composeChapter({
+      book,
+      bookDir,
+      chapterNumber: 4,
+      plan,
+    });
+
+    const driftEntry = result.contextPackage.selectedContext.find((entry) =>
+      entry.source === "story/audit_drift.md",
+    );
+    expect(driftEntry).toBeDefined();
+    expect(driftEntry?.excerpt).toContain("节奏单调");
+  });
+
   it("emits hook debt briefs for agenda-targeted hooks", async () => {
     await Promise.all([
       writeFile(

+ 15 - 9
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -1225,7 +1225,7 @@ describe("PipelineRunner", () => {
     }
   });
 
-  it("writes English audit drift correction blocks for English books", async () => {
+  it("writes English audit drift guidance into a dedicated file without polluting current_state", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const englishBook = {
       ...(await state.loadBookConfig(bookId)),
@@ -1279,11 +1279,13 @@ describe("PipelineRunner", () => {
     try {
       await runner.writeNextChapter(bookId, 220);
 
+      const driftFile = await readFile(join(state.bookDir(bookId), "story", "audit_drift.md"), "utf-8");
       const currentState = await readFile(join(state.bookDir(bookId), "story", "current_state.md"), "utf-8");
-      expect(currentState).toContain("## Audit Drift Correction");
-      expect(currentState).toContain("> Chapter 1 audit found the following issues");
-      expect(currentState).not.toContain("## 审计纠偏");
-      expect(currentState).not.toContain("下一章写作前参照");
+      expect(driftFile).toContain("## Audit Drift Correction");
+      expect(driftFile).toContain("> Chapter 1 audit found the following issues");
+      expect(driftFile).not.toContain("## 审计纠偏");
+      expect(driftFile).not.toContain("下一章写作前参照");
+      expect(currentState).not.toContain("Audit Drift Correction");
     } finally {
       await rm(root, { recursive: true, force: true });
     }
@@ -3363,7 +3365,7 @@ describe("PipelineRunner", () => {
     }
   });
 
-  it("feeds long-span fatigue warnings back into pipeline audit and drift correction", async () => {
+  it("feeds long-span fatigue warnings back into pipeline audit and dedicated drift guidance", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");
     const now = "2026-03-19T00:00:00.000Z";
@@ -3443,16 +3445,18 @@ describe("PipelineRunner", () => {
 
     try {
       const result = await runner.writeNextChapter(bookId);
+      const driftFile = await readFile(join(storyDir, "audit_drift.md"), "utf-8");
       const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
 
       expect(result.auditResult.issues.some((issue) => issue.category === "节奏单调")).toBe(true);
-      expect(currentState).toContain("节奏单调");
+      expect(driftFile).toContain("节奏单调");
+      expect(currentState).not.toContain("节奏单调");
     } finally {
       await rm(root, { recursive: true, force: true });
     }
   });
 
-  it("feeds hook health warnings back into pipeline audit and drift correction", async () => {
+  it("feeds hook health warnings back into pipeline audit and dedicated drift guidance", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");
 
@@ -3502,11 +3506,13 @@ describe("PipelineRunner", () => {
 
     try {
       const result = await runner.writeNextChapter(bookId);
+      const driftFile = await readFile(join(storyDir, "audit_drift.md"), "utf-8");
       const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
       const savedIndex = await state.loadChapterIndex(bookId);
 
       expect(result.auditResult.issues.some((issue) => issue.category === "伏笔债务")).toBe(true);
-      expect(currentState).toContain("伏笔债务");
+      expect(driftFile).toContain("伏笔债务");
+      expect(currentState).not.toContain("伏笔债务");
       expect(savedIndex[0]?.auditIssues).toEqual(
         expect.arrayContaining([
           expect.stringContaining("活跃伏笔过多"),

+ 5 - 0
packages/core/src/agents/composer.ts

@@ -118,6 +118,11 @@ export class ComposerAgent extends BaseAgent {
   ): Promise<ContextPackage["selectedContext"]> {
     const entries = await Promise.all([
       this.maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."),
+      this.maybeContextSource(
+        storyDir,
+        "audit_drift.md",
+        "Carry forward audit drift guidance from the previous chapter without polluting hard state facts.",
+      ),
       this.maybeContextSource(
         storyDir,
         "current_state.md",

+ 93 - 34
packages/core/src/pipeline/runner.ts

@@ -641,6 +641,15 @@ export class PipelineRunner {
         : ch,
     );
     await this.state.saveChapterIndex(bookId, updated);
+    const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
+    if (targetChapter === latestChapter) {
+      await this.persistAuditDriftGuidance({
+        bookDir,
+        chapterNumber: targetChapter,
+        issues: result.issues.filter((issue) => issue.severity === "critical" || issue.severity === "warning"),
+        language,
+      }).catch(() => undefined);
+    }
 
     await this.emitWebhook(
       result.passed ? "audit-passed" : "audit-failed",
@@ -877,6 +886,17 @@ export class PipelineRunner {
           : ch,
       );
       await this.state.saveChapterIndex(bookId, updatedIndex);
+      const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
+      if (targetChapter === latestChapter) {
+        await this.persistAuditDriftGuidance({
+          bookDir,
+          chapterNumber: targetChapter,
+          issues: effectivePostRevision.auditResult.issues.filter(
+            (issue) => issue.severity === "critical" || issue.severity === "warning",
+          ),
+          language,
+        }).catch(() => undefined);
+      }
 
       // Re-snapshot
       this.logStage(stageLanguage, {
@@ -1379,43 +1399,16 @@ export class PipelineRunner {
     await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
     await this.markBookActiveIfNeeded(bookId);
 
-    // 5.5 Audit drift correction — feed audit findings back into state
-    // This prevents the writer from repeating mistakes in the next chapter
+    // 5.5 Persist audit drift guidance without polluting current_state.md
     const driftIssues = auditResult.issues.filter(
       (i) => i.severity === "critical" || i.severity === "warning",
     );
-    if (chapterStatus !== "state-degraded" && driftIssues.length > 0) {
-      const storyDir = join(bookDir, "story");
-      try {
-        const statePath = join(storyDir, "current_state.md");
-        const currentState = await readFile(statePath, "utf-8").catch(() => "");
-
-        // Append drift correction section (or replace existing one)
-        const correctionHeader = this.localize(stageLanguage, {
-          zh: "## 审计纠偏(自动生成,下一章写作前参照)",
-          en: "## Audit Drift Correction",
-        });
-        const correctionBlock = [
-          correctionHeader,
-          this.localize(stageLanguage, {
-            zh: `> 第${chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
-            en: `> Chapter ${chapterNumber} audit found the following issues to avoid in the next chapter:`,
-          }),
-          ...driftIssues.map((i) => `> - [${i.severity}] ${i.category}: ${i.description}`),
-          "",
-        ].join("\n");
-
-        // Replace existing correction block or append
-        const existingCorrectionIdx = currentState.indexOf(correctionHeader);
-        const updatedState = existingCorrectionIdx >= 0
-          ? currentState.slice(0, existingCorrectionIdx) + correctionBlock
-          : currentState + "\n\n" + correctionBlock;
-
-        await writeFile(statePath, updatedState, "utf-8");
-      } catch {
-        // Non-critical — don't block pipeline if drift correction fails
-      }
-    }
+    await this.persistAuditDriftGuidance({
+      bookDir,
+      chapterNumber,
+      issues: chapterStatus === "state-degraded" ? [] : driftIssues,
+      language: stageLanguage,
+    }).catch(() => undefined);
 
     // 5.6 Snapshot state for rollback support
     if (chapterStatus !== "state-degraded") {
@@ -2712,6 +2705,72 @@ ${matrix}`,
     };
   }
 
+  private async persistAuditDriftGuidance(params: {
+    readonly bookDir: string;
+    readonly chapterNumber: number;
+    readonly issues: ReadonlyArray<AuditIssue>;
+    readonly language: LengthLanguage;
+  }): Promise<void> {
+    const storyDir = join(params.bookDir, "story");
+    const driftPath = join(storyDir, "audit_drift.md");
+    const statePath = join(storyDir, "current_state.md");
+    const currentState = await readFile(statePath, "utf-8").catch(() => "");
+    const sanitizedState = this.stripAuditDriftCorrectionBlock(currentState).trimEnd();
+
+    if (sanitizedState !== currentState) {
+      await writeFile(statePath, sanitizedState, "utf-8");
+    }
+
+    if (params.issues.length === 0) {
+      await rm(driftPath, { force: true }).catch(() => undefined);
+      return;
+    }
+
+    const block = [
+      this.localize(params.language, {
+        zh: "# 审计纠偏",
+        en: "# Audit Drift",
+      }),
+      "",
+      this.localize(params.language, {
+        zh: "## 审计纠偏(自动生成,下一章写作前参照)",
+        en: "## Audit Drift Correction",
+      }),
+      "",
+      this.localize(params.language, {
+        zh: `> 第${params.chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
+        en: `> Chapter ${params.chapterNumber} audit found the following issues to avoid in the next chapter:`,
+      }),
+      ...params.issues.map((issue) => `> - [${issue.severity}] ${issue.category}: ${issue.description}`),
+      "",
+    ].join("\n");
+
+    await writeFile(driftPath, block, "utf-8");
+  }
+
+  private stripAuditDriftCorrectionBlock(currentState: string): string {
+    const headers = [
+      "## 审计纠偏(自动生成,下一章写作前参照)",
+      "## Audit Drift Correction",
+      "# 审计纠偏",
+      "# Audit Drift",
+    ];
+
+    let cutIndex = -1;
+    for (const header of headers) {
+      const index = currentState.indexOf(header);
+      if (index >= 0 && (cutIndex < 0 || index < cutIndex)) {
+        cutIndex = index;
+      }
+    }
+
+    if (cutIndex < 0) {
+      return currentState;
+    }
+
+    return currentState.slice(0, cutIndex).trimEnd();
+  }
+
   private logLengthWarnings(lengthWarnings: ReadonlyArray<string>): void {
     for (const warning of lengthWarnings) {
       this.config.logger?.warn(warning);