continuity.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  1. import { BaseAgent } from "./base.js";
  2. import type { GenreProfile } from "../models/genre-profile.js";
  3. import type { BookRules } from "../models/book-rules.js";
  4. import type { FanficMode } from "../models/book.js";
  5. import type { ContextPackage, RuleStack } from "../models/input-governance.js";
  6. import { readGenreProfile, readBookLanguage, readBookRules } from "./rules-reader.js";
  7. import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
  8. import { readFile, readdir } from "node:fs/promises";
  9. import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
  10. import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
  11. import { join } from "node:path";
  12. export interface AuditResult {
  13. readonly passed: boolean;
  14. readonly issues: ReadonlyArray<AuditIssue>;
  15. readonly summary: string;
  16. readonly tokenUsage?: {
  17. readonly promptTokens: number;
  18. readonly completionTokens: number;
  19. readonly totalTokens: number;
  20. };
  21. }
  22. export interface AuditIssue {
  23. readonly severity: "critical" | "warning" | "info";
  24. readonly category: string;
  25. readonly description: string;
  26. readonly suggestion: string;
  27. }
  28. type PromptLanguage = "zh" | "en";
  29. const DIMENSION_LABELS: Record<number, { readonly zh: string; readonly en: string }> = {
  30. 1: { zh: "OOC检查", en: "OOC Check" },
  31. 2: { zh: "时间线检查", en: "Timeline Check" },
  32. 3: { zh: "设定冲突", en: "Lore Conflict Check" },
  33. 4: { zh: "战力崩坏", en: "Power Scaling Check" },
  34. 5: { zh: "数值检查", en: "Numerical Consistency Check" },
  35. 6: { zh: "伏笔检查", en: "Hook Check" },
  36. 7: { zh: "节奏检查", en: "Pacing Check" },
  37. 8: { zh: "文风检查", en: "Style Check" },
  38. 9: { zh: "信息越界", en: "Information Boundary Check" },
  39. 10: { zh: "词汇疲劳", en: "Lexical Fatigue Check" },
  40. 11: { zh: "利益链断裂", en: "Incentive Chain Check" },
  41. 12: { zh: "年代考据", en: "Era Accuracy Check" },
  42. 13: { zh: "配角降智", en: "Side Character Competence Check" },
  43. 14: { zh: "配角工具人化", en: "Side Character Instrumentalization Check" },
  44. 15: { zh: "爽点虚化", en: "Payoff Dilution Check" },
  45. 16: { zh: "台词失真", en: "Dialogue Authenticity Check" },
  46. 17: { zh: "流水账", en: "Chronicle Drift Check" },
  47. 18: { zh: "知识库污染", en: "Knowledge Base Pollution Check" },
  48. 19: { zh: "视角一致性", en: "POV Consistency Check" },
  49. 20: { zh: "段落等长", en: "Paragraph Uniformity Check" },
  50. 21: { zh: "套话密度", en: "Cliche Density Check" },
  51. 22: { zh: "公式化转折", en: "Formulaic Twist Check" },
  52. 23: { zh: "列表式结构", en: "List-like Structure Check" },
  53. 24: { zh: "支线停滞", en: "Subplot Stagnation Check" },
  54. 25: { zh: "弧线平坦", en: "Arc Flatline Check" },
  55. 26: { zh: "节奏单调", en: "Pacing Monotony Check" },
  56. 27: { zh: "敏感词检查", en: "Sensitive Content Check" },
  57. 28: { zh: "正传事件冲突", en: "Mainline Canon Event Conflict" },
  58. 29: { zh: "未来信息泄露", en: "Future Knowledge Leak Check" },
  59. 30: { zh: "世界规则跨书一致性", en: "Cross-Book World Rule Check" },
  60. 31: { zh: "番外伏笔隔离", en: "Spinoff Hook Isolation Check" },
  61. 32: { zh: "读者期待管理", en: "Reader Expectation Check" },
  62. 33: { zh: "大纲偏离检测", en: "Outline Drift Check" },
  63. 34: { zh: "角色还原度", en: "Character Fidelity Check" },
  64. 35: { zh: "世界规则遵守", en: "World Rule Compliance Check" },
  65. 36: { zh: "关系动态", en: "Relationship Dynamics Check" },
  66. 37: { zh: "正典事件一致性", en: "Canon Event Consistency Check" },
  67. };
  68. function containsChinese(text: string): boolean {
  69. return /[\u4e00-\u9fff]/u.test(text);
  70. }
  71. function resolveGenreLabel(genreId: string, profileName: string, language: PromptLanguage): string {
  72. if (language === "zh" || !containsChinese(profileName)) {
  73. return profileName;
  74. }
  75. if (genreId === "other") {
  76. return "general";
  77. }
  78. return genreId.replace(/[_-]+/g, " ");
  79. }
  80. function dimensionName(id: number, language: PromptLanguage): string | undefined {
  81. return DIMENSION_LABELS[id]?.[language];
  82. }
  83. function joinLocalized(items: ReadonlyArray<string>, language: PromptLanguage): string {
  84. return items.join(language === "en" ? ", " : "、");
  85. }
  86. function formatFanficSeverityNote(
  87. severity: "critical" | "warning" | "info",
  88. language: PromptLanguage,
  89. ): string {
  90. if (language === "en") {
  91. return severity === "critical"
  92. ? "Strict check."
  93. : severity === "info"
  94. ? "Log only; do not fail the chapter."
  95. : "Warning level.";
  96. }
  97. return severity === "critical"
  98. ? "(严格检查)"
  99. : severity === "info"
  100. ? "(仅记录,不判定失败)"
  101. : "(警告级别)";
  102. }
  103. function buildDimensionNote(
  104. id: number,
  105. language: PromptLanguage,
  106. gp: GenreProfile,
  107. bookRules: BookRules | null,
  108. fanficMode: FanficMode | undefined,
  109. fanficConfig: ReturnType<typeof getFanficDimensionConfig> | undefined,
  110. ): string {
  111. const words = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
  112. ? bookRules.fatigueWordsOverride
  113. : gp.fatigueWords;
  114. if (fanficConfig?.notes.has(id) && language === "zh") {
  115. return fanficConfig.notes.get(id)!;
  116. }
  117. if (id === 1 && fanficMode === "ooc") {
  118. return language === "en"
  119. ? "In OOC mode, personality drift can be intentional; record only, do not fail. Evaluate against the character dossiers in fanfic_canon.md."
  120. : "OOC模式下角色可偏离性格底色,此维度仅记录不判定失败。参照 fanfic_canon.md 角色档案评估偏离程度。";
  121. }
  122. if (id === 1 && fanficMode === "canon") {
  123. return language === "en"
  124. ? "Canon-faithful fanfic: characters must stay close to their original personality core. Evaluate against fanfic_canon.md character dossiers."
  125. : "原作向同人:角色必须严格遵守性格底色。参照 fanfic_canon.md 角色档案中的性格底色和行为模式。";
  126. }
  127. if (id === 10 && words.length > 0) {
  128. return language === "en"
  129. ? `Fatigue words: ${words.join(", ")}. Also check AI tell markers (仿佛/不禁/宛如/竟然/忽然/猛地); warn when any appears more than once per 3,000 words.`
  130. : `高疲劳词:${words.join("、")}。同时检查AI标记词(仿佛/不禁/宛如/竟然/忽然/猛地)密度,每3000字超过1次即warning`;
  131. }
  132. if (id === 15 && gp.satisfactionTypes.length > 0) {
  133. return language === "en"
  134. ? `Payoff types: ${gp.satisfactionTypes.join(", ")}`
  135. : `爽点类型:${gp.satisfactionTypes.join("、")}`;
  136. }
  137. if (id === 12 && bookRules?.eraConstraints) {
  138. const era = bookRules.eraConstraints;
  139. const parts = [era.period, era.region].filter(Boolean);
  140. if (parts.length > 0) {
  141. return language === "en"
  142. ? `Era: ${parts.join(", ")}`
  143. : `年代:${parts.join(",")}`;
  144. }
  145. }
  146. switch (id) {
  147. case 19:
  148. return language === "en"
  149. ? "Check whether POV shifts are signaled clearly and stay consistent with the configured viewpoint."
  150. : "检查视角切换是否有过渡、是否与设定视角一致";
  151. case 24:
  152. return language === "en"
  153. ? "Cross-check subplot_board and chapter_summaries: if any subplot goes unmentioned or unadvanced for more than 5 chapters -> warning. If subplots exist but none move in the last 3 chapters -> warning."
  154. : "对照 subplot_board 和 chapter_summaries:如果任何支线超过5章未被提及或推进→warning。如果存在支线但近3章完全没有任何支线推进→warning";
  155. case 25:
  156. return language === "en"
  157. ? "Cross-check emotional_arcs and chapter_summaries: if a major character shows no emotional change for 3 straight chapters (no new pressure, release, or turn) -> warning. Distinguish unchanged circumstances from unchanged inner movement."
  158. : "对照 emotional_arcs 和 chapter_summaries:如果主要角色连续3章情绪状态无变化(没有新的压力、释放、转变)→warning。注意区分'角色处境未变'和'角色内心未变'";
  159. case 26:
  160. return language === "en"
  161. ? "Cross-check chapter_summaries for chapter-type distribution: 3+ consecutive chapters of the same type -> warning. No payoff or climax chapter for 5+ chapters -> warning. Explicitly list the recent type sequence."
  162. : "对照 chapter_summaries 的章节类型分布:连续≥3章相同类型(如连续3个事件章/战斗章/布局章)→warning。≥5章没有出现回收章或高潮章→warning。请明确列出最近章节的类型序列";
  163. case 28:
  164. return language === "en"
  165. ? "Check whether spinoff events contradict the mainline canon constraints."
  166. : "检查番外事件是否与正典约束表矛盾";
  167. case 29:
  168. return language === "en"
  169. ? "Check whether characters reference information that should only be revealed after the divergence point (see the information-boundary table)."
  170. : "检查角色是否引用了分歧点之后才揭示的信息(参照信息边界表)";
  171. case 30:
  172. return language === "en"
  173. ? "Check whether the spinoff violates mainline world rules (power system, geography, factions)."
  174. : "检查番外是否违反正传世界规则(力量体系、地理、阵营)";
  175. case 31:
  176. return language === "en"
  177. ? "Check whether the spinoff resolves mainline hooks without authorization (warning level)."
  178. : "检查番外是否越权回收正传伏笔(warning级别)";
  179. case 32:
  180. return language === "en"
  181. ? "Check: does the chapter ending provide a hook? Has there been a meaningful payoff within the last 3-5 chapters? Is emotional pressure being suppressed for more than 3 chapters without release? Are reader expectation gaps accumulating or being satisfied?"
  182. : "检查:章尾是否有钩子?最近3-5章内是否有爽点落地?是否存在超过3章的情绪压制无释放?读者的情绪缺口是否在积累或被满足?";
  183. case 33:
  184. return language === "en"
  185. ? "Cross-check volume_outline: does this chapter match the planned beat for the current chapter range? Did it skip planned nodes or consume later nodes too early? Does actual pacing match the planned chapter span? If a beat planned for N chapters is consumed in 1-2 chapters -> critical."
  186. : "对照 volume_outline:本章内容是否对应卷纲中当前章节范围的剧情节点?是否跳过了节点或提前消耗了后续节点?剧情推进速度是否与卷纲规划的章节跨度匹配?如果卷纲规划某段剧情跨N章但实际1-2章就讲完→critical";
  187. case 34:
  188. case 35:
  189. case 36:
  190. case 37: {
  191. if (!fanficConfig) return "";
  192. const severity = fanficConfig.severityOverrides.get(id) ?? "warning";
  193. const baseNote = language === "en"
  194. ? {
  195. 34: "Check whether dialogue tics, speaking style, and behavior remain consistent with the character dossiers in fanfic_canon.md. Deviations need clear situational motivation.",
  196. 35: "Check whether the chapter violates world rules documented in fanfic_canon.md (geography, power system, faction relations).",
  197. 36: "Check whether relationship beats remain plausible and aligned with, or meaningfully develop from, the key relationships documented in fanfic_canon.md.",
  198. 37: "Check whether the chapter contradicts the key event timeline in fanfic_canon.md.",
  199. }[id]
  200. : FANFIC_DIMENSIONS.find((dimension) => dimension.id === id)?.baseNote;
  201. return baseNote
  202. ? `${baseNote} ${formatFanficSeverityNote(severity, language)}`
  203. : "";
  204. }
  205. default:
  206. return "";
  207. }
  208. }
  209. function buildDimensionList(
  210. gp: GenreProfile,
  211. bookRules: BookRules | null,
  212. language: PromptLanguage,
  213. hasParentCanon = false,
  214. fanficMode?: FanficMode,
  215. ): ReadonlyArray<{ readonly id: number; readonly name: string; readonly note: string }> {
  216. const activeIds = new Set(gp.auditDimensions);
  217. // Add book-level additional dimensions (supports both numeric IDs and name strings)
  218. if (bookRules?.additionalAuditDimensions) {
  219. // Build reverse lookup: name → id
  220. const nameToId = new Map<string, number>();
  221. for (const [id, labels] of Object.entries(DIMENSION_LABELS)) {
  222. nameToId.set(labels.zh, Number(id));
  223. nameToId.set(labels.en, Number(id));
  224. }
  225. for (const d of bookRules.additionalAuditDimensions) {
  226. if (typeof d === "number") {
  227. activeIds.add(d);
  228. } else if (typeof d === "string") {
  229. // Try exact match first, then substring match
  230. const exactId = nameToId.get(d);
  231. if (exactId !== undefined) {
  232. activeIds.add(exactId);
  233. } else {
  234. // Fuzzy: find dimension whose name contains the string
  235. for (const [name, id] of nameToId) {
  236. if (name.includes(d) || d.includes(name)) {
  237. activeIds.add(id);
  238. break;
  239. }
  240. }
  241. }
  242. }
  243. }
  244. }
  245. // Always-active dimensions
  246. activeIds.add(32); // 读者期待管理 — universal
  247. activeIds.add(33); // 大纲偏离检测 — universal
  248. // Conditional overrides
  249. if (gp.eraResearch || bookRules?.eraConstraints?.enabled) {
  250. activeIds.add(12);
  251. }
  252. // Spinoff dimensions — activated when parent_canon.md exists (but NOT in fanfic mode)
  253. if (hasParentCanon && !fanficMode) {
  254. activeIds.add(28); // 正传事件冲突
  255. activeIds.add(29); // 未来信息泄露
  256. activeIds.add(30); // 世界规则跨书一致性
  257. activeIds.add(31); // 番外伏笔隔离
  258. }
  259. // Fanfic dimensions — replace spinoff dims with fanfic-specific checks
  260. let fanficConfig: ReturnType<typeof getFanficDimensionConfig> | undefined;
  261. if (fanficMode) {
  262. fanficConfig = getFanficDimensionConfig(fanficMode, bookRules?.allowedDeviations);
  263. for (const id of fanficConfig.activeIds) {
  264. activeIds.add(id);
  265. }
  266. for (const id of fanficConfig.deactivatedIds) {
  267. activeIds.delete(id);
  268. }
  269. }
  270. const dims: Array<{ id: number; name: string; note: string }> = [];
  271. for (const id of [...activeIds].sort((a, b) => a - b)) {
  272. const name = dimensionName(id, language);
  273. if (!name) continue;
  274. const note = buildDimensionNote(id, language, gp, bookRules, fanficMode, fanficConfig);
  275. dims.push({ id, name, note });
  276. }
  277. return dims;
  278. }
  279. export class ContinuityAuditor extends BaseAgent {
  280. get name(): string {
  281. return "continuity-auditor";
  282. }
  283. async auditChapter(
  284. bookDir: string,
  285. chapterContent: string,
  286. chapterNumber: number,
  287. genre?: string,
  288. options?: {
  289. temperature?: number;
  290. chapterIntent?: string;
  291. contextPackage?: ContextPackage;
  292. ruleStack?: RuleStack;
  293. truthFileOverrides?: {
  294. currentState?: string;
  295. ledger?: string;
  296. hooks?: string;
  297. };
  298. },
  299. ): Promise<AuditResult> {
  300. const [diskCurrentState, diskLedger, diskHooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries, parentCanon, fanficCanon, volumeOutline] =
  301. await Promise.all([
  302. this.readFileSafe(join(bookDir, "story/current_state.md")),
  303. this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
  304. this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
  305. this.readFileSafe(join(bookDir, "story/style_guide.md")),
  306. this.readFileSafe(join(bookDir, "story/subplot_board.md")),
  307. this.readFileSafe(join(bookDir, "story/emotional_arcs.md")),
  308. this.readFileSafe(join(bookDir, "story/character_matrix.md")),
  309. this.readFileSafe(join(bookDir, "story/chapter_summaries.md")),
  310. this.readFileSafe(join(bookDir, "story/parent_canon.md")),
  311. this.readFileSafe(join(bookDir, "story/fanfic_canon.md")),
  312. this.readFileSafe(join(bookDir, "story/volume_outline.md")),
  313. ]);
  314. const currentState = options?.truthFileOverrides?.currentState ?? diskCurrentState;
  315. const ledger = options?.truthFileOverrides?.ledger ?? diskLedger;
  316. const hooks = options?.truthFileOverrides?.hooks ?? diskHooks;
  317. const hasParentCanon = parentCanon !== "(文件不存在)";
  318. const hasFanficCanon = fanficCanon !== "(文件不存在)";
  319. // Load last chapter full text for fine-grained continuity checking
  320. const previousChapter = await this.loadPreviousChapter(bookDir, chapterNumber);
  321. // Load genre profile and book rules
  322. const genreId = genre ?? "other";
  323. const [{ profile: gp }, bookLanguage] = await Promise.all([
  324. readGenreProfile(this.ctx.projectRoot, genreId),
  325. readBookLanguage(bookDir),
  326. ]);
  327. const parsedRules = await readBookRules(bookDir);
  328. const bookRules = parsedRules?.rules ?? null;
  329. // Fallback: use book_rules body when style_guide.md doesn't exist
  330. const styleGuide = styleGuideRaw !== "(文件不存在)"
  331. ? styleGuideRaw
  332. : (parsedRules?.body ?? "(无文风指南)");
  333. const resolvedLanguage = bookLanguage ?? gp.language;
  334. const isEnglish = resolvedLanguage === "en";
  335. const fanficMode = hasFanficCanon ? (bookRules?.fanficMode as FanficMode | undefined) : undefined;
  336. const dimensions = buildDimensionList(gp, bookRules, resolvedLanguage, hasParentCanon, fanficMode);
  337. const dimList = dimensions
  338. .map((d) => `${d.id}. ${d.name}${d.note ? (isEnglish ? ` (${d.note})` : `(${d.note})`) : ""}`)
  339. .join("\n");
  340. const genreLabel = resolveGenreLabel(genreId, gp.name, resolvedLanguage);
  341. const protagonistBlock = bookRules?.protagonist
  342. ? isEnglish
  343. ? `\n\nProtagonist lock: ${bookRules.protagonist.name}; personality locks: ${joinLocalized(bookRules.protagonist.personalityLock, resolvedLanguage)}; behavioral constraints: ${joinLocalized(bookRules.protagonist.behavioralConstraints, resolvedLanguage)}.`
  344. : `\n主角人设锁定:${bookRules.protagonist.name},${bookRules.protagonist.personalityLock.join("、")},行为约束:${bookRules.protagonist.behavioralConstraints.join("、")}`
  345. : "";
  346. const searchNote = gp.eraResearch
  347. ? isEnglish
  348. ? "\n\nYou have web-search capability (search_web / fetch_url). For real-world eras, people, events, geography, or policies, you must verify with search_web instead of relying on memory. Cross-check at least 2 sources."
  349. : "\n\n你有联网搜索能力(search_web / fetch_url)。对于涉及真实年代、人物、事件、地理、政策的内容,你必须用search_web核实,不可凭记忆判断。至少对比2个来源交叉验证。"
  350. : "";
  351. const systemPrompt = isEnglish
  352. ? `You are a strict ${genreLabel} web fiction editor. Audit the chapter for continuity, consistency, and quality. ALL OUTPUT MUST BE IN ENGLISH.${protagonistBlock}${searchNote}
  353. Audit dimensions:
  354. ${dimList}
  355. Output format MUST be JSON:
  356. {
  357. "passed": true/false,
  358. "issues": [
  359. {
  360. "severity": "critical|warning|info",
  361. "category": "dimension name",
  362. "description": "specific issue description",
  363. "suggestion": "fix suggestion"
  364. }
  365. ],
  366. "summary": "one-sentence audit conclusion"
  367. }
  368. passed is false ONLY when critical-severity issues exist.`
  369. : `你是一位严格的${gp.name}网络小说审稿编辑。你的任务是对章节进行连续性、一致性和质量审查。${protagonistBlock}${searchNote}
  370. 审查维度:
  371. ${dimList}
  372. 输出格式必须为 JSON:
  373. {
  374. "passed": true/false,
  375. "issues": [
  376. {
  377. "severity": "critical|warning|info",
  378. "category": "审查维度名称",
  379. "description": "具体问题描述",
  380. "suggestion": "修改建议"
  381. }
  382. ],
  383. "summary": "一句话总结审查结论"
  384. }
  385. 只有当存在 critical 级别问题时,passed 才为 false。`;
  386. const ledgerBlock = gp.numericalSystem
  387. ? isEnglish
  388. ? `\n## Resource Ledger\n${ledger}`
  389. : `\n## 资源账本\n${ledger}`
  390. : "";
  391. // Smart context filtering for auditor — same logic as writer
  392. const bookRulesForFilter = parsedRules?.rules ?? null;
  393. const filteredSubplots = filterSubplots(subplotBoard);
  394. const filteredArcs = filterEmotionalArcs(emotionalArcs, chapterNumber);
  395. const filteredMatrix = filterCharacterMatrix(characterMatrix, volumeOutline, bookRulesForFilter?.protagonist?.name);
  396. const filteredSummaries = filterSummaries(chapterSummaries, chapterNumber);
  397. const filteredHooks = filterHooks(hooks);
  398. const governedMemoryBlocks = options?.contextPackage
  399. ? buildGovernedMemoryEvidenceBlocks(options.contextPackage, resolvedLanguage)
  400. : undefined;
  401. const hooksBlock = governedMemoryBlocks?.hooksBlock
  402. ?? (filteredHooks !== "(文件不存在)"
  403. ? isEnglish
  404. ? `\n## Pending Hooks\n${filteredHooks}\n`
  405. : `\n## 伏笔池\n${filteredHooks}\n`
  406. : "");
  407. const subplotBlock = filteredSubplots !== "(文件不存在)"
  408. ? isEnglish
  409. ? `\n## Subplot Board\n${filteredSubplots}\n`
  410. : `\n## 支线进度板\n${filteredSubplots}\n`
  411. : "";
  412. const emotionalBlock = filteredArcs !== "(文件不存在)"
  413. ? isEnglish
  414. ? `\n## Emotional Arcs\n${filteredArcs}\n`
  415. : `\n## 情感弧线\n${filteredArcs}\n`
  416. : "";
  417. const matrixBlock = filteredMatrix !== "(文件不存在)"
  418. ? isEnglish
  419. ? `\n## Character Interaction Matrix\n${filteredMatrix}\n`
  420. : `\n## 角色交互矩阵\n${filteredMatrix}\n`
  421. : "";
  422. const summariesBlock = governedMemoryBlocks?.summariesBlock
  423. ?? (filteredSummaries !== "(文件不存在)"
  424. ? isEnglish
  425. ? `\n## Chapter Summaries (for pacing checks)\n${filteredSummaries}\n`
  426. : `\n## 章节摘要(用于节奏检查)\n${filteredSummaries}\n`
  427. : "");
  428. const volumeSummariesBlock = governedMemoryBlocks?.volumeSummariesBlock ?? "";
  429. const canonBlock = hasParentCanon
  430. ? isEnglish
  431. ? `\n## Mainline Canon Reference (for spinoff audit)\n${parentCanon}\n`
  432. : `\n## 正传正典参照(番外审查专用)\n${parentCanon}\n`
  433. : "";
  434. const fanficCanonBlock = hasFanficCanon
  435. ? isEnglish
  436. ? `\n## Fanfic Canon Reference (for fanfic audit)\n${fanficCanon}\n`
  437. : `\n## 同人正典参照(同人审查专用)\n${fanficCanon}\n`
  438. : "";
  439. const outlineBlock = volumeOutline !== "(文件不存在)"
  440. ? isEnglish
  441. ? `\n## Volume Outline (for outline drift checks)\n${volumeOutline}\n`
  442. : `\n## 卷纲(用于大纲偏离检测)\n${volumeOutline}\n`
  443. : "";
  444. const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack
  445. ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack, resolvedLanguage)
  446. : "";
  447. const styleGuideBlock = reducedControlBlock.length === 0
  448. ? isEnglish
  449. ? `\n## Style Guide\n${styleGuide}`
  450. : `\n## 文风指南\n${styleGuide}`
  451. : "";
  452. const prevChapterBlock = previousChapter
  453. ? isEnglish
  454. ? `\n## Previous Chapter Full Text (for transition checks)\n${previousChapter}\n`
  455. : `\n## 上一章全文(用于衔接检查)\n${previousChapter}\n`
  456. : "";
  457. const userPrompt = isEnglish
  458. ? `Review chapter ${chapterNumber}.
  459. ## Current State Card
  460. ${currentState}
  461. ${ledgerBlock}
  462. ${hooksBlock}${volumeSummariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
  463. ## Chapter Content Under Review
  464. ${chapterContent}`
  465. : `请审查第${chapterNumber}章。
  466. ## 当前状态卡
  467. ${currentState}
  468. ${ledgerBlock}
  469. ${hooksBlock}${volumeSummariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
  470. ## 待审章节内容
  471. ${chapterContent}`;
  472. const chatMessages = [
  473. { role: "system" as const, content: systemPrompt },
  474. { role: "user" as const, content: userPrompt },
  475. ];
  476. const chatOptions = { temperature: options?.temperature ?? 0.3, maxTokens: 8192 };
  477. // Use web search for fact verification when eraResearch is enabled
  478. const response = gp.eraResearch
  479. ? await this.chatWithSearch(chatMessages, chatOptions)
  480. : await this.chat(chatMessages, chatOptions);
  481. const result = this.parseAuditResult(response.content, resolvedLanguage);
  482. return { ...result, tokenUsage: response.usage };
  483. }
  484. private parseAuditResult(content: string, language: PromptLanguage): AuditResult {
  485. // Try multiple JSON extraction strategies (handles small/local models)
  486. // Strategy 1: Find balanced JSON object (not greedy)
  487. const balanced = this.extractBalancedJson(content);
  488. if (balanced) {
  489. const result = this.tryParseAuditJson(balanced, language);
  490. if (result) return result;
  491. }
  492. // Strategy 2: Try the whole content as JSON (some models output pure JSON)
  493. const trimmed = content.trim();
  494. if (trimmed.startsWith("{")) {
  495. const result = this.tryParseAuditJson(trimmed, language);
  496. if (result) return result;
  497. }
  498. // Strategy 3: Look for ```json code blocks
  499. const codeBlockMatch = content.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
  500. if (codeBlockMatch) {
  501. const result = this.tryParseAuditJson(codeBlockMatch[1]!.trim(), language);
  502. if (result) return result;
  503. }
  504. // Strategy 4: Try to extract individual fields via regex (last resort fallback)
  505. const passedMatch = content.match(/"passed"\s*:\s*(true|false)/);
  506. const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/);
  507. const summaryMatch = content.match(/"summary"\s*:\s*"([^"]*)"/);
  508. if (passedMatch) {
  509. const issues: AuditIssue[] = [];
  510. if (issuesMatch) {
  511. // Try to parse individual issue objects
  512. const issuePattern = /\{[^{}]*"severity"\s*:\s*"[^"]*"[^{}]*\}/g;
  513. let match: RegExpExecArray | null;
  514. while ((match = issuePattern.exec(issuesMatch[1]!)) !== null) {
  515. try {
  516. const issue = JSON.parse(match[0]);
  517. issues.push({
  518. severity: issue.severity ?? "warning",
  519. category: issue.category ?? (language === "en" ? "Uncategorized" : "未分类"),
  520. description: issue.description ?? "",
  521. suggestion: issue.suggestion ?? "",
  522. });
  523. } catch {
  524. // skip malformed individual issue
  525. }
  526. }
  527. }
  528. return {
  529. passed: passedMatch[1] === "true",
  530. issues,
  531. summary: summaryMatch?.[1] ?? "",
  532. };
  533. }
  534. return {
  535. passed: false,
  536. issues: [{
  537. severity: "critical",
  538. category: language === "en" ? "System Error" : "系统错误",
  539. description: language === "en"
  540. ? "Audit output format was invalid and could not be parsed as JSON."
  541. : "审稿输出格式异常,无法解析为 JSON",
  542. suggestion: language === "en"
  543. ? "The model may not support reliable structured output. Try a stronger model or inspect the API response format."
  544. : "可能是模型不支持结构化输出。尝试换一个更大的模型,或检查 API 返回格式。",
  545. }],
  546. summary: language === "en" ? "Audit output parsing failed" : "审稿输出解析失败",
  547. };
  548. }
  549. private buildReducedControlBlock(
  550. chapterIntent: string,
  551. contextPackage: ContextPackage,
  552. ruleStack: RuleStack,
  553. language: PromptLanguage,
  554. ): string {
  555. const selectedContext = contextPackage.selectedContext
  556. .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`)
  557. .join("\n");
  558. const overrides = ruleStack.activeOverrides.length > 0
  559. ? ruleStack.activeOverrides
  560. .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`)
  561. .join("\n")
  562. : "- none";
  563. return language === "en"
  564. ? `\n## Chapter Control Inputs (compiled by Planner/Composer)
  565. ${chapterIntent}
  566. ### Selected Context
  567. ${selectedContext || "- none"}
  568. ### Rule Stack
  569. - Hard guardrails: ${ruleStack.sections.hard.join(", ") || "(none)"}
  570. - Soft constraints: ${ruleStack.sections.soft.join(", ") || "(none)"}
  571. - Diagnostic rules: ${ruleStack.sections.diagnostic.join(", ") || "(none)"}
  572. ### Active Overrides
  573. ${overrides}\n`
  574. : `\n## 本章控制输入(由 Planner/Composer 编译)
  575. ${chapterIntent}
  576. ### 已选上下文
  577. ${selectedContext || "- none"}
  578. ### 规则栈
  579. - 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"}
  580. - 软约束:${ruleStack.sections.soft.join("、") || "(无)"}
  581. - 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"}
  582. ### 当前覆盖
  583. ${overrides}\n`;
  584. }
  585. private extractBalancedJson(text: string): string | null {
  586. const start = text.indexOf("{");
  587. if (start === -1) return null;
  588. let depth = 0;
  589. for (let i = start; i < text.length; i++) {
  590. if (text[i] === "{") depth++;
  591. if (text[i] === "}") depth--;
  592. if (depth === 0) return text.slice(start, i + 1);
  593. }
  594. return null;
  595. }
  596. private tryParseAuditJson(json: string, language: PromptLanguage = "zh"): AuditResult | null {
  597. try {
  598. const parsed = JSON.parse(json);
  599. if (typeof parsed.passed !== "boolean" && parsed.passed !== undefined) return null;
  600. return {
  601. passed: Boolean(parsed.passed ?? false),
  602. issues: Array.isArray(parsed.issues)
  603. ? parsed.issues.map((i: Record<string, unknown>) => ({
  604. severity: (i.severity as string) ?? "warning",
  605. category: (i.category as string) ?? (language === "en" ? "Uncategorized" : "未分类"),
  606. description: (i.description as string) ?? "",
  607. suggestion: (i.suggestion as string) ?? "",
  608. }))
  609. : [],
  610. summary: String(parsed.summary ?? ""),
  611. };
  612. } catch {
  613. return null;
  614. }
  615. }
  616. private async loadPreviousChapter(bookDir: string, currentChapter: number): Promise<string> {
  617. if (currentChapter <= 1) return "";
  618. const chaptersDir = join(bookDir, "chapters");
  619. try {
  620. const files = await readdir(chaptersDir);
  621. const paddedPrev = String(currentChapter - 1).padStart(4, "0");
  622. const prevFile = files.find((f) => f.startsWith(paddedPrev) && f.endsWith(".md"));
  623. if (!prevFile) return "";
  624. return await readFile(join(chaptersDir, prevFile), "utf-8");
  625. } catch {
  626. return "";
  627. }
  628. }
  629. private async readFileSafe(path: string): Promise<string> {
  630. try {
  631. return await readFile(path, "utf-8");
  632. } catch {
  633. return "(文件不存在)";
  634. }
  635. }
  636. }