state-validator.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  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. * Non-blocking: returns warnings but does not prevent truth file writes.
  14. */
  15. export class StateValidatorAgent extends BaseAgent {
  16. get name(): string {
  17. return "state-validator";
  18. }
  19. async validate(
  20. chapterContent: string,
  21. chapterNumber: number,
  22. oldState: string,
  23. newState: string,
  24. oldHooks: string,
  25. newHooks: string,
  26. language: "zh" | "en" = "zh",
  27. ): Promise<ValidationResult> {
  28. const stateDiff = this.computeDiff(oldState, newState, "State Card");
  29. const hooksDiff = this.computeDiff(oldHooks, newHooks, "Hooks Pool");
  30. // Skip validation if nothing changed
  31. if (!stateDiff && !hooksDiff) {
  32. return { warnings: [], passed: true };
  33. }
  34. const langInstruction = language === "en"
  35. ? "Respond in English."
  36. : "用中文回答。";
  37. const systemPrompt = `You are a continuity validator for a novel writing system. ${langInstruction}
  38. Given the chapter text and the CHANGES made to truth files (state card + hooks pool), check for contradictions:
  39. 1. State change without narrative support — truth file says something changed but the chapter text doesn't describe it
  40. 2. Missing state change — chapter text describes something happening but the truth file didn't capture it
  41. 3. Temporal impossibility — character moves locations without transition, injury heals without time passing
  42. 4. Hook anomaly — a hook disappeared without being marked resolved, or a new hook has no basis in the chapter
  43. 5. Retroactive edit — truth file change implies something happened in a PREVIOUS chapter, not the current one
  44. Output JSON:
  45. {
  46. "warnings": [
  47. { "category": "missing_state_change", "description": "..." },
  48. { "category": "unsupported_change", "description": "..." }
  49. ],
  50. "passed": true/false
  51. }
  52. passed = true means no serious contradictions found. Minor observations are still reported as warnings.
  53. If there are no issues at all, return {"warnings": [], "passed": true}.`;
  54. const userPrompt = `Chapter ${chapterNumber} validation:
  55. ## State Card Changes
  56. ${stateDiff || "(no changes)"}
  57. ## Hooks Pool Changes
  58. ${hooksDiff || "(no changes)"}
  59. ## Chapter Text (for reference)
  60. ${chapterContent.slice(0, 6000)}`;
  61. try {
  62. const response = await this.chat(
  63. [
  64. { role: "system", content: systemPrompt },
  65. { role: "user", content: userPrompt },
  66. ],
  67. { temperature: 0, maxTokens: 2048 },
  68. );
  69. return this.parseResult(response.content);
  70. } catch (e) {
  71. // Validation failure should never block the pipeline
  72. this.log?.warn(`State validation failed: ${e}`);
  73. return { warnings: [], passed: true };
  74. }
  75. }
  76. private computeDiff(oldText: string, newText: string, label: string): string | null {
  77. if (oldText === newText) return null;
  78. const oldLines = oldText.split("\n").filter((l) => l.trim());
  79. const newLines = newText.split("\n").filter((l) => l.trim());
  80. const added = newLines.filter((l) => !oldLines.includes(l));
  81. const removed = oldLines.filter((l) => !newLines.includes(l));
  82. if (added.length === 0 && removed.length === 0) return null;
  83. const parts = [`### ${label}`];
  84. if (removed.length > 0) parts.push("Removed:\n" + removed.map((l) => `- ${l}`).join("\n"));
  85. if (added.length > 0) parts.push("Added:\n" + added.map((l) => `+ ${l}`).join("\n"));
  86. return parts.join("\n");
  87. }
  88. private parseResult(content: string): ValidationResult {
  89. try {
  90. const jsonMatch = content.match(/\{[\s\S]*\}/);
  91. if (!jsonMatch) return { warnings: [], passed: true };
  92. const parsed = JSON.parse(jsonMatch[0]) as { warnings?: Array<{ category?: string; description?: string }>; passed?: boolean };
  93. return {
  94. warnings: (parsed.warnings ?? []).map((w) => ({
  95. category: w.category ?? "unknown",
  96. description: w.description ?? "",
  97. })),
  98. passed: parsed.passed !== false,
  99. };
  100. } catch {
  101. return { warnings: [], passed: true };
  102. }
  103. }
  104. }