dsh-novel-writer 2.0.2 → 2.5.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 +20 -98
- package/README.md +60 -91
- package/lib/analysis.js +187 -7
- package/lib/client.js +159 -55
- package/lib/index.js +294 -3
- package/lib/lexicons/dutir_seven.json +1 -0
- package/lib/lexicons/webnovel-tropes.md +183 -0
- package/lib/vibe.js +339 -0
- package/package.json +3 -3
- package/skills/novel-writing/SKILL.md +6 -3
package/README.en.md
CHANGED
|
@@ -1,104 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-novel-writer — Novel Writing Assistant Plugin (v2.5.0)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A DSH (DeepSeek Harness) bundle plugin. **Ships a local semantic embedding engine (bge-small-zh-v1.5 ONNX, bundled, CPU inference, 0 token cost)** — semantic capabilities without any API fees.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
[](https://github.com/siweina/dsh-novel-writer/stargazers)
|
|
8
|
-
[](https://github.com/siweina/dsh-novel-writer/releases)
|
|
9
|
-
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
5
|
+
## Tools (15, each with an independent UI switch)
|
|
6
|
+
novel_books / novel_chapters / novel_read / novel_keywords / novel_new_chapter / novel_import / novel_sentence_analysis / novel_sentence_config / novel_style_check / novel_plot / novel_settings / novel_summary / novel_continuity_check / novel_semantic_search (new in v2.0.0) / **novel_style_report (new in v2.5.0)**
|
|
10
7
|
|
|
11
|
-
|
|
8
|
+
## v2.0.0 — Semantic Layer
|
|
9
|
+
- **Local engine** (lib/embedding.js): Xenova/bge-small-zh-v1.5 (quantized), 512-dim Chinese vectors, ~23MB, bundled; @huggingface/tokenizers + onnxruntime-web (WASM), local CPU, 0 token / 0 API cost;
|
|
10
|
+
- **Lazy load** (~0.3s first call); automatic fallback to pure-rule mode if unavailable;
|
|
11
|
+
- **Index cache**: per-book vectors at `.novel-writer/embedding/<book>.json` — repeated searches are instant;
|
|
12
|
+
- **novel_semantic_search**: natural-language search across the whole book (foreshadowing clues, emotional scenes, setting mentions) — matches by meaning even when keywords differ;
|
|
13
|
+
- **novel_style_check upgrade**: adds semantic similarity (target chapter vs rest of book) alongside rule-based fingerprint similarity;
|
|
14
|
+
- **Switch: semanticEmbedding** — default ON (probe-based: auto-enabled when the model is present); turn it off in the sidebar「写作助手功能」panel to fully return to rule-only mode.
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
## History (v0.3 → v1.6)
|
|
17
|
+
Sentence-pattern analysis; emotion purification + AI review (strong/weak lexicon, pollution caveat); emotion quantification (Valence sliding window: variance/slope/conflict + implicit imagery + complexity score); worldview/genre/theme detection (modern/western/eastern + 15 genres + 35 themes); pragmatic-style review; settings tables (5) + foreshadowing registry + summaries + continuity audit + style check; analysis cache & report export (`.novel-writer/analysis/`).
|
|
14
18
|
|
|
15
|
-
##
|
|
19
|
+
## v2.5.0: Style Portrait Report (measurement/judgment separation)
|
|
20
|
+
- **novel_style_report** (15th tool): aggregates 6 measurement dimensions (style fingerprint / keywords / theme / emotion quantification / 12-axis vibe / semantic style distances);
|
|
21
|
+
- Plugin only reports numbers, never labels — style judgment is left to the LLM; AI judgment can be saved via `aiJudgment` (stored in .novel-writer/style-reports/) and read with `action=get`;
|
|
22
|
+
- Vibe spectrum: 12 axes (added 文艺唯美 Aesthetic / 情欲暧昧 Sensual), rule-based conclusions removed;
|
|
23
|
+
- Semantic style distances: 12 style prototype sets × book vector (local embedding, 0 token).
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
```sh
|
|
20
|
-
dsh plugin --profile web add dsh-novel-writer
|
|
21
|
-
# or
|
|
22
|
-
npm install dsh-novel-writer
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
**Option 2: From GitHub**
|
|
26
|
-
|
|
27
|
-
```sh
|
|
28
|
-
dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
After installing, **restart the web app** to activate (host registers 14 tools + state/reveal routes; browser mounts the "Writing Assistant" sidebar toggle panel).
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## v2.0.0 New: Local Semantic Engine (0 token)
|
|
36
|
-
|
|
37
|
-
- 🧠 **Built-in Chinese semantic model** (bge-small-zh-v1.5 quantized, 23MB, shipped with the plugin): 512-dim vectors, local CPU inference, **zero API cost**;
|
|
38
|
-
- 🔍 **novel_semantic_search**: search the whole book with natural language for semantically related passages (foreshadowing/emotional scenes/settings), even without matching keywords;
|
|
39
|
-
- 📊 **Semantic style comparison**: novel_style_check adds a semantic-similarity dimension beyond rule-based fingerprints;
|
|
40
|
-
- 💭 **Semantic implicit emotion**: emotion prototype sentences scan the book index to find "imagery passages outside the word list";
|
|
41
|
-
- 🪶 Lazy loading + automatic fallback to pure-rule mode (never breaks existing features).
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
## Features
|
|
46
|
-
|
|
47
|
-
1. **Chapter library**: chapters in `novels/<book>/第N章.md` (or .txt/.markdown); auto encoding detection (UTF-8/UTF-16/GBK).
|
|
48
|
-
2. **Sentence-pattern analysis**: 9 categories, arrangement patterns, rhythm, emotion curve, style fingerprint + guidance, with cache & report export.
|
|
49
|
-
3. **Emotion purification**: strong/weak emotion-word grading, pollution-source detection, caveat warning + AI re-verification, cleanDominant true baseline.
|
|
50
|
-
4. **Emotion quantification** (v1.6.0): Valence mapping + sliding window → variance V / delta Δ / conflict index C; implicit imagery carriers + explicit-implicit conflict; complexity score + composite emotion pairs.
|
|
51
|
-
5. **Style check**: chapter vs book → similarity + deviation list + advice.
|
|
52
|
-
6. **Plot tracking**: foreshadowing/plot-hook registry (open/done), typed fields + auto mention tracking.
|
|
53
|
-
7. **Settings management**: five tables — characters / locations / items / timeline / worldview.
|
|
54
|
-
8. **Worldview & pragmatics detection**: auto cultural-baseline detection with confidence; speechStyle title/honorifics/rituals/tone norms.
|
|
55
|
-
9. **Genre & theme detection**: primary/secondary themes + genre, low-frequency noise filtered.
|
|
56
|
-
10. **Chapter summaries**: per-chapter digest + key events for long-book continuation.
|
|
57
|
-
11. **Continuity audit**: settings conflicts + pragmatics conflicts with replacement suggestions.
|
|
58
|
-
12. **Batch import**: auto-detect book names & chapter numbers, classified import.
|
|
59
|
-
13. **Continuation writing**: read-first workflow; keeps style/foreshadowing/worldview consistent.
|
|
60
|
-
14. **Per-tool UI toggles**: "Writing Assistant" sidebar panel (master + per-tool + feature toggles).
|
|
61
|
-
|
|
62
|
-
---
|
|
63
|
-
|
|
64
|
-
## Provided Tools (14)
|
|
65
|
-
|
|
66
|
-
| Tool | Description |
|
|
67
|
-
|------|-------------|
|
|
68
|
-
| `novel_books` | List all books in library |
|
|
69
|
-
| `novel_chapters` | List a book's chapters |
|
|
70
|
-
| `novel_read` | Read a chapter (paginated) |
|
|
71
|
-
| `novel_keywords` | Keywords: bigram/trigram/name candidates |
|
|
72
|
-
| `novel_new_chapter` | Create new chapter file |
|
|
73
|
-
| `novel_import` | Batch import manuscripts |
|
|
74
|
-
| `novel_sentence_analysis` | Sentence-pattern analysis |
|
|
75
|
-
| `novel_sentence_config` | View/set tool & feature toggles |
|
|
76
|
-
| `novel_style_check` | Style check (rule + semantic) |
|
|
77
|
-
| `novel_plot` | Plot/foreshadowing tracker |
|
|
78
|
-
| `novel_settings` | Settings management (+worldview) |
|
|
79
|
-
| `novel_summary` | Chapter summaries |
|
|
80
|
-
| `novel_continuity_check` | Continuity audit |
|
|
81
|
-
| `novel_semantic_search` | **Semantic search** (v2.0.0, local embedding, 0 token) |
|
|
82
|
-
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
## Configuration
|
|
86
|
-
|
|
87
|
-
```yaml
|
|
88
|
-
- id: novel-writer
|
|
89
|
-
config:
|
|
90
|
-
root: 'D:/my-novel-library'
|
|
91
|
-
allowLanState: false # true = allow state save from LAN GUI access
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
---
|
|
95
|
-
|
|
96
|
-
## Data Directory
|
|
97
|
-
|
|
98
|
-
Under `<library-root>/.novel-writer/`: `plots` / `settings` / `summaries` / `analysis` / `audits` / `embedding` (semantic index cache).
|
|
99
|
-
|
|
100
|
-
---
|
|
101
|
-
|
|
102
|
-
## License
|
|
103
|
-
|
|
104
|
-
[MIT](./LICENSE)
|
|
25
|
+
## Size
|
|
26
|
+
~95MB total (23MB quantized model + 70MB runtime); zip 31MB. If disk is tight, disable the semantic switch and delete `lib/models/` + `node_modules/` to go back to rule-only.
|
package/README.md
CHANGED
|
@@ -1,104 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-novel-writer — 小说写作助手插件(v2.0.0)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DSH(DeepSeek Harness)小说写作助手 bundle 插件。**v2.0.0 集成本地语义嵌入引擎(bge-small-zh-v1.5 ONNX,随插件分发,本地 CPU 推理,0 token 成本)**,在不增加任何 API 费用的前提下获得语义级能力。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
[](LICENSE)
|
|
7
|
-
[](https://github.com/siweina/dsh-novel-writer/stargazers)
|
|
8
|
-
[](https://github.com/siweina/dsh-novel-writer/releases)
|
|
9
|
-
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
5
|
+
## 工具清单(14 个,全部带独立 UI 开关)
|
|
10
6
|
|
|
11
|
-
|
|
7
|
+
novel_books / novel_chapters / novel_read / novel_keywords / novel_new_chapter / novel_import / novel_sentence_analysis / novel_sentence_config / novel_style_check / novel_plot / novel_settings(设定管理)/ novel_summary(章节摘要)/ novel_continuity_check(连贯性审计)/ **novel_semantic_search(语义检索,v2.0.0 新增)**
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
## v2.0.0 新增能力(语义层)
|
|
14
10
|
|
|
15
|
-
|
|
11
|
+
### 本地语义嵌入引擎(embedding.js)
|
|
12
|
+
- 模型:Xenova/bge-small-zh-v1.5(512 维中文向量,约 91MB),随插件分发,开箱即用;
|
|
13
|
+
- 运行时:**@huggingface/tokenizers**(官方轻量分词,294KB 零依赖)+ onnxruntime-web(WASM 推理),本地 CPU,0 token、0 API 费用;
|
|
14
|
+
- 懒加载:首次调用才加载(约 0.3s);加载失败自动回退纯规则,不影响任何既有功能;
|
|
15
|
+
- 索引缓存:每书向量落盘 `.novel-writer/embedding/<书>.json`,重复检索秒开(47 万字约 3100 段,首次建索引约 20-30s,之后命中缓存)。
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
### 新工具:novel_semantic_search(语义检索)
|
|
18
|
+
用自然语言描述在全书中检索语义相关段落——找伏笔线索、情感场景、设定提及,**即使原文没有相同关键词也能命中**(如搜「压抑克制的时刻」能命中没有"压抑"二字的段落)。
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# 或
|
|
22
|
-
npm install dsh-novel-writer
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
**方式二:从 GitHub 安装**
|
|
26
|
-
|
|
27
|
-
```sh
|
|
28
|
-
dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
安装后**重启 web 应用**生效(宿主端注册 14 个工具与 state/reveal 路由,浏览器端挂载侧边栏「写作助手功能」开关面板)。
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## v2.0.0 新增:本地语义引擎(0 token)
|
|
36
|
-
|
|
37
|
-
- 🧠 **内置中文语义模型**(bge-small-zh-v1.5 quantized,23MB,随插件分发):512 维向量,本地 CPU 推理,**零 API 费用**;
|
|
38
|
-
- 🔍 **novel_semantic_search**:用自然语言搜全书语义相关段落(伏笔/情感场景/设定提及),即使原文没有相同关键词也能命中;
|
|
39
|
-
- 📊 **语义级风格对比**:novel_style_check 在规则指纹之外新增语义相似度维度;
|
|
40
|
-
- 💭 **语义隐性情感**:情感原型句扫全书索引,发现"词表外疑似意象段落";
|
|
41
|
-
- 🪶 懒加载 + 失败自动回退纯规则(不影响任何既有功能)。
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
## 功能
|
|
20
|
+
### novel_style_check 升级:语义级风格对比
|
|
21
|
+
规则指纹相似度(句式/句长/情绪)之外,新增**语义相似度**(目标章 vs 全书其余部分向量余弦),双维度判定风格一致性。
|
|
46
22
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
4. **情感量化**(v1.6.0):Valence 效价映射 + 滑动窗口 → 方差 V / 斜率 Δ / 矛盾指数 C;隐性意象载体 + 显隐冲突;复杂度评分 + 复合情感共现。
|
|
51
|
-
5. **风格自检**:章节 vs 全书 → 相似度 + 偏差清单 + 续写建议。
|
|
52
|
-
6. **伏笔登记表**:open/done 状态、字段化登记、章节提及自动追踪。
|
|
53
|
-
7. **设定管理**:人物/地点/道具/时间线/世界观 五张表。
|
|
54
|
-
8. **世界观与语用检测**:自动判断文化基准(西/东/混合)+ 置信度;speechStyle 称谓/客套/仪式/语气规范。
|
|
55
|
-
9. **题材与流派检测**:主副题材 + 流派识别,低频噪音自动过滤。
|
|
56
|
-
10. **章节摘要**:每章摘要 + 关键事件,长书续写先读摘要。
|
|
57
|
-
11. **连贯性审计**:设定矛盾 + 语用冲突候选,带建议替换。
|
|
58
|
-
12. **批量导入**:原稿件自动识别书名与章节号分类导入。
|
|
59
|
-
13. **续写辅助**:先读后写、保持文风/伏笔/世界观一致。
|
|
60
|
-
14. **全工具 UI 开关**:侧边栏「写作助手功能」面板(总开关 + 每工具开关 + 功能开关)。
|
|
23
|
+
### 功能开关:语义增强(semanticEmbedding)
|
|
24
|
+
- **默认开启(探测式)**:模型存在即自动启用;用户可在侧边栏「写作助手功能」面板关闭,关闭后语义检索返回提示、风格检查跳过语义维度;
|
|
25
|
+
- 关闭后插件完整回退到 v1.6.0 纯规则能力。
|
|
61
26
|
|
|
62
|
-
|
|
27
|
+
## 历史能力(v0.3 → v1.6 累积)
|
|
28
|
+
- 句式模式分析(九类句式分布/排列/段落/句长/情感曲线/风格指纹/密度);
|
|
29
|
+
- 情感净化 + AI 复核(强/弱情绪词分级、污染预警 caveat、强制抽查);
|
|
30
|
+
- 情感量化(Valence 滑动窗口:方差/斜率/矛盾指数 + 隐性意象 + 复杂度评分);
|
|
31
|
+
- 世界观/流派/题材检测(modern/western/eastern + 15 流派 + 35 题材,主副题材);
|
|
32
|
+
- 语用级审查(称谓/客套/仪式/语气,中西/现代禁用词);
|
|
33
|
+
- 设定管理五张表 + 伏笔登记 + 章节摘要 + 连贯性审计 + 风格自检;
|
|
34
|
+
- 分析缓存与报告导出(`.novel-writer/analysis/`)。
|
|
63
35
|
|
|
64
|
-
##
|
|
36
|
+
## 功能开关(侧边栏「写作助手功能」面板)
|
|
37
|
+
- 总开关 enabled;autoAnalyze;14 个工具级开关;
|
|
38
|
+
- 功能级:emotionCaveat(情感净化预警)/ genreTheme(题材与流派检测)/ emotionComplexity(情感量化)/ **semanticEmbedding(语义增强,v2.0.0)**。
|
|
65
39
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
| `novel_settings` | 设定管理(人物/地点/道具/时间线/世界观) |
|
|
79
|
-
| `novel_summary` | 章节摘要(长书续写辅助) |
|
|
80
|
-
| `novel_continuity_check` | 连贯性审计(设定矛盾+语用冲突候选) |
|
|
81
|
-
| `novel_semantic_search` | **语义检索**(v2.0.0,本地 embedding,0 token) |
|
|
82
|
-
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
## 配置
|
|
86
|
-
|
|
87
|
-
```yaml
|
|
88
|
-
- id: novel-writer
|
|
89
|
-
config:
|
|
90
|
-
root: 'D:/我的小说库'
|
|
91
|
-
allowLanState: false # true=局域网访问 GUI 时也允许保存开关
|
|
40
|
+
## 目录结构
|
|
41
|
+
```
|
|
42
|
+
dsh-novel-writer-v2.0.0/
|
|
43
|
+
├── lib/
|
|
44
|
+
│ ├── index.js # 宿主端(14 工具 + state 路由 + 开关门禁)
|
|
45
|
+
│ ├── analysis.js # 规则引擎(句式/情感净化/情感量化/题材)
|
|
46
|
+
│ ├── embedding.js # 本地语义嵌入引擎(bge-small-zh ONNX)
|
|
47
|
+
│ ├── models/ # 模型文件(onnx/model.onnx 91MB + tokenizer)
|
|
48
|
+
│ └── client.js # 浏览器端(侧边栏面板 + 全部开关)
|
|
49
|
+
├── node_modules/ # @huggingface/tokenizers + onnxruntime-web(随插件分发)
|
|
50
|
+
├── test/ # 引擎 + client + e2e 测试
|
|
51
|
+
└── skills/novel-writing/SKILL.md # 模型使用指南
|
|
92
52
|
```
|
|
93
53
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
54
|
+
## v2.1.0 新增:氛围光谱 + 多方向意象表
|
|
55
|
+
- **氛围光谱(vibe)**:detect 输出 10 轴氛围坐标(噩梦感/焦虑压抑/温馨治愈/甜宠日常/催泪虐心/黑暗残酷/悬疑神秘/热血激昂/荒诞无厘头/孤独疏离)+ 组合结论 + 置信度 + 证据链——设定型气质(类克苏鲁等)以数值表达,不贴死标签;
|
|
56
|
+
- **多方向意象表**:变色龙词(雨/烛火/火光/夜/灯…)按语境触发词裁决方向,触发词未命中时用本地语义引擎与正/负原型句比余弦,仍不确定则双计分并标记歧义——修复"甜宠文误判负向 100%"类问题;
|
|
57
|
+
- **词表**:内置大连理工七类情感词表(27,413 词)供意象/情感扩展。
|
|
58
|
+
|
|
59
|
+
## v2.2.0 新增:网文信号层(vibe 三路补强)
|
|
60
|
+
- **动作/套路词群**:164 词(含 webnovel-writer genre-tropes 现成套路词:退婚/打脸/秒杀/追妻火葬场…),按 10 轴分组,封顶权重(每轴 ≤0.3);
|
|
61
|
+
- **题材联动**:题材命中 → 相关轴加分(豪门总裁→甜宠、系统流→荒诞/热血、玄幻→热血…),仅当该轴已有基础信号时触发;
|
|
62
|
+
- **情感直给词密度**:joy/fear/sorrow/anger 词计数 → 对应轴密度加分;
|
|
63
|
+
- **防污染四道防线**:加信号不换引擎 / 低权重封顶 / 来源标注可审计 / features.webnovelVibe 开关可回退;三组回归(公版文学 8 本 + 手写样本 6 本 + 网文 6 本)验证无污染;
|
|
64
|
+
- **词表资源**:lib/lexicons/webnovel-tropes.md(现成套路模板)+ dutir_seven.json(大连理工 27,413 情感词)。
|
|
65
|
+
|
|
66
|
+
## v2.5.0 新增:风格画像报告(测量与判断分离)
|
|
67
|
+
- **novel_style_report(第 15 个工具)**:聚合 6 维测量数据(文风指纹/高频词汇/题材流派/情感量化/氛围光谱 12 轴/语义风格距离)输出结构化报告;
|
|
68
|
+
- **插件只报数不贴标签**:氛围光谱 12 轴(新增 文艺唯美/情欲暧昧),砍掉规则结论文案——判断交给大模型;
|
|
69
|
+
- **AI 判断回传存盘**:AI 结合报告给出风格气质判断 → aiJudgment 参数回传 → 存入 .novel-writer/style-reports/<书>.json,后续 action=get 读取,续写保持风格一致;
|
|
70
|
+
- **语义风格距离**:12 组风格原型句 × 全书向量(本地 embedding,0 token)——实测示例书:克苏鲁诡异 0.79/压抑致郁 0.79。
|
|
71
|
+
|
|
72
|
+
## 体积说明
|
|
73
|
+
**zip 约 31MB**(含本地语义模型与 WASM 运行时,解压后代码+模型约 130MB);已移除 transformers.js/onnxruntime-node/sharp 全家桶(省 85%)。如磁盘紧张可在「语义增强」开关关闭后删除 `lib/models/` 与 `node_modules/`,插件回到纯规则模式。
|
package/lib/analysis.js
CHANGED
|
@@ -347,6 +347,28 @@ function emotionOf(text) {
|
|
|
347
347
|
cleanDominant = emotion;
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
|
+
// v2.5 修复:dutir_seven.json(大连理工 27,413 词)兜底——小词表未覆盖的情感词也计分
|
|
351
|
+
// 性能安全:只用"文本中出现的相邻二字组"查表(O(句长)),不做 27k 词全表扫描
|
|
352
|
+
try {
|
|
353
|
+
const seenWords = new Set();
|
|
354
|
+
for (const list of Object.values(words)) for (const w of list) seenWords.add(w);
|
|
355
|
+
dutirEmotion(""); // 确保 dutirLookup 已构建(懒加载)
|
|
356
|
+
const bigrams = text.match(/[\u4e00-\u9fa5]{2}/g) || [];
|
|
357
|
+
for (const w of new Set(bigrams)) {
|
|
358
|
+
if (seenWords.has(w)) continue;
|
|
359
|
+
const emo = dutirLookup.get(w);
|
|
360
|
+
if (!emo) continue;
|
|
361
|
+
scores[emo] += 1;
|
|
362
|
+
cleanScores[emo] += 1;
|
|
363
|
+
words[emo].push(w);
|
|
364
|
+
seenWords.add(w);
|
|
365
|
+
}
|
|
366
|
+
dominant = "neutral";
|
|
367
|
+
let best = 0;
|
|
368
|
+
for (const emotion of ["joy", "anger", "sorrow", "fear", "surprise"]) {
|
|
369
|
+
if (scores[emotion] > best) { best = scores[emotion]; dominant = emotion; }
|
|
370
|
+
}
|
|
371
|
+
} catch { /* dutir 兜底失败不影响原结果 */ }
|
|
350
372
|
return {
|
|
351
373
|
scores,
|
|
352
374
|
words,
|
|
@@ -474,18 +496,143 @@ const IMPLICIT_CARRIERS = Object.freeze([
|
|
|
474
496
|
{ words: ["垂手", "放下手", "手垂落"], valence: -0.15, label: "无力·放弃" }
|
|
475
497
|
]);
|
|
476
498
|
|
|
499
|
+
/**
|
|
500
|
+
* v2.1.0 多方向意象表:同一载体在不同语境可表达不同情感("变色龙词")。
|
|
501
|
+
* 每个词允许多个方向条目,各自带触发语境词(规则层裁决):
|
|
502
|
+
* - 命中触发词 → 按该方向计分
|
|
503
|
+
* - 无触发词 → 交给语义层(与正/负原型句比相似度)
|
|
504
|
+
* - 语义层也不确定 → 双计分 + 歧义标记
|
|
505
|
+
*/
|
|
506
|
+
const AMBIGUOUS_CARRIERS = Object.freeze({
|
|
507
|
+
"雨": [
|
|
508
|
+
{ valence: -0.3, label: "压抑·萧瑟", triggers: ["冷", "夜", "秋", "寒", "阴", "灰", "敲", "不停", "绵", "细", "孤"] },
|
|
509
|
+
{ valence: 0.2, label: "清新·复苏", triggers: ["晴", "彩虹", "洗净", "春", "润", "后", "停", "甘"] }
|
|
510
|
+
],
|
|
511
|
+
"烛火": [
|
|
512
|
+
{ valence: -0.2, label: "诡异·不安", triggers: ["摇曳", "昏", "暗", "影", "鬼", "摇", "颤", "燃尽", "跳"] },
|
|
513
|
+
{ valence: 0.25, label: "温馨", triggers: ["暖", "炉", "家", "围", "饭", "柔", "亮"] }
|
|
514
|
+
],
|
|
515
|
+
"火光": [
|
|
516
|
+
{ valence: -0.25, label: "灾难·恐惧", triggers: ["烧", "浓烟", "废墟", "惨叫", "逃", "夜", "红"] },
|
|
517
|
+
{ valence: 0.2, label: "希望·温暖", triggers: ["暖", "黎明", "亮", "驱散", "炉", "温"] }
|
|
518
|
+
],
|
|
519
|
+
"夜": [
|
|
520
|
+
{ valence: -0.2, label: "孤独·恐惧", triggers: ["深", "黑", "静", "冷", "无眠", "怕", "漫长", "沉"] },
|
|
521
|
+
{ valence: 0.15, label: "安宁·静谧", triggers: ["星", "月", "静好", "温柔", "安"] }
|
|
522
|
+
],
|
|
523
|
+
"风": [
|
|
524
|
+
{ valence: -0.15, label: "萧瑟·离别", triggers: ["冷", "寒", "秋", "吹散", "凛冽", "呜咽"] },
|
|
525
|
+
{ valence: 0.15, label: "清爽·自由", triggers: ["暖", "春", "清新", "拂", "轻", "晴"] }
|
|
526
|
+
],
|
|
527
|
+
"海": [
|
|
528
|
+
{ valence: -0.3, label: "深邃·恐惧", triggers: ["黑", "沉", "浪", "吞", "潮", "深", "暗", "涌"] },
|
|
529
|
+
{ valence: 0.2, label: "开阔·浪漫", triggers: ["蓝", "晴", "暖", "浪花", "笑", "沙滩", "夕阳"] }
|
|
530
|
+
],
|
|
531
|
+
"灯": [
|
|
532
|
+
{ valence: -0.15, label: "孤独·守望", triggers: ["孤", "昏", "暗", "残", "灭", "夜"] },
|
|
533
|
+
{ valence: 0.2, label: "温暖·归处", triggers: ["暖", "亮", "家", "等", "柔", "光"] }
|
|
534
|
+
],
|
|
535
|
+
"影子": [
|
|
536
|
+
{ valence: -0.25, label: "不安·诡异", triggers: ["长", "暗", "摇晃", "鬼", "拖", "黑"] },
|
|
537
|
+
{ valence: 0.1, label: "陪伴", triggers: ["暖", "短", "依偎"] }
|
|
538
|
+
],
|
|
539
|
+
"笑": [
|
|
540
|
+
{ valence: -0.15, label: "苦笑·强颜", triggers: ["苦", "勉", "僵", "假", "惨", "涩", "硬"] },
|
|
541
|
+
{ valence: 0.25, label: "欢乐", triggers: ["开怀", "灿烂", "暖", "甜", "大", "爽朗", "咯咯"] }
|
|
542
|
+
],
|
|
543
|
+
"眼泪": [
|
|
544
|
+
{ valence: -0.3, label: "悲伤", triggers: ["落", "流", "止不住", "擦", "咸", "含", "忍"] },
|
|
545
|
+
{ valence: 0.15, label: "感动·释然", triggers: ["感动", "幸福", "喜极", "温暖", "笑"] }
|
|
546
|
+
],
|
|
547
|
+
"沉默": [
|
|
548
|
+
{ valence: -0.2, label: "压抑·隔阂", triggers: ["久", "冷", "尴尬", "低头", "死寂", "不开口"] },
|
|
549
|
+
{ valence: 0.1, label: "默契·安宁", triggers: ["温柔", "懂", "默契", "安静", "并肩"] }
|
|
550
|
+
],
|
|
551
|
+
"花开": [
|
|
552
|
+
{ valence: -0.1, label: "易逝·伤春", triggers: ["落", "谢", "春尽", "残"] },
|
|
553
|
+
{ valence: 0.25, label: "美好·希望", triggers: ["盛", "香", "春", "灿烂", "暖"] }
|
|
554
|
+
],
|
|
555
|
+
"黄昏": [
|
|
556
|
+
{ valence: -0.25, label: "迟暮·萧瑟", triggers: ["残", "落", "暗", "孤", "冷", "尽"] },
|
|
557
|
+
{ valence: 0.15, label: "温柔·归家", triggers: ["暖", "金", "柔", "炊烟", "并肩"] }
|
|
558
|
+
],
|
|
559
|
+
"奔跑": [
|
|
560
|
+
{ valence: -0.2, label: "逃离·慌乱", triggers: ["逃", "拼命", "慌", "追", "喘", "夜"] },
|
|
561
|
+
{ valence: 0.2, label: "自由·奔赴", triggers: ["向", "奔", "扑", "迎", "笑", "阳光"] }
|
|
562
|
+
]
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
/** v2.1.0:查找多方向载体命中(返回所有方向条目 + 上下文命中触发词)。 */
|
|
566
|
+
export function matchAmbiguousCarriers(text) {
|
|
567
|
+
const hits = [];
|
|
568
|
+
for (const [word, entries] of Object.entries(AMBIGUOUS_CARRIERS)) {
|
|
569
|
+
let idx = 0;
|
|
570
|
+
while ((idx = text.indexOf(word, idx)) !== -1) {
|
|
571
|
+
// 上下文窗口:前后 30 字
|
|
572
|
+
const ctx = text.slice(Math.max(0, idx - 30), idx + word.length + 30);
|
|
573
|
+
const matched = entries.map((e) => {
|
|
574
|
+
const trigger = e.triggers.find((t) => ctx.includes(t));
|
|
575
|
+
return { ...e, triggerHit: trigger || null };
|
|
576
|
+
});
|
|
577
|
+
hits.push({ word, ctx, entries: matched });
|
|
578
|
+
idx += word.length;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return hits;
|
|
582
|
+
}
|
|
583
|
+
|
|
477
584
|
/** v1.6.0 隐性载体扫描:意象/动作 → 负向/正向占比 + top 载体 + 脆弱标记。 */
|
|
478
|
-
export function implicitEmotionScan(text) {
|
|
585
|
+
export function implicitEmotionScan(text, semResolver = null) {
|
|
479
586
|
let negHits = 0, posHits = 0, fragileHits = 0;
|
|
587
|
+
let ambHits = 0;
|
|
480
588
|
const carrierCounts = new Map();
|
|
589
|
+
const ambiguous = [];
|
|
590
|
+
const see = (label, word, n, valence) => {
|
|
591
|
+
if (valence < 0) negHits += n * -valence;
|
|
592
|
+
else { posHits += n * valence; if (false) fragileHits += n; }
|
|
593
|
+
carrierCounts.set(label + ":" + word, (carrierCounts.get(label + ":" + word) ?? 0) + n);
|
|
594
|
+
};
|
|
595
|
+
// ① 单方向表(原有)
|
|
481
596
|
for (const carrier of IMPLICIT_CARRIERS) {
|
|
482
597
|
for (const word of carrier.words) {
|
|
483
598
|
const n = text.split(word).length - 1;
|
|
484
599
|
if (n > 0) {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
600
|
+
see(carrier.label, word, n, carrier.valence);
|
|
601
|
+
if (carrier.valence >= 0 && carrier.fragile) fragileHits += n;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// ② 多方向表(变色龙词):规则触发 → 定方向;无触发 → 语义层;都不确定 → 双计分+歧义
|
|
606
|
+
for (const [word, entries] of Object.entries(AMBIGUOUS_CARRIERS)) {
|
|
607
|
+
let idx = 0;
|
|
608
|
+
while ((idx = text.indexOf(word, idx)) !== -1) {
|
|
609
|
+
const ctx = text.slice(Math.max(0, idx - 30), idx + word.length + 30);
|
|
610
|
+
const triggerMatched = entries.filter((e) => e.triggers.some((t) => ctx.includes(t)));
|
|
611
|
+
if (triggerMatched.length === 1) {
|
|
612
|
+
const e = triggerMatched[0];
|
|
613
|
+
see(e.label, word, 1, e.valence);
|
|
614
|
+
} else if (triggerMatched.length > 1) {
|
|
615
|
+
// 多个触发同时命中:双计分 + 歧义
|
|
616
|
+
for (const e of triggerMatched) see(e.label, word, 1, e.valence * 0.5);
|
|
617
|
+
ambHits += 1;
|
|
618
|
+
ambiguous.push({ word, reason: "多触发冲突", ctx: ctx.trim().slice(0, 40) });
|
|
619
|
+
} else if (semResolver) {
|
|
620
|
+
// 无触发 → 语义层裁决(异步由调用方包装)
|
|
621
|
+
const r = semResolver(ctx, entries);
|
|
622
|
+
if (r && r.resolved) see(r.label, word, 1, r.valence);
|
|
623
|
+
else {
|
|
624
|
+
// 语义不确定 → 双计分 + 歧义标记
|
|
625
|
+
for (const e of entries) see(e.label, word, 1, e.valence * 0.5);
|
|
626
|
+
ambHits += 1;
|
|
627
|
+
ambiguous.push({ word, reason: "语义不确定", ctx: ctx.trim().slice(0, 40) });
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
// 无语义层可用 → 按词表首条目弱计分 + 歧义
|
|
631
|
+
for (const e of entries.slice(0, 2)) see(e.label, word, 1, e.valence * 0.5);
|
|
632
|
+
ambHits += 1;
|
|
633
|
+
ambiguous.push({ word, reason: "未裁决", ctx: ctx.trim().slice(0, 40) });
|
|
488
634
|
}
|
|
635
|
+
idx += word.length;
|
|
489
636
|
}
|
|
490
637
|
}
|
|
491
638
|
const total = negHits + posHits;
|
|
@@ -493,8 +640,10 @@ export function implicitEmotionScan(text) {
|
|
|
493
640
|
return {
|
|
494
641
|
negative: total === 0 ? 0 : Math.round((negHits / total) * 100) / 100,
|
|
495
642
|
positive: total === 0 ? 0 : Math.round((posHits / total) * 100) / 100,
|
|
643
|
+
ambiguousRatio: total === 0 ? 0 : Math.round((ambHits / (total + ambHits)) * 100) / 100,
|
|
496
644
|
fragile: fragileHits > 0,
|
|
497
645
|
totalHits: negHits + posHits,
|
|
646
|
+
ambiguous,
|
|
498
647
|
topCarriers: top
|
|
499
648
|
};
|
|
500
649
|
}
|
|
@@ -675,9 +824,9 @@ export function emotionComplexity(perChapter) {
|
|
|
675
824
|
/**
|
|
676
825
|
* v1.6.0 情感量化入口:三指标 + 显隐对比 + 复杂度 + 复合共现(纯规则 0 token)。
|
|
677
826
|
*/
|
|
678
|
-
export function emotionalQuantification(text, perChapter, blocks) {
|
|
827
|
+
export function emotionalQuantification(text, perChapter, blocks, semResolver = null) {
|
|
679
828
|
const stats = valenceStats(text);
|
|
680
|
-
const implicit = implicitEmotionScan(text);
|
|
829
|
+
const implicit = implicitEmotionScan(text, semResolver);
|
|
681
830
|
const meanValence = stats.meanValence ?? 0;
|
|
682
831
|
// perChapter 为空(单章/无分章输入)时,用当前文本自身 clean 计数聚合,保证 complexity 恒有值
|
|
683
832
|
if (!Array.isArray(perChapter) || perChapter.length === 0) {
|
|
@@ -942,7 +1091,7 @@ export function analyzeText(text, options = {}) {
|
|
|
942
1091
|
.slice(0, 12),
|
|
943
1092
|
curve: emotionCurve(blockMeta, curveSegments),
|
|
944
1093
|
// v1.6.0:情感量化(Valence 三指标 + 显隐对比 + 复杂度 + 复合共现)
|
|
945
|
-
quantification: emotionalQuantification(text, [], blockMeta.map((m) => m.sentences).filter((s) => s.length > 0))
|
|
1094
|
+
quantification: emotionalQuantification(text, [], blockMeta.map((m) => m.sentences).filter((s) => s.length > 0), options.semResolver || null)
|
|
946
1095
|
};
|
|
947
1096
|
|
|
948
1097
|
// 主观性指数(启发式 0-100)
|
|
@@ -1150,3 +1299,34 @@ export function densityOf(text) {
|
|
|
1150
1299
|
sense: { ...sense }
|
|
1151
1300
|
};
|
|
1152
1301
|
}
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
// v2.5 修复:接入 lib/lexicons/dutir_seven.json(大连理工七类情感词表 27,413 词)
|
|
1305
|
+
// 懒加载 + 内存缓存;七类 → 五情感映射,供 sentimentCounts 扩展计数(词表未覆盖的词也能计分)
|
|
1306
|
+
import { createRequire } from "node:module";
|
|
1307
|
+
const __req = createRequire(import.meta.url);
|
|
1308
|
+
let DUTIR = null;
|
|
1309
|
+
function loadDutir() {
|
|
1310
|
+
if (DUTIR) return DUTIR;
|
|
1311
|
+
try {
|
|
1312
|
+
const p = __req.resolve("./lexicons/dutir_seven.json");
|
|
1313
|
+
DUTIR = JSON.parse(__req("node:fs").readFileSync(p, "utf8"));
|
|
1314
|
+
} catch {
|
|
1315
|
+
DUTIR = {};
|
|
1316
|
+
}
|
|
1317
|
+
return DUTIR;
|
|
1318
|
+
}
|
|
1319
|
+
const DUTIR_TO_EMOTION = { 乐: "joy", 好: "joy", 怒: "anger", 哀: "sorrow", 惧: "fear", 恶: "sorrow", 惊: "surprise" };
|
|
1320
|
+
const dutirLookup = new Map();
|
|
1321
|
+
function dutirEmotion(word) {
|
|
1322
|
+
if (dutirLookup.size === 0) {
|
|
1323
|
+
const dutir = loadDutir();
|
|
1324
|
+
for (const [cat, words] of Object.entries(dutir)) {
|
|
1325
|
+
const emo = DUTIR_TO_EMOTION[cat];
|
|
1326
|
+
if (!emo) continue;
|
|
1327
|
+
for (const w of words) if (typeof w === "string" && w.length >= 2) dutirLookup.set(w, emo);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return dutirLookup.get(word);
|
|
1331
|
+
}
|
|
1332
|
+
export function dutirEmotionOf(word) { return dutirEmotion(word); }
|