post-write-validator.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853
  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 { analyzeChapterCadence } from "../utils/chapter-cadence.js";
  8. import type { BookRules } from "../models/book-rules.js";
  9. import type { GenreProfile } from "../models/genre-profile.js";
  10. export interface PostWriteViolation {
  11. readonly rule: string;
  12. readonly severity: "error" | "warning";
  13. readonly description: string;
  14. readonly suggestion: string;
  15. }
  16. interface ParagraphShape {
  17. readonly paragraphs: ReadonlyArray<string>;
  18. readonly shortThreshold: number;
  19. readonly shortParagraphs: ReadonlyArray<string>;
  20. readonly shortRatio: number;
  21. readonly averageLength: number;
  22. readonly maxConsecutiveShort: number;
  23. }
  24. // --- Marker word lists ---
  25. /** AI转折/惊讶标记词 */
  26. const SURPRISE_MARKERS = ["仿佛", "忽然", "竟然", "猛地", "猛然", "不禁", "宛如"];
  27. /** 元叙事/编剧旁白模式 */
  28. const META_NARRATION_PATTERNS = [
  29. /到这里[,,]?算是/,
  30. /接下来[,,]?(?:就是|将会|即将)/,
  31. /(?:后面|之后)[,,]?(?:会|将|还会)/,
  32. /(?:故事|剧情)(?:发展)?到了/,
  33. /读者[,,]?(?:可能|应该|也许)/,
  34. /我们[,,]?(?:可以|不妨|来看)/,
  35. ];
  36. /** 分析报告式术语(禁止出现在正文中) */
  37. const REPORT_TERMS = [
  38. "核心动机", "信息边界", "信息落差", "核心风险", "利益最大化",
  39. "当前处境", "行为约束", "性格过滤", "情绪外化", "锚定效应",
  40. "沉没成本", "认知共鸣",
  41. ];
  42. /** 作者说教词 */
  43. const SERMON_WORDS = ["显然", "毋庸置疑", "不言而喻", "众所周知", "不难看出"];
  44. /** 全场震惊类集体反应 */
  45. const COLLECTIVE_SHOCK_PATTERNS = [
  46. /(?:全场|众人|所有人|在场的人)[,,]?(?:都|全|齐齐|纷纷)?(?:震惊|惊呆|倒吸凉气|目瞪口呆|哗然|惊呼)/,
  47. /(?:全场|一片)[,,]?(?:寂静|哗然|沸腾|震动)/,
  48. ];
  49. // --- Validator ---
  50. export function validatePostWrite(
  51. content: string,
  52. genreProfile: GenreProfile,
  53. bookRules: BookRules | null,
  54. languageOverride?: "zh" | "en",
  55. ): ReadonlyArray<PostWriteViolation> {
  56. const violations: PostWriteViolation[] = [];
  57. // Skip Chinese-specific rules for English content
  58. const isEnglish = (languageOverride ?? genreProfile.language) === "en";
  59. if (isEnglish) {
  60. // For English, only run book-specific prohibitions and paragraph length check
  61. return validatePostWriteEnglish(content, genreProfile, bookRules);
  62. }
  63. // 1. 硬性禁令: "不是…而是…" 句式
  64. if (/不是[^,。!?\n]{0,30}[,,]?\s*而是/.test(content)) {
  65. violations.push({
  66. rule: "禁止句式",
  67. severity: "error",
  68. description: "出现了「不是……而是……」句式",
  69. suggestion: "改用直述句",
  70. });
  71. }
  72. // 2. 硬性禁令: 破折号
  73. if (content.includes("——")) {
  74. violations.push({
  75. rule: "禁止破折号",
  76. severity: "error",
  77. description: "出现了破折号「——」",
  78. suggestion: "用逗号或句号断句",
  79. });
  80. }
  81. // 3. 转折/惊讶标记词密度 ≤ 1次/3000字
  82. const markerCounts: Record<string, number> = {};
  83. let totalMarkerCount = 0;
  84. for (const word of SURPRISE_MARKERS) {
  85. const matches = content.match(new RegExp(word, "g"));
  86. const count = matches?.length ?? 0;
  87. if (count > 0) {
  88. markerCounts[word] = count;
  89. totalMarkerCount += count;
  90. }
  91. }
  92. const markerLimit = Math.max(1, Math.floor(content.length / 3000));
  93. if (totalMarkerCount > markerLimit) {
  94. const detail = Object.entries(markerCounts)
  95. .map(([w, c]) => `"${w}"×${c}`)
  96. .join("、");
  97. violations.push({
  98. rule: "转折词密度",
  99. severity: "warning",
  100. description: `转折/惊讶标记词共${totalMarkerCount}次(上限${markerLimit}次/${content.length}字),明细:${detail}`,
  101. suggestion: "改用具体动作或感官描写传递突然性",
  102. });
  103. }
  104. // 4. 高疲劳词检查(从 genreProfile 读取,单章每词 ≤ 1次)
  105. const fatigueWords = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
  106. ? bookRules.fatigueWordsOverride
  107. : genreProfile.fatigueWords;
  108. for (const word of fatigueWords) {
  109. const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  110. const matches = content.match(new RegExp(escaped, "g"));
  111. const count = matches?.length ?? 0;
  112. if (count > 1) {
  113. violations.push({
  114. rule: "高疲劳词",
  115. severity: "warning",
  116. description: `高疲劳词"${word}"出现${count}次(上限1次/章)`,
  117. suggestion: `替换多余的"${word}"为同义但不同形式的表达`,
  118. });
  119. }
  120. }
  121. // 5. 元叙事检查(编剧旁白)
  122. for (const pattern of META_NARRATION_PATTERNS) {
  123. const match = content.match(pattern);
  124. if (match) {
  125. violations.push({
  126. rule: "元叙事",
  127. severity: "warning",
  128. description: `出现编剧旁白式表述:"${match[0]}"`,
  129. suggestion: "删除元叙事,让剧情自然展开",
  130. });
  131. break; // 报一次即可
  132. }
  133. }
  134. // 6. 分析报告式术语
  135. const foundTerms: string[] = [];
  136. for (const term of REPORT_TERMS) {
  137. if (content.includes(term)) {
  138. foundTerms.push(term);
  139. }
  140. }
  141. if (foundTerms.length > 0) {
  142. violations.push({
  143. rule: "报告术语",
  144. severity: "error",
  145. description: `正文中出现分析报告术语:${foundTerms.map(t => `"${t}"`).join("、")}`,
  146. suggestion: "这些术语只能用于 PRE_WRITE_CHECK 内部推理,正文中用口语化表达替代",
  147. });
  148. }
  149. // 7. 正文中的章节号指称(如"第33章"、"chapter 33")
  150. const chapterRefPattern = /(?:第\s*\d+\s*章|[Cc]hapter\s+\d+)/g;
  151. const chapterRefs = content.match(chapterRefPattern);
  152. if (chapterRefs && chapterRefs.length > 0) {
  153. const unique = [...new Set(chapterRefs)];
  154. violations.push({
  155. rule: isEnglish ? "chapter-number-reference" : "章节号指称",
  156. severity: "error",
  157. description: isEnglish
  158. ? `Chapter text contains explicit chapter number references: ${unique.map(r => `"${r}"`).join(", ")}. Characters do not know they are in a numbered chapter.`
  159. : `正文中出现了章节号指称:${unique.map(r => `"${r}"`).join("、")}。角色不知道自己在第几章。`,
  160. suggestion: isEnglish
  161. ? "Replace with natural references: 'that night', 'when the warehouse burned', 'the incident at the dock'"
  162. : '改成自然表达:"那天晚上"、"仓库出事那次"、"码头上的事"',
  163. });
  164. }
  165. // 8. 作者说教词
  166. const foundSermons: string[] = [];
  167. for (const word of SERMON_WORDS) {
  168. if (content.includes(word)) {
  169. foundSermons.push(word);
  170. }
  171. }
  172. if (foundSermons.length > 0) {
  173. violations.push({
  174. rule: "作者说教",
  175. severity: "warning",
  176. description: `出现说教词:${foundSermons.map(w => `"${w}"`).join("、")}`,
  177. suggestion: "删除说教词,让读者自己从情节中判断",
  178. });
  179. }
  180. // 8. 全场震惊类集体反应
  181. for (const pattern of COLLECTIVE_SHOCK_PATTERNS) {
  182. const match = content.match(pattern);
  183. if (match) {
  184. violations.push({
  185. rule: "集体反应",
  186. severity: "warning",
  187. description: `出现集体反应套话:"${match[0]}"`,
  188. suggestion: "改写成1-2个具体角色的身体反应",
  189. });
  190. break;
  191. }
  192. }
  193. // 9. 连续"了"字检查(3句以上连续含"了")
  194. const sentences = content
  195. .split(/[。!?]/)
  196. .map(s => s.trim())
  197. .filter(s => s.length > 2);
  198. let consecutiveLe = 0;
  199. let maxConsecutiveLe = 0;
  200. for (const sentence of sentences) {
  201. if (sentence.includes("了")) {
  202. consecutiveLe++;
  203. maxConsecutiveLe = Math.max(maxConsecutiveLe, consecutiveLe);
  204. } else {
  205. consecutiveLe = 0;
  206. }
  207. }
  208. if (maxConsecutiveLe >= 6) {
  209. violations.push({
  210. rule: "连续了字",
  211. severity: "warning",
  212. description: `检测到${maxConsecutiveLe}句连续包含"了"字,节奏拖沓`,
  213. suggestion: "保留最有力的一个「了」,其余改为无「了」句式",
  214. });
  215. }
  216. // 10. 段落长度检查(手机阅读适配:50-250字/段为宜)
  217. const paragraphs = content
  218. .split(/\n\s*\n/)
  219. .map(p => p.trim())
  220. .filter(p => p.length > 0);
  221. const longParagraphs = paragraphs.filter(p => p.length > 300);
  222. if (longParagraphs.length >= 2) {
  223. violations.push({
  224. rule: "段落过长",
  225. severity: "warning",
  226. description: `${longParagraphs.length}个段落超过300字,不适合手机阅读`,
  227. suggestion: "长段落拆分为3-5行的短段落,在动作切换或情绪节点处断开",
  228. });
  229. }
  230. violations.push(...detectParagraphShapeWarnings(content, "zh"));
  231. // 11. Book-level prohibitions
  232. // Short prohibitions (2-30 chars): exact substring match
  233. // Long prohibitions (>30 chars): skip — these are conceptual rules for prompt-level enforcement only
  234. if (bookRules?.prohibitions) {
  235. for (const prohibition of bookRules.prohibitions) {
  236. if (prohibition.length >= 2 && prohibition.length <= 30 && content.includes(prohibition)) {
  237. violations.push({
  238. rule: "本书禁忌",
  239. severity: "error",
  240. description: `出现了本书禁忌内容:"${prohibition}"`,
  241. suggestion: "删除或改写该内容",
  242. });
  243. }
  244. }
  245. }
  246. return violations;
  247. }
  248. /**
  249. * Cross-chapter repetition check.
  250. * Detects phrases from the current chapter that also appeared in recent chapters.
  251. */
  252. export function detectCrossChapterRepetition(
  253. currentContent: string,
  254. recentChaptersContent: string,
  255. language: "zh" | "en" = "zh",
  256. ): ReadonlyArray<PostWriteViolation> {
  257. if (!recentChaptersContent || recentChaptersContent.length < 100) return [];
  258. const violations: PostWriteViolation[] = [];
  259. const isEnglish = language === "en";
  260. if (isEnglish) {
  261. // Extract 3-word phrases from current chapter
  262. const words = currentContent.toLowerCase().replace(/[^\w\s']/g, "").split(/\s+/).filter(w => w.length > 2);
  263. const phraseCounts = new Map<string, number>();
  264. for (let i = 0; i < words.length - 2; i++) {
  265. const phrase = `${words[i]} ${words[i + 1]} ${words[i + 2]}`;
  266. phraseCounts.set(phrase, (phraseCounts.get(phrase) ?? 0) + 1);
  267. }
  268. // Check which repeated phrases (2+ in current) also appear in recent chapters
  269. const recentLower = recentChaptersContent.toLowerCase();
  270. const crossRepeats: string[] = [];
  271. for (const [phrase, count] of phraseCounts) {
  272. if (count >= 2 && recentLower.includes(phrase)) {
  273. crossRepeats.push(`"${phrase}" (×${count})`);
  274. }
  275. }
  276. if (crossRepeats.length >= 3) {
  277. violations.push({
  278. rule: "Cross-chapter repetition",
  279. severity: "warning",
  280. description: `${crossRepeats.length} repeated phrases also found in recent chapters: ${crossRepeats.slice(0, 5).join(", ")}`,
  281. suggestion: "Vary action verbs and descriptive phrases to avoid cross-chapter repetition",
  282. });
  283. }
  284. } else {
  285. // Chinese: 6-char ngrams
  286. const chars = currentContent.replace(/[\s\n\r]/g, "");
  287. const phraseCounts = new Map<string, number>();
  288. for (let i = 0; i < chars.length - 5; i++) {
  289. const phrase = chars.slice(i, i + 6);
  290. if (/^[\u4e00-\u9fff]{6}$/.test(phrase)) {
  291. phraseCounts.set(phrase, (phraseCounts.get(phrase) ?? 0) + 1);
  292. }
  293. }
  294. const recentClean = recentChaptersContent.replace(/[\s\n\r]/g, "");
  295. const crossRepeats: string[] = [];
  296. for (const [phrase, count] of phraseCounts) {
  297. if (count >= 2 && recentClean.includes(phrase)) {
  298. crossRepeats.push(`"${phrase}"(×${count})`);
  299. }
  300. }
  301. if (crossRepeats.length >= 3) {
  302. violations.push({
  303. rule: "跨章重复",
  304. severity: "warning",
  305. description: `${crossRepeats.length}个重复短语在近期章节中也出现过:${crossRepeats.slice(0, 5).join("、")}`,
  306. suggestion: "变换动作描写和场景用语,避免跨章节机械重复",
  307. });
  308. }
  309. }
  310. return violations;
  311. }
  312. export function detectParagraphLengthDrift(
  313. currentContent: string,
  314. recentChaptersContent: string,
  315. language: "zh" | "en" = "zh",
  316. ): ReadonlyArray<PostWriteViolation> {
  317. if (!recentChaptersContent || recentChaptersContent.trim().length === 0) return [];
  318. const current = analyzeParagraphShape(currentContent, language);
  319. const recent = analyzeParagraphShape(recentChaptersContent, language);
  320. if (current.paragraphs.length < 4 || recent.paragraphs.length < 4) return [];
  321. if (recent.averageLength <= 0 || current.averageLength <= 0) return [];
  322. const shrinkRatio = current.averageLength / recent.averageLength;
  323. const shortRatioDelta = current.shortRatio - recent.shortRatio;
  324. if (shrinkRatio >= 0.6 || current.shortRatio < 0.5 || shortRatioDelta < 0.25) {
  325. return [];
  326. }
  327. const dropPercent = Math.round((1 - shrinkRatio) * 100);
  328. return [
  329. language === "en"
  330. ? {
  331. rule: "Paragraph density drift",
  332. severity: "warning",
  333. description: `Average paragraph length dropped from ${Math.round(recent.averageLength)} to ${Math.round(current.averageLength)} characters (${dropPercent}% shorter) compared with recent chapters.`,
  334. suggestion: "Let action, observation, and reaction share paragraphs more often instead of cutting every beat into a single short line.",
  335. }
  336. : {
  337. rule: "段落密度漂移",
  338. severity: "warning",
  339. description: `当前章平均段长从近期章节的${Math.round(recent.averageLength)}字降到${Math.round(current.averageLength)}字,缩短了${dropPercent}%。`,
  340. suggestion: "不要把每个动作都切成单独短句;适当把动作、观察和反应并入同一段,恢复段落层次。",
  341. },
  342. ];
  343. }
  344. /** English-specific post-write validation rules. */
  345. function validatePostWriteEnglish(
  346. content: string,
  347. genreProfile: GenreProfile,
  348. bookRules: BookRules | null,
  349. ): ReadonlyArray<PostWriteViolation> {
  350. const violations: PostWriteViolation[] = [];
  351. // 1. AI-tell word density (from en-prompt-sections IRON LAW 3)
  352. const aiTellWords = ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "embark", "comprehensive", "nuanced"];
  353. for (const word of aiTellWords) {
  354. const regex = new RegExp(`\\b${word}\\b`, "gi");
  355. const matches = content.match(regex);
  356. if (matches && matches.length > Math.ceil(content.length / 3000)) {
  357. violations.push({
  358. rule: "AI-tell word density",
  359. severity: "warning",
  360. description: `"${word}" appears ${matches.length} times (limit: 1 per 3000 chars)`,
  361. suggestion: `Replace with a more specific word`,
  362. });
  363. }
  364. }
  365. // 2. Paragraph overflow (same rule applies to English)
  366. const paragraphs = content.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
  367. const longParagraphs = paragraphs.filter((p) => p.length > 500);
  368. if (longParagraphs.length >= 2) {
  369. violations.push({
  370. rule: "Paragraph length",
  371. severity: "warning",
  372. description: `${longParagraphs.length} paragraphs exceed 500 characters`,
  373. suggestion: "Break into shorter paragraphs for readability",
  374. });
  375. }
  376. violations.push(...detectParagraphShapeWarnings(content, "en"));
  377. // 2.5. Multi-character scene with almost no direct exchange
  378. const quotedLines = content.match(/"[^"]+"/g) ?? [];
  379. const englishNames = [...new Set(
  380. (content.match(/\b[A-Z][a-z]{2,}\b/g) ?? [])
  381. .filter((name) => !ENGLISH_NAME_STOP_WORDS.has(name)),
  382. )];
  383. if (englishNames.length >= 2 && quotedLines.length < 2 && content.length >= 120) {
  384. violations.push({
  385. rule: "Dialogue pressure",
  386. severity: "warning",
  387. description: `Multi-character scene appears to rely on narration with almost no direct exchange (${englishNames.slice(0, 3).join(", ")}).`,
  388. suggestion: "Add at least one resistance-bearing exchange so characters push back, withhold, or pressure each other directly.",
  389. });
  390. }
  391. // 3. Book-specific prohibitions
  392. if (bookRules?.prohibitions) {
  393. for (const prohibition of bookRules.prohibitions) {
  394. if (prohibition.length >= 2 && prohibition.length <= 50 && content.toLowerCase().includes(prohibition.toLowerCase())) {
  395. violations.push({
  396. rule: "Book prohibition",
  397. severity: "error",
  398. description: `Found banned content: "${prohibition}"`,
  399. suggestion: "Remove or rewrite this content",
  400. });
  401. }
  402. }
  403. }
  404. // 4. Genre fatigue words
  405. const fatigueWords = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
  406. ? bookRules.fatigueWordsOverride
  407. : genreProfile.fatigueWords;
  408. for (const word of fatigueWords) {
  409. const regex = new RegExp(`\\b${word}\\b`, "gi");
  410. const matches = content.match(regex);
  411. if (matches && matches.length > 1) {
  412. violations.push({
  413. rule: "Fatigue word",
  414. severity: "warning",
  415. description: `"${word}" appears ${matches.length} times (max 1 per chapter)`,
  416. suggestion: "Vary the vocabulary",
  417. });
  418. }
  419. }
  420. return violations;
  421. }
  422. function appendParagraphShapeWarnings(
  423. violations: PostWriteViolation[],
  424. content: string,
  425. language: "zh" | "en",
  426. ): void {
  427. const shape = analyzeParagraphShape(content, language);
  428. if (shape.paragraphs.length < 4) return;
  429. if (shape.shortParagraphs.length >= 4 && shape.shortRatio >= 0.6) {
  430. violations.push(
  431. language === "en"
  432. ? {
  433. rule: "Paragraph fragmentation",
  434. severity: "warning",
  435. description: `${shape.shortParagraphs.length} of ${shape.paragraphs.length} paragraphs are shorter than ${shape.shortThreshold} characters.`,
  436. suggestion: "Merge adjacent action, observation, and reaction beats so the chapter does not collapse into one-line paragraphs.",
  437. }
  438. : {
  439. rule: "段落过碎",
  440. severity: "warning",
  441. description: `${shape.paragraphs.length}个段落里有${shape.shortParagraphs.length}个不足${shape.shortThreshold}字,段落被切得过碎。`,
  442. suggestion: "把相邻的动作、观察、反应适当并段,不要每句话都单独起段。",
  443. },
  444. );
  445. }
  446. if (shape.maxConsecutiveShort >= 3) {
  447. violations.push(
  448. language === "en"
  449. ? {
  450. rule: "Consecutive short paragraphs",
  451. severity: "warning",
  452. description: `${shape.maxConsecutiveShort} short paragraphs appear back to back.`,
  453. suggestion: "Break the one-beat-per-paragraph rhythm by folding connected beats into fuller paragraphs.",
  454. }
  455. : {
  456. rule: "连续短段",
  457. severity: "warning",
  458. description: `连续出现${shape.maxConsecutiveShort}个不足${shape.shortThreshold}字的短段,容易形成短句堆砌。`,
  459. suggestion: "把连续的碎动作重新编组,至少让一个段落承载完整的动作链或情绪推进。",
  460. },
  461. );
  462. }
  463. }
  464. export function detectParagraphShapeWarnings(
  465. content: string,
  466. language: "zh" | "en" = "zh",
  467. ): ReadonlyArray<PostWriteViolation> {
  468. const violations: PostWriteViolation[] = [];
  469. appendParagraphShapeWarnings(violations, content, language);
  470. return violations;
  471. }
  472. function isDialogueParagraph(paragraph: string): boolean {
  473. const trimmed = paragraph.trim();
  474. return /^[""「『'《]/.test(trimmed) || /^[""]/.test(trimmed) || /^——/.test(trimmed);
  475. }
  476. function analyzeParagraphShape(content: string, language: "zh" | "en"): ParagraphShape {
  477. const paragraphs = extractParagraphs(content);
  478. // Exclude dialogue lines from short paragraph counting — dialogue is naturally short
  479. const narrativeParagraphs = paragraphs.filter((p) => !isDialogueParagraph(p));
  480. const shortThreshold = language === "en" ? 120 : 35;
  481. const shortParagraphs = narrativeParagraphs.filter((paragraph) => paragraph.length < shortThreshold);
  482. const averageLength = paragraphs.length > 0
  483. ? paragraphs.reduce((sum, paragraph) => sum + paragraph.length, 0) / paragraphs.length
  484. : 0;
  485. let maxConsecutiveShort = 0;
  486. let currentConsecutive = 0;
  487. for (const paragraph of narrativeParagraphs) {
  488. if (paragraph.length < shortThreshold) {
  489. currentConsecutive++;
  490. maxConsecutiveShort = Math.max(maxConsecutiveShort, currentConsecutive);
  491. } else {
  492. currentConsecutive = 0;
  493. }
  494. }
  495. return {
  496. paragraphs,
  497. shortThreshold,
  498. shortParagraphs,
  499. shortRatio: narrativeParagraphs.length > 0 ? shortParagraphs.length / narrativeParagraphs.length : 0,
  500. averageLength,
  501. maxConsecutiveShort,
  502. };
  503. }
  504. function extractParagraphs(content: string): string[] {
  505. return content
  506. .split(/\n\s*\n/)
  507. .map((paragraph) => paragraph.trim())
  508. .filter((paragraph) => paragraph.length > 0)
  509. .filter((paragraph) => paragraph !== "---")
  510. .filter((paragraph) => !paragraph.startsWith("#"));
  511. }
  512. const ENGLISH_NAME_STOP_WORDS = new Set([
  513. "The",
  514. "And",
  515. "But",
  516. "When",
  517. "While",
  518. "After",
  519. "Before",
  520. "Even",
  521. "Then",
  522. "They",
  523. ]);
  524. const CHINESE_TITLE_STOP_WORDS = new Set([
  525. "这次",
  526. "正文",
  527. "标题",
  528. "重复",
  529. "不同",
  530. "完全",
  531. "只是",
  532. "碰巧",
  533. "没有",
  534. "回头",
  535. ]);
  536. const CHINESE_TITLE_STOP_CHARS = new Set(["的", "了", "着", "一", "只", "从", "在", "和", "与", "把", "被", "有", "没", "里", "又", "才"]);
  537. /**
  538. * Detect duplicate or near-duplicate chapter titles.
  539. * Compares the new title against existing chapter titles from index.
  540. */
  541. export function detectDuplicateTitle(
  542. newTitle: string,
  543. existingTitles: ReadonlyArray<string>,
  544. ): ReadonlyArray<PostWriteViolation> {
  545. if (!newTitle.trim()) return [];
  546. const normalized = newTitle.trim().toLowerCase();
  547. const violations: PostWriteViolation[] = [];
  548. for (const existing of existingTitles) {
  549. const existingNorm = existing.trim().toLowerCase();
  550. if (!existingNorm) continue;
  551. // Exact match
  552. if (normalized === existingNorm) {
  553. violations.push({
  554. rule: "duplicate-title",
  555. severity: "warning",
  556. description: `章节标题"${newTitle}"与已有章节标题完全相同`,
  557. suggestion: "更换一个不同的章节标题",
  558. });
  559. break;
  560. }
  561. // Near-duplicate: one is substring of the other, or only differs by punctuation/numbers
  562. const stripPunct = (s: string) => s.replace(/[^\p{L}\p{N}]/gu, "");
  563. if (stripPunct(normalized) === stripPunct(existingNorm)) {
  564. violations.push({
  565. rule: "near-duplicate-title",
  566. severity: "warning",
  567. description: `章节标题"${newTitle}"与已有标题"${existing}"高度相似`,
  568. suggestion: "避免使用相似的章节标题",
  569. });
  570. break;
  571. }
  572. }
  573. return violations;
  574. }
  575. export function resolveDuplicateTitle(
  576. newTitle: string,
  577. existingTitles: ReadonlyArray<string>,
  578. language: "zh" | "en" = "zh",
  579. options?: {
  580. readonly content?: string;
  581. },
  582. ): {
  583. readonly title: string;
  584. readonly issues: ReadonlyArray<PostWriteViolation>;
  585. } {
  586. const trimmed = newTitle.trim();
  587. if (!trimmed) {
  588. return { title: newTitle, issues: [] };
  589. }
  590. const duplicateIssues = detectDuplicateTitle(trimmed, existingTitles);
  591. if (duplicateIssues.length > 0) {
  592. const regenerated = regenerateDuplicateTitle(trimmed, existingTitles, language, options?.content);
  593. if (regenerated && detectDuplicateTitle(regenerated, existingTitles).length === 0) {
  594. return { title: regenerated, issues: duplicateIssues };
  595. }
  596. let counter = 2;
  597. while (counter < 100) {
  598. const candidate = language === "en"
  599. ? `${trimmed} (${counter})`
  600. : `${trimmed}(${counter})`;
  601. if (detectDuplicateTitle(candidate, existingTitles).length === 0) {
  602. return { title: candidate, issues: duplicateIssues };
  603. }
  604. counter++;
  605. }
  606. return { title: trimmed, issues: duplicateIssues };
  607. }
  608. const collapseIssues = detectTitleCollapse(trimmed, existingTitles, language);
  609. if (collapseIssues.length === 0) {
  610. return { title: trimmed, issues: [] };
  611. }
  612. const regenerated = regenerateCollapsedTitle(trimmed, existingTitles, language, options?.content);
  613. if (
  614. regenerated
  615. && detectDuplicateTitle(regenerated, existingTitles).length === 0
  616. && detectTitleCollapse(regenerated, existingTitles, language).length === 0
  617. ) {
  618. return { title: regenerated, issues: collapseIssues };
  619. }
  620. return { title: trimmed, issues: collapseIssues };
  621. }
  622. function detectTitleCollapse(
  623. newTitle: string,
  624. existingTitles: ReadonlyArray<string>,
  625. language: "zh" | "en",
  626. ): ReadonlyArray<PostWriteViolation> {
  627. const recentTitles = existingTitles
  628. .map((title) => title.trim())
  629. .filter(Boolean)
  630. .slice(-3);
  631. if (recentTitles.length < 3) {
  632. return [];
  633. }
  634. const cadence = analyzeChapterCadence({
  635. language,
  636. rows: [...recentTitles, newTitle].map((title, index) => ({
  637. chapter: index + 1,
  638. title,
  639. mood: "",
  640. chapterType: "",
  641. })),
  642. });
  643. const titlePressure = cadence.titlePressure;
  644. if (!titlePressure || titlePressure.pressure !== "high") {
  645. return [];
  646. }
  647. if (!newTitle.includes(titlePressure.repeatedToken)) {
  648. return [];
  649. }
  650. return [
  651. language === "en"
  652. ? {
  653. rule: "title-collapse",
  654. severity: "warning",
  655. description: `Chapter title "${newTitle}" keeps leaning on the recent "${titlePressure.repeatedToken}" title shell.`,
  656. suggestion: "Rename the chapter around a new image, action, consequence, or character focus.",
  657. }
  658. : {
  659. rule: "title-collapse",
  660. severity: "warning",
  661. description: `章节标题"${newTitle}"仍在沿用近期围绕“${titlePressure.repeatedToken}”的命名壳。`,
  662. suggestion: "换一个新的意象、动作、后果或人物焦点来命名。",
  663. },
  664. ];
  665. }
  666. function regenerateDuplicateTitle(
  667. baseTitle: string,
  668. existingTitles: ReadonlyArray<string>,
  669. language: "zh" | "en",
  670. content?: string,
  671. ): string | undefined {
  672. if (!content || !content.trim()) {
  673. return undefined;
  674. }
  675. const qualifier = language === "en"
  676. ? extractEnglishTitleQualifier(baseTitle, existingTitles, content)
  677. : extractChineseTitleQualifier(baseTitle, existingTitles, content);
  678. if (!qualifier) {
  679. return undefined;
  680. }
  681. return language === "en"
  682. ? `${baseTitle}: ${qualifier}`
  683. : `${baseTitle}:${qualifier}`;
  684. }
  685. function regenerateCollapsedTitle(
  686. baseTitle: string,
  687. existingTitles: ReadonlyArray<string>,
  688. language: "zh" | "en",
  689. content?: string,
  690. ): string | undefined {
  691. if (!content || !content.trim()) {
  692. return undefined;
  693. }
  694. const fresh = language === "en"
  695. ? extractEnglishTitleQualifier(baseTitle, existingTitles, content)
  696. : extractChineseTitleQualifier(baseTitle, existingTitles, content);
  697. if (!fresh) {
  698. return undefined;
  699. }
  700. return fresh === baseTitle ? undefined : fresh;
  701. }
  702. function extractEnglishTitleQualifier(
  703. baseTitle: string,
  704. existingTitles: ReadonlyArray<string>,
  705. content: string,
  706. ): string | undefined {
  707. const blocked = new Set(extractEnglishTitleTerms([baseTitle, ...existingTitles].join(" ")));
  708. const words = (content.match(/[A-Za-z]{4,}/g) ?? [])
  709. .map((word) => word.toLowerCase())
  710. .filter((word) => !ENGLISH_NAME_STOP_WORDS.has(capitalize(word)))
  711. .filter((word) => !blocked.has(word));
  712. const first = words[0];
  713. if (!first) {
  714. return undefined;
  715. }
  716. const second = words.find((word) => word !== first && !blocked.has(word));
  717. return second
  718. ? `${capitalize(first)} ${capitalize(second)}`
  719. : capitalize(first);
  720. }
  721. function extractChineseTitleQualifier(
  722. baseTitle: string,
  723. existingTitles: ReadonlyArray<string>,
  724. content: string,
  725. ): string | undefined {
  726. const blocked = new Set(extractChineseTitleTerms([baseTitle, ...existingTitles].join("")));
  727. const segments = content.match(/[\u4e00-\u9fff]+/g) ?? [];
  728. for (const segment of segments) {
  729. for (let start = 0; start < segment.length; start += 1) {
  730. for (let size = 2; size <= 4; size += 1) {
  731. const candidate = segment.slice(start, start + size).trim();
  732. if (candidate.length < 2) continue;
  733. if (CHINESE_TITLE_STOP_WORDS.has(candidate)) continue;
  734. if ([...candidate].some((char) => CHINESE_TITLE_STOP_CHARS.has(char))) continue;
  735. if (blocked.has(candidate)) continue;
  736. return candidate;
  737. }
  738. }
  739. }
  740. return undefined;
  741. }
  742. function extractEnglishTitleTerms(text: string): string[] {
  743. return [...new Set((text.match(/[A-Za-z]{4,}/g) ?? []).map((word) => word.toLowerCase()))];
  744. }
  745. function extractChineseTitleTerms(text: string): string[] {
  746. const terms = new Set<string>();
  747. const segments = text.match(/[\u4e00-\u9fff]+/g) ?? [];
  748. for (const segment of segments) {
  749. for (let start = 0; start < segment.length; start += 1) {
  750. for (let size = 2; size <= 4; size += 1) {
  751. const candidate = segment.slice(start, start + size).trim();
  752. if (candidate.length < 2) continue;
  753. if ([...candidate].some((char) => CHINESE_TITLE_STOP_CHARS.has(char))) continue;
  754. terms.add(candidate);
  755. }
  756. }
  757. }
  758. return [...terms];
  759. }
  760. function capitalize(word: string): string {
  761. return word.length === 0 ? word : `${word[0]!.toUpperCase()}${word.slice(1)}`;
  762. }