| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- import { BaseAgent } from "./base.js";
- export interface ValidationWarning {
- readonly category: string;
- readonly description: string;
- }
- export interface ValidationResult {
- readonly warnings: ReadonlyArray<ValidationWarning>;
- readonly passed: boolean;
- }
- /**
- * 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",
- ): 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
- 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 userPrompt = `Chapter ${chapterNumber} validation:
- ## 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, maxTokens: 2048 },
- );
- 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 parseResult(content: string): ValidationResult {
- const trimmed = content.trim();
- if (!trimmed) {
- // Empty response = assume pass (fail-open for empty)
- return { warnings: [], passed: true };
- }
- const lines = trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
- if (lines.length === 0) {
- return { warnings: [], passed: true };
- }
- // First line determines verdict
- const verdictLine = lines[0]!.toUpperCase();
- const passed = !verdictLine.startsWith("FAIL");
- // Try JSON fallback for backwards compatibility — extract first balanced {}
- if (lines[0]!.startsWith("{")) {
- const jsonResult = this.tryParseJsonResult(lines[0]!);
- if (jsonResult) return jsonResult;
- }
- // Parse remaining lines as warnings
- const warnings: ValidationWarning[] = [];
- for (let i = 1; i < lines.length; i++) {
- const line = lines[i]!;
- // Skip lines that are just "PASS" or "FAIL" echoes
- 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 {
- 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;
- }
- }
- }
|