| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- import { BaseAgent } from "./base.js";
- export interface ValidationWarning {
- readonly category: string;
- readonly description: string;
- }
- export interface ValidationResult {
- readonly warnings: ReadonlyArray<ValidationWarning>;
- readonly passed: boolean;
- }
- export interface StateValidationAuthorityContext {
- readonly storyFrame?: string;
- readonly bookRules?: string;
- readonly chapterSummaries?: string;
- }
- /**
- * Validates Settler output by comparing old and new truth files via LLM.
- * Catches contradictions, missing state changes, and temporal inconsistencies.
- *
- * Uses a minimal verdict protocol instead of requiring structured JSON:
- * Line 1: PASS or FAIL
- * Remaining lines: free-form warnings (one per line, optional category prefix)
- */
- export class StateValidatorAgent extends BaseAgent {
- get name(): string {
- return "state-validator";
- }
- async validate(
- chapterContent: string,
- chapterNumber: number,
- oldState: string,
- newState: string,
- oldHooks: string,
- newHooks: string,
- language: "zh" | "en" = "zh",
- authorityContext?: StateValidationAuthorityContext,
- ): Promise<ValidationResult> {
- const stateDiff = this.computeDiff(oldState, newState, "State Card");
- const hooksDiff = this.computeDiff(oldHooks, newHooks, "Hooks Pool");
- // Skip validation if nothing changed
- if (!stateDiff && !hooksDiff) {
- return { warnings: [], passed: true };
- }
- const langInstruction = language === "en"
- ? "Respond in English."
- : "用中文回答。";
- const systemPrompt = `You are a continuity validator for a novel writing system. ${langInstruction}
- Given the chapter text and the CHANGES made to truth files (state card + hooks pool), check for contradictions:
- 1. State change without narrative support — truth file says something changed but the chapter text doesn't describe it
- 2. Missing state change — chapter text describes something happening but the truth file didn't capture it
- 3. Temporal impossibility — character moves locations without transition, injury heals without time passing
- 4. Hook anomaly — a hook disappeared without being marked resolved, or a new hook has no basis in the chapter
- 5. Retroactive edit — truth file change implies something happened in a PREVIOUS chapter, not the current one
- 6. Cross-truth key-setting conflict — numbered rules, named laws, ranks, identities, locations, or relationship labels in the new truth files contradict the chapter text or the authority context
- Output format (simple, NOT JSON):
- - First line: exactly PASS or FAIL (nothing else on this line)
- - Following lines: one warning per line, optionally prefixed with [category]
- - If no issues at all, just output: PASS
- Example:
- PASS
- [unsupported_change] State card says character moved to the forest, but text only shows intent
- [minor] Hook H03 advanced but text mention is brief
- Or if there are hard contradictions:
- FAIL
- [contradiction] State says character is dead but chapter text shows them speaking
- [unsupported_change] New location not mentioned anywhere in chapter text
- IMPORTANT: Output FAIL ONLY for hard contradictions — facts that directly conflict with the chapter text. Do NOT fail for:
- - Slightly ahead-of-text inferences
- - Missing details that the state card didn't capture
- - Reasonable extrapolations from text
- - Hook management differences that don't contradict text
- These should be warnings with PASS, not FAIL.`;
- const authorityBlock = this.buildAuthorityContextBlock(authorityContext);
- const userPrompt = `Chapter ${chapterNumber} validation:
- ${authorityBlock}
- ## State Card Changes
- ${stateDiff || "(no changes)"}
- ## Hooks Pool Changes
- ${hooksDiff || "(no changes)"}
- ## Chapter Text (for reference)
- ${chapterContent.slice(0, 6000)}`;
- try {
- const response = await this.chat(
- [
- { role: "system", content: systemPrompt },
- { role: "user", content: userPrompt },
- ],
- { temperature: 0.1 },
- );
- return this.parseResult(response.content);
- } catch (error) {
- this.log?.warn(`State validation failed: ${error}`);
- throw error;
- }
- }
- private computeDiff(oldText: string, newText: string, label: string): string | null {
- if (oldText === newText) return null;
- const oldLines = oldText.split("\n").filter((l) => l.trim());
- const newLines = newText.split("\n").filter((l) => l.trim());
- const added = newLines.filter((l) => !oldLines.includes(l));
- const removed = oldLines.filter((l) => !newLines.includes(l));
- if (added.length === 0 && removed.length === 0) return null;
- const parts = [`### ${label}`];
- if (removed.length > 0) parts.push("Removed:\n" + removed.map((l) => `- ${l}`).join("\n"));
- if (added.length > 0) parts.push("Added:\n" + added.map((l) => `+ ${l}`).join("\n"));
- return parts.join("\n");
- }
- private buildAuthorityContextBlock(authorityContext?: StateValidationAuthorityContext): string {
- if (!authorityContext) return "## Authority / Cross-Truth Context\n(no authority context provided)";
- const storyFrame = this.truncateHead(authorityContext.storyFrame ?? "", 3500);
- const bookRules = this.truncateHead(authorityContext.bookRules ?? "", 2000);
- const chapterSummaries = this.truncateTail(authorityContext.chapterSummaries ?? "", 3500);
- return [
- "## Authority / Cross-Truth Context",
- "Authority priority: current chapter text > runtime truth files/current summaries > story_frame/book_rules > legacy story_bible intro or marketing-style prose. If the current chapter establishes a numbered/name mapping, new truth files must follow that mapping instead of preserving an older intro-only version.",
- "",
- "### story_frame / legacy story_bible excerpt",
- storyFrame || "(empty)",
- "",
- "### book_rules excerpt",
- bookRules || "(empty)",
- "",
- "### recent chapter_summaries excerpt",
- chapterSummaries || "(empty)",
- ].join("\n");
- }
- private truncateHead(text: string, maxChars: number): string {
- const trimmed = text.trim();
- if (trimmed.length <= maxChars) return trimmed;
- return `${trimmed.slice(0, maxChars).trimEnd()}\n\n[...truncated...]`;
- }
- private truncateTail(text: string, maxChars: number): string {
- const trimmed = text.trim();
- if (trimmed.length <= maxChars) return trimmed;
- return `[...truncated...]\n\n${trimmed.slice(-maxChars).trimStart()}`;
- }
- private parseResult(content: string): ValidationResult {
- const trimmed = content.trim();
- if (!trimmed) {
- throw new Error("LLM returned empty response");
- }
- const jsonResult = this.tryParseJsonResult(trimmed);
- if (jsonResult) {
- return jsonResult;
- }
- const lines = trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
- if (lines.length === 0) {
- throw new Error("LLM returned empty response");
- }
- const verdictLine = lines[0]!;
- if (!/^(PASS|FAIL)$/i.test(verdictLine)) {
- throw new Error("State validator returned invalid response");
- }
- const passed = /^PASS$/i.test(verdictLine);
- const warnings: ValidationWarning[] = [];
- for (let i = 1; i < lines.length; i++) {
- const line = lines[i]!;
- if (/^(PASS|FAIL)$/i.test(line)) continue;
- const categoryMatch = line.match(/^\[([^\]]+)\]\s*(.+)$/);
- if (categoryMatch) {
- warnings.push({
- category: categoryMatch[1]!.trim(),
- description: categoryMatch[2]!.trim(),
- });
- } else if (line.startsWith("- ") || line.startsWith("* ")) {
- warnings.push({
- category: "general",
- description: line.slice(2).trim(),
- });
- } else if (line.length > 5) {
- warnings.push({
- category: "general",
- description: line,
- });
- }
- }
- return { warnings, passed };
- }
- private tryParseJsonResult(text: string): ValidationResult | null {
- const direct = this.tryParseExactJsonResult(text);
- if (direct) {
- return direct;
- }
- const candidate = extractBalancedJsonObject(text);
- if (!candidate) {
- return null;
- }
- return this.tryParseExactJsonResult(candidate);
- }
- private tryParseExactJsonResult(text: string): ValidationResult | null {
- try {
- const parsed = JSON.parse(text) as {
- warnings?: Array<{ category?: string; description?: string }>;
- passed?: boolean;
- };
- if (typeof parsed.passed !== "boolean") return null;
- return {
- warnings: (parsed.warnings ?? []).map((w) => ({
- category: w.category ?? "unknown",
- description: w.description ?? "",
- })),
- passed: parsed.passed,
- };
- } catch {
- return null;
- }
- }
- }
- function extractBalancedJsonObject(text: string): string | null {
- const start = text.indexOf("{");
- if (start < 0) {
- return null;
- }
- let depth = 0;
- let inString = false;
- let escaped = false;
- let endIndex = -1;
- for (let index = start; index < text.length; index += 1) {
- const char = text[index]!;
- if (inString) {
- if (escaped) {
- escaped = false;
- continue;
- }
- if (char === "\\") {
- escaped = true;
- continue;
- }
- if (char === "\"") {
- inString = false;
- }
- continue;
- }
- if (char === "\"") {
- inString = true;
- continue;
- }
- if (char === "{") {
- depth += 1;
- continue;
- }
- if (char === "}") {
- depth -= 1;
- if (depth === 0) {
- endIndex = index;
- break;
- }
- if (depth < 0) {
- return null;
- }
- }
- }
- if (endIndex < 0) return null;
- // Only accept the candidate if what follows the closing brace is
- // nothing, whitespace, or a structural JSON terminator.
- // This rejects trailing content like "{...} more text here"
- const followingChar = text[endIndex + 1];
- if (
- followingChar !== undefined &&
- followingChar !== "\n" &&
- followingChar !== "\r" &&
- followingChar !== "\t" &&
- followingChar !== " " &&
- followingChar !== "," &&
- followingChar !== "]" &&
- followingChar !== "}"
- ) {
- return null;
- }
- return text.slice(start, endIndex + 1);
- }
|