| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- /**
- * Post-write rule-based validator.
- *
- * Deterministic, zero-LLM-cost checks that run after every chapter generation.
- * Catches violations that prompt-only rules cannot guarantee.
- */
- import type { BookRules } from "../models/book-rules.js";
- import type { GenreProfile } from "../models/genre-profile.js";
- export interface PostWriteViolation {
- readonly rule: string;
- readonly severity: "error" | "warning";
- readonly description: string;
- readonly suggestion: string;
- }
- // --- Marker word lists ---
- /** AI转折/惊讶标记词 */
- const SURPRISE_MARKERS = ["仿佛", "忽然", "竟然", "猛地", "猛然", "不禁", "宛如"];
- /** 元叙事/编剧旁白模式 */
- const META_NARRATION_PATTERNS = [
- /到这里[,,]?算是/,
- /接下来[,,]?(?:就是|将会|即将)/,
- /(?:后面|之后)[,,]?(?:会|将|还会)/,
- /(?:故事|剧情)(?:发展)?到了/,
- /读者[,,]?(?:可能|应该|也许)/,
- /我们[,,]?(?:可以|不妨|来看)/,
- ];
- /** 分析报告式术语(禁止出现在正文中) */
- const REPORT_TERMS = [
- "核心动机", "信息边界", "信息落差", "核心风险", "利益最大化",
- "当前处境", "行为约束", "性格过滤", "情绪外化", "锚定效应",
- "沉没成本", "认知共鸣",
- ];
- /** 作者说教词 */
- const SERMON_WORDS = ["显然", "毋庸置疑", "不言而喻", "众所周知", "不难看出"];
- /** 全场震惊类集体反应 */
- const COLLECTIVE_SHOCK_PATTERNS = [
- /(?:全场|众人|所有人|在场的人)[,,]?(?:都|全|齐齐|纷纷)?(?:震惊|惊呆|倒吸凉气|目瞪口呆|哗然|惊呼)/,
- /(?:全场|一片)[,,]?(?:寂静|哗然|沸腾|震动)/,
- ];
- // --- Validator ---
- export function validatePostWrite(
- content: string,
- genreProfile: GenreProfile,
- bookRules: BookRules | null,
- ): ReadonlyArray<PostWriteViolation> {
- const violations: PostWriteViolation[] = [];
- // 1. 硬性禁令: "不是…而是…" 句式
- if (/不是[^,。!?\n]{0,30}[,,]?\s*而是/.test(content)) {
- violations.push({
- rule: "禁止句式",
- severity: "error",
- description: "出现了「不是……而是……」句式",
- suggestion: "改用直述句",
- });
- }
- // 2. 硬性禁令: 破折号
- if (content.includes("——")) {
- violations.push({
- rule: "禁止破折号",
- severity: "error",
- description: "出现了破折号「——」",
- suggestion: "用逗号或句号断句",
- });
- }
- // 3. 转折/惊讶标记词密度 ≤ 1次/3000字
- const markerCounts: Record<string, number> = {};
- let totalMarkerCount = 0;
- for (const word of SURPRISE_MARKERS) {
- const matches = content.match(new RegExp(word, "g"));
- const count = matches?.length ?? 0;
- if (count > 0) {
- markerCounts[word] = count;
- totalMarkerCount += count;
- }
- }
- const markerLimit = Math.max(1, Math.floor(content.length / 3000));
- if (totalMarkerCount > markerLimit) {
- const detail = Object.entries(markerCounts)
- .map(([w, c]) => `"${w}"×${c}`)
- .join("、");
- violations.push({
- rule: "转折词密度",
- severity: "warning",
- description: `转折/惊讶标记词共${totalMarkerCount}次(上限${markerLimit}次/${content.length}字),明细:${detail}`,
- suggestion: "改用具体动作或感官描写传递突然性",
- });
- }
- // 4. 高疲劳词检查(从 genreProfile 读取,单章每词 ≤ 1次)
- const fatigueWords = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
- ? bookRules.fatigueWordsOverride
- : genreProfile.fatigueWords;
- for (const word of fatigueWords) {
- const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const matches = content.match(new RegExp(escaped, "g"));
- const count = matches?.length ?? 0;
- if (count > 1) {
- violations.push({
- rule: "高疲劳词",
- severity: "warning",
- description: `高疲劳词"${word}"出现${count}次(上限1次/章)`,
- suggestion: `替换多余的"${word}"为同义但不同形式的表达`,
- });
- }
- }
- // 5. 元叙事检查(编剧旁白)
- for (const pattern of META_NARRATION_PATTERNS) {
- const match = content.match(pattern);
- if (match) {
- violations.push({
- rule: "元叙事",
- severity: "warning",
- description: `出现编剧旁白式表述:"${match[0]}"`,
- suggestion: "删除元叙事,让剧情自然展开",
- });
- break; // 报一次即可
- }
- }
- // 6. 分析报告式术语
- const foundTerms: string[] = [];
- for (const term of REPORT_TERMS) {
- if (content.includes(term)) {
- foundTerms.push(term);
- }
- }
- if (foundTerms.length > 0) {
- violations.push({
- rule: "报告术语",
- severity: "error",
- description: `正文中出现分析报告术语:${foundTerms.map(t => `"${t}"`).join("、")}`,
- suggestion: "这些术语只能用于 PRE_WRITE_CHECK 内部推理,正文中用口语化表达替代",
- });
- }
- // 7. 作者说教词
- const foundSermons: string[] = [];
- for (const word of SERMON_WORDS) {
- if (content.includes(word)) {
- foundSermons.push(word);
- }
- }
- if (foundSermons.length > 0) {
- violations.push({
- rule: "作者说教",
- severity: "warning",
- description: `出现说教词:${foundSermons.map(w => `"${w}"`).join("、")}`,
- suggestion: "删除说教词,让读者自己从情节中判断",
- });
- }
- // 8. 全场震惊类集体反应
- for (const pattern of COLLECTIVE_SHOCK_PATTERNS) {
- const match = content.match(pattern);
- if (match) {
- violations.push({
- rule: "集体反应",
- severity: "warning",
- description: `出现集体反应套话:"${match[0]}"`,
- suggestion: "改写成1-2个具体角色的身体反应",
- });
- break;
- }
- }
- // 9. 连续"了"字检查(3句以上连续含"了")
- const sentences = content
- .split(/[。!?]/)
- .map(s => s.trim())
- .filter(s => s.length > 2);
- let consecutiveLe = 0;
- let maxConsecutiveLe = 0;
- for (const sentence of sentences) {
- if (sentence.includes("了")) {
- consecutiveLe++;
- maxConsecutiveLe = Math.max(maxConsecutiveLe, consecutiveLe);
- } else {
- consecutiveLe = 0;
- }
- }
- if (maxConsecutiveLe >= 6) {
- violations.push({
- rule: "连续了字",
- severity: "warning",
- description: `检测到${maxConsecutiveLe}句连续包含"了"字,节奏拖沓`,
- suggestion: "保留最有力的一个「了」,其余改为无「了」句式",
- });
- }
- // 10. 段落长度检查(手机阅读适配:50-250字/段为宜)
- const paragraphs = content
- .split(/\n\s*\n/)
- .map(p => p.trim())
- .filter(p => p.length > 0);
- const longParagraphs = paragraphs.filter(p => p.length > 300);
- if (longParagraphs.length >= 2) {
- violations.push({
- rule: "段落过长",
- severity: "warning",
- description: `${longParagraphs.length}个段落超过300字,不适合手机阅读`,
- suggestion: "长段落拆分为3-5行的短段落,在动作切换或情绪节点处断开",
- });
- }
- // 11. Book-level prohibitions
- // Short prohibitions (2-30 chars): exact substring match
- // Long prohibitions (>30 chars): skip — these are conceptual rules for prompt-level enforcement only
- if (bookRules?.prohibitions) {
- for (const prohibition of bookRules.prohibitions) {
- if (prohibition.length >= 2 && prohibition.length <= 30 && content.includes(prohibition)) {
- violations.push({
- rule: "本书禁忌",
- severity: "error",
- description: `出现了本书禁忌内容:"${prohibition}"`,
- suggestion: "删除或改写该内容",
- });
- }
- }
- }
- return violations;
- }
|