Parcourir la source

fix(pipeline): scope revision blockers and normalize delta chapters

Ma il y a 7 mois
Parent
commit
b4f5cc6d96

+ 126 - 7
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -1962,7 +1962,7 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
     await rm(root, { recursive: true, force: true });
   });
   });
 
 
-  it("does not corrupt persisted runtime state when writer delta is invalid", async () => {
+  it("repairs chapter-number drift in writer delta before persisting runtime state", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture({
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "legacy",
       inputGovernanceMode: "legacy",
     });
     });
@@ -1997,9 +1997,6 @@ describe("PipelineRunner", () => {
       }, null, 2), "utf-8"),
       }, null, 2), "utf-8"),
     ]);
     ]);
 
 
-    const beforeState = await readFile(join(storyDir, "current_state.md"), "utf-8");
-    const beforeManifest = await readFile(join(storyDir, "state", "manifest.json"), "utf-8");
-
     vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
     vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
       createWriterOutput({
         content: "Broken chapter body.",
         content: "Broken chapter body.",
@@ -2025,10 +2022,13 @@ describe("PipelineRunner", () => {
       }),
       }),
     );
     );
 
 
-    await expect(runner.writeNextChapter(bookId)).rejects.toThrow();
+    const result = await runner.writeNextChapter(bookId);
 
 
-    await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe(beforeState);
-    await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8")).resolves.toBe(beforeManifest);
+    expect(result.status).toBe("ready-for-review");
+    await expect(readFile(join(storyDir, "current_state.md"), "utf-8"))
+      .resolves.toMatch(/\|\s*(Current Chapter|当前章节)\s*\|\s*1\s*\|/);
+    await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8"))
+      .resolves.toContain("\"lastAppliedChapter\": 1");
 
 
     await rm(root, { recursive: true, force: true });
     await rm(root, { recursive: true, force: true });
   });
   });
