post-write-validator.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. /**
  2. * Post-write rule-based validator.
  3. *
  4. * Deterministic, zero-LLM-cost checks that run after every chapter generation.
  5. * Catches violations that prompt-only rules cannot guarantee.
  6. */
  7. import type { BookRules } from "../models/book-rules.js";
  8. import type { GenreProfile } from "../models/genre-profile.js";
  9. export interface PostWriteViolation {
  10. readonly rule: string;
  11. readonly severity: "error" | "warning";
  12. readonly description: string;
  13. readonly suggestion: string;
  14. }
  15. // --- Marker word lists ---
  16. /** AI转折/惊讶标记词 */
  17. const SURPRISE_MARKERS = ["仿佛", "忽然", "竟然", "猛地", "猛然", "不禁", "宛如"];
  18. /** 元叙事/编剧旁白模式 */
  19. const META_NARRATION_PATTERNS = [
  20. /到这里[,,]?算是/,
  21. /接下来[,,]?(?:就是|将会|即将)/,
  22. /(?:后面|之后)[,,]?(?:会|将|还会)/,
  23. /(?:故事|剧情)(?:发展)?到了/,
  24. /读者[,,]?(?:可能|应该|也许)/,
  25. /我们[,,]?(?:可以|不妨|来看)/,
  26. ];
  27. /** 分析报告式术语(禁止出现在正文中) */
  28. const REPORT_TERMS = [
  29. "核心动机", "信息边界", "信息落差", "核心风险", "利益最大化",
  30. "当前处境", "行为约束", "性格过滤", "情绪外化", "锚定效应",
  31. "沉没成本", "认知共鸣",
  32. ];
  33. /** 作者说教词 */
  34. const SERMON_WORDS = ["显然", "毋庸置疑", "不言而喻", "众所周知", "不难看出"];
  35. /** 全场震惊类集体反应 */
  36. const COLLECTIVE_SHOCK_PATTERNS = [
  37. /(?:全场|众人|所有人|在场的人)[,,]?(?:都|全|齐齐|纷纷)?(?:震惊|惊呆|倒吸凉气|目瞪口呆|哗然|惊呼)/,
  38. /(?:全场|一片)[,,]?(?:寂静|哗然|沸腾|震动)/,
  39. ];
  40. // --- Validator ---
  41. export function validatePostWrite(
  42. content: string,
  43. genreProfile: GenreProfile,
  44. bookRules: BookRules | null,
  45. ): ReadonlyArray<PostWriteViolation> {
  46. const violations: PostWriteViolation[] = [];
  47. // Skip Chinese-specific rules for English content
  48. const isEnglish = genreProfile.language === "en";
  49. if (isEnglish) {
  50. // For English, only run book-specific prohibitions and paragraph length check
  51. return validatePostWriteEnglish(content, genreProfile, bookRules);
  52. }
  53. // 1. 硬性禁令: "不是…而是…" 句式
  54. if (/不是[^,。!?\n]{0,30}[,,]?\s*而是/.test(content)) {
  55. violations.push({
  56. rule: "禁止句式",
  57. severity: "error",
  58. description: "出现了「不是……而是……」句式",
  59. suggestion: "改用直述句",
  60. });
  61. }
  62. // 2. 硬性禁令: 破折号
  63. if (content.includes("——")) {
  64. violations.push({
  65. rule: "禁止破折号",
  66. severity: "error",
  67. description: "出现了破折号「——」",
  68. suggestion: "用逗号或句号断句",
  69. });
  70. }
  71. // 3. 转折/惊讶标记词密度 ≤ 1次/3000字
  72. const markerCounts: Record<string, number> = {};
  73. let totalMarkerCount = 0;
  74. for (const word of SURPRISE_MARKERS) {
  75. const matches = content.match(new RegExp(word, "g"));
  76. const count = matches?.length ?? 0;
  77. if (count > 0) {
  78. markerCounts[word] = count;
  79. totalMarkerCount += count;
  80. }
  81. }
  82. const markerLimit = Math.max(1, Math.floor(content.length / 3000));
  83. if (totalMarkerCount > markerLimit) {
  84. const detail = Object.entries(markerCounts)
  85. .map(([w, c]) => `"${w}"×${c}`)
  86. .join("、");
  87. violations.push({
  88. rule: "转折词密度",
  89. severity: "warning",
  90. description: `转折/惊讶标记词共${totalMarkerCount}次(上限${markerLimit}次/${content.length}字),明细:${detail}`,
  91. suggestion: "改用具体动作或感官描写传递突然性",
  92. });
  93. }
  94. // 4. 高疲劳词检查(从 genreProfile 读取,单章每词 ≤ 1次)
  95. const fatigueWords = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
  96. ? bookRules.fatigueWordsOverride
  97. : genreProfile.fatigueWords;
  98. for (const word of fatigueWords) {
  99. const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  100. const matches = content.match(new RegExp(escaped, "g"));
  101. const count = matches?.length ?? 0;
  102. if (count > 1) {
  103. violations.push({
  104. rule: "高疲劳词",
  105. severity: "warning",
  106. description: `高疲劳词"${word}"出现${count}次(上限1次/章)`,
  107. suggestion: `替换多余的"${word}"为同义但不同形式的表达`,
  108. });
  109. }
  110. }
  111. // 5. 元叙事检查(编剧旁白)
  112. for (const pattern of META_NARRATION_PATTERNS) {
  113. const match = content.match(pattern);
  114. if (match) {
  115. violations.push({
  116. rule: "元叙事",
  117. severity: "warning",
  118. description: `出现编剧旁白式表述:"${match[0]}"`,
  119. suggestion: "删除元叙事,让剧情自然展开",
  120. });
  121. break; // 报一次即可
  122. }
  123. }
  124. // 6. 分析报告式术语
  125. const foundTerms: string[] = [];
  126. for (const term of REPORT_TERMS) {
  127. if (content.includes(term)) {
  128. foundTerms.push(term);
  129. }
  130. }
  131. if (foundTerms.length > 0) {
  132. violations.push({
  133. rule: "报告术语",
  134. severity: "error",
  135. description: `正文中出现分析报告术语:${foundTerms.map(t => `"${t}"`).join("、")}`,
  136. suggestion: "这些术语只能用于 PRE_WRITE_CHECK 内部推理,正文中用口语化表达替代",
  137. });
  138. }
  139. // 7. 作者说教词
  140. const foundSermons: string[] = [];
  141. for (const word of SERMON_WORDS) {
  142. if (content.includes(word)) {
  143. foundSermons.push(word);
  144. }
  145. }
  146. if (foundSermons.length > 0) {
  147. violations.push({
  148. rule: "作者说教",
  149. severity: "warning",
  150. description: `出现说教词:${foundSermons.map(w => `"${w}"`).join("、")}`,
  151. suggestion: "删除说教词,让读者自己从情节中判断",
  152. });
  153. }
  154. // 8. 全场震惊类集体反应
  155. for (const pattern of COLLECTIVE_SHOCK_PATTERNS) {
  156. const match = content.match(pattern);
  157. if (match) {
  158. violations.push({
  159. rule: "集体反应",
  160. severity: "warning",
  161. description: `出现集体反应套话:"${match[0]}"`,
  162. suggestion: "改写成1-2个具体角色的身体反应",
  163. });
  164. break;
  165. }
  166. }
  167. // 9. 连续"了"字检查(3句以上连续含"了")
  168. const sentences = content
  169. .split(/[。!?]/)
  170. .map(s => s.trim())
  171. .filter(s => s.length > 2);
  172. let consecutiveLe = 0;
  173. let maxConsecutiveLe = 0;
  174. for (const sentence of sentences) {
  175. if (sentence.includes("了")) {
  176. consecutiveLe++;
  177. maxConsecutiveLe = Math.max(maxConsecutiveLe, consecutiveLe);
  178. } else {
  179. consecutiveLe = 0;
  180. }
  181. }
  182. if (maxConsecutiveLe >= 6) {
  183. violations.push({
  184. rule: "连续了字",
  185. severity: "warning",
  186. description: `检测到${maxConsecutiveLe}句连续包含"了"字,节奏拖沓`,
  187. suggestion: "保留最有力的一个「了」,其余改为无「了」句式",
  188. });
  189. }
  190. // 10. 段落长度检查(手机阅读适配:50-250字/段为宜)
  191. const paragraphs = content
  192. .split(/\n\s*\n/)
  193. .map(p => p.trim())
  194. .filter(p => p.length > 0);
  195. const longParagraphs = paragraphs.filter(p => p.length > 300);
  196. if (longParagraphs.length >= 2) {
  197. violations.push({
  198. rule: "段落过长",
  199. severity: "warning",
  200. description: `${longParagraphs.length}个段落超过300字,不适合手机阅读`,
  201. suggestion: "长段落拆分为3-5行的短段落,在动作切换或情绪节点处断开",
  202. });
  203. }
  204. // 11. Book-level prohibitions
  205. // Short prohibitions (2-30 chars): exact substring match
  206. // Long prohibitions (>30 chars): skip — these are conceptual rules for prompt-level enforcement only
  207. if (bookRules?.prohibitions) {
  208. for (const prohibition of bookRules.prohibitions) {
  209. if (prohibition.length >= 2 && prohibition.length <= 30 && content.includes(prohibition)) {
  210. violations.push({
  211. rule: "本书禁忌",
  212. severity: "error",
  213. description: `出现了本书禁忌内容:"${prohibition}"`,
  214. suggestion: "删除或改写该内容",
  215. });
  216. }
  217. }
  218. }
  219. return violations;
  220. }
  221. /** English-specific post-write validation rules. */
  222. function validatePostWriteEnglish(
  223. content: string,
  224. genreProfile: GenreProfile,
  225. bookRules: BookRules | null,
  226. ): ReadonlyArray<PostWriteViolation> {
  227. const violations: PostWriteViolation[] = [];
  228. // 1. AI-tell word density (from en-prompt-sections IRON LAW 3)
  229. const aiTellWords = ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "embark", "comprehensive", "nuanced"];
  230. for (const word of aiTellWords) {
  231. const regex = new RegExp(`\\b${word}\\b`, "gi");
  232. const matches = content.match(regex);
  233. if (matches && matches.length > Math.ceil(content.length / 3000)) {
  234. violations.push({
  235. rule: "AI-tell word density",
  236. severity: "warning",
  237. description: `"${word}" appears ${matches.length} times (limit: 1 per 3000 chars)`,
  238. suggestion: `Replace with a more specific word`,
  239. });
  240. }
  241. }
  242. // 2. Paragraph overflow (same rule applies to English)
  243. const paragraphs = content.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
  244. const longParagraphs = paragraphs.filter((p) => p.length > 500);
  245. if (longParagraphs.length >= 2) {
  246. violations.push({
  247. rule: "Paragraph length",
  248. severity: "warning",
  249. description: `${longParagraphs.length} paragraphs exceed 500 characters`,
  250. suggestion: "Break into shorter paragraphs for readability",
  251. });
  252. }
  253. // 3. Book-specific prohibitions
  254. if (bookRules?.prohibitions) {
  255. for (const prohibition of bookRules.prohibitions) {
  256. if (prohibition.length >= 2 && prohibition.length <= 50 && content.toLowerCase().includes(prohibition.toLowerCase())) {
  257. violations.push({
  258. rule: "Book prohibition",
  259. severity: "error",
  260. description: `Found banned content: "${prohibition}"`,
  261. suggestion: "Remove or rewrite this content",
  262. });
  263. }
  264. }
  265. }
  266. // 4. Genre fatigue words
  267. const fatigueWords = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
  268. ? bookRules.fatigueWordsOverride
  269. : genreProfile.fatigueWords;
  270. for (const word of fatigueWords) {
  271. const regex = new RegExp(`\\b${word}\\b`, "gi");
  272. const matches = content.match(regex);
  273. if (matches && matches.length > 1) {
  274. violations.push({
  275. rule: "Fatigue word",
  276. severity: "warning",
  277. description: `"${word}" appears ${matches.length} times (max 1 per chapter)`,
  278. suggestion: "Vary the vocabulary",
  279. });
  280. }
  281. }
  282. return violations;
  283. }