dsh-novel-writer 6.1.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -10,17 +10,19 @@ English | [中文](./README.md)
10
10
  [![GitHub stars](https://img.shields.io/github/stars/siweina/dsh-novel-writer.svg?style=flat-square&color=orange)](https://github.com/siweina/dsh-novel-writer/stargazers)
11
11
  [![siweina/dsh-novel-writer MCP server](https://glama.ai/mcp/servers/siweina/dsh-novel-writer/badges/score.svg)](https://glama.ai/mcp/servers/siweina/dsh-novel-writer)
12
12
 
13
- **18 tools covering the whole loop: gather inputs before you write → quantify & self-check → work a prioritised fix list → audit the cross-chapter structure.**
13
+ **19 tools covering the whole loop: gather inputs before you write → quantify & self-check → work a prioritised fix list → audit the cross-chapter structure.**
14
14
  One call before writing gathers **16 inputs** (missing one is how style drift starts); after you write, you don't just get numbers — you get a **priority-ordered to-do list** (line-anchored original text, current vs. target values, reference passages, **direction only, never ghost-written prose**); across chapters you can finally see "the thread snapped, a character vanished, a hook was forgotten".
15
15
  Sentence, emotion and semantic analysis all run **on your machine**: a 24MB Chinese model ships with the package,
16
16
  **zero API cost, your manuscript never leaves the device**. Built for DeepSeek Harness (DSH); the same engine is also
17
17
  exposed as a **stdio MCP server** for Claude Desktop / Cursor.
18
18
 
19
- [Website](https://siweina.github.io/dsh-novel-writer/) · [Technical docs (parameters & examples for all 18 tools)](https://siweina.github.io/dsh-novel-writer/tools/) · [Install](#install) · [The writing loop](#the-writing-loop-end-to-end) · [60-second start](#60-second-start) · [See the output](#see-the-output) · [The 18 tools](#provided-tools-18) · [MCP server](#mcp-server-usable-outside-dsh)
19
+ [Website](https://siweina.github.io/dsh-novel-writer/) · [Technical docs (parameters & examples for all 19 tools)](https://siweina.github.io/dsh-novel-writer/tools/) · [Install](#install) · [The writing loop](#the-writing-loop-end-to-end) · [60-second start](#60-second-start) · [See the output](#see-the-output) · [The 19 tools](#provided-tools-19) · [MCP server](#mcp-server-usable-outside-dsh)
20
20
 
21
21
  ---
22
22
 
23
- For the latest version and downloads, see [**Releases**](https://github.com/siweina/dsh-novel-writer/releases).
23
+ ## v6.3.0 local test build
24
+
25
+ This release works through the v6.1.0 / v6.2.0 defect lists (protocol layer, rendering, lexicon data safety, UI and metadata) and rebuilds the required-lexicon panel. See [CHANGELOG.md](./CHANGELOG.md). Development: `npm ci`, `npm run build:check`, and `npm test`. All client build sources are included in this repository.
24
26
 
25
27
  ## The writing loop (end to end)
26
28
 
@@ -152,7 +154,7 @@ fixAnchors:3 条原著锚段(对话 / 心理 / 描写各一条,供逐句
152
154
  | Cost | **0 (local inference)** | Billed per token | Self-hosted |
153
155
  | Purpose-built for Chinese fiction | **Yes** | Generic | No |
154
156
  | Style baseline (μ±σ) | **Yes** | Rare | No |
155
- | DSH integration | **18 tools + official seats & website-style UI** | None | None |
157
+ | DSH integration | **19 tools + official seats & website-style UI** | None | None |
156
158
  | Usable without DSH | **Yes (MCP)** | Yes | You wrap it yourself |
157
159
 
158
160
  > **A note for non-Chinese users**: this plugin is designed for analysing and writing Chinese fiction — the sentence-pattern,
@@ -180,7 +182,7 @@ fixAnchors:3 条原著锚段(对话 / 心理 / 描写各一条,供逐句
180
182
 
181
183
  ---
182
184
 
183
- ## Provided Tools (18)
185
+ ## Provided Tools (19)
184
186
 
185
187
  > Parameters, return shapes and worked examples for every tool live in the [online tool manual](https://siweina.github.io/dsh-novel-writer/tools/).
186
188
 
@@ -204,12 +206,13 @@ fixAnchors:3 条原著锚段(对话 / 心理 / 描写各一条,供逐句
204
206
  | `novel_outline` | **Creation-file management** (bible/characters/outline/hooks/status) |
205
207
  | `novel_chapter_brief` | **Chapter brief** — one call before writing gathers everything (previous-chapter hand-off / chapter direction / characters / open threads / wording rules / style baseline / anchors & skeletons / avoid-list / checklist) |
206
208
  | `novel_fix_plan` | **Fix plan** — turns style diagnostics into a priority-ordered to-do list (line anchors + reference passages), re-checkable after edits; **direction only, never generates prose** |
209
+ | `novel_lexicon` | **Required-lexicon table** — per-book + global two-layer registry of proper nouns and author-favoured wording, batch import, hard-checklist injection into the chapter brief, and a wording audit for finished drafts |
207
210
 
208
211
  ---
209
212
 
210
213
  ## MCP server (usable outside DSH)
211
214
 
212
- The package ships a **stdio MCP server** (`mcp/server.mjs`) that exposes all 18 tools to any MCP client,
215
+ The package ships a **stdio MCP server** (`mcp/server.mjs`) that exposes all 19 tools to any MCP client,
213
216
  e.g. Claude Desktop or Cursor. **It runs as a local process on your own machine — no server, no network, no daemon.**
214
217
 
215
218
  ```bash
package/README.md CHANGED
@@ -10,16 +10,18 @@
10
10
  [![GitHub stars](https://img.shields.io/github/stars/siweina/dsh-novel-writer.svg?style=flat-square&color=orange)](https://github.com/siweina/dsh-novel-writer/stargazers)
11
11
  [![siweina/dsh-novel-writer MCP server](https://glama.ai/mcp/servers/siweina/dsh-novel-writer/badges/score.svg)](https://glama.ai/mcp/servers/siweina/dsh-novel-writer)
12
12
 
13
- **18 个工具,覆盖「动笔前取材料 → 写完量化自检 → 改稿按优先级复测 → 跨章结构体检」整条流程。**
13
+ **19 个工具,覆盖「动笔前取材料 → 写完量化自检 → 改稿按优先级复测 → 跨章结构体检」整条流程。**
14
14
  动笔前**一次调用取齐 16 项材料**(漏读一样就漂移);改稿不再只丢给你一堆数字,而是**按严重度排好的待办**(带原句行号、当前值与目标值、原著锚段,**只给方向不代写**);跨章层面能查「线断了、人丢了、伏笔忘了」。
15
15
  句式 / 情感 / 语义全部**在本地算**:24MB 中文模型随包分发,**零 API 花费、正文不出本机**。
16
16
  为 DeepSeek Harness(DSH)打造;同一套能力也可作为 **MCP 服务器**给 Claude Desktop / Cursor 使用。
17
17
 
18
- [官网](https://siweina.github.io/dsh-novel-writer/) · [技术文档(18 个工具的参数与示例)](https://siweina.github.io/dsh-novel-writer/tools/) · [安装](#安装) · [写作流程](#写作流程一条能走完的闭环) · [60 秒上手](#60-秒上手) · [看看输出](#看看输出长什么样) · [18 个工具](#提供的工具18-个) · [MCP 服务器](#mcp-服务器非-dsh-用户也能用)
18
+ [官网](https://siweina.github.io/dsh-novel-writer/) · [技术文档(19 个工具的参数与示例)](https://siweina.github.io/dsh-novel-writer/tools/) · [安装](#安装) · [写作流程](#写作流程一条能走完的闭环) · [60 秒上手](#60-秒上手) · [看看输出](#看看输出长什么样) · [19 个工具](#提供的工具19-个) · [MCP 服务器](#mcp-服务器非-dsh-用户也能用)
19
19
 
20
20
  ---
21
21
 
22
- 最新版本与下载请查看 [**Releases**](https://github.com/siweina/dsh-novel-writer/releases)。
22
+ ## v6.3.0 本地测试版
23
+
24
+ 本版按 v6.1.0 / v6.2.0 两份缺陷清单逐条修复(协议层、渲染层、词表数据安全、界面与元数据),并重做了「必用词表」面板。完整改动见 [CHANGELOG.md](./CHANGELOG.md)。开发者可运行 `npm ci`、`npm run build:check`、`npm test`;构建源已随仓库提供,不再依赖其他目录。发布准备见 [docs/releasing.md](https://github.com/siweina/dsh-novel-writer/blob/main/docs/releasing.md)。
23
25
 
24
26
  ## 写作流程(一条能走完的闭环)
25
27
 
@@ -151,7 +153,7 @@ fixAnchors:3 条原著锚段(对话 / 心理 / 描写各一条,供逐句
151
153
  | 花费 | **0(本地推理)** | 按 token 计费 | 自建 |
152
154
  | 中文小说专用 | **是** | 通用 | 否 |
153
155
  | 风格基线(μ±σ) | **有** | 少见 | 无 |
154
- | 与 DSH 集成 | **18 工具 + 官方席位与官网式界面** | 无 | 无 |
156
+ | 与 DSH 集成 | **19 工具 + 官方席位与官网式界面** | 无 | 无 |
155
157
  | 非 DSH 用户可用 | **可以(MCP)** | 可以 | 需自己封装 |
156
158
 
157
159
  > **致非中文用户**:本插件为中文小说分析写作而设计——句式、情感、意象等核心能力以及内置的语义模型,全部针对中文语料构建与调优。在深耕中文的同时兼顾英文等其他语言,确实超出了我目前的能力范围。若因此给您带来不便,我深感抱歉,恳请谅解。
@@ -176,7 +178,7 @@ fixAnchors:3 条原著锚段(对话 / 心理 / 描写各一条,供逐句
176
178
 
177
179
  ---
178
180
 
179
- ## 提供的工具(18 个)
181
+ ## 提供的工具(19 个)
180
182
 
181
183
  > 每个工具的参数、返回结构与示例,见[在线工具手册](https://siweina.github.io/dsh-novel-writer/tools/)。
182
184
 
@@ -200,12 +202,13 @@ fixAnchors:3 条原著锚段(对话 / 心理 / 描写各一条,供逐句
200
202
  | `novel_outline` | **创作资料管理**(创作设定/人物/大纲/钩子/状态卡) |
201
203
  | `novel_chapter_brief` | **开写包**——动笔前一次调用取齐材料(上一章承接口/本章方向/相关人物/待回收伏笔/用语规范/风格基线/锚段与骨架/禁用清单/开写清单) |
202
204
  | `novel_fix_plan` | **改稿台**——把风格诊断变成按优先级排好的待办(带行号定位与锚段),改完可复测;**只给方向不生成正文** |
205
+ | `novel_lexicon` | **必用词表**——专有词/作者惯用词的书级+全局两层登记、批量导入、开写包硬清单注入、成稿用词审计 |
203
206
 
204
207
  ---
205
208
 
206
209
  ## MCP 服务器(非 DSH 用户也能用)
207
210
 
208
- 包里自带一个 **stdio MCP 服务器**(`mcp/server.mjs`),把 18 个工具原样暴露给任何 MCP 客户端,
211
+ 包里自带一个 **stdio MCP 服务器**(`mcp/server.mjs`),把 19 个工具原样暴露给任何 MCP 客户端,
209
212
  例如 Claude Desktop、Cursor。**它是跑在你自己电脑上的本地进程,不需要服务器、不需要联网、不需要常驻。**
210
213
 
211
214
  ```bash
package/cordis.patch.yml CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-novel-writer v5.2.0 bundle patch: 18 tools (incl. novel_semantic_search) + local embedding engine.
1
+ # dsh-novel-writer v6.3.0 bundle patch: 19 tools (incl. novel_semantic_search, novel_lexicon) + local embedding engine.
2
2
  # dsh-novel-writer bundle patch: mounts the novel-writing assistant plugin
3
3
  # into the profile's loader tree. The entry's name is the plugin package
4
4
  # itself, resolved from the profile's node_modules.
package/lib/analysis.js CHANGED
@@ -18,6 +18,15 @@
18
18
  * 它们是纯函数(无副作用、无宿主依赖),当前在包内只被 analysis.js 自身与 test/pattern-test.mjs 之外
19
19
  * 的调用链间接使用;保留导出是为了让 MCP 侧、未来的独立分析工具或使用方脚本能在不改本模块的前提下复用。
20
20
  * 若确实要收缩公开面,请在**下一个大版本**统一处理,并同步 README 的 API 说明。
21
+ *
22
+ * v4.3.0 —— 关于量化结果里 9 个「零代码读者」字段(回应清单第 34 条的「零读即删」标准冲突):
23
+ * `valenceStats` 的 windows / hitWindows / adjPairs / deltaBasis / deltaRobust / posRatio / negRatio
24
+ * 与 `implicitEmotionScan` 的 totalAllHits / topCarriers,在包内确实只有定义处出现,
25
+ * 但它们**不是死代码**,而是对外契约:index.js 的 output.schema 以 additionalProperties:true
26
+ * 把 stats / implicit 整包透传给模型,CHANGELOG 亦已把 deltaBasis / adjPairs 公告为新增返回字段。
27
+ * 因此本模块对「返回面字段」统一取**保留 + 本节声明**,不按零读即删处理;
28
+ * 「零读即删」只适用于纯内部中间变量(如已删除的 windowCount / hitWindowCount / weightedTotal)。
29
+ * 若维护者决定收缩返回面,必须与 index.js 的 schema 及 CHANGELOG 同批变更,否则模型侧会静默少字段。
21
30
  */
22
31
 
23
32
  export const CATEGORY_LABELS = Object.freeze({
@@ -143,7 +152,9 @@ const QUOTE_PAIRS = [["“", "”"], ["「", "」"], ["『", "』"], ["‘", "
143
152
 
144
153
  /** 文本 → 段落块:空行分段;无空行时每行视为一段。过滤 Markdown 标题与分割线。 */
145
154
  export function splitBlocks(text) {
146
- const normalized = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
155
+ // v4.3.0 修正(清单第 33 条,同族入参防御):旧版 String(text) 对 null 会造出一个 "null" 段落
156
+ // (splitBlocks(null) → ["null"]),与其余导出的空值兜底口径不一致。
157
+ const normalized = String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
147
158
  const lines = normalized.split("\n");
148
159
  // Only blank lines between the first and last content lines separate paragraphs;
149
160
  // a run of blank lines is one separator, while trailing blanks leave line mode intact.
@@ -182,10 +193,14 @@ export function splitBlocks(text) {
182
193
 
183
194
  /** 段落 → 句子:按 。!?… 分句,吞并连续结束符与后随的闭合引号。 */
184
195
  export function splitSentences(block) {
196
+ // v4.3.0 修正(清单第 33 条):入口统一空值兜底(旧版 splitSentences(null) 抛
197
+ // TypeError: Cannot read properties of null (reading 'length'),而同族的
198
+ // splitBlocks/implicitEmotionScan/valenceStats/analyzeText 都做了兜底)。
199
+ const src = String(block ?? "");
185
200
  const sentences = [];
186
201
  let buffer = "";
187
- for (let i = 0; i < block.length; i += 1) {
188
- const ch = block[i];
202
+ for (let i = 0; i < src.length; i += 1) {
203
+ const ch = src[i];
189
204
  // v3.5.0 #50b:无标点换行也是句边界(逐行排版正确分句,整篇不再算 1 句)
190
205
  if (ch === "\n" || ch === "\r") {
191
206
  const lineSentence = buffer.trim();
@@ -195,14 +210,14 @@ export function splitSentences(block) {
195
210
  }
196
211
  buffer += ch;
197
212
  if (TERMINATORS.includes(ch)) {
198
- while (i + 1 < block.length && TERMINATORS.includes(block[i + 1])) {
199
- buffer += block[i + 1];
213
+ while (i + 1 < src.length && TERMINATORS.includes(src[i + 1])) {
214
+ buffer += src[i + 1];
200
215
  i += 1;
201
216
  }
202
217
  let quotes = 0;
203
218
  // v4.0.0 修正:去掉恒真的 TERMINATORS 判断(引号字符与"。!?…"无交集)
204
- while (quotes < 2 && i + 1 < block.length && "”」』’\"'".includes(block[i + 1])) {
205
- buffer += block[i + 1];
219
+ while (quotes < 2 && i + 1 < src.length && "”」』’\"'".includes(src[i + 1])) {
220
+ buffer += src[i + 1];
206
221
  i += 1;
207
222
  quotes += 1;
208
223
  }
@@ -359,7 +374,9 @@ function isImperative(text) {
359
374
  * 引语优先:引号内的直接引语算对话;引号内含心理标记(内心独白)算心理。
360
375
  */
361
376
  export function classifySentence(raw) {
362
- const text = String(raw).trim();
377
+ // v6.3.0:同族空值兜底(splitSentences / densityOf / splitBlocks 都已 ?? ""),
378
+ // 此前 classifySentence(null) 会 String(null) → 造出一句假句 text:"null"
379
+ const text = (raw === null || raw === undefined ? "" : String(raw)).trim();
363
380
  if (text === "") return { type: "statement", text, quoted: false };
364
381
  const quotedSegments = extractQuoted(text).filter((segment) => !isWrittenQuote(text, segment.start));
365
382
  if (quotedSegments.length > 0) {
@@ -544,14 +561,17 @@ function emotionOf(text) {
544
561
  // v2.5 修复:dutir_seven.json(大连理工 27,413 词)兜底——小词表未覆盖的情感词也计分
545
562
  // 性能安全:只查"文本中出现的 2-8 字片段"(O(句长×7)),不做 27k 词全表扫描
546
563
  try {
547
- const seenWords = new Set();
548
- for (const list of Object.values(words)) for (const w of list) seenWords.add(w);
549
- dutirEmotion(""); // 确保 dutirLookup 已构建(懒加载)
564
+ ensureDutirLoaded(); // 确保 dutirLookup 已构建(懒加载)
550
565
  // v4.0.0 修正:旧版只查相邻二字组,词表里 3 字及以上条目(约 63%)永不命中;
551
566
  // 改为 4/3/2 字最长优先扫描 + 区间消费(长词命中后其子串不再计分),命中处逐次做否定过滤
552
567
  // v4.3.0 修正(P1-5):上限由写死的 4 提到 DUTIR_MAX_WORD_LEN(8)——词表里 853 条 ≥5 字条目
553
568
  // (久旱逢甘雨/一步一个脚印/打开天窗说亮话…)此前永不命中。内层循环最多 7 次切片,
554
569
  // 实测 2000 字文本 dutir 兜底耗时由 ~1.1ms 升至 ~2.6ms(见 fixA-perf.mjs),可接受。
570
+ // v4.3.0 修正(清单第 26 条):删除旧的 `if (seenWords.has(w)) { hitLen = len; break; }` 分支与其
571
+ // `seenWords.add(hitWord)` 写入——该分支对主词表命中恒不可达(重叠已由上面的 occupied 区间检查
572
+ // 拦住),唯一实际效果是把**同一句内第二次出现的 DUTIR 词**消费掉且不计分:
573
+ // 实测 "他一帆风顺,一帆风顺。" joy=1(应为 2)、"他心花怒放×3" joy=1(应为 3),
574
+ // 与 1379 行「按出现次数计数」口径冲突,使 27k 词兜底与主词表计数不可比。
555
575
  for (let bi = 0; bi < text.length; bi += 1) {
556
576
  if (occupied[bi] === 1) continue;
557
577
  let hitWord = "", hitEmo = null, hitLen = 0;
@@ -562,19 +582,20 @@ function emotionOf(text) {
562
582
  // These words already modify nearby primary-lexicon hits; DUTIR also
563
583
  // tags some of them as emotions, which would count the same phrase twice.
564
584
  if (STRONG_ADVERBS.includes(w) || MILD_ADVERBS.includes(w)) continue;
565
- // 主词表(或本轮)已计过 → 消费该区间,避免"悲痛欲绝"之外再计"悲痛"
566
- if (seenWords.has(w)) { hitLen = len; break; }
567
585
  const emo = dutirLookup.get(w);
568
586
  if (emo) { hitWord = w; hitEmo = emo; hitLen = len; break; }
569
587
  }
570
588
  if (hitLen === 0) continue;
571
589
  for (let p = bi; p < bi + hitLen; p += 1) occupied[p] = 1;
572
- if (hitEmo === null) continue;
573
590
  if (negatedAt(text, bi)) continue;
574
591
  // v3.7.0 高5:DUTIR 27k 词无强弱分级(呵呵/傻乐等口语弱词)——只进 raw,不进 clean
575
592
  scores[hitEmo] += 1;
576
- words[hitEmo].push(hitWord);
577
- seenWords.add(hitWord);
593
+ // v4.3.0(清单第 26 条):每次出现都计分(上一行),但词表仍按词去重——
594
+ // 旧版靠 seenWords 顺带去重,删除后改用 hitWords 保证 words[emotion] 无重复项。
595
+ if (!hitWords.has(hitWord)) {
596
+ hitWords.add(hitWord);
597
+ words[hitEmo].push(hitWord);
598
+ }
578
599
  hitList.push({ word: hitWord, emotion: hitEmo, weight: 1, strong: false });
579
600
  }
580
601
  } catch { /* dutir 兜底失败不影响原结果 */ }
@@ -599,10 +620,25 @@ function median(values) {
599
620
  return sorted.length % 2 === 1 ? sorted[mid] : round((sorted[mid - 1] + sorted[mid]) / 2, 2);
600
621
  }
601
622
 
623
+ /**
624
+ * v4.3.0 修正(清单第 32 条):按**码点**截断,避免切断代理对。
625
+ * 旧版用 String.prototype.slice(0, n)(按 UTF-16 码元)截示例句与意象上下文,
626
+ * 实测 analyzeText("啊".repeat(41) + "😀" + "结尾。") 的 categories[].examples[0]
627
+ * 以孤立高代理项 \uD83D 结尾(落盘成 \ud83d、展示乱码)。
628
+ * 码点数 ≤ max 时原样返回(不因码元长度超限而误切)。
629
+ */
630
+ function truncateCodePoints(text, max) {
631
+ const src = String(text ?? "");
632
+ if (src.length <= max) return src;
633
+ const points = [...src];
634
+ return points.length <= max ? src : points.slice(0, max).join("");
635
+ }
636
+
602
637
  /** 跑长编码压缩排列序列(来自 v0.4.0):S×8 ENV×2 PSY×3 … */
603
638
  export function compressSequence(codes, maxRuns = 48) {
639
+ // v4.3.0(清单第 33 条):入参兜底(旧版 compressSequence(null) 抛 "codes is not iterable")
604
640
  const runs = [];
605
- for (const code of codes) {
641
+ for (const code of Array.isArray(codes) ? codes : []) {
606
642
  const last = runs[runs.length - 1];
607
643
  if (last !== void 0 && last.code === code) last.n += 1;
608
644
  else runs.push({ code, n: 1 });
@@ -614,6 +650,11 @@ export function compressSequence(codes, maxRuns = 48) {
614
650
 
615
651
  /** 生成给模型的节奏建议文本(来自 v0.4.0 buildGuidance,适配九类)。 */
616
652
  export function buildGuidance(ratios, topPatterns, chapterSequences) {
653
+ // v4.3.0(清单第 33 条):三个入参统一兜底(旧版 buildGuidance(null,null,null) 抛
654
+ // "Cannot read properties of null (reading 'statement')")
655
+ ratios = ratios ?? {};
656
+ topPatterns = Array.isArray(topPatterns) ? topPatterns : [];
657
+ chapterSequences = Array.isArray(chapterSequences) ? chapterSequences : [];
617
658
  const ratioText = CATEGORY_ORDER
618
659
  .filter((code) => (ratios[code] ?? 0) > 0)
619
660
  .map((code) => TYPE_CODE[code] + " " + Math.round((ratios[code] ?? 0) * 100) + "%")
@@ -856,10 +897,11 @@ export function implicitEmotionScan(text) {
856
897
  if (e.valence > 0 && e.fragile) fragileHits += 1;
857
898
  } else if (matched.size > 1) {
858
899
  ambCount += 1;
859
- ambiguous.push({ word: occ.word, reason: "多触发冲突", ctx: ctx.trim().slice(0, 40) });
900
+ // v4.3.0(清单第 32 条):ctx 按码点截断(旧版 slice(0,40) 会切断代理对)
901
+ ambiguous.push({ word: occ.word, reason: "多触发冲突", ctx: truncateCodePoints(ctx.trim(), 40) });
860
902
  } else {
861
903
  ambCount += 1;
862
- ambiguous.push({ word: occ.word, reason: "未裁决", ctx: ctx.trim().slice(0, 40) });
904
+ ambiguous.push({ word: occ.word, reason: "未裁决", ctx: truncateCodePoints(ctx.trim(), 40) });
863
905
  }
864
906
  }
865
907
  const total = negCount + posCount;
@@ -869,16 +911,24 @@ export function implicitEmotionScan(text) {
869
911
  // 旧版 negative/positive 用两分分母(neg+pos)、ambiguousRatio 用三分分母(neg+pos+amb),
870
912
  // 同一份数据能渲染出"负向 100% / 歧义 50%"这种自相矛盾的组合(实测"雨打芭蕉。":
871
913
  // neg=1 amb=1 → negative=1、ambiguousRatio=0.5,三项之和 1.5)。
872
- // 现 negative=negCount/totalAll、positive=posCount/totalAll、ambiguousRatio=ambCount/totalAll,
873
- // 三项之和恒 ≤1(无命中时为 0)。
914
+ // 现 negative=negCount/totalAll、positive=posCount/totalAll、ambiguousRatio 见下方(清单第 28 条改为派生),
915
+ // 三项之和恒为 1(无命中时为 0)。
874
916
  // 行为变化:negative/positive 数值下降(分母变大),0.6 之类的绝对阈值含义随之变化——
875
917
  // 下游 explicitImplicitCompare 的方向判定阈值(analysis.js 内,本次同步调整见该函数注释)
876
918
  // 与可能存在的 core.js/index.js/vibe.js 阈值消费方需一并复核(见报告「需父代理裁决」)。
877
919
  // 同一本书新旧报告不可直接比较。
920
+ // v4.3.0 修正(清单第 28 条):三项此前各自独立四舍五入到两位小数,和可以 >1——
921
+ // 实测 implicitEmotionScan("雨夜雨夜沉默灯火") 得 0.67+0.17+0.17=1.01,
922
+ // index.js 的画像报告会把它渲染成「意象:负 67% / 正 17% / 歧义 17%」这种 101% 的用户可见结果。
923
+ // 现 negative/positive 照常四舍五入,ambiguousRatio 由 1 - negative - positive 派生,
924
+ // 三项之和恒为 1(无命中时三项均为 0);代价是歧义率最多偏离真值 0.01(上例真值 0.1667)。
925
+ const negative = totalAll === 0 ? 0 : Math.round((negCount / totalAll) * 100) / 100;
926
+ const positive = totalAll === 0 ? 0 : Math.round((posCount / totalAll) * 100) / 100;
927
+ const ambiguousRatio = totalAll === 0 ? 0 : Math.max(0, Math.round((1 - negative - positive) * 100) / 100);
878
928
  return {
879
- negative: totalAll === 0 ? 0 : Math.round((negCount / totalAll) * 100) / 100,
880
- positive: totalAll === 0 ? 0 : Math.round((posCount / totalAll) * 100) / 100,
881
- ambiguousRatio: totalAll === 0 ? 0 : Math.round((ambCount / totalAll) * 100) / 100,
929
+ negative,
930
+ positive,
931
+ ambiguousRatio,
882
932
  fragile: fragileHits > 0,
883
933
  totalHits: total,
884
934
  // v4.0.0:negHits/posHits/ambHits 统一为命中次数(旧版是加权效价和,与 ambHits 次数混用)
@@ -1066,10 +1116,12 @@ export function explicitImplicitCompare(explicitMean, implicit) {
1066
1116
  /** v1.6.0 复合情感共现(规则):同段多情感类别 → 高频矛盾对。 */
1067
1117
  export function compositeEmotionPairs(blocks) {
1068
1118
  const pairCounts = new Map();
1069
- for (const block of blocks) {
1119
+ // v4.3.0(清单第 33 条):入参兜底——旧版 compositeEmotionPairs([[{}]]) 抛
1120
+ // "Cannot read properties of undefined (reading 'cleanScores')"(可选链没盖住 e 本身)。
1121
+ for (const block of Array.isArray(blocks) ? blocks : []) {
1070
1122
  const present = new Set();
1071
- for (const sentence of block) {
1072
- const e = sentence.emotion;
1123
+ for (const sentence of Array.isArray(block) ? block : []) {
1124
+ const e = sentence?.emotion ?? {};
1073
1125
  for (const k of EMOTION_CATS) if ((e.cleanScores?.[k] ?? 0) > 0) present.add(k);
1074
1126
  }
1075
1127
  const list = [...present];
@@ -1104,8 +1156,12 @@ export function chapterEmotionStats(counts) {
1104
1156
  diversity,
1105
1157
  dominant: sorted[0].emotion,
1106
1158
  dominantRatio: Math.round(sorted[0].ratio * 1000) / 1000,
1107
- secondary: sorted[1].emotion,
1108
- secondaryRatio: Math.round(sorted[1].ratio * 1000) / 1000
1159
+ // v4.3.0 修正(清单第 35 条):旧版无条件取 sorted[1],只有一种情感命中时
1160
+ // sorted[1].ratio 为 0 却仍报 secondary:"joy"/secondaryRatio:0 —— emotionComplexity 进而
1161
+ // 渲染 conflict:"sorrow↔joy"(全书只有 sorrow),与空分支的 conflict:"" 自相矛盾。
1162
+ // 现 secondary 仅在第二名占比 > 0 时给出,否则为空串(secondaryRatio 同步为 0)。
1163
+ secondary: sorted[1].ratio > 0 ? sorted[1].emotion : "",
1164
+ secondaryRatio: sorted[1].ratio > 0 ? Math.round(sorted[1].ratio * 1000) / 1000 : 0
1109
1165
  };
1110
1166
  }
1111
1167
 
@@ -1118,7 +1174,9 @@ export function emotionComplexity(perChapter) {
1118
1174
  // 无情感词命中:不复杂(low),恒有值供模型读取
1119
1175
  return {
1120
1176
  score: 0, level: "low", entropy: 0, maxEntropy: Math.round(EMOTION_MAX_ENTROPY * 1000) / 1000,
1121
- diversity: 0, dominant: "neutral", dominantRatio: 0, secondary: "neutral", secondaryRatio: 0,
1177
+ // v4.3.0(清单第 35 条):无次要情感时 secondary 统一为空串(旧版此处"neutral"、
1178
+ // 上方非空分支却给"joy"这类 0 占比情感,两处口径不同)。
1179
+ diversity: 0, dominant: "neutral", dominantRatio: 0, secondary: "", secondaryRatio: 0,
1122
1180
  conflict: "", conflictStrength: 0,
1123
1181
  chapterDrift: { meanEntropy: 0, entropyVariance: 0, swinging: false }
1124
1182
  };
@@ -1146,7 +1204,9 @@ export function emotionComplexity(perChapter) {
1146
1204
  dominantRatio: global.dominantRatio,
1147
1205
  secondary: global.secondary,
1148
1206
  secondaryRatio: global.secondaryRatio,
1149
- conflict: global.dominant + "↔" + global.secondary,
1207
+ // v4.3.0(清单第 35 条):只有在真的存在次要情感(占比 > 0)时才给「A↔B」,
1208
+ // 否则沿用空分支的 "" 语义(旧版会渲染 "sorrow↔joy" 这种指向 0 占比情感的假冲突)。
1209
+ conflict: global.secondaryRatio > 0 ? global.dominant + "↔" + global.secondary : "",
1150
1210
  conflictStrength: Math.round(conflictStrength * 100) / 100,
1151
1211
  chapterDrift: {
1152
1212
  meanEntropy: Math.round(meanEntropy * 1000) / 1000,
@@ -1160,15 +1220,19 @@ export function emotionComplexity(perChapter) {
1160
1220
  * v1.6.0 情感量化入口:三指标 + 显隐对比 + 复杂度 + 复合共现(纯规则 0 token)。
1161
1221
  */
1162
1222
  export function emotionalQuantification(text, perChapter, blocks) {
1163
- const stats = valenceStats(text);
1164
- const implicit = implicitEmotionScan(text);
1223
+ // v4.3.0(清单第 33 条):入参兜底——旧版 emotionalQuantification("x", [{chapter,counts}])
1224
+ // 抛 "blocks is not iterable",而它下面的 fallback 分支本意就是支持"无分章数据"这一输入。
1225
+ const src = String(text ?? "");
1226
+ const stats = valenceStats(src);
1227
+ const implicit = implicitEmotionScan(src);
1165
1228
  const meanValence = stats.meanValence ?? 0;
1229
+ const blockList = Array.isArray(blocks) ? blocks : [];
1166
1230
  // perChapter 为空(单章/无分章输入)时,用当前文本自身 clean 计数聚合,保证 complexity 恒有值
1167
1231
  if (!Array.isArray(perChapter) || perChapter.length === 0) {
1168
1232
  const counts = { joy: 0, anger: 0, sorrow: 0, fear: 0, surprise: 0 };
1169
- for (const block of blocks) {
1170
- for (const sentence of block) {
1171
- const cs = sentence.emotion.cleanScores ?? {};
1233
+ for (const block of blockList) {
1234
+ for (const sentence of Array.isArray(block) ? block : []) {
1235
+ const cs = sentence?.emotion?.cleanScores ?? {};
1172
1236
  for (const k of EMOTION_CATS) counts[k] += cs[k] ?? 0;
1173
1237
  }
1174
1238
  }
@@ -1179,7 +1243,7 @@ export function emotionalQuantification(text, perChapter, blocks) {
1179
1243
  implicit,
1180
1244
  compare: explicitImplicitCompare(meanValence, implicit),
1181
1245
  complexity: emotionComplexity(perChapter),
1182
- composites: compositeEmotionPairs(blocks)
1246
+ composites: compositeEmotionPairs(blockList)
1183
1247
  };
1184
1248
  }
1185
1249
 
@@ -1251,7 +1315,8 @@ export function analyzeText(text, options = {}) {
1251
1315
  counts[sentence.type] += 1;
1252
1316
  lengthByType[sentence.type].push(sentence.len);
1253
1317
  if (examplesByType[sentence.type].length < 3) {
1254
- const sample = sentence.text.length > 42 ? sentence.text.slice(0, 42) + "…" : sentence.text;
1318
+ // v4.3.0(清单第 32 条):示例截断改按码点(旧版 slice(0,42) 会留下孤立代理项)
1319
+ const sample = sentence.text.length > 42 ? truncateCodePoints(sentence.text, 42) + "…" : sentence.text;
1255
1320
  examplesByType[sentence.type].push(sample);
1256
1321
  }
1257
1322
  }
@@ -1377,11 +1442,16 @@ export function analyzeText(text, options = {}) {
1377
1442
  cleanEmotionCounts[emotion] += count;
1378
1443
  }
1379
1444
  // v3.7.0 引擎③:按文本内实际出现次数计数("开心开心开心"不再 count=1)
1380
- // v4.0.0 修正:与 scores 同口径——被否定("不害怕")的命中不再计入词频
1445
+ // v4.0.0 修正:与 scores 同源地做否定过滤——被否定("不害怕")的命中不再计入词频
1381
1446
  // v4.3.0 修正(P0-1):直接累加 emotionOf 带出的命中列表(hitList,已经过区间消费 +
1382
1447
  // 否定过滤 + 副词加权),删除旧的"words 去重回原文 indexOf 重扫"——
1383
1448
  // 那次重扫不认识区间消费,"悲痛欲绝"里嵌的"悲痛"被再计一次,
1384
1449
  // 实测 "他悲痛欲绝,悲痛得说不出话。" scores.sorrow=2 而 topWords 合计 3。
1450
+ // v4.3.0 修正(清单第 27 条):此处统一的是**区间消费 + 否定过滤**,不是"加权分"——
1451
+ // 下面每条命中固定 +1(语义:出现次数),而 scores 累加 adverbWeight(弱副词 0.6 / 强副词 1.5)。
1452
+ // 实测 "她有点悲伤,悲伤。" → scores.sorrow=1.6 而 topWords=悲伤×2,两者**不能交叉核对**。
1453
+ // 之所以不改成绩效权重累加:index.js:542 把 topWords[].count 声明为 {type:"integer"} 且
1454
+ // additionalProperties:false,把小数权重写进去会被 output.schema 拒收。
1385
1455
  for (const hit of sentence.emotion.hitList) {
1386
1456
  emotionWordCounts.set(hit.word, (emotionWordCounts.get(hit.word) ?? 0) + 1);
1387
1457
  }
@@ -1420,12 +1490,29 @@ export function analyzeText(text, options = {}) {
1420
1490
  if (battleDensity >= 3) pollutedBy.push("高密度战斗/爽文描写(每千字 " + battleDensity + ")");
1421
1491
  if (horrorDensity >= 3) pollutedBy.push("高密度恐怖/疯狂描写(每千字 " + horrorDensity + ")");
1422
1492
  // v3.7.0 引擎②:clean 无强词主导(全来自弱词)→ medium(不再判 high)
1423
- const confidence = polluted ? "low" : (cleanDominantEmotion === "neutral" ? "medium" : (cleanDominantEmotion !== dominantEmotion ? "medium" : "high"));
1424
- const caveat = polluted
1425
- ? "⚠️ 检测到" + pollutedBy.join("、") + ",dominant(" + EMOTION_LABELS[dominantEmotion] + ")可能来自生理/爽感反应词而非真实情感。请勿直接采信,须 novel_read 抽查 2-3 段原文复核真实情感基调后再下结论。"
1426
- : (cleanDominantEmotion !== dominantEmotion
1427
- ? "ℹ️ 剔除易污染的情绪词后,主导情感为「" + EMOTION_LABELS[cleanDominantEmotion] + "」(raw 为「" + EMOTION_LABELS[dominantEmotion] + "」),差异来自场景性用词,请结合原文判断。"
1428
- : "");
1493
+ // v4.3.0 修正(清单第 29 条):零命中与"仅弱词"此前不可区分——旧版把
1494
+ // `cleanDominantEmotion === "neutral"` 一律判 medium,而"一个情感词都没有"时该条件同样成立:
1495
+ // 实测 analyzeText("他拿起杯子,喝了一口水。") → confidence="medium"、intensity=0、caveat="",
1496
+ // 模型会把「无数据」读成「中等置信度的中性结论」。现拆成三档:
1497
+ // 有污染 → low / 零命中 → low + 显式"无数据"说明 / 仅弱词或 raw↔clean 不一致 → medium / 其余 → high。
1498
+ let confidence;
1499
+ let caveat;
1500
+ if (polluted) {
1501
+ confidence = "low";
1502
+ caveat = "⚠️ 检测到" + pollutedBy.join("、") + ",dominant(" + EMOTION_LABELS[dominantEmotion] + ")可能来自生理/爽感反应词而非真实情感。请勿直接采信,须 novel_read 抽查 2-3 段原文复核真实情感基调后再下结论。";
1503
+ } else if (totalEmotion === 0) {
1504
+ confidence = "low";
1505
+ caveat = "ℹ️ 未检出任何情感词(raw/clean 计数均为 0),本次没有可供判定情感基调的数据——dominant/cleanDominant 的「中性」表示无数据,不是「文本基调中性」,请勿据此下结论。";
1506
+ } else if (cleanDominantEmotion === "neutral") {
1507
+ confidence = "medium";
1508
+ caveat = "ℹ️ 仅命中弱情绪词(clean 无强词),主导情感「" + EMOTION_LABELS[dominantEmotion] + "」可信度中等,请结合原文判断。";
1509
+ } else if (cleanDominantEmotion !== dominantEmotion) {
1510
+ confidence = "medium";
1511
+ caveat = "ℹ️ 剔除易污染的情绪词后,主导情感为「" + EMOTION_LABELS[cleanDominantEmotion] + "」(raw 为「" + EMOTION_LABELS[dominantEmotion] + "」),差异来自场景性用词,请结合原文判断。";
1512
+ } else {
1513
+ confidence = "high";
1514
+ caveat = "";
1515
+ }
1429
1516
  // v4.0.0 修正:chapterDrift 有分章输入时按「章」聚合——旧版按段分组,单情绪段的熵恒为 0,
1430
1517
  // meanEntropy/entropyVariance/swinging 在常见文本上几乎恒 false,与字段名"章间漂移"不符。
1431
1518
  // 单章分析(无 chapterTexts)仍回退段级,并在结果里标出 basis 以免误读。
@@ -1535,7 +1622,16 @@ export function analyzeText(text, options = {}) {
1535
1622
  for (const item of options.chapterTexts) {
1536
1623
  // v4.0.0 修正:缺失 text 时旧版 String(undefined) → "undefined" 被当成陈述句污染节奏指纹
1537
1624
  if (typeof item?.text !== "string") continue;
1538
- const chapterCodes = splitSentences(item.text).slice(0, maxSentences).map((s) => classifySentence(s).type);
1625
+ // v4.3.0 修正(清单第 25 条):分章节奏改用与主路径同口径的「分块 → 分句」。
1626
+ // 旧版直接 splitSentences(item.text),绕过 splitBlocks 的 Markdown 标题/分割线过滤,
1627
+ // 而本插件创建的章节文件首行恒为 "# 标题"(index.js:3458)——
1628
+ // 实测 "# 第01章 夜雨\n\n雨下了一整夜。\n\n她走了。\n" 主路径 totalSentences=2
1629
+ // 而 chapterPatterns 给出 S×3(含 "---" 时 S×4)。该序列经 buildGuidance 写进 guidance
1630
+ // 交给模型,属于对模型可见的假节奏指纹。
1631
+ const chapterCodes = splitBlocks(item.text)
1632
+ .flatMap((block) => splitSentences(block))
1633
+ .slice(0, maxSentences)
1634
+ .map((s) => classifySentence(s).type);
1539
1635
  chapterPatterns.push({
1540
1636
  chapter: String(item.chapter ?? "?"),
1541
1637
  sequence: compressSequence(chapterCodes.map((type) => TYPE_CODE[type]))
@@ -1624,14 +1720,16 @@ function buildFingerprint(categories, lengths, style, emotion, motifs) {
1624
1720
  /** 两篇文本的风格指纹相似度(0~1):句式分布 + 句长 + 主观性的余弦相似度。 */
1625
1721
  export function fingerprintSimilarity(a, b) {
1626
1722
  const vectorOf = (r) => {
1723
+ // v4.3.0(清单第 33 条):入参兜底(旧版 fingerprintSimilarity(null,null) 抛 reading 'categories')
1724
+ const src = r ?? {};
1627
1725
  const v = [];
1628
- const catMap = new Map((r.categories ?? []).map((x) => [x.type, x.ratio ?? 0]));
1726
+ const catMap = new Map((src.categories ?? []).map((x) => [x.type, x.ratio ?? 0]));
1629
1727
  for (const type of CATEGORY_ORDER) v.push(catMap.get(type) ?? 0);
1630
- const len = r.lengths?.avg ?? 0;
1728
+ const len = src.lengths?.avg ?? 0;
1631
1729
  v.push(Math.min(len, 100) / 100);
1632
- v.push(r.lengths?.shortRatio ?? 0);
1633
- v.push(r.lengths?.longRatio ?? 0);
1634
- v.push((r.style?.subjectivityIndex ?? 0) / 100);
1730
+ v.push(src.lengths?.shortRatio ?? 0);
1731
+ v.push(src.lengths?.longRatio ?? 0);
1732
+ v.push((src.style?.subjectivityIndex ?? 0) / 100);
1635
1733
  return v;
1636
1734
  };
1637
1735
  const va = vectorOf(a);
@@ -1650,6 +1748,9 @@ export function fingerprintSimilarity(a, b) {
1650
1748
 
1651
1749
  /** 风格偏差清单:b 相对 a 的显著差异(阈值可调)。 */
1652
1750
  export function styleDiffs(a, b, threshold = 0.06) {
1751
+ // v4.3.0(清单第 33 条):入参兜底(旧版 styleDiffs(null,null) 抛 reading 'categories')
1752
+ a = a ?? {};
1753
+ b = b ?? {};
1653
1754
  const diffs = [];
1654
1755
  const catA = new Map((a.categories ?? []).map((x) => [x.type, x.ratio]));
1655
1756
  const catB = new Map((b.categories ?? []).map((x) => [x.type, x.ratio]));
@@ -1676,7 +1777,7 @@ export function styleDiffs(a, b, threshold = 0.06) {
1676
1777
  }
1677
1778
 
1678
1779
  /** v0.8.0 细节密度词表:动作动词/物件名词/感官词。 */
1679
- const DENSITY_ACTION_VERBS = /摸|拿|推|拉|走|跑|蹲|站|坐|转|伸|握|点|掀|翻|跪|爬|钻|抱|搂|抬|放|挂|摘|系|披|踩|迈|跨|扑|撞|躲|闪|看|盯|瞥|望|听|闻|尝|咬|喝|吃|敲|拍|捏|揉|擦|吹|按|拨|拧|捡|拾|丢|抛|接|递|塞|掏|捞|舀|倒|浇|洗|叠|铺|盖|关|开|掩|合/;
1780
+ const DENSITY_ACTION_VERBS = /摸|拿|推|拉|走|跑|蹲|躺|靠|趴|倚|站|坐|转|伸|握|点|掀|翻|跪|爬|钻|抱|搂|抬|放|挂|摘|系|披|踩|迈|跨|扑|撞|躲|闪|看|盯|瞥|望|听|闻|尝|咬|喝|吃|敲|拍|捏|揉|擦|吹|按|拨|拧|捡|拾|丢|抛|接|递|塞|掏|捞|舀|倒|浇|洗|叠|铺|盖|关|开|掩|合/;
1680
1781
  const DENSITY_OBJECT_WORDS = /油灯|灯|床|门|窗|桌|椅|凳|杯|碗|碟|盘|筷|勺|剑|刀|枪|书|本|纸|笔|衣|裙|帽|鞋|镜|烛台|蜡烛|铁罐|罐|盒|箱|马车|车|墙|柱|帘|毯|被|枕|簪|铃|钟|画|架|梯|绳|袋|篮|桶|壶|锅|铲|柴|炭|火|灰/;
1681
1782
  const DENSITY_SENSE = {
1682
1783
  visual: /看|见|映入|光|亮|暗|色|影|闪|照|映|目光|眼神|脸|面|状|样/,
@@ -1694,9 +1795,11 @@ const countMatches = (str, re) => (str.match(re) || []).length;
1694
1795
 
1695
1796
  /** 细节密度统计(v0.8.0):动作链/物件名词/感官词,按千字归一。 */
1696
1797
  export function densityOf(text) {
1697
- const sentences = splitSentences(text);
1798
+ // v4.3.0(清单第 33 条):入口空值兜底(旧版 densityOf(null) 抛 reading 'length')
1799
+ const src = String(text ?? "");
1800
+ const sentences = splitSentences(src);
1698
1801
  // v3.5.0 #62:分母口径统一(含空白,与 style-metrics 的 per1000 一致,跨模块数值可比)
1699
- const totalChars = String(text).length;
1802
+ const totalChars = src.length;
1700
1803
  let actionVerbs = 0;
1701
1804
  let actionChainSentences = 0;
1702
1805
  let objectHits = 0;
@@ -1707,7 +1810,8 @@ export function densityOf(text) {
1707
1810
  const verbs = countMatches(sentence, DENSITY_ACTION_RE);
1708
1811
  if (verbs >= 2) {
1709
1812
  actionChainSentences += 1;
1710
- if (actionChains.length < 3) actionChains.push(sentence.slice(0, 40));
1813
+ // v4.3.0(清单第 32 条):示例截断改按码点(旧版 slice(0,40) 会留下孤立代理项)
1814
+ if (actionChains.length < 3) actionChains.push(truncateCodePoints(sentence, 40));
1711
1815
  }
1712
1816
  actionVerbs += verbs;
1713
1817
  objectHits += countMatches(sentence, DENSITY_OBJECT_RE);
@@ -1748,13 +1852,30 @@ function loadDutir() {
1748
1852
  const DUTIR_TO_EMOTION = { 乐: "joy", 好: "joy", 怒: "anger", 哀: "sorrow", 惧: "fear", 恶: "anger", 惊: "surprise" }; // v3.5.0 H4:恶(厌恶) 归 anger 而非 sorrow
1749
1853
  /**
1750
1854
  * v4.3.0(P1-5):dutir 兜底扫描的最长词长上限。
1751
- * 旧版在 emotionOf 里写死 `Math.min(4, …)`,而词表里 853 条 ≥5 字条目
1855
+ * 旧版在 emotionOf 里写死 `Math.min(4, …)`,词表里大量「原始长度 ≥5」条目
1752
1856
  * (久旱逢甘雨/一步一个脚印/打开天窗说亮话…)永远无法命中——占比 3.1%,且都是成语级强情感词。
1753
- * 现提到 8(词表最长条目 7 字),内层最多 7 次 slice,实测 2000 字文本兜底耗时 1.1ms→2.6ms。
1857
+ * 现提到 8,内层最多 7 次 slice,实测 2000 字文本兜底耗时 1.1ms→2.6ms。
1858
+ *
1859
+ * v4.3.0 修正(清单第 30 条,按附录 B 的核验口径实测重写本注释的数字):
1860
+ * 解压 lib/lexicons/dutir_seven.json.gz 实测 27,413 条:
1861
+ * - 「原始字符串长度 ≥5」共 853 条(853/27413 = 3.11%,与上句自称的 3.1% 吻合)——该数字无误;
1862
+ * - 旧注释的「词表最长条目 7 字」是**硬错误**:原始字符串最长 15 字
1863
+ * (「先天下之忧而忧,后天下之乐而乐」;纯汉字最长 10 字「不以规矩就不能成方圆」)。
1864
+ * 7 也从来不是该表的任何长度上限;建表区间(纯汉字、2~8 字)内的最长条目实测为 8 字。
1865
+ * - 建表(下方 ensureDutirLoaded)只收 trim 后纯汉字、2 ≤ 长度 ≤ 8 的条目:
1866
+ * 纯汉字且 ≥5 字共 420 条,其中 >8 字的仅 3 条(做一天和尚撞一天钟 / 傲一天和尚撞一天钟 /
1867
+ * 不以规矩就不能成方圆)——这 3 条受上限 8 限制仍不可命中;即上限 4→8 真正**新可命中 417 条**。
1754
1868
  */
1755
1869
  const DUTIR_MAX_WORD_LEN = 8;
1756
1870
  const dutirLookup = new Map();
1757
- function dutirEmotion(word) {
1871
+ /**
1872
+ * v4.3.0 修正(清单第 31 条):本函数原签名 dutirEmotion(word) 名不副实——
1873
+ * 唯一调用点(emotionOf 顶部)传空串并丢弃返回值,而建表处有 `w.length < 2` 过滤,使
1874
+ * dutirEmotion("") 恒返回 undefined;真实查表走模块内的 `dutirLookup.get(word)`。
1875
+ * 故"查表"这半边是永不触发的死代码,实际语义只有"确保懒加载建表完成"。
1876
+ * 现改名为 ensureDutirLoaded(),只做懒加载 + 内存缓存,并把建好的 Map 返回给需要直接查表的调用方。
1877
+ */
1878
+ function ensureDutirLoaded() {
1758
1879
  if (dutirLookup.size === 0) {
1759
1880
  const dutir = loadDutir();
1760
1881
  for (const [cat, words] of Object.entries(dutir)) {
@@ -1773,6 +1894,7 @@ function dutirEmotion(word) {
1773
1894
  }
1774
1895
  }
1775
1896
  }
1776
- return dutirLookup.get(word);
1897
+ return dutirLookup;
1777
1898
  }
1778
- // v4.0.0:删除零调用的导出 dutirEmotionOf(全仓库仅定义处出现;内部用 dutirEmotion)
1899
+ // v4.0.0:删除零调用的导出 dutirEmotionOf(全仓库仅定义处出现;内部用 dutirLookup)
1900
+ // v4.3.0:dutirEmotion(word) 已按实际语义改名为 ensureDutirLoaded()(见上)