@@ -3707,6 +3707,125 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("excludes pure sequence-level fatigue from revision blocker counts", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const bookDir = state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+    const book = await state.loadBookConfig(bookId);
+
+    await writeFile(join(storyDir, "chapter_summaries.md"), [
+      "# 章节摘要",
+      "",
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 1 | 旧门 | 林越 | 进入旧门 | 压力升高 | none | 冷峻 | 调查 |",
+      "| 2 | 灰灯 | 林越 | 检查灰灯 | 压力升高 | none | 冷峻 | 调查 |",
+      "| 3 | 纸页 | 林越 | 对照纸页 | 压力升高 | none | 冷峻 | 调查 |",
+      "",
+    ].join("\n"), "utf-8");
+
+    const result = await (
+      runner as unknown as {
+        evaluateMergedAudit: (params: {
+          auditor: Pick<ContinuityAuditor, "auditChapter">;
+          book: BookConfig;
+          bookDir: string;
+          chapterContent: string;
+          chapterNumber: number;
+          language: "zh" | "en";
+        }) => Promise<{
+          auditResult: AuditResult;
+          aiTellCount: number;
+          blockingCount: number;
+          criticalCount: number;
+        }>;
+      }
+    ).evaluateMergedAudit({
+      auditor: {
+        auditChapter: vi.fn().mockResolvedValue(
+          createAuditResult({
+            passed: true,
+            issues: [],
+            summary: "clean",
+          }),
+        ),
+      },
+      book,
+      bookDir,
+      chapterContent: "林越把纸页摊平,先看角上的水痕,再看最末那道被抹掉的签名。",
+      chapterNumber: 3,
+      language: "zh",
+    });
+
+    expect(result.auditResult.issues.some((issue) => issue.category === "节奏单调")).toBe(true);
+    expect(result.blockingCount).toBe(0);
+    expect(result.criticalCount).toBe(0);
+
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("keeps chapter-level blockers even when sequence-level fatigue shares the same category label", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const bookDir = state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+    const book = await state.loadBookConfig(bookId);
+
+    await writeFile(join(storyDir, "chapter_summaries.md"), [
+      "# 章节摘要",
+      "",
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 1 | 旧门 | 林越 | 进入旧门 | 压力升高 | none | 冷峻 | 调查 |",
+      "| 2 | 灰灯 | 林越 | 检查灰灯 | 压力升高 | none | 冷峻 | 调查 |",
+      "| 3 | 纸页 | 林越 | 对照纸页 | 压力升高 | none | 冷峻 | 调查 |",
+      "",
+    ].join("\n"), "utf-8");
+
+    const result = await (
+      runner as unknown as {
+        evaluateMergedAudit: (params: {
+          auditor: Pick<ContinuityAuditor, "auditChapter">;
+          book: BookConfig;
+          bookDir: string;
+          chapterContent: string;
+          chapterNumber: number;
+          language: "zh" | "en";
+        }) => Promise<{
+          auditResult: AuditResult;
+          aiTellCount: number;
+          blockingCount: number;
+          criticalCount: number;
+        }>;
+      }
+    ).evaluateMergedAudit({
+      auditor: {
+        auditChapter: vi.fn().mockResolvedValue(
+          createAuditResult({
+            passed: false,
+            issues: [{
+              severity: "warning",
+              category: "节奏单调",
+              description: "这一章的推进依然原地打转,没有完成当前场景应有的落点。",
+              suggestion: "让当前章把既定动作落下,不要继续停在同一观察节拍。",
+            }],
+            summary: "needs revision",
+          }),
+        ),
+      },
+      book,
+      bookDir,
+      chapterContent: "林越把纸页摊平,先看角上的水痕,再看最末那道被抹掉的签名。",
+      chapterNumber: 3,
+      language: "zh",
+    });
+
+    expect(result.auditResult.issues.filter((issue) => issue.category === "节奏单调")).toHaveLength(2);
+    expect(result.blockingCount).toBe(1);
+    expect(result.criticalCount).toBe(0);
+
+    await rm(root, { recursive: true, force: true });
+  });
+
   it("uses chapter length telemetry target for manual revise when available", async () => {
   it("uses chapter length telemetry target for manual revise when available", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");
     const storyDir = join(state.bookDir(bookId), "story");

+ 144 - 0
packages/core/src/__tests__/writer.test.ts

@@ -377,6 +377,150 @@ describe("WriterAgent", () => {
     }
     }
   });
   });
 
 
