← dsh-novel-writer 技术文档 / novel_continuity_check

novel_continuity_check

连贯性审计。四种模式由参数选择且互斥chapter 做单章衔接检查(时间跳变 / 语义距离 / 人物延续 / 钩子承接),outline 做创作资料大纲方向行与正文开头的关键词重合度对照,ooc 做登记角色的情绪基线偏离哨兵;三者都不传时对照设定表扫描全书,输出矛盾候选(同章号多文件 / 数字口径 / 人物缺场 / 别名 / 设定重复 / 用语与语用冲突)。全部判定由本地规则与本地向量引擎完成,不调用在线模型;返回值是候选而非结论,逐条需人工确认。

v4.3.1 新增:同章号多文件候选。全书扫描模式现在检查章节文件名解析出的章号是否有重复,逐组生成一条 type: "同章号多文件" 的候选(chapters 为该组全部文件名)。这类书用章号定位的工具会直接报错(见 novel_read),所以把它放进审计里,可以在报错之前先看到是哪几章撞了。

参数

参数类型必填说明
bookstring书名,即 novels/ 下的子目录名。经 sanitizeSegment 过滤(剥离 \ / : * ? " < > | 与首尾点、空白),清洗后为空即报错。
chapterstring衔接检查模式。章号 / 文件名 / 标题子串,由 findChapter 解析。与 outlineooc 互斥。
outlineboolean大纲对照模式。仅 true 计入模式判定;false 等同未传。与 chapterooc 互斥。
oocbooleanOOC 哨兵模式。仅 true 计入模式判定。与 chapteroutline 互斥。
rootstring章节库根目录。省略时取 config.root,再退到会话工作目录。MCP 形态下必须落在启动参数 --root 之内(含根本身),越界会被静默回退该根。
互斥的判定口径。模式数按「chapter 已定义且字符串化后非空」「outline === true」「ooc === true」三项计数,计数 > 1 直接抛错,不做优先级择一。因此 chapter: "" 不算衔接模式,会落回全书扫描。

输出结构

契约必返字段(output.schema.required):bookcandidatesadvice。其余字段按模式出现。

字段类型内容
bookstring净化后的书名
candidatesarray候选列表。每项为 { type, detail, chapters? }typedetail 必返;chapters 为章节文件名数组
advicestring按模式与候选条数生成的处理建议
actionstring哨兵模式返回 "衔接" / "大纲对照" / "OOC";无参数扫描模式不返回该字段
chapterstring衔接模式返回目标章的文件名(「第一章无上一章可比」的提前返回除外)
reportFilestring仅无参数扫描模式返回审计报告路径;三种哨兵模式不落盘、不返回该字段
没有 brief 精简模式。本工具的参数 schema 为 additionalProperties: false,不含 brief;渲染层也没有精简分支。输出体积由内部的候选条数与章节列表上限控制(见「约束与边界」),调用方无法用参数进一步压缩。渲染文本为 <path> / <type>novel-continuity-check</type> / <content> 包裹的清单;候选为空时输出「未发现明显矛盾候选。」。

计算原理

1 · 衔接检查(chapter

先取上一章(scanChapters 按章号排序后的前一个文件)的末 600 字与本章开头 800 字;两段文本任一去空白后为空则直接返回空数组。四路检查依次执行,任一路的失败都只影响该路。

prevTail = 上一章正文[-600:] currHead = 本章正文[:800] ① 时间跳变 hard = 词表 hard 在 currHead 内的命中 soft = 词表 soft 在 currHead 内的命中 prevHasTime = hard ∪ soft 在 prevTail 内有命中 hard 非空 → 候选「衔接·时间跳跃」(列出前 2 个词) 否则 soft 非空 且 !prevHasTime → 候选「衔接·时间过渡」 ② 语义距离(受 semanticStyle 开关 + 引擎可用性双重门控) vPrev = embed(prevTail),vCurr = embed(currHead) ← 512 维归一化向量 sim = cosine(vPrev, vCurr) sim < 0.30 → 候选「衔接·语义距离」(偏低) 0.30 ≤ sim < 0.45 → 候选「衔接·语义距离」(一般) sim ≥ 0.45 → 不产生候选 ③ 人物延续 chars = settings.characters 中 name.length ≥ 2 的条目 prevNames = name 在 prevTail 内出现者 currNames = name 在 currHead 内出现者 prevNames 非空 且 currNames 为空 → 候选「衔接·人物断线」(列出前 3 个名字) ④ 钩子承接 lastHook = 钩子记录.md 中章号 == parseChapterNumber(prevFile) 的「- N 」行内容 ratio = |{c ∈ dePunct(lastHook)} ∩ {c ∈ currHead[:300]}| / |dePunct(lastHook)| ratio < 0.25 且 lastHook.length > 4 → 候选「衔接·钩子未接」 读取章节文本抛错 → 候选「衔接·检查跳过」(含错误信息前 120 字)

dePunct 指剔除以 ,。!?、 与空白后的字符集合;钩子比对是单字级集合命中率而非 2-gram,语义接续但字面弱的情况不误报。时间词表 hard 为 28 项(第二天 / 三天后 / 半年后 / 多年后 / 不久后 …),soft 为 12 项(次日 / 翌日 / 隔天 / 深夜 / 黎明 / 清晨 / 黄昏 / 午夜 / 当晚 …)。

2 · OOC 哨兵(ooc

需要设定表已登记角色(name.length ≥ 2),否则返回空候选并提示先登记。对每个角色逐章取上下文窗口,按章求情绪均值,再与该角色的全书均值比较。

// 采样:每章每角色最多 5 处 positions = 该角色名在本章文本中 indexOf 的前 5 个位置 around = 每个位置前后各 120 字的切片拼接 val = valenceStats(around).meanValence ← lib/analysis.js // 判定:出现章数 ≥ 2 才参与 μ = Σ valᵢ / n σ = sqrt( Σ (valᵢ − μ)² / n ) ← 总体标准差;结果为 0 时取 0.05 thr = n ≤ 4 ? 0.30 : 2σ |valᵢ − μ| > thr → 候选「OOC·情绪偏离」(detail 标注用的是固定阈值还是 2σ)

meanValence 来自 valenceStats:词表按最长优先消费区间,命中位置先过 negatedAt 否定过滤,再以 100 字窗口统计。零命中窗口不进入均值序列(避免 0 稀释),因此无情感词的章不产生效价值——该角色在该章不进样本。

3 · 大纲对照(outline

directionLines = 剧情大纲.md 中匹配 /^- \d+ / 的行 逐行: num = 行首数字 direction = 去掉「- N 」后的方向文本;length < 4 直接跳过 ch = chapters 中 number === num 的章(章号口径同 scanChapters);无则跳过 text = 该章正文[:600](读失败 → skipped+1,跳过该章) grams = { direction[i:i+2] | 0 ≤ i < len−1 } ← 相邻二字组 ratio = |{ g ∈ grams : text 含 g }| / |grams| ratio < 0.12 → 候选「大纲·可能偏离」

两个提前返回:大纲文件读不到时返回 advice: "该书无创作资料…";文件存在但无方向行时返回 advice: "创作资料大纲没有方向行…"。两种都是空候选。skipped > 0advice 追加「(跳过 N 章读取失败)」。本模式不读取全书正文,只按需读命中的章节开头。

4 · 全书设定表扫描(无参数)

先登记 readSettings 的五张表(characters / locations / items / timeline / worldview),再逐章读全文,按下列固定顺序生成候选。

同章号多文件(v4.3.1 起,排在全部检查之前)。scanChapters 的结果按解析出的章号分组,凡一组内文件数 > 1 就生成一条候选,detail 列出该组全部文件名(不套用 5 章截断——撞号分组必须完整可见)。章号解析不出(number 缺省)的文件不参与分组。

类型顺序:数字口径 → 人物缺失/人物缺场 → 别名未用 → 设定重复 → 用语冲突 → 语用冲突·客套 → 语用冲突·仪式 → 语用冲突·称谓

数字口径。扫描 \d+\s*(?:万亿|千万|百万|十万|[万亿千百])[零一二两三四五六七八九十]{1,2}(?:万亿|千万|百万|十万|[万亿千百]),去掉空白后要求写法以数量单位字(万亿 / 千万 / 百万 / 十万 / 万 / 亿 / 千 / 百 / 十)结尾,并剔除「十」「百」「千」「万」「亿」这类纯单位字写法。把写法解析回数值(万亿=1e12、千万=1e7、百万=1e6、十万=1e5、亿=1e8、=1e4、=1e3、=1e2、=1e1),只对数值相同而写法不同的成对条目生成候选,总数上限 20 条。

人物与别名。对每个登记人物统计「全文不含其 name 的章节」:全部章节都不含 → 「人物缺失」;部分不含且全书章节数 > 1 → 「人物缺场」;两者 chapters 都只取前 5 章。若登记了 alias 却全书无任何别名出现 → 「别名未用」(chapters 为空数组)。

设定重复。对 characters / locations / items 三张表,取 normalizeSettingList 后的 name 序列,出现次数大于 1 的名字 → 「设定重复」。

用语与语用。禁用词表来源按优先级确定:取 worldview 中最近一条 bannedWords 非空的条目;没有则取最近一条含 bannedWords 字段(含显式空数组)的条目;都没有则走默认——此时先对全书正文跑 detectCulture,判定为 eastern 时用 SPEECH_STYLE_RULES.eastern.honorBad 作禁用词表(建议词用 honorGood),判定为 modern 时禁用词表为空(整路跳过),western / mixed / unknown 时用默认欧式中世纪晚期词表。语用三路各自扫描 speechStyle.honorBadspeechStyle.ritualBadPatterns(逐条 RegExp 全局匹配)与称谓规范——称谓仅在 speechStyle.title 文本中含「不用…小姐 / 小姐…禁用 / 禁用…小姐 / 不称…小姐」这类否定式时才扫描 XX小姐

审计报告落盘。扫描模式把候选写入 <root>/.novel-writer/audits/<book>-<sha1(book|章节文件名逗号串)[:16]>.json,内容为 { book, generatedAt, candidates };文件名中的哈希只由书名与章节文件名列表决定,正文改动不换文件,增删章节才换。

约束与边界

  • 三种哨兵模式互斥。同传 ≥ 2 种直接抛错,不做优先级择一;调用方必须拆分调用。要同时得到衔接与设定表结论时,先跑无参数扫描,再单独跑 chapter
  • 第一章的衔接检查是空结果,不是「通过」。目标章在排序后位于第 0 位时提前返回 candidates: [],且该返回体不含 chapter 字段advice 明确写「第一章无上一章可比对」。
  • 候选条数与章节列表都有硬上限。每个候选的 chapters 最多 5 个文件名(CANDIDATE_CHAPTER_LIMIT),超出时 detail 追加「(共 N 章,仅列前 5)」。数字口径候选总数上限 20。例外:人物缺失 / 人物缺场两路同样是前 5 章,但追加该提示文案——不能靠有没有该文案判断是否被截断。
  • 三路衔接检查会被静默跳过。语义距离一路需要 semanticStyle 功能开启且本地模型文件在位(加载失败有 30 秒冷却后重试);人物延续一路需要设定表登记了长度 ≥ 2 的角色名;钩子承接一路需要 novels/创作资料/<书>/钩子记录.md 里有与上一章章号相同的方向行。任一前提不成立时该路不产生任何候选——「没报语义距离」不等于「语义衔接良好」。
  • 唯一不静默的降级是整章读不出来。章节文本读取抛错时反向透出「衔接·检查跳过」候选,并在 detail 内附原因前 120 字,说明时间 / 人物 / 钩子三路本次全部跳过。
  • OOC 是统计哨兵而非判定。采样窗口固定为角色名前后各 120 字、每章最多 5 处,短场景与对白密集章的代表性有限;出现章数 ≤ 4 时改用固定阈值 0.3(小样本的 σ 会被离群值撑大)。detail 会写明本次用的是固定阈值还是 2σ,并在文案中提示「若有重大剧情触发情绪变化可忽略」。
  • 角色过滤口径两种模式不一致。OOC 模式要求 name.length ≥ 2;全书扫描的人物缺场检查只要求 name !== ""。单字角色名只在后者参与检测。
  • 大纲对照是字面重合度,不是语义对照。只看方向行的相邻二字组在该章前 600 字中的命中比例,阈值 0.12;方向文本长度 < 4 的方向行、章号对不上的方向行都被跳过且不计入「跳过」计数。返回值只是「可能偏离」提示,偏离本身不一定是错。
  • 章号对齐用解析结果,不取文件名首段数字。大纲与钩子两处都走 parseChapterNumber,因此 第1卷03章.md 按章号 3 匹配;全角章号(第01章)先做宽度归一。
  • 扫描模式只读正文与设定表,唯一写入是审计报告。报告写盘失败不阻塞执行;但 reportFile 字段在写盘前就已算出,写入失败时返回值里仍会出现这个路径,该文件此时并不存在。
  • 未登记 worldview 的影响是双向的。「查不到禁用词表」不代表「不扫描」:会先用 detectCulture 判定全书文化基准再选词表(eastern → 中式禁词、modern → 跳过、其余 → 默认欧式基准)。显式登记 bannedWords: [](「不要任何禁用词」)与「未登记」语义不同,前者会直接使用空词表。
  • 数值与条数不可跨版本横比。v4.3.0 把候选章节列表统一截断到 5(此前用语 / 客套 / 仪式 / 称谓四路会把全书章节名全部塞进候选)、数字口径改为按数值分组去重(此前任意两两配对会产生 N(N−1)/2 条候选)、衔接模式的读取失败不再吞成空数组、书名在 detectChapterBridge 入口统一净化。同一本书在不同版本下的候选条数不可直接比较。
  • 语义距离的引擎状态与 novel_semantic_search 同源。该路复用 embedding.isAvailable / embed / cosine,与索引检索无关,是两次单段嵌入;仅对 600 字与 800 字文本做一次余弦,不落盘、不进索引缓存。

相关工具

  • novel_settings —— 五张设定表的维护入口;本工具的人物缺场 / 别名 / 设定重复 / 用语与语用五路全部读它。
  • novel_outline —— 创作资料(含 剧情大纲.md钩子记录.md)的初始化与回填;大纲对照与钩子承接两路依赖它写入的 - N 方向行。
  • novel_summary —— 章节摘要;长书审计后按摘要复核候选比逐章重读省 token。
  • novel_chapters —— 章节清单;chapter 参数取值先用它确认。

源码位置

  • lib/index.js —— 工具注册与四种模式的实现:registerNovelContinuityCheckcapCandidateChaptersCANDIDATE_CHAPTER_LIMIT
  • lib/core.js —— detectChapterBridge(衔接四路)、TIME_JUMP_WORDSreadSettingsfindChapterparseChapterNumberdetectCulturenormalizeSettingList
  • lib/analysis.js —— valenceStats / valenceSeries(OOC 的情绪效价与否定过滤)
  • lib/embedding.js —— isAvailable / embed / cosine(语义距离一路)
  • lib/lexicons/markers.js —— DEFAULT_BANNED_WORDS / SPEECH_STYLE_RULES / CULTURE_MARKERS
  • mcp/server.mjs —— MCP 侧的 root 注入与越界回退