From b4f5cc6d968581aed4cb4c3f4c6e5f255bffed16 Mon Sep 17 00:00:00 2001 From: Ma Date: Thu, 2 Apr 2026 16:54:17 +0800 Subject: [PATCH] fix(pipeline): scope revision blockers and normalize delta chapters --- .../src/__tests__/pipeline-runner.test.ts | 133 +++++++++++++++- packages/core/src/__tests__/writer.test.ts | 144 ++++++++++++++++++ packages/core/src/agents/writer.ts | 68 ++++++++- packages/core/src/pipeline/runner.ts | 39 +++-- 4 files changed, 357 insertions(+), 27 deletions(-) diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index b4a65d0b..41df270f 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -1962,7 +1962,7 @@ describe("PipelineRunner", () => { 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({ inputGovernanceMode: "legacy", }); @@ -1997,9 +1997,6 @@ describe("PipelineRunner", () => { }, 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( createWriterOutput({ 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 }); }); @@ -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; + 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; + 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 () => { const { root, runner, state, bookId } = await createRunnerFixture(); const storyDir = join(state.bookDir(bookId), "story"); diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index d2d54de0..9f482fbe 100644 --- a/packages/core/src/__tests__/writer.test.ts +++ b/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 () => { const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-")); const bookDir = join(root, "book"); diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 5faf8c0c..3f25c979 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -311,6 +311,7 @@ export class WriterAgent extends BaseAgent { bookDir, settlement.runtimeStateDelta, resolvedLanguage, + chapterNumber, ); const resolvedRuntimeStateDelta = runtimeStateArtifacts?.resolvedDelta ?? settlement.runtimeStateDelta; const priorHookIds = new Set(parsePendingHooksMarkdown(hooks).map((hook) => hook.hookId)); @@ -935,15 +936,69 @@ ${overrides}\n`; 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( bookDir: string, delta: RuntimeStateDelta | undefined, language: "zh" | "en", + authoritativeChapterNumber?: number, ): Promise { if (!delta) return null; + const safeDelta = authoritativeChapterNumber === undefined + ? delta + : this.normalizeRuntimeStateDeltaChapter(delta, authoritativeChapterNumber); return buildRuntimeStateArtifacts({ bookDir, - delta, + delta: safeDelta, language, }); } @@ -954,15 +1009,20 @@ ${overrides}\n`; language: "zh" | "en", ): Promise { if (!output.runtimeStateDelta) return null; + const safeDelta = this.normalizeRuntimeStateDeltaChapter( + output.runtimeStateDelta, + output.chapterNumber, + ); if ( - output.runtimeStateSnapshot + safeDelta === output.runtimeStateDelta + && output.runtimeStateSnapshot && output.updatedChapterSummaries && output.updatedState && output.updatedHooks ) { return { snapshot: output.runtimeStateSnapshot, - resolvedDelta: output.runtimeStateDelta, + resolvedDelta: safeDelta, currentStateMarkdown: output.updatedState, hooksMarkdown: output.updatedHooks, chapterSummariesMarkdown: output.updatedChapterSummaries, @@ -971,7 +1031,7 @@ ${overrides}\n`; return buildRuntimeStateArtifacts({ bookDir, - delta: output.runtimeStateDelta, + delta: safeDelta, language, }); } diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 48910617..c30c3489 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -124,6 +124,14 @@ export interface BookStatusInfo { readonly chapters: ReadonlyArray; } +interface MergedAuditEvaluation { + readonly auditResult: AuditResult; + readonly aiTellCount: number; + readonly blockingCount: number; + readonly criticalCount: number; + readonly revisionBlockingIssues: ReadonlyArray; +} + export interface ImportChaptersInput { readonly bookId: string; readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>; @@ -2275,19 +2283,16 @@ ${matrix}`, aiTellCount: number; blockingCount: number; criticalCount: number; + revisionBlockingIssues: ReadonlyArray; }, next: { auditResult: AuditResult; aiTellCount: number; blockingCount: number; criticalCount: number; + revisionBlockingIssues: ReadonlyArray; }, - ): { - auditResult: AuditResult; - aiTellCount: number; - blockingCount: number; - criticalCount: number; - } { + ): MergedAuditEvaluation { const auditResult = this.restoreLostAuditIssues(previous.auditResult, next.auditResult); if (auditResult === next.auditResult) { return next; @@ -2296,8 +2301,9 @@ ${matrix}`, return { ...next, 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; }; }; - }): Promise<{ - auditResult: AuditResult; - aiTellCount: number; - blockingCount: number; - criticalCount: number; - }> { + }): Promise { const llmAudit = await params.auditor.auditChapter( params.bookDir, params.chapterContent, @@ -2347,6 +2348,11 @@ ${matrix}`, ...sensitiveResult.issues, ...longSpanFatigue.issues, ]; + const revisionBlockingIssues: ReadonlyArray = [ + ...llmAudit.issues, + ...aiTells.issues, + ...sensitiveResult.issues, + ]; return { auditResult: { @@ -2356,8 +2362,9 @@ ${matrix}`, tokenUsage: llmAudit.tokenUsage, }, 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, }; }