|
|
@@ -0,0 +1,236 @@
|
|
|
+/**
|
|
|
+ * 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 >= 4) {
|
|
|
+ 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 (substring match for short prohibitions, min 2 chars)
|
|
|
+ if (bookRules?.prohibitions) {
|
|
|
+ for (const prohibition of bookRules.prohibitions) {
|
|
|
+ if (prohibition.length >= 2 && prohibition.length <= 15 && content.includes(prohibition)) {
|
|
|
+ violations.push({
|
|
|
+ rule: "本书禁忌",
|
|
|
+ severity: "error",
|
|
|
+ description: `出现了本书禁忌内容:"${prohibition}"`,
|
|
|
+ suggestion: "删除或改写该内容",
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return violations;
|
|
|
+}
|