refactor(pipeline): extract chapter state recovery

This commit is contained in:
Ma
2026-04-01 16:21:45 +08:00
parent ef7d4ebb6a
commit 616f807d6f
3 changed files with 484 additions and 215 deletions
@@ -0,0 +1,232 @@
import { describe, expect, it, vi } from "vitest";
import type { AuditIssue } from "../agents/continuity.js";
import type {
ValidationResult,
ValidationWarning,
} from "../agents/state-validator.js";
import type { WriteChapterOutput } from "../agents/writer.js";
import type { BookConfig } from "../models/book.js";
import type { ChapterMeta } from "../models/chapter.js";
import {
buildStateDegradedPersistenceOutput,
buildStateDegradedReviewNote,
parseStateDegradedReviewNote,
resolveStateDegradedBaseStatus,
retrySettlementAfterValidationFailure,
} from "../pipeline/chapter-state-recovery.js";
function createBook(): BookConfig {
return {
id: "test-book",
title: "Test Book",
platform: "tomato",
genre: "xuanhuan",
status: "active",
targetChapters: 10,
chapterWordCount: 3000,
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
};
}
function createValidationWarning(
overrides: Partial<ValidationWarning> = {},
): ValidationWarning {
return {
category: overrides.category ?? "current-state",
description: overrides.description ?? "铜牌位置与正文矛盾",
};
}
function createValidationResult(
overrides: Partial<ValidationResult> = {},
): ValidationResult {
return {
passed: overrides.passed ?? false,
warnings: overrides.warnings ?? [createValidationWarning()],
};
}
function createWriteChapterOutput(
overrides: Partial<WriteChapterOutput> = {},
): WriteChapterOutput {
return {
chapterNumber: 3,
title: "第三章",
content: "铜牌贴在胸口。",
wordCount: "铜牌贴在胸口。".length,
preWriteCheck: "ok",
postSettlement: "ok",
updatedState: "new state",
updatedLedger: "new ledger",
updatedHooks: "new hooks",
chapterSummary: "| 3 | 第三章 |",
updatedSubplots: "new subplots",
updatedEmotionalArcs: "new emotional arcs",
updatedCharacterMatrix: "new character matrix",
postWriteErrors: [],
postWriteWarnings: [],
...overrides,
};
}
function createChapterMeta(
overrides: Partial<ChapterMeta> = {},
): ChapterMeta {
return {
number: 3,
title: "第三章",
status: "state-degraded",
wordCount: 1200,
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
auditIssues: [],
lengthWarnings: [],
...overrides,
};
}
describe("chapter-state-recovery", () => {
it("retries settlement with localized validation feedback and recovers on a clean retry", async () => {
let capturedFeedback = "";
const writer = {
settleChapterState: vi.fn(async (input: { validationFeedback?: string }) => {
capturedFeedback = input.validationFeedback ?? "";
return createWriteChapterOutput({
updatedState: "fixed state",
updatedHooks: "fixed hooks",
});
}),
};
const validator = {
validate: vi.fn(async () => createValidationResult({
passed: true,
warnings: [],
})),
};
const logWarn = vi.fn();
const warn = vi.fn();
const result = await retrySettlementAfterValidationFailure({
writer: writer as never,
validator: validator as never,
book: createBook(),
bookDir: "/tmp/test-book",
chapterNumber: 3,
title: "第三章",
content: "铜牌贴在胸口。",
oldState: "old state",
oldHooks: "old hooks",
originalValidation: createValidationResult(),
language: "zh",
logWarn,
logger: { warn } as never,
});
expect(result.kind).toBe("recovered");
expect(capturedFeedback).toContain("上一次状态结算未通过校验");
expect(capturedFeedback).toContain("铜牌位置与正文矛盾");
expect(logWarn).toHaveBeenCalledWith(expect.objectContaining({
zh: expect.stringContaining("仅重试结算层"),
}));
expect(warn).not.toHaveBeenCalled();
});
it("returns localized degraded issues when settlement retry still fails", async () => {
const validatorWarning = createValidationWarning({
description: "挂坠状态仍与正文冲突",
});
const result = await retrySettlementAfterValidationFailure({
writer: {
settleChapterState: vi.fn(async () => createWriteChapterOutput()),
} as never,
validator: {
validate: vi.fn(async () => createValidationResult({
passed: false,
warnings: [validatorWarning],
})),
} as never,
book: createBook(),
bookDir: "/tmp/test-book",
chapterNumber: 3,
title: "第三章",
content: "铜牌贴在胸口。",
oldState: "old state",
oldHooks: "old hooks",
originalValidation: createValidationResult({
warnings: [validatorWarning],
}),
language: "zh",
logWarn: vi.fn(),
logger: { warn: vi.fn() } as never,
});
expect(result.kind).toBe("degraded");
if (result.kind === "degraded") {
expect(result.issues).toEqual([
expect.objectContaining({
category: "state-validation",
description: "挂坠状态仍与正文冲突",
suggestion: "请先基于已保存正文修复本章 state,再继续后续章节。",
}),
]);
}
});
it("freezes truth outputs when degrading persisted settlement", () => {
const output = createWriteChapterOutput({
runtimeStateDelta: { chapter: 3 } as never,
runtimeStateSnapshot: {
chapter: 3,
facts: [],
hooks: [],
chapterSummary: undefined,
} as never,
updatedChapterSummaries: "| 3 | 新摘要 |",
});
const degraded = buildStateDegradedPersistenceOutput({
output,
oldState: "stable state",
oldHooks: "stable hooks",
oldLedger: "stable ledger",
});
expect(degraded.updatedState).toBe("stable state");
expect(degraded.updatedHooks).toBe("stable hooks");
expect(degraded.updatedLedger).toBe("stable ledger");
expect(degraded.runtimeStateDelta).toBeUndefined();
expect(degraded.runtimeStateSnapshot).toBeUndefined();
expect(degraded.updatedChapterSummaries).toBeUndefined();
});
it("round-trips degraded review metadata and resolves fallback base status", () => {
const issues: AuditIssue[] = [{
severity: "warning",
category: "state-validation",
description: "状态结算重试后仍未通过校验。",
suggestion: "请先基于已保存正文修复本章 state,再继续后续章节。",
}];
const note = buildStateDegradedReviewNote("audit-failed", issues);
expect(parseStateDegradedReviewNote(note)).toEqual({
kind: "state-degraded",
baseStatus: "audit-failed",
injectedIssues: ["[warning] 状态结算重试后仍未通过校验。"],
});
expect(resolveStateDegradedBaseStatus(createChapterMeta({
reviewNote: note,
}))).toBe("audit-failed");
expect(resolveStateDegradedBaseStatus(createChapterMeta({
reviewNote: "{bad json",
auditIssues: ["[critical] still broken"],
}))).toBe("audit-failed");
expect(resolveStateDegradedBaseStatus(createChapterMeta({
reviewNote: "{bad json",
auditIssues: ["[warning] needs review"],
}))).toBe("ready-for-review");
});
});
@@ -0,0 +1,235 @@
import type { AuditIssue } from "../agents/continuity.js";
import type {
ValidationResult,
ValidationWarning,
} from "../agents/state-validator.js";
import type { StateValidatorAgent } from "../agents/state-validator.js";
import type { WriteChapterOutput } from "../agents/writer.js";
import type { WriterAgent } from "../agents/writer.js";
import type { Logger } from "../utils/logger.js";
import type { BookConfig } from "../models/book.js";
import type { ChapterMeta } from "../models/chapter.js";
import type { ContextPackage, RuleStack } from "../models/input-governance.js";
import type { LengthLanguage } from "../utils/length-metrics.js";
export interface SettlementRetryParams {
readonly writer: Pick<WriterAgent, "settleChapterState">;
readonly validator: Pick<StateValidatorAgent, "validate">;
readonly book: BookConfig;
readonly bookDir: string;
readonly chapterNumber: number;
readonly title: string;
readonly content: string;
readonly reducedControlInput?: {
chapterIntent: string;
contextPackage: ContextPackage;
ruleStack: RuleStack;
};
readonly oldState: string;
readonly oldHooks: string;
readonly originalValidation: ValidationResult;
readonly language: LengthLanguage;
readonly logWarn?: (message: { zh: string; en: string }) => void;
readonly logger?: Pick<Logger, "warn">;
}
export type SettlementRetryResult =
| {
readonly kind: "recovered";
readonly output: WriteChapterOutput;
readonly validation: ValidationResult;
}
| {
readonly kind: "degraded";
readonly issues: ReadonlyArray<AuditIssue>;
};
export async function retrySettlementAfterValidationFailure(
params: SettlementRetryParams,
): Promise<SettlementRetryResult> {
params.logWarn?.({
zh: `状态校验失败,正在仅重试结算层(第${params.chapterNumber}章)`,
en: `State validation failed; retrying settlement only for chapter ${params.chapterNumber}`,
});
const retryOutput = await params.writer.settleChapterState({
book: params.book,
bookDir: params.bookDir,
chapterNumber: params.chapterNumber,
title: params.title,
content: params.content,
chapterIntent: params.reducedControlInput?.chapterIntent,
contextPackage: params.reducedControlInput?.contextPackage,
ruleStack: params.reducedControlInput?.ruleStack,
validationFeedback: buildStateValidationFeedback(
params.originalValidation.warnings,
params.language,
),
});
let retryValidation: ValidationResult;
try {
retryValidation = await params.validator.validate(
params.content,
params.chapterNumber,
params.oldState,
retryOutput.updatedState,
params.oldHooks,
retryOutput.updatedHooks,
params.language,
);
} catch (error) {
throw new Error(`State validation retry failed for chapter ${params.chapterNumber}: ${String(error)}`);
}
if (retryValidation.warnings.length > 0) {
params.logWarn?.({
zh: `状态校验重试后,第${params.chapterNumber}章仍有 ${retryValidation.warnings.length} 条警告`,
en: `State validation retry still reports ${retryValidation.warnings.length} warning(s) for chapter ${params.chapterNumber}`,
});
for (const warning of retryValidation.warnings) {
params.logger?.warn(` [${warning.category}] ${warning.description}`);
}
}
if (retryValidation.passed) {
return {
kind: "recovered",
output: retryOutput,
validation: retryValidation,
};
}
return {
kind: "degraded",
issues: buildStateDegradedIssues(retryValidation.warnings, params.language),
};
}
export function buildStateValidationFeedback(
warnings: ReadonlyArray<ValidationWarning>,
language: LengthLanguage,
): string {
if (warnings.length === 0) {
return language === "en"
? "The previous settlement contradicted the chapter text. Reconcile truth files strictly to the body."
: "上一次状态结算与正文矛盾。请严格以正文为准修正 truth files。";
}
if (language === "en") {
return [
"The previous settlement failed validation. Fix these contradictions against the chapter body:",
...warnings.map((warning) => `- [${warning.category}] ${warning.description}`),
].join("\n");
}
return [
"上一次状态结算未通过校验。请对照正文修正以下矛盾:",
...warnings.map((warning) => `- [${warning.category}] ${warning.description}`),
].join("\n");
}
export function buildStateDegradedIssues(
warnings: ReadonlyArray<ValidationWarning>,
language: LengthLanguage,
): ReadonlyArray<AuditIssue> {
if (warnings.length > 0) {
return warnings.map((warning) => ({
severity: "warning" as const,
category: "state-validation",
description: warning.description,
suggestion: language === "en"
? "Repair chapter state from the persisted body before continuing."
: "请先基于已保存正文修复本章 state,再继续后续章节。",
}));
}
return [{
severity: "warning",
category: "state-validation",
description: language === "en"
? "State validation still failed after settlement retry."
: "状态结算重试后仍未通过校验。",
suggestion: language === "en"
? "Repair chapter state from the persisted body before continuing."
: "请先基于已保存正文修复本章 state,再继续后续章节。",
}];
}
export function buildStateDegradedPersistenceOutput(params: {
readonly output: WriteChapterOutput;
readonly oldState: string;
readonly oldHooks: string;
readonly oldLedger: string;
}): WriteChapterOutput {
return {
...params.output,
runtimeStateDelta: undefined,
runtimeStateSnapshot: undefined,
updatedState: params.oldState,
updatedLedger: params.oldLedger,
updatedHooks: params.oldHooks,
updatedChapterSummaries: undefined,
};
}
export interface StateDegradedReviewNote {
readonly kind: "state-degraded";
readonly baseStatus: "ready-for-review" | "audit-failed";
readonly injectedIssues: ReadonlyArray<string>;
}
export function buildStateDegradedReviewNote(
baseStatus: "ready-for-review" | "audit-failed",
issues: ReadonlyArray<AuditIssue>,
): string {
return JSON.stringify({
kind: "state-degraded",
baseStatus,
injectedIssues: issues.map((issue) => `[${issue.severity}] ${issue.description}`),
} satisfies StateDegradedReviewNote);
}
export function parseStateDegradedReviewNote(
reviewNote?: string,
): StateDegradedReviewNote | null {
if (!reviewNote) {
return null;
}
try {
const parsed = JSON.parse(reviewNote) as {
kind?: unknown;
baseStatus?: unknown;
injectedIssues?: unknown;
};
if (
parsed.kind !== "state-degraded"
|| (parsed.baseStatus !== "ready-for-review" && parsed.baseStatus !== "audit-failed")
|| !Array.isArray(parsed.injectedIssues)
) {
return null;
}
return {
kind: "state-degraded",
baseStatus: parsed.baseStatus,
injectedIssues: parsed.injectedIssues.filter((issue): issue is string => typeof issue === "string"),
};
} catch {
return null;
}
}
export function resolveStateDegradedBaseStatus(
chapter: Pick<ChapterMeta, "reviewNote" | "auditIssues">,
): "ready-for-review" | "audit-failed" {
const metadata = parseStateDegradedReviewNote(chapter.reviewNote);
if (metadata) {
return metadata.baseStatus;
}
return chapter.auditIssues.some((issue) => issue.startsWith("[critical]"))
? "audit-failed"
: "ready-for-review";
}
+17 -215
View File
@@ -34,6 +34,13 @@ import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state
import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises";
import { join, relative } from "node:path";
import {
buildStateDegradedPersistenceOutput,
buildStateDegradedReviewNote,
parseStateDegradedReviewNote,
resolveStateDegradedBaseStatus,
retrySettlementAfterValidationFailure,
} from "./chapter-state-recovery.js";
export interface PipelineConfig {
readonly client: LLMClient;
@@ -1297,7 +1304,7 @@ export class PipelineRunner {
}
}
if (!validation.passed) {
const recovery = await this.retrySettlementAfterValidationFailure({
const recovery = await retrySettlementAfterValidationFailure({
writer,
validator,
book,
@@ -1310,6 +1317,8 @@ export class PipelineRunner {
oldHooks,
originalValidation: validation,
language: pipelineLang,
logWarn: (message) => this.logWarn(pipelineLang, message),
logger: this.config.logger,
});
if (recovery.kind === "recovered") {
@@ -1318,7 +1327,7 @@ export class PipelineRunner {
} else {
chapterStatus = "state-degraded";
degradedIssues = recovery.issues;
persistenceOutput = this.buildStateDegradedPersistenceOutput({
persistenceOutput = buildStateDegradedPersistenceOutput({
output: persistenceOutput,
oldState,
oldHooks,
@@ -1388,7 +1397,7 @@ export class PipelineRunner {
),
lengthWarnings,
reviewNote: chapterStatus === "state-degraded"
? this.buildStateDegradedReviewNote(
? buildStateDegradedReviewNote(
auditResult.passed ? "ready-for-review" : "audit-failed",
degradedIssues,
)
@@ -1514,7 +1523,7 @@ export class PipelineRunner {
);
if (!validation.passed) {
const recovery = await this.retrySettlementAfterValidationFailure({
const recovery = await retrySettlementAfterValidationFailure({
writer,
validator,
book,
@@ -1526,6 +1535,8 @@ export class PipelineRunner {
oldHooks,
originalValidation: validation,
language: pipelineLang,
logWarn: (message) => this.logWarn(pipelineLang, message),
logger: this.config.logger,
});
if (recovery.kind !== "recovered") {
throw new Error(
@@ -1548,8 +1559,8 @@ export class PipelineRunner {
await this.state.snapshotState(bookId, targetChapter);
await this.syncCurrentStateFactHistory(bookId, targetChapter);
const baseStatus = this.resolveStateDegradedBaseStatus(targetMeta);
const degradedMetadata = this.parseStateDegradedReviewNote(targetMeta.reviewNote);
const baseStatus = resolveStateDegradedBaseStatus(targetMeta);
const degradedMetadata = parseStateDegradedReviewNote(targetMeta.reviewNote);
const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []);
index[targetIndex] = {
...targetMeta,
@@ -2031,215 +2042,6 @@ ${matrix}`,
);
}
private async retrySettlementAfterValidationFailure(params: {
readonly writer: WriterAgent;
readonly validator: StateValidatorAgent;
readonly book: BookConfig;
readonly bookDir: string;
readonly chapterNumber: number;
readonly title: string;
readonly content: string;
readonly reducedControlInput?: {
chapterIntent: string;
contextPackage: ContextPackage;
ruleStack: RuleStack;
};
readonly oldState: string;
readonly oldHooks: string;
readonly originalValidation: ValidationResult;
readonly language: LengthLanguage;
}): Promise<
| {
readonly kind: "recovered";
readonly output: WriteChapterOutput;
readonly validation: ValidationResult;
}
| {
readonly kind: "degraded";
readonly issues: ReadonlyArray<AuditIssue>;
}
> {
this.logWarn(params.language, {
zh: `状态校验失败,正在仅重试结算层(第${params.chapterNumber}章)`,
en: `State validation failed; retrying settlement only for chapter ${params.chapterNumber}`,
});
const retryOutput = await params.writer.settleChapterState({
book: params.book,
bookDir: params.bookDir,
chapterNumber: params.chapterNumber,
title: params.title,
content: params.content,
chapterIntent: params.reducedControlInput?.chapterIntent,
contextPackage: params.reducedControlInput?.contextPackage,
ruleStack: params.reducedControlInput?.ruleStack,
validationFeedback: this.buildStateValidationFeedback(
params.originalValidation.warnings,
params.language,
),
});
let retryValidation: ValidationResult;
try {
retryValidation = await params.validator.validate(
params.content,
params.chapterNumber,
params.oldState,
retryOutput.updatedState,
params.oldHooks,
retryOutput.updatedHooks,
params.language,
);
} catch (error) {
throw new Error(`State validation retry failed for chapter ${params.chapterNumber}: ${String(error)}`);
}
if (retryValidation.warnings.length > 0) {
this.logWarn(params.language, {
zh: `状态校验重试后,第${params.chapterNumber}章仍有 ${retryValidation.warnings.length} 条警告`,
en: `State validation retry still reports ${retryValidation.warnings.length} warning(s) for chapter ${params.chapterNumber}`,
});
for (const warning of retryValidation.warnings) {
this.config.logger?.warn(` [${warning.category}] ${warning.description}`);
}
}
if (retryValidation.passed) {
return {
kind: "recovered",
output: retryOutput,
validation: retryValidation,
};
}
return {
kind: "degraded",
issues: this.buildStateDegradedIssues(retryValidation.warnings, params.language),
};
}
private buildStateValidationFeedback(
warnings: ReadonlyArray<ValidationWarning>,
language: LengthLanguage,
): string {
if (warnings.length === 0) {
return language === "en"
? "The previous settlement contradicted the chapter text. Reconcile truth files strictly to the body."
: "上一次状态结算与正文矛盾。请严格以正文为准修正 truth files。";
}
if (language === "en") {
return [
"The previous settlement failed validation. Fix these contradictions against the chapter body:",
...warnings.map((warning) => `- [${warning.category}] ${warning.description}`),
].join("\n");
}
return [
"上一次状态结算未通过校验。请对照正文修正以下矛盾:",
...warnings.map((warning) => `- [${warning.category}] ${warning.description}`),
].join("\n");
}
private buildStateDegradedIssues(
warnings: ReadonlyArray<ValidationWarning>,
language: LengthLanguage,
): ReadonlyArray<AuditIssue> {
if (warnings.length > 0) {
return warnings.map((warning) => ({
severity: "warning" as const,
category: "state-validation",
description: warning.description,
suggestion: language === "en"
? "Repair chapter state from the persisted body before continuing."
: "请先基于已保存正文修复本章 state,再继续后续章节。",
}));
}
return [{
severity: "warning",
category: "state-validation",
description: language === "en"
? "State validation still failed after settlement retry."
: "状态结算重试后仍未通过校验。",
suggestion: language === "en"
? "Repair chapter state from the persisted body before continuing."
: "请先基于已保存正文修复本章 state,再继续后续章节。",
}];
}
private buildStateDegradedPersistenceOutput(params: {
readonly output: WriteChapterOutput;
readonly oldState: string;
readonly oldHooks: string;
readonly oldLedger: string;
}): WriteChapterOutput {
return {
...params.output,
runtimeStateDelta: undefined,
runtimeStateSnapshot: undefined,
updatedState: params.oldState,
updatedLedger: params.oldLedger,
updatedHooks: params.oldHooks,
updatedChapterSummaries: undefined,
};
}
private buildStateDegradedReviewNote(
baseStatus: "ready-for-review" | "audit-failed",
issues: ReadonlyArray<AuditIssue>,
): string {
return JSON.stringify({
kind: "state-degraded",
baseStatus,
injectedIssues: issues.map((issue) => `[${issue.severity}] ${issue.description}`),
});
}
private parseStateDegradedReviewNote(reviewNote?: string): {
readonly kind: "state-degraded";
readonly baseStatus: "ready-for-review" | "audit-failed";
readonly injectedIssues: ReadonlyArray<string>;
} | null {
if (!reviewNote) {
return null;
}
try {
const parsed = JSON.parse(reviewNote) as {
kind?: unknown;
baseStatus?: unknown;
injectedIssues?: unknown;
};
if (
parsed.kind !== "state-degraded"
|| (parsed.baseStatus !== "ready-for-review" && parsed.baseStatus !== "audit-failed")
|| !Array.isArray(parsed.injectedIssues)
) {
return null;
}
return {
kind: "state-degraded",
baseStatus: parsed.baseStatus,
injectedIssues: parsed.injectedIssues.filter((issue): issue is string => typeof issue === "string"),
};
} catch {
return null;
}
}
private resolveStateDegradedBaseStatus(chapter: ChapterMeta): "ready-for-review" | "audit-failed" {
const metadata = this.parseStateDegradedReviewNote(chapter.reviewNote);
if (metadata) {
return metadata.baseStatus;
}
return chapter.auditIssues.some((issue) => issue.startsWith("[critical]"))
? "audit-failed"
: "ready-for-review";
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------