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

novel_sentence_analysis

句式模式分析。逐句把正文分成九类句式并统计占比、转移、模板与段落结构,同时给出句长分布、情感曲线、细节密度、风格指纹与节奏建议。分析范围可以是全书,也可以是单章。全部为本地规则统计:情感词命中走词表加否定过滤与副词加权,语义隐性情感是在此之上的可选增强。结果写入书库缓存并同时落盘一份报告 JSON,可用 fresh 强制重算。

参数

参数类型必填说明
bookstring书名。经 sanitizeSegment 过滤;对应目录内无章节文件时抛错。
chapterstring只分析该章(章号 / 文件名 / 标题子串)。省略则分析全书,scope 形如「全书 N 章」。
topinteger返回的高频句式模板条数。允许区间 1–50,默认 8;越界或非整数抛错(不静默钳制)。
maxSentencesinteger采样句数上限。允许区间 100–100000,默认 20000;触顶后按前缀截断并在建议文本尾部标注。
curveSegmentsinteger情感曲线分段数。允许区间 1–50,默认 20;实际段数为 min(有效段落数, curveSegments)
freshbooleantrue 跳过缓存读取并强制重算;结果仍写回同一缓存文件。
briefbooleantrue 时返回 brief 单句摘要,渲染层只输出该句并提示「省略 brief 重新调用」。
rootstring章节库根目录。省略时取插件 config.root,再省略取会话工作目录;MCP 形态下受启动参数 --root 约束。

输出结构

契约字段(output.schema.required):bookscopeenabledtotalCharstotalSentences。功能关闭时返回体只含这五项加 message,且 totalCharstotalSentences 均为 0。

字段类型内容
scopestring单章时为文件名;全书时为「全书 N 章」
enabled / messageboolean / string句式分析总开关状态;关闭时 message 给出开启方式
autoAnalyzeboolean生效的「分析作品时主动使用」开关状态
totalChars / totalSentencesinteger全文字符数与实际参与统计的句数(受 maxSentences 影响)
categoriesarray固定九项,顺序为 CATEGORY_ORDER{type, label, count, ratio, avgLength, examples}examples 每类最多 3 条、每条超 42 字截断加「…」
transitionsarray相邻句类型转移,{from, to, count},按次数降序,最多 12 条
motifsarray2 连句与 3 连句模板合并排序后取前 top 条,{pattern, count},模式用 连接
paragraphsobjecttotal / avgSentences / opening / closing / dialogueOnly / psychologyOnly / mixed / narrationOnly / exchanges;四类段落计数之和恒等于 total
lengthsobjectavg / median / shortRatio / mediumRatio / longRatio / distribution(四桶 1-10 / 11-24 / 25-40 / 41+)
styleobject对话、心理、环境、疑问(含反问)、感叹占比,短句与长句占比,subjectivityIndex(0–100 整数)、emotionDensityavgSentenceLengthfirstPersonDensity
emotionobjectdominantcleanDominantconfidencecaveataiActionpollutionscorescleanScoresintensitytopWords(≤12)、curvequantification
chapterPatternsarray全书分析时的分章压缩节奏序列,{chapter, sequence};单章分析为空数组
guidancestring节奏建议多行文本,末尾可能附前缀截断提示
densityobjectactionVerbsPer1000 / actionChainRatio / actionChainExamples / objectNounsPer1000 / sensePer1000 / sense(五感官通道)
fingerprintstring一维风格指纹串,格式与 novel_style_check 一致
reportFile / cache / cachedAtstring报告落盘路径;cache 枚举 hit / misscachedAt 为 ISO 时间
briefstringbrief:true 时的单句摘要

渲染层按 【句式分布】→【句式排列】→【句长与风格】→【主观情感】→【情感净化预警】→【情感量化】→【语义隐性情感】→【细节密度】→【节奏建议】→【风格指纹】→【AI 解读提示】 分段输出纯文本;情感净化预警段仅在 cleanDominant !== dominant 时出现,语义隐性情感段仅在 quantification.semanticImplicit.hits 非空时出现。

