post-write-validator.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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. // 1. 硬性禁令: "不是…而是…" 句式
  48. if (/不是[^,。!?\n]{0,30}[,,]?\s*而是/.test(content)) {
  49. violations.push({
  50. rule: "禁止句式",
  51. severity: "error",
  52. description: "出现了「不是……而是……」句式",
  53. suggestion: "改用直述句",
  54. });
  55. }
  56. // 2. 硬性禁令: 破折号
  57. if (content.includes("——")) {
  58. violations.push({
  59. rule: "禁止破折号",
  60. severity: "error",
  61. description: "出现了破折号「——」",
  62. suggestion: "用逗号或句号断句",
  63. });
  64. }
  65. // 3. 转折/惊讶标记词密度 ≤ 1次/3000字
  66. const markerCounts: Record<string, number> = {};
  67. let totalMarkerCount = 0;
  68. for (const word of SURPRISE_MARKERS) {
  69. const matches = content.match(new RegExp(word, "g"));
  70. const count = matches?.length ?? 0;
  71. if (count > 0) {
  72. markerCounts[word] = count;
  73. totalMarkerCount += count;
  74. }
  75. }
  76. const markerLimit = Math.max(1, Math.floor(content.length / 3000));
  77. if (totalMarkerCount > markerLimit) {
  78. const detail = Object.entries(markerCounts)
  79. .map(([w, c]) => `"${w}"×${c}`)
  80. .join("、");
  81. violations.push({
  82. rule: "转折词密度",
  83. severity: "warning",
  84. description: `转折/惊讶标记词共${totalMarkerCount}次(上限${markerLimit}次/${content.length}字),明细:${detail}`,
  85. suggestion: "改用具体动作或感官描写传递突然性",
  86. });
  87. }
  88. // 4. 高疲劳词检查(从 genreProfile 读取,单章每词 ≤ 1次)
  89. const fatigueWords = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
  90. ? bookRules.fatigueWordsOverride
  91. : genreProfile.fatigueWords;
  92. for (const word of fatigueWords) {
  93. const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  94. const matches = content.match(new RegExp(escaped, "g"));
  95. const count = matches?.length ?? 0;
  96. if (count > 1) {
  97. violations.push({
  98. rule: "高疲劳词",
  99. severity: "warning",
  100. description: `高疲劳词"${word}"出现${count}次(上限1次/章)`,
  101. suggestion: `替换多余的"${word}"为同义但不同形式的表达`,
  102. });
  103. }
  104. }
  105. // 5. 元叙事检查(编剧旁白)
  106. for (const pattern of META_NARRATION_PATTERNS) {
  107. const match = content.match(pattern);
  108. if (match) {
  109. violations.push({
  110. rule: "元叙事",
  111. severity: "warning",
  112. description: `出现编剧旁白式表述:"${match[0]}"`,
  113. suggestion: "删除元叙事,让剧情自然展开",
  114. });
  115. break; // 报一次即可
  116. }
  117. }
  118. // 6. 分析报告式术语
  119. const foundTerms: string[] = [];
  120. for (const term of REPORT_TERMS) {
  121. if (content.includes(term)) {
  122. foundTerms.push(term);
  123. }
  124. }
  125. if (foundTerms.length > 0) {
  126. violations.push({
  127. rule: "报告术语",
  128. severity: "error",
  129. description: `正文中出现分析报告术语:${foundTerms.map(t => `"${t}"`).join("、")}`,
  130. suggestion: "这些术语只能用于 PRE_WRITE_CHECK 内部推理,正文中用口语化表达替代",
  131. });
  132. }
  133. // 7. 作者说教词
  134. const foundSermons: string[] = [];
  135. for (const word of SERMON_WORDS) {
  136. if (content.includes(word)) {
  137. foundSermons.push(word);
  138. }
  139. }
  140. if (foundSermons.length > 0) {
  141. violations.push({
  142. rule: "作者说教",
  143. severity: "warning",
  144. description: `出现说教词:${foundSermons.map(w => `"${w}"`).join("、")}`,
  145. suggestion: "删除说教词,让读者自己从情节中判断",
  146. });
  147. }
  148. // 8. 全场震惊类集体反应
  149. for (const pattern of COLLECTIVE_SHOCK_PATTERNS) {
  150. const match = content.match(pattern);
  151. if (match) {
  152. violations.push({
  153. rule: "集体反应",
  154. severity: "warning",
  155. description: `出现集体反应套话:"${match[0]}"`,
  156. suggestion: "改写成1-2个具体角色的身体反应",
  157. });
  158. break;
  159. }
  160. }
  161. // 9. 连续"了"字检查(3句以上连续含"了")
  162. const sentences = content
  163. .split(/[。!?]/)
  164. .map(s => s.trim())
  165. .filter(s => s.length > 2);
  166. let consecutiveLe = 0;
  167. let maxConsecutiveLe = 0;
  168. for (const sentence of sentences) {
  169. if (sentence.includes("了")) {
  170. consecutiveLe++;
  171. maxConsecutiveLe = Math.max(maxConsecutiveLe, consecutiveLe);
  172. } else {
  173. consecutiveLe = 0;
  174. }
  175. }
  176. if (maxConsecutiveLe >= 6) {
  177. violations.push({
  178. rule: "连续了字",
  179. severity: "warning",
  180. description: `检测到${maxConsecutiveLe}句连续包含"了"字,节奏拖沓`,
  181. suggestion: "保留最有力的一个「了」,其余改为无「了」句式",
  182. });
  183. }
  184. // 10. 段落长度检查(手机阅读适配:50-250字/段为宜)
  185. const paragraphs = content
  186. .split(/\n\s*\n/)
  187. .map(p => p.trim())
  188. .filter(p => p.length > 0);
  189. const longParagraphs = paragraphs.filter(p => p.length > 300);
  190. if (longParagraphs.length >= 2) {
  191. violations.push({
  192. rule: "段落过长",
  193. severity: "warning",
  194. description: `${longParagraphs.length}个段落超过300字,不适合手机阅读`,
  195. suggestion: "长段落拆分为3-5行的短段落,在动作切换或情绪节点处断开",
  196. });
  197. }
  198. // 11. Book-level prohibitions
  199. // Short prohibitions (2-30 chars): exact substring match
  200. // Long prohibitions (>30 chars): skip — these are conceptual rules for prompt-level enforcement only
  201. if (bookRules?.prohibitions) {
  202. for (const prohibition of bookRules.prohibitions) {
  203. if (prohibition.length >= 2 && prohibition.length <= 30 && content.includes(prohibition)) {
  204. violations.push({
  205. rule: "本书禁忌",
  206. severity: "error",
  207. description: `出现了本书禁忌内容:"${prohibition}"`,
  208. suggestion: "删除或改写该内容",
  209. });
  210. }
  211. }
  212. }
  213. return violations;
  214. }