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

novel_style_check

风格自检。把目标章节与「全书除本章外的其余章」当作两个样本,先算句式指纹的余弦相似度并给出 high / medium / low 分级与偏差清单,再用同一套六维文笔指标做「本章值 vs 其他章基线 μ±σ」的带外判定,最后附上原著锚段供对照校准语感基线永远不含目标章:目标章只参与判定,不参与统计,否则风格漂移会把自己的基线一起拉走。

参数

参数类型必填说明
bookstring书名,即 novels/ 下的子目录名。经 requiredString 判空后交 sanitizeSegment 过滤。
chapterstring目标章节标识:章号(1 / 01)、文件名(第01章.md)或标题子串,由 findChapter 解析;找不到则抛错。
rootstring章节库根目录。省略时取插件 config.root,再省略取会话工作目录。MCP 形态下必须落在启动参数 --root 指定的书库根内,越界会被静默回退到该书库根。

本工具没有 thresholdtop 一类可调参数:偏差阈值与判定分级都是常量,语义增强是否参与由侧边栏功能开关决定。

输出结构

契约字段(output.schema.required):bookchapterbaselineScopesimilarityverdictdiffschapterFingerprintbaselineFingerprintadvicesemantic。其余字段按需出现。

字段类型内容
chapterstring解析后的目标章文件名(不是调用方传入的原始值)
baselineScopestring仅 1 个其他章时是该章文件名,否则为「全书除本章外的 N 章」
similaritynumber0~1,千分位取整(Math.round(x*1000)/1000
verdictstring枚举 high / medium / low
diffsarray偏差清单,元素为 {dimension, diff, note}diff 单位为百分点(句长项为字)
chapterFingerprintstring本章指纹串(九类占比 + 句长 + 主观性 + 主导情绪 + 首选句式模板)
baselineFingerprintstring基线指纹串,同格式,可直接肉眼对照
semanticobject必返对象。enabled:true 时含 similaritynote;不可用时仅 enabled:false 与说明文案
metricobject六维对照。enabled:true 时含 baseline / metrics / verdicts / outCount / skippedDims / summary;不可用时仅 enabled:falsenote
fixAnchorsarray原著锚段,元素为 {label, text},最多 3 条;锚包构建失败时缺省
advicestring面向生成端的建议文本,已与六维判定统一口径。v5.1.0 起不再要求「逐句对比修正」:改为「先判断这是不是有意为之(章节类型不同本来就会偏离全书均值);确实跑偏时才对照 fixAnchors 只调整那几处,不要整章重写,也不要为对齐数字改文

渲染层(render)依次输出:检查章节、对比基线、风格相似度百分比与中文分级、偏差清单(无偏差时写「偏差: 无明显偏差」)、语义对比备注、原著锚段(带「逐句对比本章对应写法,只修跑偏部分,不得整章重写」的约束语)、本章指纹与基线指纹、文笔六维逐维「本章 v vs 基线 μ(±%,marks)」、advice。六维行的状态符号为 ⚠ 出带 / △ 提醒 / ✓ 在带metric.enabled === false 时该段只打印 note

计算原理

本工具做两类判定,共用一次逐章读盘:句式指纹相似度(整章 vs 全书其他章的文本聚合)与六维文笔基线对照(逐章测量后再汇总)。

1 · 风格指纹与向量化

指纹串由 buildFingerprint 拼装,向量由 fingerprintSimilarity 内部按固定维度顺序构造,共 13 维:

指纹串 = 九类短码占比(‰,保留 1 位小数,空格分隔) + " | len:" + 平均句长 + " subj:" + 主观性指数 + " emo:" + 主导情绪 + " | motif:" + 最高频句式模板 v[0..8] = 九类句式占比(CATEGORY_ORDER 顺序,缺失补 0) v[9] = min(平均句长, 100) / 100 v[10] = 短句占比 v[11] = 长句占比 v[12] = 主观性指数 / 100 similarity = dot(va, vb) / (‖va‖ · ‖vb‖) ← 任一范数为 0 则返回 0

句长维度先按 100 字截顶再归一,其余各维本身已在 0~1 量级,因此余弦不会被句长一项主导。九类短码为 S / ENV / PSY / DLG / Q / RQ / EX / IMP / …,与 novel_sentence_analysisTYPE_CODE 同源。

2 · 分级与偏差清单

verdict = similarity ≥ 0.9 ? "high" : similarity ≥ 0.75 ? "medium" : "low"

偏差清单由 styleDiffs(baseline, chapter) 产生,方向为「本章相对基线」,四类判据与阈值固定:

句式占比:|Δratio| ≥ 0.06 → {dimension:"X占比", diff: Δ×100(1 位小数), note: 偏高/偏低} 平均句长:|Δavg| ≥ 5 字 → {dimension:"平均句长", diff: Δ(1 位小数), note: 偏长/偏短} 短句占比:|Δshort| ≥ 0.08 → {dimension:"短句占比", diff: Δ×100, note: 偏高/偏低} 主导情绪:baseline.dominant ≠ chapter.dominant → {diff: 0, note: "由「A」变为「B」"}

清单不做数量上限:九类 + 句长 + 短句 + 情绪最多 12 条。但 advice 文案只取前 4 条拼入自然语言建议,其余条目仍完整保留在 diffs 里由渲染层逐条打印。方向词映射为「偏高→略多、偏低→略少、偏长→略长、偏短→略短」。

3 · 六维基线的「排除本章」口径

先用 metricChaptersCached 取得全书每章的六维测量值(带缓存),再分两步算两组基线:

excluding = perChapter.filter(文件 ≠ 目标章) ← 判定用基线 b = computeBaselineFromPerChapter(excluding) allBaseline = computeBaselineFromPerChapter(perChapter) ← 容差来源(含目标章) tol[k] = { low: -allBaseline[k].recTol, high: +allBaseline[k].recTol } 若用户自定义了 styleTolerance[k] 的 low/high(均为 number)→ 该维改用自定义值 judge = judgeAgainstBaseline(measureStyleMetrics(目标章).metrics, b, tol)

容差取「含目标章的全书推荐值」而非「排除目标章后的推荐值」,是因为排除后样本更少、σ 更不稳定;用户自定义容差只覆盖显式给出的维度,未设置的维度继续沿用全书推荐值。recTol 缺失或非有限值时回退 15。六维的定义、μ±σ 公式与 out / warn / ok 判据与 novel_style_report 完全同源,见该页「六个维度的定义与公式」与「基线带 μ±σ 的计算」两节。

advice 与六维判定统一口径(v5.1.0 改措辞)。 六维判定算完之后,若 metric.outCount > 0advice 会被覆写:指纹无偏差时改写为「句式指纹与全书高度一致;六维层面:<summary>——单一维度出带属正常波动(带宽 = 作者自身波动的 1.5σ),只有偏离达到 2 倍容差以上、或那一处读起来确实别扭时才需要调整,不要为对齐数字改写文本」,有偏差时在原建议后追加「另(六维):…——同上,轻微出带属正常波动,不必逐项消除」。此举避免了同一份输出既说「可以放心续写」又标「⚠ 需修正」的自相矛盾。
v5.1.0 另修两处会诱导"反复打磨"的措辞:① 六维汇总行(style-metrics.jsjudge.summary)由「N 个维度超出容差带,需修正」改为「N 个维度超出容差带(先判断是否为有意为之,偏离明显或读起来别扭才改)」;② 渲染层删掉了句尾那句「——请对照基线修正后再续写」——它与紧随其后的建议段自相矛盾,而模型会听更早、更硬的那半句。
v5.1.0:advice 只说「怎么判断」,不再说「逐句改」。改写后的文本由「先判断这是不是有意为之(章节类型不同本来就会偏离全书均值);确实跑偏时才对照 fixAnchors 只调整那几处,不要整章重写,也不要为对齐数字改文」构成。原来的「逐句对比修正」被执行成「把整章按锚段重写一遍」,而六维容差带本身是 1.5σ(约作者自身章节波动)——在这个带宽下,六维里至少有一维出带是常见现象,把它当成缺陷反复打磨只会把作者的写法磨掉。因此「出带」不是「必改」:只有偏离达到 2 倍容差以上、或那一处读起来确实别扭时才动笔。fixAnchors 的用途也随之收敛为「拿去对味、校准语感」,而不是「照着逐句仿写」。

4 · 原著锚段 fixAnchors

锚段由 buildStyleAnchorPackage 从**基线章文本**(已读入内存的逐章文本,不再二次读盘)中挑选,段落需 trim().length ≥ 40

对话锚:含 " 或 “ 且长度 ≤ 200 → 取 2 条 心理锚:命中 /想|觉得|心|怕|慌|记忆/ 且不含引号 且 ≤ 200 → 取 1 条 描写锚:不含引号 且 80 ≤ 长度 ≤ 200 → 取 2 条 每类内部按「全书等距抽样」选取(首尾必取、均匀取 n 条;n=1 取中位段) 锚段文本统一截断到 200 字,label 为 对话 / 心理 / 描写 全空时回退为 3 条 label="原文" 的等距段落 fixAnchors = 按 label 去重后的前 3 条;不足 3 条时按原顺序补齐(最多 3 条)

等距抽样而非顺序取前 n 条,是为了避免锚段全部来自序章与开头章。跨桶去重(对话×2 → 心理×1 → 描写×2 中先各取一条)修正了此前「slice(0,2) 恒取到两条对话锚、心理与描写锚永远进不了输出」的缺陷。

5 · 语义风格相似度(可选增强)

仅当 semanticStyle(含 semanticEmbedding 总开关)开启且本地引擎可用时执行。目标章与基线文本各自切块后合并为一份临时索引,块 id 加 "0|" / "1|" 前缀以区分两侧:

targetVecs = index.filter(id 以 "0|" 开头) baseVecs = index.filter(id 不以 "0|" 开头) baseSample = baseVecs 长度 ≤ 20 ? 全部 : 按 (len-1)/19 步长等距取 20 条 semSim = Σ cosine(tv, bv) / (targetVecs.length × baseSample.length) note = semSim ≥ 0.55 ? "语义风格高度一致" : semSim ≥ 0.45 ? "语义风格中等一致" : "语义风格存在差异,注意写法口吻" similarity = round(semSim × 1000) / 1000

基准侧的 20 条等距抽样是必要的:早先的 slice(0,20) 只覆盖全书开头约 20 段,大书结果系统性偏置。索引缓存键为 <book>__style_<章名清洗后>,写盘失败不阻塞本次结果,只在 semantic.note 末尾追加「(语义索引缓存写入失败,下次会重建)」。

约束与边界

  • 三处前置断言会直接抛错:写作助手总开关关闭时抛「写作助手功能(句式模式分析)当前已关闭…」;作品章数 ≤ 1 时抛「只有 N 章,无法做风格对比(至少需要 2 章)」;chapter 解析不到时抛「找不到章节」。调用方应把这三类视为参数/环境错误,而不是降级结果。
  • 基线缓存每书一份、按内容哈希失效。基线分析写入 <root>/.novel-writer/analysis/<book>__base-full.json,失效键是 sha1(基线全文) 前 16 位加算法版本号。bookAnalysisCached 命中时要求 thver 同时相等;任一章节改动都会改变基线全文,从而整份重建。文件不含目标章名,因此检查不同章不会堆积近似全书大小的缓存文件。
  • 六维逐章测量缓存单独一份<root>/.novel-writer/analysis/<book>-chapters-metrics.json,指纹按文件名排序后拼 file + "\0" + text 再哈希,与传入章序无关——本工具传「其他章 + 目标章」,novel_style_report 传全书序,两者共用同一缓存不会互相击穿。
  • 六维对照可能整体不可用:排除目标章后没有可用基线章时返回 {enabled:false, note:"基线章节不足(至少需要 1 个其他章节)"};测量过程抛异常时返回 {enabled:false, note:"六维测量失败:…"}(异常文本截断到 80 字)。两种情形下 diffs / similarity 仍是有效结果,调用方不应把 metric.enabled === false 读成「六维合格」。
  • 六维可能静默跳过维度:某维基线 μ=0 且 σ=0 时无法做相对判定,该维登记进 metric.skippedDimssummary 会写明「另有 N 个维度基线均值为 0、未做相对判定(维度名)」。μ=0 但 σ>0 时改用绝对尺度(|v| > 1.5σ 判 out),该项 devPctnullbasis"absolute",渲染层打印「基线为 0,按绝对尺度判定」而不是 null%
  • 语义增强有三条降级路径,文案各异:功能未开启时为 {enabled:false, note:"语义增强未启用(纯规则模式)"};目标侧或基准侧无段落时为 {enabled:false, note:"语义对比样本不足(目标章或基准章为空)"};计算抛异常时为 {enabled:false, note:"语义对比失败:…"}(异常文本截断到 80 字)。semantic 在 schema 中是必返 object,enabled:false 属正常返回而非错误。
  • 被静默跳过的情形:锚包构建抛异常时 fixAnchors 缺省,判定与建议不受影响;语义索引写盘失败只追加一行备注;cleanOutput 会统一把 -0 归一为 0、把 NaN 归一为 null、剔除 undefined 值的键,以满足宿主无损 JSON 校验。
  • 跨版本不可横比:六维口径在 v4.3.0 有多处行为变化(句末符统一为 。!?…!? 且省略号单独成句、动作词表去重清洗到 625 条、抽象度改为最长匹配、修饰密度新增停用词表、留白指数量纲统一为千字密度)。同一本书在旧版本下算出的 similarity / diffs / metric 数值不可与新版本直接比较;缓存另设算法版本键,升级后自动失效重建。
  • 语义索引指纹算法变更属一次性代价:v4.3.0 为内容指纹字段加了长度前缀定界(消除字段含分隔符导致的边界歧义),既存索引与风格缓存的指纹一次性失效并重建,之后恢复增量命中。

相关工具

  • novel_style_report —— 提供六维指标的定义、词表规模与 μ±σ 完整公式;本工具的 metric 段是其判定路径的「单章版」。
  • novel_sentence_analysis —— 九类句式分布、句长分布、情感曲线与风格指纹的原始实现,本工具的 diffs 全部来自该分析结果。
  • novel_semantic_search —— 与本节「语义风格相似度」共用同一个本地向量引擎与同一份索引缓存文件。

源码位置

  • lib/index.js —— 工具注册与执行(registerNovelStyleCheck,第 899 行起;execute 自第 989 行)、渲染(第 947 行)、语义门控 semanticGate(第 109 行)
  • lib/analysis.js —— buildFingerprint(第 1600 行)、fingerprintSimilarity(第 1607 行)、styleDiffs(第 1634 行)、analyzeText(第 1174 行)
  • lib/style-metrics.js —— measureStyleMetrics(第 242 行)、computeBaselineFromPerChapter(第 399 行)、judgeAgainstBaseline(第 460 行)、METRIC_ORDER(第 394 行)
  • lib/core.js —— metricChaptersCached(第 1864 行)、bookAnalysisCached(第 1881 行)、buildStyleAnchorPackage(第 2071 行)、cleanOutput(第 2308 行)
  • lib/embedding.js —— chunkText(第 520 行)、buildIndexIncremental(第 463 行)、loadIndexMeta(第 440 行)、saveIndex(第 408 行)、fingerprint(第 338 行)、cosine(第 179 行)
  • mcp/server.mjs —— MCP 形态下的 --root 越界拒绝与回退(resolveLibraryRoot 第 130 行、参数补全与校验第 259~281 行)