state-validator.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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. export interface StateValidationAuthorityContext {
  11. readonly storyFrame?: string;
  12. readonly bookRules?: string;
  13. readonly chapterSummaries?: string;
  14. }
  15. /**
  16. * Validates Settler output by comparing old and new truth files via LLM.
  17. * Catches contradictions, missing state changes, and temporal inconsistencies.
  18. *
  19. * Uses a minimal verdict protocol instead of requiring structured JSON:
  20. * Line 1: PASS or FAIL
  21. * Remaining lines: free-form warnings (one per line, optional category prefix)
  22. */
  23. export class StateValidatorAgent extends BaseAgent {
  24. get name(): string {
  25. return "state-validator";
  26. }
  27. async validate(
  28. chapterContent: string,
  29. chapterNumber: number,
  30. oldState: string,
  31. newState: string,
  32. oldHooks: string,
  33. newHooks: string,
  34. language: "zh" | "en" = "zh",
  35. authorityContext?: StateValidationAuthorityContext,
  36. ): Promise<ValidationResult> {
  37. const stateDiff = this.computeDiff(oldState, newState, "State Card");
  38. const hooksDiff = this.computeDiff(oldHooks, newHooks, "Hooks Pool");
  39. // Skip validation if nothing changed
  40. if (!stateDiff && !hooksDiff) {
  41. return { warnings: [], passed: true };
  42. }
  43. const langInstruction = language === "en"
  44. ? "Respond in English."
  45. : "用中文回答。";
  46. const systemPrompt = `You are a continuity validator for a novel writing system. ${langInstruction}
  47. Given the chapter text and the CHANGES made to truth files (state card + hooks pool), check for contradictions:
  48. 1. State change without narrative support — truth file says something changed but the chapter text doesn't describe it
  49. 2. Missing state change — chapter text describes something happening but the truth file didn't capture it
  50. 3. Temporal impossibility — character moves locations without transition, injury heals without time passing
  51. 4. Hook anomaly — a hook disappeared without being marked resolved, or a new hook has no basis in the chapter
  52. 5. Retroactive edit — truth file change implies something happened in a PREVIOUS chapter, not the current one
  53. 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
  54. Output format (simple, NOT JSON):
  55. - First line: exactly PASS or FAIL (nothing else on this line)
  56. - Following lines: one warning per line, optionally prefixed with [category]
  57. - If no issues at all, just output: PASS
  58. Example:
  59. PASS
  60. [unsupported_change] State card says character moved to the forest, but text only shows intent
  61. [minor] Hook H03 advanced but text mention is brief
  62. Or if there are hard contradictions:
  63. FAIL
  64. [contradiction] State says character is dead but chapter text shows them speaking
  65. [unsupported_change] New location not mentioned anywhere in chapter text
  66. IMPORTANT: Output FAIL ONLY for hard contradictions — facts that directly conflict with the chapter text. Do NOT fail for:
  67. - Slightly ahead-of-text inferences
  68. - Missing details that the state card didn't capture
  69. - Reasonable extrapolations from text
  70. - Hook management differences that don't contradict text
  71. These should be warnings with PASS, not FAIL.`;
  72. const authorityBlock = this.buildAuthorityContextBlock(authorityContext);
  73. const userPrompt = `Chapter ${chapterNumber} validation:
  74. ${authorityBlock}
  75. ## State Card Changes
  76. ${stateDiff || "(no changes)"}
  77. ## Hooks Pool Changes
  78. ${hooksDiff || "(no changes)"}
  79. ## Chapter Text (for reference)
  80. ${chapterContent.slice(0, 6000)}`;
  81. try {
  82. const response = await this.chat(
  83. [
  84. { role: "system", content: systemPrompt },
  85. { role: "user", content: userPrompt },
  86. ],
  87. { temperature: 0.1 },
  88. );
  89. return this.parseResult(response.content);
  90. } catch (error) {
  91. this.log?.warn(`State validation failed: ${error}`);
  92. throw error;
  93. }
  94. }
  95. private computeDiff(oldText: string, newText: string, label: string): string | null {
  96. if (oldText === newText) return null;
  97. const oldLines = oldText.split("\n").filter((l) => l.trim());
  98. const newLines = newText.split("\n").filter((l) => l.trim());
  99. const added = newLines.filter((l) => !oldLines.includes(l));
  100. const removed = oldLines.filter((l) => !newLines.includes(l));
  101. if (added.length === 0 && removed.length === 0) return null;
  102. const parts = [`### ${label}`];
  103. if (removed.length > 0) parts.push("Removed:\n" + removed.map((l) => `- ${l}`).join("\n"));
  104. if (added.length > 0) parts.push("Added:\n" + added.map((l) => `+ ${l}`).join("\n"));
  105. return parts.join("\n");
  106. }
  107. private buildAuthorityContextBlock(authorityContext?: StateValidationAuthorityContext): string {
  108. if (!authorityContext) return "## Authority / Cross-Truth Context\n(no authority context provided)";
  109. const storyFrame = this.truncateHead(authorityContext.storyFrame ?? "", 3500);
  110. const bookRules = this.truncateHead(authorityContext.bookRules ?? "", 2000);
  111. const chapterSummaries = this.truncateTail(authorityContext.chapterSummaries ?? "", 3500);
  112. return [
  113. "## Authority / Cross-Truth Context",
  114. "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.",
  115. "",
  116. "### story_frame / legacy story_bible excerpt",
  117. storyFrame || "(empty)",
  118. "",
  119. "### book_rules excerpt",
  120. bookRules || "(empty)",
  121. "",
  122. "### recent chapter_summaries excerpt",
  123. chapterSummaries || "(empty)",
  124. ].join("\n");
  125. }
  126. private truncateHead(text: string, maxChars: number): string {
  127. const trimmed = text.trim();
  128. if (trimmed.length <= maxChars) return trimmed;
  129. return `${trimmed.slice(0, maxChars).trimEnd()}\n\n[...truncated...]`;
  130. }
  131. private truncateTail(text: string, maxChars: number): string {
  132. const trimmed = text.trim();
  133. if (trimmed.length <= maxChars) return trimmed;
  134. return `[...truncated...]\n\n${trimmed.slice(-maxChars).trimStart()}`;
  135. }
  136. private parseResult(content: string): ValidationResult {
  137. const trimmed = content.trim();
  138. if (!trimmed) {
  139. throw new Error("LLM returned empty response");
  140. }
  141. const jsonResult = this.tryParseJsonResult(trimmed);
  142. if (jsonResult) {
  143. return jsonResult;
  144. }
  145. const lines = trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
  146. if (lines.length === 0) {
  147. throw new Error("LLM returned empty response");
  148. }
  149. const verdictLine = lines[0]!;
  150. if (!/^(PASS|FAIL)$/i.test(verdictLine)) {
  151. throw new Error("State validator returned invalid response");
  152. }
  153. const passed = /^PASS$/i.test(verdictLine);
  154. const warnings: ValidationWarning[] = [];
  155. for (let i = 1; i < lines.length; i++) {
  156. const line = lines[i]!;
  157. if (/^(PASS|FAIL)$/i.test(line)) continue;
  158. const categoryMatch = line.match(/^\[([^\]]+)\]\s*(.+)$/);
  159. if (categoryMatch) {
  160. warnings.push({
  161. category: categoryMatch[1]!.trim(),
  162. description: categoryMatch[2]!.trim(),
  163. });
  164. } else if (line.startsWith("- ") || line.startsWith("* ")) {
  165. warnings.push({
  166. category: "general",
  167. description: line.slice(2).trim(),
  168. });
  169. } else if (line.length > 5) {
  170. warnings.push({
  171. category: "general",
  172. description: line,
  173. });
  174. }
  175. }
  176. return { warnings, passed };
  177. }
  178. private tryParseJsonResult(text: string): ValidationResult | null {
  179. const direct = this.tryParseExactJsonResult(text);
  180. if (direct) {
  181. return direct;
  182. }
  183. const candidate = extractBalancedJsonObject(text);
  184. if (!candidate) {
  185. return null;
  186. }
  187. return this.tryParseExactJsonResult(candidate);
  188. }
  189. private tryParseExactJsonResult(text: string): ValidationResult | null {
  190. try {
  191. const parsed = JSON.parse(text) as {
  192. warnings?: Array<{ category?: string; description?: string }>;
  193. passed?: boolean;
  194. };
  195. if (typeof parsed.passed !== "boolean") return null;
  196. return {
  197. warnings: (parsed.warnings ?? []).map((w) => ({
  198. category: w.category ?? "unknown",
  199. description: w.description ?? "",
  200. })),
  201. passed: parsed.passed,
  202. };
  203. } catch {
  204. return null;
  205. }
  206. }
  207. }
  208. function extractBalancedJsonObject(text: string): string | null {
  209. const start = text.indexOf("{");
  210. if (start < 0) {
  211. return null;
  212. }
  213. let depth = 0;
  214. let inString = false;
  215. let escaped = false;
  216. let endIndex = -1;
  217. for (let index = start; index < text.length; index += 1) {
  218. const char = text[index]!;
  219. if (inString) {
  220. if (escaped) {
  221. escaped = false;
  222. continue;
  223. }
  224. if (char === "\\") {
  225. escaped = true;
  226. continue;
  227. }
  228. if (char === "\"") {
  229. inString = false;
  230. }
  231. continue;
  232. }
  233. if (char === "\"") {
  234. inString = true;
  235. continue;
  236. }
  237. if (char === "{") {
  238. depth += 1;
  239. continue;
  240. }
  241. if (char === "}") {
  242. depth -= 1;
  243. if (depth === 0) {
  244. endIndex = index;
  245. break;
  246. }
  247. if (depth < 0) {
  248. return null;
  249. }
  250. }
  251. }
  252. if (endIndex < 0) return null;
  253. // Only accept the candidate if what follows the closing brace is
  254. // nothing, whitespace, or a structural JSON terminator.
  255. // This rejects trailing content like "{...} more text here"
  256. const followingChar = text[endIndex + 1];
  257. if (
  258. followingChar !== undefined &&
  259. followingChar !== "\n" &&
  260. followingChar !== "\r" &&
  261. followingChar !== "\t" &&
  262. followingChar !== " " &&
  263. followingChar !== "," &&
  264. followingChar !== "]" &&
  265. followingChar !== "}"
  266. ) {
  267. return null;
  268. }
  269. return text.slice(start, endIndex + 1);
  270. }