mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
fix(pipeline): scope revision blockers and normalize delta chapters
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user