state-validator.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import { BaseAgent } from "./base.js";
  2. export interface ValidationWarning {
  3. readonly category: string;
  4. readonly description: string;
  5. }
  6. export interface ValidationResult {
  7. readonly warnings: ReadonlyArray<ValidationWarning>;
  8. readonly passed: boolean;
  9. }
  10. /**
  11. * Validates Settler output by comparing old and new truth files via LLM.
  12. * Catches contradictions, missing state changes, and temporal inconsistencies.
  13. *
  14. * Uses a minimal verdict protocol instead of requiring structured JSON:
  15. * Line 1: PASS or FAIL
  16. * Remaining lines: free-form warnings (one per line, optional category prefix)
  17. */
  18. export class StateValidatorAgent extends BaseAgent {
  19. get name(): string {
  20. return "state-validator";
  21. }
  22. async validate(
  23. chapterContent: string,
  24. chapterNumber: number,
  25. oldState: string,
  26. newState: string,
  27. oldHooks: string,
  28. newHooks: string,
  29. language: "zh" | "en" = "zh",
  30. ): Promise<ValidationResult> {
  31. const stateDiff = this.computeDiff(oldState, newState, "State Card");
  32. const hooksDiff = this.computeDiff(oldHooks, newHooks, "Hooks Pool");
  33. // Skip validation if nothing changed
  34. if (!stateDiff && !hooksDiff) {
  35. return { warnings: [], passed: true };
  36. }
  37. const langInstruction = language === "en"
  38. ? "Respond in English."
  39. : "用中文回答。";
  40. const systemPrompt = `You are a continuity validator for a novel writing system. ${langInstruction}
  41. Given the chapter text and the CHANGES made to truth files (state card + hooks pool), check for contradictions:
  42. 1. State change without narrative support — truth file says something changed but the chapter text doesn't describe it
  43. 2. Missing state change — chapter text describes something happening but the truth file didn't capture it
  44. 3. Temporal impossibility — character moves locations without transition, injury heals without time passing
  45. 4. Hook anomaly — a hook disappeared without being marked resolved, or a new hook has no basis in the chapter
  46. 5. Retroactive edit — truth file change implies something happened in a PREVIOUS chapter, not the current one
  47. Output format (simple, NOT JSON):
  48. - First line: exactly PASS or FAIL (nothing else on this line)
  49. - Following lines: one warning per line, optionally prefixed with [category]
  50. - If no issues at all, just output: PASS
  51. Example:
  52. PASS
  53. [unsupported_change] State card says character moved to the forest, but text only shows intent
  54. [minor] Hook H03 advanced but text mention is brief
  55. Or if there are hard contradictions:
  56. FAIL
  57. [contradiction] State says character is dead but chapter text shows them speaking
  58. [unsupported_change] New location not mentioned anywhere in chapter text
  59. IMPORTANT: Output FAIL ONLY for hard contradictions — facts that directly conflict with the chapter text. Do NOT fail for:
  60. - Slightly ahead-of-text inferences
  61. - Missing details that the state card didn't capture
  62. - Reasonable extrapolations from text
  63. - Hook management differences that don't contradict text
  64. These should be warnings with PASS, not FAIL.`;
  65. const userPrompt = `Chapter ${chapterNumber} validation:
  66. ## State Card Changes
  67. ${stateDiff || "(no changes)"}
  68. ## Hooks Pool Changes
  69. ${hooksDiff || "(no changes)"}
  70. ## Chapter Text (for reference)
  71. ${chapterContent.slice(0, 6000)}`;
  72. try {
  73. const response = await this.chat(
  74. [
  75. { role: "system", content: systemPrompt },
  76. { role: "user", content: userPrompt },
  77. ],
  78. { temperature: 0.1, maxTokens: 2048 },
  79. );
  80. return this.parseResult(response.content);
  81. } catch (error) {
  82. this.log?.warn(`State validation failed: ${error}`);
  83. throw error;
  84. }
  85. }
  86. private computeDiff(oldText: string, newText: string, label: string): string | null {
  87. if (oldText === newText) return null;
  88. const oldLines = oldText.split("\n").filter((l) => l.trim());
  89. const newLines = newText.split("\n").filter((l) => l.trim());
  90. const added = newLines.filter((l) => !oldLines.includes(l));
  91. const removed = oldLines.filter((l) => !newLines.includes(l));
  92. if (added.length === 0 && removed.length === 0) return null;
  93. const parts = [`### ${label}`];
  94. if (removed.length > 0) parts.push("Removed:\n" + removed.map((l) => `- ${l}`).join("\n"));
  95. if (added.length > 0) parts.push("Added:\n" + added.map((l) => `+ ${l}`).join("\n"));
  96. return parts.join("\n");
  97. }
  98. private parseResult(content: string): ValidationResult {
  99. const trimmed = content.trim();
  100. if (!trimmed) {
  101. // Empty response = assume pass (fail-open for empty)
  102. return { warnings: [], passed: true };
  103. }
  104. const lines = trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
  105. if (lines.length === 0) {
  106. return { warnings: [], passed: true };
  107. }
  108. // First line determines verdict
  109. const verdictLine = lines[0]!.toUpperCase();
  110. const passed = !verdictLine.startsWith("FAIL");
  111. // Try JSON fallback for backwards compatibility — extract first balanced {}
  112. if (lines[0]!.startsWith("{")) {
  113. const jsonResult = this.tryParseJsonResult(lines[0]!);
  114. if (jsonResult) return jsonResult;
  115. }
  116. // Parse remaining lines as warnings
  117. const warnings: ValidationWarning[] = [];
  118. for (let i = 1; i < lines.length; i++) {
  119. const line = lines[i]!;
  120. // Skip lines that are just "PASS" or "FAIL" echoes
  121. if (/^(PASS|FAIL)$/i.test(line)) continue;
  122. const categoryMatch = line.match(/^\[([^\]]+)\]\s*(.+)$/);
  123. if (categoryMatch) {
  124. warnings.push({
  125. category: categoryMatch[1]!.trim(),
  126. description: categoryMatch[2]!.trim(),
  127. });
  128. } else if (line.startsWith("- ") || line.startsWith("* ")) {
  129. warnings.push({
  130. category: "general",
  131. description: line.slice(2).trim(),
  132. });
  133. } else if (line.length > 5) {
  134. warnings.push({
  135. category: "general",
  136. description: line,
  137. });
  138. }
  139. }
  140. return { warnings, passed };
  141. }
  142. private tryParseJsonResult(text: string): ValidationResult | null {
  143. try {
  144. const parsed = JSON.parse(text) as {
  145. warnings?: Array<{ category?: string; description?: string }>;
  146. passed?: boolean;
  147. };
  148. if (typeof parsed.passed !== "boolean") return null;
  149. return {
  150. warnings: (parsed.warnings ?? []).map((w) => ({
  151. category: w.category ?? "unknown",
  152. description: w.description ?? "",
  153. })),
  154. passed: parsed.passed,
  155. };
  156. } catch {
  157. return null;
  158. }
  159. }
  160. }