mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
refactor(pipeline): extract chapter state recovery
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user