dsh-novel-writer 0.5.0 → 0.7.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/INSTALL.md +21 -24
- package/README.md +38 -52
- package/lib/analysis.js +115 -17
- package/lib/client.js +198 -31
- package/lib/index.js +462 -32
- package/package.json +2 -2
- package/skills/novel-writing/SKILL.md +23 -25
- package/test/e2e-test.mjs +105 -0
- package/test/pattern-test.mjs +24 -28
package/INSTALL.md
CHANGED
|
@@ -1,39 +1,36 @@
|
|
|
1
|
-
# dsh-novel-writer v0.
|
|
1
|
+
# dsh-novel-writer v0.7.0 — 安装指南
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
本包 UI:侧边栏「句式分析」大开关面板(统一开关位置);设置 > 插件配置 卡片仅显示状态 + 跳转按钮。
|
|
3
|
+
基于 v0.6.0 工程化版的 UI 升级:10 个工具全部带独立 UI 开关(侧边栏「写作助手功能」面板统一管理)。
|
|
5
4
|
|
|
6
5
|
```
|
|
7
|
-
dsh-novel-writer-v0.
|
|
6
|
+
dsh-novel-writer-v0.6.0/
|
|
8
7
|
├── lib/
|
|
9
|
-
│ ├── index.js # 宿主端(
|
|
10
|
-
│ ├── analysis.js #
|
|
11
|
-
│ └── client.js # 浏览器端(侧边栏面板 +
|
|
12
|
-
├── test/ #
|
|
13
|
-
├── package.json
|
|
14
|
-
├── cordis.patch.yml
|
|
15
|
-
├── README.md
|
|
16
|
-
├── INSTALL.md
|
|
8
|
+
│ ├── index.js # 宿主端(10 个工具全开关 + state 路由 + 缓存)
|
|
9
|
+
│ ├── analysis.js # 句式模式分析引擎(含 fingerprintSimilarity / styleDiffs)
|
|
10
|
+
│ └── client.js # 浏览器端(侧边栏面板 + 设置页状态卡片)
|
|
11
|
+
├── test/ # 引擎 + client + e2e 测试
|
|
12
|
+
├── package.json / cordis.patch.yml / README.md / INSTALL.md
|
|
17
13
|
└── skills/novel-writing/SKILL.md
|
|
18
14
|
```
|
|
19
15
|
|
|
20
16
|
## 安装
|
|
21
17
|
|
|
22
|
-
1.
|
|
18
|
+
1. 放入 profile 的 node_modules(或 `dsh plugin --profile web add <路径>`);
|
|
23
19
|
2. profile `package.json` 的 `dsh.profile.bundles` 加入 `dsh-novel-writer`;
|
|
24
|
-
3.
|
|
25
|
-
4.
|
|
20
|
+
3. (可选)复制 `skills/novel-writing` 到工作区 `.dsh/skills/`;
|
|
21
|
+
4. 重启 web 应用。
|
|
26
22
|
|
|
27
23
|
## 验证
|
|
28
24
|
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
25
|
+
- 工具列表出现 10 个 novel_* 工具(含 novel_style_check / novel_plot);
|
|
26
|
+
- `novel_sentence_analysis` 返回含 `reportFile` / `cache` 字段;
|
|
27
|
+
- 缓存目录 `~/.dsh/dsh-novel-writer/cache/` 出现报告 JSON;
|
|
28
|
+
- 伏笔表文件 `<root>/.novel-writer/<书名>.json`。
|
|
33
29
|
|
|
34
|
-
##
|
|
30
|
+
## 数据位置
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
32
|
+
| 数据 | 路径 |
|
|
33
|
+
|---|---|
|
|
34
|
+
| 开关状态 | `~/.dsh/dsh-novel-writer/state.json` |
|
|
35
|
+
| 分析报告缓存 | `~/.dsh/dsh-novel-writer/cache/<书>-<hash>.json` |
|
|
36
|
+
| 伏笔登记表 | `<书库根>/.novel-writer/<书>.json` |
|
package/README.md
CHANGED
|
@@ -1,56 +1,53 @@
|
|
|
1
|
-
# dsh-novel-writer — 小说写作助手插件(v0.
|
|
1
|
+
# dsh-novel-writer — 小说写作助手插件(v0.7.0:全工具 UI 开关)
|
|
2
2
|
|
|
3
3
|
DSH(DeepSeek Harness)小说写作助手 bundle 插件。宿主端零第三方依赖(仅 Node 内置模块),
|
|
4
4
|
浏览器端仅依赖 Web GUI 自带的 react。
|
|
5
|
-
**v0.
|
|
5
|
+
**v0.7.0 在 v0.6.0 基础上为全部 10 个工具增加独立 UI 开关(侧边栏「写作助手功能」面板统一管理)**:分析缓存与报告导出、风格自检、伏笔登记表、
|
|
6
|
+
关键词三字组/疑似人名、情感词典去噪、祈使句与环境分类改进、局域网开关可选放行。
|
|
6
7
|
|
|
7
|
-
##
|
|
8
|
-
|
|
9
|
-
1. **章节库**:章节存于 `novels/<书名>/第N章.md`(或 `.txt`/`.markdown`),
|
|
10
|
-
`novel_books` / `novel_chapters` / `novel_read` 浏览与读取。
|
|
11
|
-
章号任意位置 + 中文数字识别;UTF-8 / UTF-16 LE/BE / GBK 编码自动探测。
|
|
12
|
-
2. **分析**:`novel_keywords` 提取全书/单章高频关键词(中文二字词组 + 英文词)。
|
|
13
|
-
3. **句式模式分析**:`novel_sentence_analysis` 对全书/单章输出——
|
|
14
|
-
- 九类句式:陈述 / 环境 / 心理 / 对话 / 疑问 / 反问 / 感叹 / 祈使 / 省略留白(比例、句数、均长、例句);
|
|
15
|
-
- 排列规律:句式转移、2/3 连句高频模板、段首/段尾句式、**按章节的压缩节奏序列**(S×8 ENV×2 PSY×3…);
|
|
16
|
-
- 句长节奏:均值/中位数、短句/长句占比;
|
|
17
|
-
- 主观情感:情感词典(喜/怒/哀/惧/惊)+ 强度副词加权 → 主导情绪、情绪分布、情感曲线、主观性指数;
|
|
18
|
-
- 风格指纹 + 给模型的**节奏建议**(guidance)。
|
|
19
|
-
⚠️ 风险约束:句式模式是"参考节奏"而非"模板套用",模型在机械复刻导致僵硬时会优先回归自然表达。
|
|
20
|
-
4. **原稿件批量导入**:`novel_import` 扫描存放多本小说的文件夹(支持子文件夹、多编码、任意位置章号),
|
|
21
|
-
从文件名与文件头双通道提取书名候选并分组(scan 预览),AI 确认后一键复制/移动到 `novels/<书名>/`(apply);
|
|
22
|
-
支持 `book` 强制归并异名同书、`files` 精确指定、`move` 移动模式。
|
|
23
|
-
5. **续写**:先读后写、保持文风与伏笔一致,`novel_new_chapter` 创建新章节文件。
|
|
24
|
-
|
|
25
|
-
## 提供的工具
|
|
8
|
+
## 工具清单(10 个,全部带独立 UI 开关)
|
|
26
9
|
|
|
27
10
|
| 工具 | 说明 |
|
|
28
11
|
|---|---|
|
|
29
12
|
| `novel_books` | 列出章节库全部作品 |
|
|
30
13
|
| `novel_chapters` | 列出某作品章节清单 |
|
|
31
|
-
| `novel_read` | 阅读某章正文(offset/limit
|
|
32
|
-
| `novel_keywords` |
|
|
14
|
+
| `novel_read` | 阅读某章正文(offset/limit 分段,编码自动探测) |
|
|
15
|
+
| `novel_keywords` | 关键词:二字组 + **三字组** + **疑似人名** + 英文词 |
|
|
33
16
|
| `novel_new_chapter` | 创建新章节文件 |
|
|
34
|
-
| `novel_import` |
|
|
35
|
-
| `novel_sentence_analysis` |
|
|
17
|
+
| `novel_import` | 原稿件批量导入/自动分类(含**异名同书提示**) |
|
|
18
|
+
| `novel_sentence_analysis` | 句式模式分析(九类/情感曲线/章节节奏/指纹,**带缓存与报告文件**) |
|
|
36
19
|
| `novel_sentence_config` | 查看/修改句式分析开关 |
|
|
20
|
+
| `novel_style_check` | **风格自检(v0.6.0 新增)**:章节 vs 全书指纹对比 + 偏差清单 |
|
|
21
|
+
| `novel_plot` | **伏笔/剧情线登记表(v0.6.0 新增)**:list/add/update/done/delete |
|
|
22
|
+
|
|
23
|
+
## v0.6.0 新增能力
|
|
37
24
|
|
|
38
|
-
|
|
25
|
+
1. **分析缓存 + 报告导出**:novel_sentence_analysis 结果自动缓存到 `~/.dsh/dsh-novel-writer/cache/<书名>-<hash>.json`,
|
|
26
|
+
返回值带 `reportFile` / `cache`(hit/miss)字段;章节未改动时重复分析秒回,传 `fresh=true` 强制重算。
|
|
27
|
+
2. **风格自检 novel_style_check**:任意章节 vs 全书其余章节 → 余弦相似度 + 句式/句长/情绪偏差清单 + 续写建议(写完新章自动查一次,防止文风漂移)。
|
|
28
|
+
3. **伏笔登记表 novel_plot**:`novels 根目录/.novel-writer/<书名>.json` 持久化;续写前 list 查看未回收伏笔。
|
|
29
|
+
4. **关键词升级**:新增三字组与疑似人名("XX说/道/问"与"XX小姐/导师"模式),人名不再被拆成"露西+西亚"。
|
|
30
|
+
5. **情感词典去噪**:单字词搭配排除("叹气"不算"气"、"笑道"不算"笑")、否定过滤("不害怕"不计)、副词距离窗口化("非常开心"才加权)。
|
|
31
|
+
6. **分类改进**:祈使句支持"快睡吧,明天还要上班"式(命令动词开头+吧);环境词表扩充(晚风/晨曦/森林/群山…)。
|
|
32
|
+
7. **情感曲线粒度可调**:`curveSegments` 参数(1-50,默认 20)。
|
|
33
|
+
8. **novel_import 异名同书提示**:scan 结果中组名包含/高重合时列出 `maybe` 字段,提示用 `book` 合并。
|
|
34
|
+
9. **局域网开关可选放行**:config `allowLanState: true` 时,state 路由对同源局域网请求放行(默认仍仅 loopback)。
|
|
39
35
|
|
|
40
|
-
|
|
41
|
-
- **设置 > 插件配置** 的 novel-writer 卡片仅**显示状态**(已启用/已关闭、自动分析开/关),并提供「打开开关面板」按钮跳转;
|
|
42
|
-
- 也可让 AI 用 `novel_sentence_config` 查看/修改。
|
|
43
|
-
- 状态写入 `~/.dsh/dsh-novel-writer/state.json`;同时**只读兼容** v0.4.0 的 `~/.dsh/novel-writer.json`(stylePattern)与 config.stylePattern。
|
|
44
|
-
- 开关默认**开启**;宿主端路由 `/api/dsh-novel-writer/state`(loopback 围栏 + 可选注入,headless 自动跳过)。
|
|
36
|
+
## UI 开关(v0.7.0:10 个工具全开关)
|
|
45
37
|
|
|
46
|
-
|
|
38
|
+
- **唯一开关位置:侧边栏「写作助手功能」面板**——总开关(enabled,控制句式分析/风格自检)+ 自动分析(autoAnalyze)+ **10 个工具各自独立开关**(全部默认开启);
|
|
39
|
+
- 关闭的工具,AI 调用时会收到明确提示;novel_sentence_config 可查/改全部开关(含 tools 字段);
|
|
40
|
+
- 设置 > 插件配置 卡片仅显示状态 + 「打开开关面板」跳转;
|
|
41
|
+
- 持久化 `~/.dsh/dsh-novel-writer/state.json`;兼容 v0.4.0 的 novel-writer.json / config.stylePattern;
|
|
42
|
+
- 默认开启;路由 `/api/dsh-novel-writer/state`(loopback 围栏,可选局域网放行)。
|
|
43
|
+
|
|
44
|
+
## 安装
|
|
47
45
|
|
|
48
46
|
```sh
|
|
49
47
|
dsh plugin --profile web add D:/tools/dsh-novel-writer
|
|
50
48
|
```
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
`dsh.profile.bundles` 中加入 `dsh-novel-writer`。安装后**重启 web 应用**生效。
|
|
50
|
+
或手动:放入 profile 的 node_modules + `dsh.profile.bundles` 加 `dsh-novel-writer`;重启 web 应用。
|
|
54
51
|
|
|
55
52
|
## 配置
|
|
56
53
|
|
|
@@ -61,23 +58,12 @@ dsh plugin --profile web add D:/tools/dsh-novel-writer
|
|
|
61
58
|
sentenceAnalysis:
|
|
62
59
|
enabled: true
|
|
63
60
|
autoAnalyze: true
|
|
64
|
-
|
|
65
|
-
```
|
|
61
|
+
allowLanState: false # true=局域网访问 GUI 时也允许保存开关
|
|
66
62
|
|
|
67
|
-
##
|
|
63
|
+
## v0.7.0 更新内容
|
|
68
64
|
|
|
69
|
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
### v0.4.0(他人改版,已并入)
|
|
76
|
-
- novel_import 批量导入自动分类;novel_style_pattern 轻量句式分析(环境类/压缩序列/guidance);
|
|
77
|
-
- 官方设置页插件配置卡片;ctx.inject 可选注入;开关默认关闭 + 风险提示。
|
|
78
|
-
|
|
79
|
-
### v0.3.0(本系列)
|
|
80
|
-
- 句式模式分析深度版(情感曲线/句长/段落/指纹);侧边栏开关面板;state 路由。
|
|
81
|
-
|
|
82
|
-
### v0.2.0 / v0.1.0
|
|
83
|
-
- 章节库、关键词、新建章节;编码探测。
|
|
65
|
+
- **10 个工具全部带独立 UI 开关**(侧边栏「写作助手功能」面板):novel_books / novel_chapters / novel_read / novel_keywords / novel_new_chapter / novel_import / novel_sentence_analysis / novel_sentence_config / novel_style_check / novel_plot,默认全开;
|
|
66
|
+
- UI 更名:侧边栏入口/面板/横幅由「句式分析」改为「写作助手功能」;设置页卡片同步;
|
|
67
|
+
- novel_sentence_config 升级:支持 tools 参数(如 `{ novel_plot: false }`)与返回各工具开关状态;
|
|
68
|
+
- 关闭的工具调用时返回明确提示(模型可见)。
|
|
69
|
+
```
|
package/lib/analysis.js
CHANGED
|
@@ -22,7 +22,8 @@ export const CATEGORY_LABELS = Object.freeze({
|
|
|
22
22
|
exclamation: "感叹",
|
|
23
23
|
imperative: "祈使",
|
|
24
24
|
ellipsis: "省略留白"
|
|
25
|
-
}
|
|
25
|
+
}
|
|
26
|
+
);
|
|
26
27
|
|
|
27
28
|
/** 输出固定顺序。 */
|
|
28
29
|
export const CATEGORY_ORDER = Object.freeze([
|
|
@@ -85,8 +86,8 @@ const QUESTION_WORDS = ["谁", "什么", "为什么", "怎么", "怎样", "如
|
|
|
85
86
|
const IMPERATIVE_HARD = ["请", "快", "赶紧", "立刻", "马上", "别", "不要", "莫", "勿", "不许", "不准", "滚", "住手", "放手", "站住", "听我说", "给我", "让", "快点儿", "快点"];
|
|
86
87
|
const IMPERATIVE_START = ["走", "来", "去", "坐", "站", "停", "开始", "出发", "算了吧", "算了", "吃", "喝", "看", "听", "说", "做", "干", "起来", "坐下", "住口"];
|
|
87
88
|
|
|
88
|
-
/** 环境描写标记词(天色/天气/景物类名词,来自 v0.4.0
|
|
89
|
-
const ENV_MARKERS =
|
|
89
|
+
/** 环境描写标记词(天色/天气/景物类名词,来自 v0.4.0,v0.6.0 扩充)。 */
|
|
90
|
+
const ENV_MARKERS = /夜色|月光|晨光|暮色|夕阳|黄昏|清晨|深夜|夜空|天边|星光|灯火|细雨|微风|大雨|暴雨|风雪|薄雾|浓雾|云层|远山|山影|河面|湖水|街道|巷子|烛火|窗|雨声|风声|树影|落叶|残雪|霜|天色|晚风|夜风|晨曦|晚霞|晴空|雨滴|溪水|江水|海浪|沙漠|草原|森林|群山|雪地|庭院|檐下|廊下/;
|
|
90
91
|
|
|
91
92
|
/** 句尾结束符。 */
|
|
92
93
|
const TERMINATORS = "。!?…";
|
|
@@ -98,7 +99,8 @@ const QUOTE_PAIRS = [["“", "”"], ["「", "」"], ["『", "』"], ["‘", "
|
|
|
98
99
|
export function splitBlocks(text) {
|
|
99
100
|
const normalized = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
100
101
|
const lines = normalized.split("\n");
|
|
101
|
-
|
|
102
|
+
// 段落分隔符 = 前后都有内容的"中间空行";仅开头/结尾的空行不算分段。
|
|
103
|
+
const hasBlank = lines.some((line, index) => line.trim() === "" && index > 0 && index < lines.length - 1);
|
|
102
104
|
const blocks = [];
|
|
103
105
|
let current = [];
|
|
104
106
|
for (const line of lines) {
|
|
@@ -206,11 +208,11 @@ function isExclamation(text) {
|
|
|
206
208
|
}
|
|
207
209
|
|
|
208
210
|
function isImperative(text) {
|
|
209
|
-
if (!/
|
|
211
|
+
if (!/吧/.test(text) && !/[!!]$/.test(text)) return false;
|
|
210
212
|
for (const verb of IMPERATIVE_HARD) {
|
|
211
213
|
if (text.includes(verb)) return true;
|
|
212
214
|
}
|
|
213
|
-
return new RegExp("^(你|您|我们|咱们|大家)?(" + IMPERATIVE_START.join("|") + ")").test(text);
|
|
215
|
+
return new RegExp("^(你|您|我们|咱们|大家)?(" + IMPERATIVE_START.join("|") + ")").test(text) && /吧/.test(text);
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
/**
|
|
@@ -236,22 +238,63 @@ export function classifySentence(raw) {
|
|
|
236
238
|
return { type: "statement", text, quoted: false };
|
|
237
239
|
}
|
|
238
240
|
|
|
239
|
-
/**
|
|
241
|
+
/** 单字情感词的多字搭配排除("叹气"不算"气"、"笑道"不算"笑")。 */
|
|
242
|
+
const EMOTION_EXCLUDE = {
|
|
243
|
+
joy: { "笑": ["笑道", "说笑", "玩笑", "搞笑", "笑话", "苦笑", "冷笑", "嘲笑", "嬉笑", "傻笑", "微笑", "笑死", "笑呵呵", "笑眯眯"] },
|
|
244
|
+
anger: { "气": ["叹气", "运气", "客气", "一口气", "服气", "脾气", "争气", "节气", "淘气", "景气", "暖气", "口气", "名气", "勇气", "底气", "俗气", "风气", "香气", "水气", "湿气", "潮气", "和气", "生气勃勃"] },
|
|
245
|
+
sorrow: { "悲": ["慈悲"], "哭": ["哭丧"] },
|
|
246
|
+
fear: { "怕": ["哪怕", "只怕", "生怕"] },
|
|
247
|
+
surprise: { "愣": ["愣住"] }
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** 情感词前紧邻的否定字("不害怕"不计入恐惧)。 */
|
|
251
|
+
const EMOTION_NEGATORS = new Set(["不", "没", "无", "别", "莫", "未"]);
|
|
252
|
+
|
|
253
|
+
/** 情感词计数(v0.6.0:扫描式匹配 + 搭配排除 + 否定过滤 + 副词窗口加权)。 */
|
|
240
254
|
function emotionOf(text) {
|
|
241
255
|
const scores = { joy: 0, anger: 0, sorrow: 0, fear: 0, surprise: 0 };
|
|
242
256
|
const words = { joy: [], anger: [], sorrow: [], fear: [], surprise: [] };
|
|
243
|
-
|
|
244
|
-
for (const adverb of STRONG_ADVERBS) {
|
|
245
|
-
if (text.includes(adverb)) { weight = Math.max(weight, 1.5); break; }
|
|
246
|
-
}
|
|
247
|
-
for (const adverb of MILD_ADVERBS) {
|
|
248
|
-
if (text.includes(adverb)) { weight = Math.min(weight, 0.6); break; }
|
|
249
|
-
}
|
|
257
|
+
const hitWords = new Set();
|
|
250
258
|
for (const [emotion, list] of Object.entries(EMOTION_WORDS)) {
|
|
251
259
|
for (const word of list) {
|
|
252
|
-
|
|
260
|
+
let from = 0;
|
|
261
|
+
while (from < text.length) {
|
|
262
|
+
const idx = text.indexOf(word, from);
|
|
263
|
+
if (idx === -1) break;
|
|
264
|
+
// 单字词的搭配排除(如"叹气"中的"气")
|
|
265
|
+
const exclude = EMOTION_EXCLUDE[emotion]?.[word];
|
|
266
|
+
if (exclude !== void 0) {
|
|
267
|
+
const two = text.slice(Math.max(0, idx - 1), idx + 2);
|
|
268
|
+
const prevTwo = text.slice(Math.max(0, idx - 1), idx + 1);
|
|
269
|
+
const nextTwo = text.slice(idx, idx + 2);
|
|
270
|
+
const prevFour = text.slice(Math.max(0, idx - 2), idx + 2);
|
|
271
|
+
let excluded = false;
|
|
272
|
+
for (const bad of exclude) {
|
|
273
|
+
if (two.includes(bad) || prevTwo === bad || nextTwo === bad || prevFour.includes(bad)) {
|
|
274
|
+
excluded = true;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (excluded) { from = idx + word.length; continue; }
|
|
279
|
+
}
|
|
280
|
+
// 否定过滤:"不害怕"不计入
|
|
281
|
+
const prevChar = idx > 0 ? text[idx - 1] : "";
|
|
282
|
+
if (EMOTION_NEGATORS.has(prevChar)) { from = idx + word.length; continue; }
|
|
283
|
+
// 副词窗口加权:词前 6 字符内出现的强/弱副词生效一次
|
|
284
|
+
const window = text.slice(Math.max(0, idx - 6), idx);
|
|
285
|
+
let weight = 1;
|
|
286
|
+
for (const adverb of STRONG_ADVERBS) {
|
|
287
|
+
if (window.includes(adverb)) { weight = Math.max(weight, 1.5); break; }
|
|
288
|
+
}
|
|
289
|
+
for (const adverb of MILD_ADVERBS) {
|
|
290
|
+
if (window.includes(adverb)) { weight = Math.min(weight, 0.6); break; }
|
|
291
|
+
}
|
|
253
292
|
scores[emotion] += weight;
|
|
254
|
-
|
|
293
|
+
if (!hitWords.has(word)) {
|
|
294
|
+
hitWords.add(word);
|
|
295
|
+
words[emotion].push(word);
|
|
296
|
+
}
|
|
297
|
+
from = idx + word.length;
|
|
255
298
|
}
|
|
256
299
|
}
|
|
257
300
|
}
|
|
@@ -321,6 +364,7 @@ export function buildGuidance(ratios, topPatterns, chapterSequences) {
|
|
|
321
364
|
export function analyzeText(text, options = {}) {
|
|
322
365
|
const top = Number.isInteger(options.top) ? options.top : 8;
|
|
323
366
|
const maxSentences = Number.isInteger(options.maxSentences) ? options.maxSentences : 20000;
|
|
367
|
+
const curveSegments = Number.isInteger(options.curveSegments) ? Math.min(Math.max(options.curveSegments, 1), 50) : 20;
|
|
324
368
|
const blocks = splitBlocks(text);
|
|
325
369
|
const sentences = [];
|
|
326
370
|
const blockMeta = [];
|
|
@@ -509,7 +553,7 @@ export function analyzeText(text, options = {}) {
|
|
|
509
553
|
})
|
|
510
554
|
.sort((a, b) => b.count - a.count || a.word.localeCompare(b.word))
|
|
511
555
|
.slice(0, 12),
|
|
512
|
-
curve: emotionCurve(blockMeta,
|
|
556
|
+
curve: emotionCurve(blockMeta, curveSegments)
|
|
513
557
|
};
|
|
514
558
|
|
|
515
559
|
// 主观性指数(启发式 0-100)
|
|
@@ -616,3 +660,57 @@ function buildFingerprint(categories, lengths, style, emotion, motifs) {
|
|
|
616
660
|
const motif = motifs.length > 0 ? motifs[0].pattern : "-";
|
|
617
661
|
return parts.join(" ") + " | len:" + lengths.avg + " subj:" + style.subjectivityIndex + " emo:" + emotion.dominant + " | motif:" + motif;
|
|
618
662
|
}
|
|
663
|
+
|
|
664
|
+
/** 两篇文本的风格指纹相似度(0~1):句式分布 + 句长 + 主观性的余弦相似度。 */
|
|
665
|
+
export function fingerprintSimilarity(a, b) {
|
|
666
|
+
const vectorOf = (r) => {
|
|
667
|
+
const v = [];
|
|
668
|
+
const catMap = new Map((r.categories ?? []).map((x) => [x.type, x.ratio ?? 0]));
|
|
669
|
+
for (const type of CATEGORY_ORDER) v.push(catMap.get(type) ?? 0);
|
|
670
|
+
const len = r.lengths?.avg ?? 0;
|
|
671
|
+
v.push(Math.min(len, 100) / 100);
|
|
672
|
+
v.push(r.lengths?.shortRatio ?? 0);
|
|
673
|
+
v.push(r.lengths?.longRatio ?? 0);
|
|
674
|
+
v.push((r.style?.subjectivityIndex ?? 0) / 100);
|
|
675
|
+
return v;
|
|
676
|
+
};
|
|
677
|
+
const va = vectorOf(a);
|
|
678
|
+
const vb = vectorOf(b);
|
|
679
|
+
let dot = 0;
|
|
680
|
+
let na = 0;
|
|
681
|
+
let nb = 0;
|
|
682
|
+
for (let i = 0; i < va.length; i += 1) {
|
|
683
|
+
dot += va[i] * vb[i];
|
|
684
|
+
na += va[i] * va[i];
|
|
685
|
+
nb += vb[i] * vb[i];
|
|
686
|
+
}
|
|
687
|
+
if (na === 0 || nb === 0) return 0;
|
|
688
|
+
return Math.round((dot / (Math.sqrt(na) * Math.sqrt(nb))) * 1000) / 1000;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** 风格偏差清单:b 相对 a 的显著差异(阈值可调)。 */
|
|
692
|
+
export function styleDiffs(a, b, threshold = 0.06) {
|
|
693
|
+
const diffs = [];
|
|
694
|
+
const catA = new Map((a.categories ?? []).map((x) => [x.type, x.ratio]));
|
|
695
|
+
const catB = new Map((b.categories ?? []).map((x) => [x.type, x.ratio]));
|
|
696
|
+
for (const type of CATEGORY_ORDER) {
|
|
697
|
+
const d = (catB.get(type) ?? 0) - (catA.get(type) ?? 0);
|
|
698
|
+
if (Math.abs(d) >= threshold) {
|
|
699
|
+
diffs.push({
|
|
700
|
+
dimension: CATEGORY_LABELS[type] + "占比",
|
|
701
|
+
diff: Math.round(d * 1000) / 10,
|
|
702
|
+
note: d > 0 ? "偏高" : "偏低"
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (b.lengths && a.lengths) {
|
|
707
|
+
const lenDiff = b.lengths.avg - a.lengths.avg;
|
|
708
|
+
if (Math.abs(lenDiff) >= 5) diffs.push({ dimension: "平均句长", diff: Math.round(lenDiff * 10) / 10, note: lenDiff > 0 ? "偏长" : "偏短" });
|
|
709
|
+
const shortDiff = (b.lengths.shortRatio ?? 0) - (a.lengths.shortRatio ?? 0);
|
|
710
|
+
if (Math.abs(shortDiff) >= 0.08) diffs.push({ dimension: "短句占比", diff: Math.round(shortDiff * 1000) / 10, note: shortDiff > 0 ? "偏高" : "偏低" });
|
|
711
|
+
}
|
|
712
|
+
if (b.emotion && a.emotion && b.emotion.dominant !== a.emotion.dominant) {
|
|
713
|
+
diffs.push({ dimension: "主导情绪", diff: 0, note: "由「" + (a.emotion.dominant ?? "?") + "」变为「" + (b.emotion.dominant ?? "?") + "」" });
|
|
714
|
+
}
|
|
715
|
+
return diffs;
|
|
716
|
+
}
|