+  it("overrides hallucinated chapter numbers across both delta and summary row", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-hallucinated-chapter-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The city still remembers 1988.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), [
+        "# Current State",
+        "",
+        "| Field | Value |",
+        "| --- | --- |",
+        "| Current Chapter | 2 |",
+        "| Current Goal | Find the vanished mentor |",
+        "| Current Conflict | Guild pressure keeps colliding with the debt trail |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), [
+        "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+        "| --- | --- | --- | --- | --- | --- | --- |",
+        "| mentor-debt | 1 | relationship | open | 2 | 6 | Still unresolved |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 2 | Old Ledger | Lin Yue | Lin Yue finds the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |",
+        "",
+      ].join("\n"), "utf-8"),
+    ]);
+
+    const agent = new WriterAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    vi.spyOn(WriterAgent.prototype as never, "chat" as never)
+      .mockResolvedValueOnce({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "River Ledger",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          "Lin Yue follows the debt into the river-port ledger. The old wall still carries the year 1988.",
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "- ok",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: "=== OBSERVATIONS ===\n- observed",
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: [
+          "=== POST_SETTLEMENT ===",
+          "- mentor-debt advanced",
+          "",
+          "=== RUNTIME_STATE_DELTA ===",
+          "```json",
+          JSON.stringify({
+            chapter: 1988,
+            currentStatePatch: {
+              currentGoal: "Trace the debt through the river-port ledger.",
+              currentConflict: "Guild pressure keeps colliding with the debt trail.",
+            },
+            hookOps: {
+              upsert: [
+                {
+                  hookId: "mentor-debt",
+                  startChapter: 1,
+                  type: "relationship",
+                  status: "progressing",
+                  lastAdvancedChapter: 1988,
+                  expectedPayoff: "Reveal the debt.",
+                  notes: "The ledger clue sharpens the line.",
+                },
+              ],
+              resolve: [],
+              defer: [],
+            },
+            chapterSummary: {
+              chapter: 1988,
+              title: "River Ledger",
+              characters: "Lin Yue",
+              events: "Lin Yue follows the debt into the river-port ledger.",
+              stateChanges: "The debt line sharpens.",
+              hookActivity: "mentor-debt advanced",
+              mood: "tense",
+              chapterType: "investigation",
+            },
+            notes: [],
+          }, null, 2),
+          "```",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    try {
+      const output = await agent.writeChapter({
+        book: {
+          id: "writer-book",
+          title: "Writer Book",
+          platform: "tomato",
+          genre: "xuanhuan",
+          status: "active",
+          targetChapters: 20,
+          chapterWordCount: 2200,
+          language: "en",
+          createdAt: "2026-03-25T00:00:00.000Z",
+          updatedAt: "2026-03-25T00:00:00.000Z",
+        },
+        bookDir,
+        chapterNumber: 3,
+        lengthSpec: buildLengthSpec(2200, "en"),
+      });
+
+      expect(output.runtimeStateDelta?.chapter).toBe(3);
+      expect(output.runtimeStateDelta?.chapterSummary?.chapter).toBe(3);
+      expect(output.runtimeStateSnapshot?.manifest.lastAppliedChapter).toBe(3);
+      expect(output.runtimeStateSnapshot?.hooks.hooks[0]?.lastAdvancedChapter).toBe(3);
+      expect(output.updatedHooks).toContain("| mentor-debt | 1 | relationship | progressing | 3 |");
+      expect(output.updatedChapterSummaries).toContain("| 3 | River Ledger |");
+      expect(output.chapterSummary).toContain("| 3 | River Ledger |");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("returns the arbiter-resolved delta instead of raw new-hook candidates", async () => {
   it("returns the arbiter-resolved delta instead of raw new-hook candidates", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-"));
     const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-"));
     const bookDir = join(root, "book");
     const bookDir = join(root, "book");

+ 64 - 4
packages/core/src/agents/writer.ts

@@ -311,6 +311,7 @@ export class WriterAgent extends BaseAgent {
       bookDir,
       bookDir,
       settlement.runtimeStateDelta,
       settlement.runtimeStateDelta,
       resolvedLanguage,
       resolvedLanguage,
+      chapterNumber,
     );
     );
     const resolvedRuntimeStateDelta = runtimeStateArtifacts?.resolvedDelta ?? settlement.runtimeStateDelta;
     const resolvedRuntimeStateDelta = runtimeStateArtifacts?.resolvedDelta ?? settlement.runtimeStateDelta;
     const priorHookIds = new Set(parsePendingHooksMarkdown(hooks).map((hook) => hook.hookId));
     const priorHookIds = new Set(parsePendingHooksMarkdown(hooks).map((hook) => hook.hookId));
@@ -935,15 +936,69 @@ ${overrides}\n`;
     return `| ${row} |`;
     return `| ${row} |`;
   }
   }
 
 
+  private normalizeRuntimeStateDeltaChapter(
+    delta: RuntimeStateDelta,
+    authoritativeChapterNumber: number,
+  ): RuntimeStateDelta {
+    const hookOps = delta.hookOps ?? {
+      upsert: [],
+      mention: [],
+      resolve: [],
+      defer: [],
+    };
+    let changed = delta.chapter !== authoritativeChapterNumber;
+    const normalizedUpserts = hookOps.upsert.map((hook) => {
+      const startChapter = Math.min(hook.startChapter, authoritativeChapterNumber);
+      const lastAdvancedChapter = Math.min(hook.lastAdvancedChapter, authoritativeChapterNumber);
+      if (startChapter !== hook.startChapter || lastAdvancedChapter !== hook.lastAdvancedChapter) {
+        changed = true;
+      }
+      if (startChapter === hook.startChapter && lastAdvancedChapter === hook.lastAdvancedChapter) {
+        return hook;
+      }
+      return {
+        ...hook,
+        startChapter,
+        lastAdvancedChapter,
+      };
+    });
+
+    if (delta.chapterSummary?.chapter !== undefined && delta.chapterSummary.chapter !== authoritativeChapterNumber) {
+      changed = true;
+    }
+    if (!changed) {
+      return delta;
+    }
+
+    return {
+      ...delta,
+      chapter: authoritativeChapterNumber,
+      hookOps: {
+        ...hookOps,
+        upsert: normalizedUpserts,
+      },
+      chapterSummary: delta.chapterSummary
+        ? {
+            ...delta.chapterSummary,
+            chapter: authoritativeChapterNumber,
+          }
+        : undefined,
+    };
+  }
+
   private async buildRuntimeStateArtifactsIfPresent(
   private async buildRuntimeStateArtifactsIfPresent(
     bookDir: string,
     bookDir: string,
     delta: RuntimeStateDelta | undefined,
     delta: RuntimeStateDelta | undefined,
     language: "zh" | "en",
     language: "zh" | "en",
+    authoritativeChapterNumber?: number,
   ): Promise<RuntimeStateArtifacts | null> {
   ): Promise<RuntimeStateArtifacts | null> {
     if (!delta) return null;
     if (!delta) return null;
+    const safeDelta = authoritativeChapterNumber === undefined
+      ? delta
+      : this.normalizeRuntimeStateDeltaChapter(delta, authoritativeChapterNumber);
     return buildRuntimeStateArtifacts({
     return buildRuntimeStateArtifacts({
       bookDir,
       bookDir,
-      delta,
+      delta: safeDelta,
       language,
       language,
     });
     });
   }
   }
@@ -954,15 +1009,20 @@ ${overrides}\n`;
     language: "zh" | "en",
     language: "zh" | "en",
   ): Promise<RuntimeStateArtifacts | null> {
   ): Promise<RuntimeStateArtifacts | null> {
     if (!output.runtimeStateDelta) return null;
     if (!output.runtimeStateDelta) return null;
+    const safeDelta = this.normalizeRuntimeStateDeltaChapter(
+      output.runtimeStateDelta,
+      output.chapterNumber,
+    );
     if (
     if (
-      output.runtimeStateSnapshot
+      safeDelta === output.runtimeStateDelta
+      && output.runtimeStateSnapshot
       && output.updatedChapterSummaries
       && output.updatedChapterSummaries
       && output.updatedState
       && output.updatedState
       && output.updatedHooks
       && output.updatedHooks
     ) {
     ) {
       return {
       return {
         snapshot: output.runtimeStateSnapshot,
         snapshot: output.runtimeStateSnapshot,
-        resolvedDelta: output.runtimeStateDelta,
+        resolvedDelta: safeDelta,
         currentStateMarkdown: output.updatedState,
         currentStateMarkdown: output.updatedState,
         hooksMarkdown: output.updatedHooks,
         hooksMarkdown: output.updatedHooks,
         chapterSummariesMarkdown: output.updatedChapterSummaries,
         chapterSummariesMarkdown: output.updatedChapterSummaries,
@@ -971,7 +1031,7 @@ ${overrides}\n`;
 
 
     return buildRuntimeStateArtifacts({
     return buildRuntimeStateArtifacts({
       bookDir,
       bookDir,
-      delta: output.runtimeStateDelta,
+      delta: safeDelta,
       language,
       language,
     });
     });
   }
   }

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

@@ -124,6 +124,14 @@ export interface BookStatusInfo {
   readonly chapters: ReadonlyArray<ChapterMeta>;
   readonly chapters: ReadonlyArray<ChapterMeta>;
 }
 }
 
 
+interface MergedAuditEvaluation {
+  readonly auditResult: AuditResult;
+  readonly aiTellCount: number;
+  readonly blockingCount: number;
+  readonly criticalCount: number;
+  readonly revisionBlockingIssues: ReadonlyArray<AuditIssue>;
+}
+
 export interface ImportChaptersInput {
 export interface ImportChaptersInput {
   readonly bookId: string;
   readonly bookId: string;
   readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>;
   readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>;
@@ -2275,19 +2283,16 @@ ${matrix}`,
       aiTellCount: number;
       aiTellCount: number;
       blockingCount: number;
       blockingCount: number;
       criticalCount: number;
       criticalCount: number;
+      revisionBlockingIssues: ReadonlyArray<AuditIssue>;
     },
     },
     next: {
     next: {
       auditResult: AuditResult;
       auditResult: AuditResult;
       aiTellCount: number;
       aiTellCount: number;
       blockingCount: number;
       blockingCount: number;
       criticalCount: number;
       criticalCount: number;
+      revisionBlockingIssues: ReadonlyArray<AuditIssue>;
     },
     },
-  ): {
-    auditResult: AuditResult;
-    aiTellCount: number;
-    blockingCount: number;
-    criticalCount: number;
-  } {
+  ): MergedAuditEvaluation {
     const auditResult = this.restoreLostAuditIssues(previous.auditResult, next.auditResult);
     const auditResult = this.restoreLostAuditIssues(previous.auditResult, next.auditResult);
     if (auditResult === next.auditResult) {
     if (auditResult === next.auditResult) {
       return next;
       return next;
@@ -2296,8 +2301,9 @@ ${matrix}`,
     return {
     return {
       ...next,
       ...next,
       auditResult,
       auditResult,
-      blockingCount: auditResult.issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
-      criticalCount: auditResult.issues.filter((issue) => issue.severity === "critical").length,
+      revisionBlockingIssues: previous.revisionBlockingIssues,
+      blockingCount: previous.blockingCount,
+      criticalCount: previous.criticalCount,
     };
     };
   }
   }
 
 
@@ -2319,12 +2325,7 @@ ${matrix}`,
         hooks?: string;
         hooks?: string;
       };
       };
     };
     };
-  }): Promise<{
-    auditResult: AuditResult;
-    aiTellCount: number;
-    blockingCount: number;
-    criticalCount: number;
-  }> {
+  }): Promise<MergedAuditEvaluation> {
     const llmAudit = await params.auditor.auditChapter(
     const llmAudit = await params.auditor.auditChapter(
       params.bookDir,
       params.bookDir,
       params.chapterContent,
       params.chapterContent,
@@ -2347,6 +2348,11 @@ ${matrix}`,
       ...sensitiveResult.issues,
       ...sensitiveResult.issues,
       ...longSpanFatigue.issues,
       ...longSpanFatigue.issues,
     ];
     ];
+    const revisionBlockingIssues: ReadonlyArray<AuditIssue> = [
+      ...llmAudit.issues,
+      ...aiTells.issues,
+      ...sensitiveResult.issues,
+    ];
 
 
     return {
     return {
       auditResult: {
       auditResult: {
@@ -2356,8 +2362,9 @@ ${matrix}`,
         tokenUsage: llmAudit.tokenUsage,
         tokenUsage: llmAudit.tokenUsage,
       },
       },
       aiTellCount: aiTells.issues.length,
       aiTellCount: aiTells.issues.length,
-      blockingCount: issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
-      criticalCount: issues.filter((issue) => issue.severity === "critical").length,
+      blockingCount: revisionBlockingIssues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
+      criticalCount: revisionBlockingIssues.filter((issue) => issue.severity === "critical").length,
+      revisionBlockingIssues,
     };
     };
   }
   }