计算原理

1 · 分块、切句与九类判定

先分段落再切句,两者都做了降噪:

splitBlocks(text): 归一 CRLF / CR 为 LF 段落分隔符 =「前后都是非空行」的中间空行;无空行时每行视为一段 跳过 Markdown 标题行(/^#{1,6}\s/) 跳过分割线(/^[-_*=]{3,}$/) 末尾过滤空串 splitSentences(block): 遇 \n 或 \r 直接切句 遇 TERMINATORS = "。!?…!?" 切句;连续句末符并入同一句 句末符后最多吞掉 2 个闭合引号(”」』’" ')

九类句式的判定是固定优先级瀑布,先命中先归类,不做打分:

1. 引号内片段含心理标记 → psychology(内心独白,quoted 标记) 2. 引号内片段(无心理标记) → dialogue 3. 全文命中心理标记 → psychology 4. 命中反问强标记 → rhetoric-question 5. 以问号结尾 → question 6. 命中祈使硬词 / 祈使动词 + 吧 → imperative 7. 命中感叹标记 → exclamation 8. 命中环境标记词表 → environment 9. 以「…」结尾 → ellipsis(省略留白) 否则 → statement

祈使排在感叹之前,是为了让「闭嘴!」这类句归祈使,感叹只留给不含祈使词的叹句。反问分支已剔除「哪里」这一最常见的真疑问词,并把「谁 + 情态」收紧为固定格式(谁知道 / 谁说的 / 谁能想到…),避免把「谁会来参加明天的会议?」判成反问。

2 · 采样上限与密度分母

逐段逐句累计;一旦 sentences.length ≥ maxSentences 即停止(前缀采样),truncated = true sampleText = sentences.map(s => s.text).join("") ← 全部密度类指标的样本 sampleChars = sampleText.length totalChars = 原文长度(顶层字段语义不变) guidance += "\n⚠️ 文本超过采样上限(N 句),以上统计基于前缀采样(X/Y 字);建议分章分析。" (仅 truncated 时)

情感密度、主观性指数、细节密度、Valence 量化全部以 sampleText 为样本,与句式计数同源。此前存在的两套口径(截断时用句子拼接、未截断时用含空行与标题行的原文)会让密度类指标仅因是否触顶而不可比,现已统一:代价是未截断路径下空行与段落分隔符不再进分母,密度读数略高于旧版本。

3 · 排列规律:转移、模板、段落结构

transitions:对 i ≥ 1 统计 (type[i-1] → type[i]);按 count 降序、from/to 字典序,取前 12 motifs :同时统计 2 连句与 3 连句;按 count 降序、pattern 字典序,取前 top paragraphs :段首 / 段尾句式各按次数降序 dialogueOnly = 全段均为 dialogue psychologyOnly= 全段均为 psychology mixed = 段落内句式种类 > 1 narrationOnly = 其余单一句式段(含全陈述段) exchanges = 段内相邻两句均为 dialogue 的次数 chapterPatterns:按章识别句式码后做游程压缩(S×8 ENV×2 …),最多 48 段,超出追加「…(共N段)」

四类段落计数互斥且穷尽,dialogueOnly + psychologyOnly + mixed + narrationOnly === paragraphs.total 恒成立——早先缺少 narrationOnly 桶时,全环境句段与全疑问句段会静默漏计。

4 · 句长分布

shortRatio = 占比(len ≤ 10) mediumRatio = 占比(10 < len ≤ 24) longRatio = 占比(len > 24) avg = 均值(2 位小数);median = 中位数(偶数个取中间两值均值,2 位小数) distribution 桶 = 1-10 / 11-24 / 25-40 / 41+

短 / 中 / 长三档与 distribution 四桶在 10 与 24 处对齐,按 distribution 复算 ratio 必然一致。

5 · 情感词扫描

三张表叠加使用:强情绪词 50 条(深层情感,进 clean)、弱情绪词 52 条(生理 / 爽感反应,只进 raw)、大连理工七类词表 27413 条(懒加载 gzip,兜底;只进 raw)。

scanEmotionHits(text, table): 收集词表全部出现 → 按「词长降序 → 表内情绪序 → 表内词序 → 起点」排序 → 逐项做区间消费,落在已消费区间内的短词不再命中 逐次命中后: ① 否定过滤 negatedAt(text, idx) 单字否定:不 没 无 别 莫 未;「别」在 36 个构词前字之后(特别/别人/告别…)不算否定 双 / 三字前缀 22 项:不要 不用 不必 不再 不是 并非 绝非 毫不 丝毫不 从不 从未 没有 没啥 未曾 无法 难以 不太 不很 不怎么 不算 算不上 谈不上 ② 副词加权 adverbWeight(text, idx) 窗口 = 当前小句(遇标点截断),再往前最多 6 字 命中强副词 13 项 → ×1.5;命中弱副词 8 项 → ×0.6;其余 ×1 单字副词(太 / 略 / 挺)必须紧邻情感词,且左邻 2 字不在 19 项同形非副词搭配内(太阳/策略/省略/挺拔…)

「最长匹配 + 区间消费」消除了嵌套双计:「悲痛欲绝」不再被拆成「悲痛」+「悲痛欲绝」两分。DUTIR 兜底同样按最长优先扫描,但将上限由 4 字提到 8 字(词表最长条目 7 字),使 853 条 ≥5 字的成语级情感词恢复可命中;兜底词不做强弱分级,只进 scores 不进 cleanScores

6 · 情绪污染源与置信度

污染词表 52 条,分三类:r18 26 条 / battle 13 条 / horror 13 条 density = 命中次数 / max(sampleChars, 1) × 1000 polluted = r18Density ≥ 1.5 ‖ battleDensity ≥ 3 ‖ horrorDensity ≥ 3 confidence = polluted ? "low" : cleanDominant === "neutral" ? "medium" : cleanDominant !== dominant ? "medium" : "high"

命中污染阈值时 caveat 会写明「检测到高密度 X 描写…请勿直接采信,须 novel_read 抽查 2-3 段原文复核真实情感基调后再下结论」,同时给出 aiAction;仅强弱主导不一致时 caveat 以「剔除易污染的情绪词后,主导情感为…」表述;两者都不成立时 caveat 为空串。

7 · 情感曲线

segmentCount = min(有效段落数, curveSegments ≤ 50) 第 s 段覆盖 blocks[floor(s·B/S) : floor((s+1)·B/S)] 段主导情感 = 段内各类 scores 之和的最大者(并列时按 joy→anger→sorrow→fear→surprise 顺序取先者) intensity = 段内情感总命中 / 段内字符数 × 1000(2 位小数)

曲线不做零强度段裁剪:段数由 min(段落数, curveSegments) 保证每段至少 1 个段落,不会产生空切片,因此强度为 0 的段是「真实的平缓段」而非伪段,跨章节曲线可按固定段数对齐。

8 · Valence 量化与隐性情感

valenceSeries(text, win = 100 字): 效价表 149 条,最长优先 + 区间消费;否定命中不计词也不进正负计数 每窗口均值仅由「有命中的窗口」构成(series,零命中窗口不产生 0 以免稀释均值) seriesFull = 全窗口等距序列(零命中窗口记 0);hasHit[w] 标记该窗口是否有命中 valenceStats: variance = Σ(x−mean)² / (n−1) ← 仅命中窗口,n<2 时为 0 adjVariance = Σ|seriesFull[i]−seriesFull[i−1]| / 有效对数 ← 仅「相邻两窗都有命中」的对 delta = 最小二乘斜率 × (m−1) ← 基于 seriesFull deltaRobust = 尾三分位均值 − 首三分位均值 ← 同基于 seriesFull(deltaBasis="seriesFull") conflict = 各窗口 2·min(pos,neg)/(pos+neg) 的均值 posRatio / negRatio / meanValence implicitEmotionScan: 单向载体 28 条(意象 + 微动作,带 valence / label / fragile) 多方向载体 14 个(雨 / 烛火 / 火光 / 夜 / 风 / 海 / 灯 / 影子 / 笑 / 眼泪 / 沉默 / 花开 / 黄昏 / 奔跑) 多方向词在 ±30 字上下文内做触发词最长优先裁决;多触发冲突或未裁决 → 计入 ambiguous negative / positive / ambiguousRatio 三项共用分母 totalAllHits,三项之和恒 ≤ 1 emotionComplexity: entropy = −Σ p·ln p(五类,最大熵 ln5 ≈ 1.609);diversity = p ≥ 0.15 的类别数 score = 0.5·(entropy / ln5) + 0.25·(meanDiversity / 5) + 0.25·(secondaryRatio / dominantRatio) level = score ≥ 0.6 ? high : score ≥ 0.4 ? medium : low chapterDrift.swinging = entropies 的方差 > 0.03 chapterDrift.basis = 有 chapterTexts 时 "chapter",单章分析回退段级 "paragraph"

explicitImplicitCompare 以显性效价均值 ±0.15 与隐性方向占比 0.6 判定「表里不一」,双向冲突(显正隐负、显负隐正)都会置位 explicitImplicitConflict。复合情感共现按段落内共现的情感类对统计,输出前 5 组并补中文标签(悲喜交加 / 又爱又恨 等)。

9 · 主观性指数、细节密度与指纹

firstPersonCount:第一人称词按最长匹配先消(「我们」不拆成 我 + 我们),再补统计剩余孤立「我 / 俺 / 咱」 subjectivityIndex = min(100, round(心理占比×50 + 感叹占比×60 + min(emotionDensity×2.5, 25) + min(firstPersonDensity×1.2, 20))) densityOf(sampleText): 动作动词正则 /摸|拿|推|拉|…|合/:单句命中 ≥ 2 记为动作链句 actionVerbsPer1000 / objectNounsPer1000 / sensePer1000 按千字归一(1 位小数) actionChainRatio = 动作链句 / 句数 × 100 感官五通道:visual / auditory / tactile / olfactory / temperature(温度词只归 temperature,防双计) fingerprint = 九类短码占比(‰) + " | len:" + 均句长 + " subj:" + 主观性 + " emo:" + 主导情绪 + " | motif:" + 首选模板

10 · 缓存与报告落盘

scopeKeyParts[i] = 章文件名 + ":" + sha1(该章前 8KB) 前 12 位 + ":" + 文件字节数 (读取失败时记 "文件:?:?") cacheKey = sha1(book | scope | scopeKeyParts.join(",") | "top=" + top | "max=" + maxSentences | "curve=" + curveSegments | "en=" + enabled | "auto=" + autoAnalyze) 前 20 位 reportFile = <root>/.novel-writer/analysis/<book>-<cacheKey>.json 命中条件:!fresh 且 文件可解析 且 cached.ver === CACHE_VERSION 写盘后按书前缀保留最近 20 份(REPORT_CACHE_KEEP,按 mtime 降序删除其余)

缓存指纹用「每章前 8KB 的内容哈希 + 文件大小」而非 stat 的 mtime:内容同大小且 mtime 未变时旧指纹不会失效。命中路径只读每章前缀 8KB,不整章读盘。

约束与边界

  • 两层开关,行为不同:工具级开关关闭时 assertToolEnabled 抛错(「工具 novel_sentence_analysis…当前已在「写作助手功能」UI 中关闭」);句式分析总开关 enabled 关闭时不抛错,返回 enabled:falsetotalChars:0totalSentences:0message,且不读章节正文、不写缓存。调用方应据 enabled 而非 totalSentences === 0 判断可用性。
  • 参数越界是错误而非钳制top / maxSentences / curveSegmentsoptionalInt 校验,非整数或越界直接抛「参数 X 必须是 A 到 B 之间的整数」。内部 analyzeText 另有 ≥11–50 的兜底钳制,供直接调用该函数的一方使用。
  • 触顶即前缀采样:超过 maxSentences 后只统计靠前部分,totalSentences 等于上限而不是全书句数。这不是全量结果,提示只写在 guidance 末尾(不加顶层字段),调用方需要全量时应分章调用。
  • 缓存键含内容指纹与全部参数:改一章内容、换一个 top 或翻转 enabled / autoAnalyze 都会生成新缓存文件,旧文件不再命中;fresh:true 跳过读取但仍写回同一文件。落盘目录按书最多保留 20 份报告,超出部分按修改时间淘汰,<book>-full.json<book>-chapters-metrics.json 是分析缓存、不匹配报告命名,不会被清理。
  • 算法版本是硬失效条件:缓存内 verCACHE_VERSION(当前等于插件版本 5.0.0,见源码位置 lib/core.js)不一致时视为过期并重新分析。这保证升级后不会复用旧口径的情感与句式结果。
  • 语义隐性情感受独立门控:仅当 semanticImplicit(含 semanticEmbedding 总开关)开启且本地引擎可用时才会补充 emotion.quantification.semanticImplicit。「用户明确关闭」与「引擎瞬时不可用」被区分对待——后者不会把缓存里已算好的 semanticImplicit 抹成空。单章分析时命中范围被约束在本章内,越界的旧缓存结果会被重算并写回。
  • 情感字段可能被裁剪emotionCaveat 关闭时 emotion 只保留 dominant / scores / intensity / topWords / curveemotionComplexity 关闭时删除 quantification。两个开关各自独立生效,关闭其一不会连带剥掉另一个。
  • 静默降级点:DUTIR 词表加载失败时跳过兜底(不影响主词表结果);语义裁决与语义隐性情感失败不阻塞规则结果;缓存写盘失败不影响本次返回值;cleanOutput 统一消除 -0、把 NaN 归一为 null、剔除 undefined 键。
  • 跨版本不可横比:v4.3.0 起,句末符口径统一(省略号单独成句)、密度分母统一为句子拼接样本、情感命中改为区间消费、Valence 的 adjVariancedeltaRobust 改口径、隐性情感三项比率共用三分分母。同一本书在旧版本下产生的报告数值不可与新版本直接比较。

相关工具

  • novel_style_report —— 本工具的 fingerprintlengthsstyleemotion 是其「维度」聚合的原始来源。
  • novel_style_check —— 复用本工具的九类占比、句长与主观性构造指纹向量,做单章对照判定。
  • novel_semantic_search —— 与本工具的语义隐性情感共用同一个本地向量引擎、同一份索引缓存与同一套切块规则。

源码位置

  • lib/index.js —— 工具注册与执行(registerNovelSentenceAnalysis,第 403 行起;execute 自第 590 行)、缓存键与 pruneAnalysisReports(第 155 行)、cropEmotionByFeatures(第 83 行)、semanticGate(第 109 行)
  • lib/analysis.js —— 九类标签与顺序(CATEGORY_LABELS 第 23 行、CATEGORY_ORDER 第 37 行、TYPE_CODE 第 43 行)、词表(第 58–142 行)、splitBlocks(第 145 行)、splitSentences(第 183 行)、classifySentence(第 348 行)、scanEmotionHits(第 451 行)、emotionOf(第 500 行)、valenceSeries(第 896 行)、valenceStats(第 953 行)、implicitEmotionScan(第 763 行)、emotionComplexity(第 1095 行)、analyzeText(第 1174 行)、emotionCurve(第 1558 行)、buildFingerprint(第 1600 行)、densityOf(第 1678 行)
  • lib/core.js —— buildBriefLine(第 1324 行)、formatSentenceAnalysis(第 1330 行)、enrichSemanticImplicit(第 793 行)、semanticFeatureEnabled(第 852 行)、cropEmotion(第 856 行)、cleanOutput(第 2308 行)、CACHE_VERSION(第 24 行)
  • lib/embedding.js —— chunkText(第 520 行)、detectImplicitEmotions(第 292 行)、IMPLICIT_EMOTION_PROTOTYPES(第 232 行,共 29 条原型句)、fingerprint(第 338 行)