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

This commit is contained in:
Ma
2026-04-02 16:54:17 +08:00
parent bbb059bcdb
commit b4f5cc6d96
4 changed files with 357 additions and 27 deletions
@@ -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<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 () => {
const { root, runner, state, bookId } = await createRunnerFixture();
const storyDir = join(state.bookDir(bookId), "story");
+144
View File
@@ -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");
+64 -4
View File
@@ -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<RuntimeStateArtifacts | null> {
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<RuntimeStateArtifacts | null> {
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,
});
}
+23 -16
View File
@@ -124,6 +124,14 @@ export interface BookStatusInfo {
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 {
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<AuditIssue>;
},
next: {
auditResult: AuditResult;
aiTellCount: number;
blockingCount: number;
criticalCount: number;
revisionBlockingIssues: ReadonlyArray<AuditIssue>;
},
): {
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<MergedAuditEvaluation> {
const llmAudit = await params.auditor.auditChapter(
params.bookDir,
params.chapterContent,
@@ -2347,6 +2348,11 @@ ${matrix}`,
...sensitiveResult.issues,
...longSpanFatigue.issues,
];
const revisionBlockingIssues: ReadonlyArray<AuditIssue> = [
...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,
};
}