dsh-novel-writer 0.5.0 → 0.6.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 +31 -53
- package/lib/analysis.js +115 -17
- package/lib/index.js +332 -15
- package/package.json +2 -2
- package/skills/novel-writing/SKILL.md +23 -25
- package/test/e2e-test.mjs +78 -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.6.0 — 安装指南
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
本包 UI:侧边栏「句式分析」大开关面板(统一开关位置);设置 > 插件配置 卡片仅显示状态 + 跳转按钮。
|
|
3
|
+
基于 v0.5.0 合并版的工程化升级。新增:分析缓存/报告导出、风格自检、伏笔登记表、关键词三字组与疑似人名、情感词典去噪、局域网开关可选放行。
|
|
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,52 @@
|
|
|
1
|
-
# dsh-novel-writer — 小说写作助手插件(v0.
|
|
1
|
+
# dsh-novel-writer — 小说写作助手插件(v0.6.0 工程化版)
|
|
2
2
|
|
|
3
3
|
DSH(DeepSeek Harness)小说写作助手 bundle 插件。宿主端零第三方依赖(仅 Node 内置模块),
|
|
4
4
|
浏览器端仅依赖 Web GUI 自带的 react。
|
|
5
|
-
**v0.
|
|
5
|
+
**v0.6.0 在 v0.5.0(合并版)基础上完成工程化升级**:分析缓存与报告导出、风格自检、伏笔登记表、
|
|
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 个)
|
|
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.5.0 相同,统一在一处)
|
|
45
37
|
|
|
46
|
-
|
|
38
|
+
- 唯一开关位置:侧边栏「句式分析」面板(总开关 enabled + 子开关 autoAnalyze);
|
|
39
|
+
- 设置 > 插件配置 卡片仅显示状态 + 「打开开关面板」跳转;
|
|
40
|
+
- 持久化 `~/.dsh/dsh-novel-writer/state.json`;兼容 v0.4.0 的 novel-writer.json / config.stylePattern;
|
|
41
|
+
- 默认开启;路由 `/api/dsh-novel-writer/state`(loopback 围栏,可选局域网放行)。
|
|
42
|
+
|
|
43
|
+
## 安装
|
|
47
44
|
|
|
48
45
|
```sh
|
|
49
46
|
dsh plugin --profile web add D:/tools/dsh-novel-writer
|
|
50
47
|
```
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
`dsh.profile.bundles` 中加入 `dsh-novel-writer`。安装后**重启 web 应用**生效。
|
|
49
|
+
或手动:放入 profile 的 node_modules + `dsh.profile.bundles` 加 `dsh-novel-writer`;重启 web 应用。
|
|
54
50
|
|
|
55
51
|
## 配置
|
|
56
52
|
|
|
@@ -61,23 +57,5 @@ dsh plugin --profile web add D:/tools/dsh-novel-writer
|
|
|
61
57
|
sentenceAnalysis:
|
|
62
58
|
enabled: true
|
|
63
59
|
autoAnalyze: true
|
|
64
|
-
|
|
60
|
+
allowLanState: false # true=局域网访问 GUI 时也允许保存开关
|
|
65
61
|
```
|
|
66
|
-
|
|
67
|
-
## 版本记录
|
|
68
|
-
|
|
69
|
-
### v0.5.0(合并版)
|
|
70
|
-
- 合并 v0.3.0 与 v0.4.0:九类句式(新增"环境")、章节压缩节奏序列、guidance 节奏建议、采样上限;
|
|
71
|
-
- 新增 novel_import 批量导入(v0.4.0);开关同时出现在侧边栏面板与官方设置页;
|
|
72
|
-
- 路由改可选注入 ctx.inject(["webServer"]),headless 兼容;保留 loopback 围栏;
|
|
73
|
-
- 兼容读取 v0.4.0 的 novel-writer.json / config.stylePattern;提示词内置"僵硬时优先自然表达"约束。
|
|
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
|
-
- 章节库、关键词、新建章节;编码探测。
|
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
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -25,7 +25,8 @@ import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs
|
|
|
25
25
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
26
|
import { basename, dirname, extname, join, sep } from "node:path";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
|
-
import {
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import { analyzeText, CATEGORY_LABELS, CATEGORY_ORDER, fingerprintSimilarity, styleDiffs } from "./analysis.js";
|
|
29
30
|
|
|
30
31
|
const name = "novel-writer";
|
|
31
32
|
const inject = ["tools", "systemPrompt"];
|
|
@@ -230,32 +231,52 @@ function findChapter(chapters, chapterArg) {
|
|
|
230
231
|
/** 简单中文分词:单字频率 + 相邻双字(二元组)频率。 */
|
|
231
232
|
function extractKeywords(text, top) {
|
|
232
233
|
const cjkBigrams = new Map();
|
|
234
|
+
const cjkTrigrams = new Map();
|
|
233
235
|
const cjkChars = new Map();
|
|
234
236
|
const enWords = new Map();
|
|
237
|
+
const nameCandidates = new Map();
|
|
235
238
|
const cjkRun = [];
|
|
236
239
|
for (const ch of text) {
|
|
237
240
|
if (/[\u4e00-\u9fff]/.test(ch)) cjkRun.push(ch);
|
|
238
241
|
else if (cjkRun.length > 0) {
|
|
239
|
-
tallyCjkRun(cjkRun, cjkChars, cjkBigrams);
|
|
242
|
+
tallyCjkRun(cjkRun, cjkChars, cjkBigrams, cjkTrigrams);
|
|
240
243
|
cjkRun.length = 0;
|
|
241
244
|
}
|
|
242
245
|
}
|
|
243
|
-
if (cjkRun.length > 0) tallyCjkRun(cjkRun, cjkChars, cjkBigrams);
|
|
246
|
+
if (cjkRun.length > 0) tallyCjkRun(cjkRun, cjkChars, cjkBigrams, cjkTrigrams);
|
|
244
247
|
for (const match of text.toLowerCase().matchAll(/[a-z]{2,}/g)) {
|
|
245
248
|
const word = match[0];
|
|
246
249
|
if (!EN_STOP_WORDS.has(word)) enWords.set(word, (enWords.get(word) ?? 0) + 1);
|
|
247
250
|
}
|
|
251
|
+
// 疑似人名/专名:2~3 字 + 动作/称谓("露西亚说着""琉璃小姐")
|
|
252
|
+
const namePatterns = [
|
|
253
|
+
/([\u4e00-\u9fff]{2,3})(?:说|道|问|喊|叫|想|笑|叹|答|喝|骂|念|哭|点头|摇头)/g,
|
|
254
|
+
/([\u4e00-\u9fff]{2,3})(?:小姐|大人|先生|少爷|姑娘|殿下|老师|导师|婆婆|爷爷|奶奶|夫人|老爷|公子|长老|神甫|陛下|殿下|大人|小姐)/g
|
|
255
|
+
];
|
|
256
|
+
for (const pattern of namePatterns) {
|
|
257
|
+
for (const match of text.matchAll(pattern)) {
|
|
258
|
+
const name = match[1];
|
|
259
|
+
if (name === "那个" || name === "这个" || name === "什么" || name === "怎么" || name === "自己" || name === "她们" || name === "他们" || name === "你们") continue;
|
|
260
|
+
nameCandidates.set(name, (nameCandidates.get(name) ?? 0) + 1);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
248
263
|
const keywords = [];
|
|
249
264
|
for (const [word, count] of cjkBigrams) {
|
|
250
265
|
if (count > 1) keywords.push({ word, count, kind: "cjk-bigram" });
|
|
251
266
|
}
|
|
267
|
+
for (const [word, count] of cjkTrigrams) {
|
|
268
|
+
if (count > 1) keywords.push({ word, count, kind: "cjk-trigram" });
|
|
269
|
+
}
|
|
270
|
+
for (const [word, count] of nameCandidates) {
|
|
271
|
+
if (count >= 2) keywords.push({ word, count, kind: "name-candidate" });
|
|
272
|
+
}
|
|
252
273
|
for (const [word, count] of enWords) {
|
|
253
274
|
if (count > 1) keywords.push({ word, count, kind: "word" });
|
|
254
275
|
}
|
|
255
276
|
keywords.sort((a, b) => b.count - a.count || a.word.localeCompare(b.word));
|
|
256
277
|
return keywords.slice(0, top);
|
|
257
278
|
}
|
|
258
|
-
function tallyCjkRun(run, cjkChars, cjkBigrams) {
|
|
279
|
+
function tallyCjkRun(run, cjkChars, cjkBigrams, cjkTrigrams) {
|
|
259
280
|
for (let i = 0; i < run.length; i += 1) {
|
|
260
281
|
const ch = run[i];
|
|
261
282
|
if (!CJK_STOP_CHARS.has(ch)) cjkChars.set(ch, (cjkChars.get(ch) ?? 0) + 1);
|
|
@@ -264,6 +285,11 @@ function tallyCjkRun(run, cjkChars, cjkBigrams) {
|
|
|
264
285
|
const meaningful = !CJK_STOP_CHARS.has(run[i]) || !CJK_STOP_CHARS.has(run[i + 1]);
|
|
265
286
|
if (meaningful) cjkBigrams.set(bigram, (cjkBigrams.get(bigram) ?? 0) + 1);
|
|
266
287
|
}
|
|
288
|
+
if (i + 2 < run.length) {
|
|
289
|
+
const trigram = run[i] + run[i + 1] + run[i + 2];
|
|
290
|
+
const nonStop = [run[i], run[i + 1], run[i + 2]].filter((ch) => !CJK_STOP_CHARS.has(ch)).length;
|
|
291
|
+
if (nonStop >= 2) cjkTrigrams.set(trigram, (cjkTrigrams.get(trigram) ?? 0) + 1);
|
|
292
|
+
}
|
|
267
293
|
}
|
|
268
294
|
}
|
|
269
295
|
|
|
@@ -378,6 +404,7 @@ function registerNovelImport(ctx, config) {
|
|
|
378
404
|
properties: {
|
|
379
405
|
book: { type: "string" },
|
|
380
406
|
from: { type: "string", enum: ["file", "content", "forced"] },
|
|
407
|
+
maybe: { type: "array", items: { type: "string" }, description: "可能同书的其他分组名(供 AI 判断是否用 book 合并)。" },
|
|
381
408
|
files: {
|
|
382
409
|
type: "array",
|
|
383
410
|
items: {
|
|
@@ -496,7 +523,24 @@ function registerNovelImport(ctx, config) {
|
|
|
496
523
|
}))
|
|
497
524
|
});
|
|
498
525
|
}
|
|
499
|
-
|
|
526
|
+
// v0.6.0:异名同书提示——组名包含关系或字符重合率高时列为"可能同书"
|
|
527
|
+
for (const group of groups) {
|
|
528
|
+
const maybe = [];
|
|
529
|
+
for (const other of groups) {
|
|
530
|
+
if (other === group || other.book === group.book) continue;
|
|
531
|
+
const a = String(group.book).replace(/\s/g, "");
|
|
532
|
+
const b = String(other.book).replace(/\s/g, "");
|
|
533
|
+
if (a.length < 2 || b.length < 2) continue;
|
|
534
|
+
const contained = a.includes(b) || b.includes(a);
|
|
535
|
+
const setA = new Set(a);
|
|
536
|
+
const setB = new Set(b);
|
|
537
|
+
let common = 0;
|
|
538
|
+
for (const ch of setA) if (setB.has(ch)) common += 1;
|
|
539
|
+
const overlap = common / Math.max(setA.size, setB.size, 1);
|
|
540
|
+
if (contained || overlap >= 0.6) maybe.push(other.book);
|
|
541
|
+
}
|
|
542
|
+
if (maybe.length > 0) group.maybe = maybe;
|
|
543
|
+
}
|
|
500
544
|
const imported = [];
|
|
501
545
|
if (mode === "apply") {
|
|
502
546
|
for (const group of groups) {
|
|
@@ -611,6 +655,23 @@ function isLoopbackRequest(request) {
|
|
|
611
655
|
try { return new URL(origin).host === hostUrl.host; } catch { return false; }
|
|
612
656
|
}
|
|
613
657
|
|
|
658
|
+
/**
|
|
659
|
+
* 请求放行判定:loopback 总是放行;allowLan=true 时额外放行同源局域网请求
|
|
660
|
+
* (仅校验 Host 与 origin 主机一致 + 非 cross-site)。
|
|
661
|
+
*/
|
|
662
|
+
function isAllowedRequest(request, allowLan) {
|
|
663
|
+
if (isLoopbackRequest(request)) return true;
|
|
664
|
+
if (allowLan !== true) return false;
|
|
665
|
+
const host = request.headers?.host;
|
|
666
|
+
if (typeof host !== "string") return false;
|
|
667
|
+
let hostUrl;
|
|
668
|
+
try { hostUrl = new URL(`http://${host}`); } catch { return false; }
|
|
669
|
+
if (request.headers?.["sec-fetch-site"] === "cross-site") return false;
|
|
670
|
+
const origin = request.headers?.origin;
|
|
671
|
+
if (origin === void 0) return true;
|
|
672
|
+
try { return new URL(origin).host === hostUrl.host; } catch { return false; }
|
|
673
|
+
}
|
|
674
|
+
|
|
614
675
|
/** 写一个 JSON 响应。 */
|
|
615
676
|
function writeJson(res, status, body) {
|
|
616
677
|
const payload = JSON.stringify(body);
|
|
@@ -631,10 +692,10 @@ async function readJsonBody(req) {
|
|
|
631
692
|
}
|
|
632
693
|
|
|
633
694
|
/** /api/dsh-novel-writer/state 路由:GET 读开关,POST 写开关。 */
|
|
634
|
-
function makeStateRoutes() {
|
|
695
|
+
function makeStateRoutes(allowLan) {
|
|
635
696
|
const routePath = "/api/dsh-novel-writer/state";
|
|
636
697
|
const handler = async (req, res) => {
|
|
637
|
-
if (!
|
|
698
|
+
if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
638
699
|
const method = req.method ?? "GET";
|
|
639
700
|
if (method === "GET") {
|
|
640
701
|
const state = await readSentenceState();
|
|
@@ -659,10 +720,10 @@ function makeStateRoutes() {
|
|
|
659
720
|
* 注册 UI 开关状态路由。用可选注入 ctx.inject(["webServer"]):
|
|
660
721
|
* headless 等没有 webServer 服务的 profile 自动跳过,不会导致插件加载失败。
|
|
661
722
|
*/
|
|
662
|
-
function registerStyleConfigRoute(ctx) {
|
|
723
|
+
function registerStyleConfigRoute(ctx, config) {
|
|
663
724
|
try {
|
|
664
725
|
ctx.inject(["webServer"], (wctx) => {
|
|
665
|
-
const routes = makeStateRoutes();
|
|
726
|
+
const routes = makeStateRoutes(config?.allowLanState === true);
|
|
666
727
|
wctx.effect?.(() => {
|
|
667
728
|
const disposers = routes.map((route) => wctx.webServer.register(route));
|
|
668
729
|
return () => {
|
|
@@ -742,6 +803,8 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
742
803
|
chapter: { type: "string", description: "可选。只分析该章节;省略则分析全书。" },
|
|
743
804
|
top: { type: "integer", description: "返回的高频句式模板数量。默认 8。" },
|
|
744
805
|
maxSentences: { type: "integer", description: "采样句数上限(超长文本保护,默认 20000)。" },
|
|
806
|
+
curveSegments: { type: "integer", description: "情感曲线分段数(1-50,默认 20)。" },
|
|
807
|
+
fresh: { type: "boolean", description: "true=强制重新分析(忽略缓存)。默认 false。" },
|
|
745
808
|
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
746
809
|
},
|
|
747
810
|
required: ["book"],
|
|
@@ -859,6 +922,9 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
859
922
|
},
|
|
860
923
|
required: ["dominant", "scores", "intensity", "topWords", "curve"]
|
|
861
924
|
},
|
|
925
|
+
reportFile: { type: "string" },
|
|
926
|
+
cache: { type: "string", enum: ["hit", "miss"] },
|
|
927
|
+
cachedAt: { type: "string" },
|
|
862
928
|
chapterPatterns: {
|
|
863
929
|
type: "array",
|
|
864
930
|
items: {
|
|
@@ -883,6 +949,8 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
883
949
|
const chapterArg = optionalString(args, "chapter");
|
|
884
950
|
const top = optionalInt(args, "top", 1, 50, 8);
|
|
885
951
|
const maxSentences = optionalInt(args, "maxSentences", 100, 100000, 20000);
|
|
952
|
+
const curveSegments = optionalInt(args, "curveSegments", 1, 50, 20);
|
|
953
|
+
const fresh = args?.fresh === true;
|
|
886
954
|
const root = resolveRoot(config, args, exec);
|
|
887
955
|
const state = await readSentenceState();
|
|
888
956
|
const effective = effectiveSentenceAnalysis(config, state);
|
|
@@ -901,10 +969,17 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
901
969
|
}
|
|
902
970
|
let text = "";
|
|
903
971
|
const chapterTexts = [];
|
|
972
|
+
const scopeKeyParts = [];
|
|
904
973
|
for (const chapter of selected) {
|
|
905
974
|
const chapterText = await readTextFile(join(dir, chapter.file), exec);
|
|
906
975
|
text += chapterText;
|
|
907
976
|
if (chapterArg === void 0) chapterTexts.push({ chapter: chapter.file, text: chapterText });
|
|
977
|
+
try {
|
|
978
|
+
const info = await stat(join(dir, chapter.file));
|
|
979
|
+
scopeKeyParts.push(chapter.file + ":" + info.mtimeMs + ":" + info.size);
|
|
980
|
+
} catch {
|
|
981
|
+
scopeKeyParts.push(chapter.file + ":?:?");
|
|
982
|
+
}
|
|
908
983
|
}
|
|
909
984
|
if (!effective.enabled) {
|
|
910
985
|
return {
|
|
@@ -916,15 +991,30 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
916
991
|
message: "句式模式分析当前已关闭。可在 Web GUI 侧边栏「句式分析」面板开启,或用 novel_sentence_config 设置 enabled=true。"
|
|
917
992
|
};
|
|
918
993
|
}
|
|
919
|
-
|
|
994
|
+
// v0.6.0:分析结果缓存 + 报告导出(用户建议的临时文件方案)
|
|
995
|
+
const cacheDir = join(homedir(), ".dsh", "dsh-novel-writer", "cache");
|
|
996
|
+
const cacheKey = createHash("sha1").update(book + "|" + scope + "|" + scopeKeyParts.join(",")).digest("hex").slice(0, 20);
|
|
997
|
+
const reportFile = join(cacheDir, book + "-" + cacheKey + ".json");
|
|
998
|
+
if (!fresh) {
|
|
999
|
+
try {
|
|
1000
|
+
const cached = JSON.parse(await readFile(reportFile, "utf8"));
|
|
1001
|
+
return { ...cached, book, scope, cache: "hit", reportFile, cachedAt: cached.cachedAt ?? new Date().toISOString() };
|
|
1002
|
+
} catch { /* 缓存缺失/损坏,重新分析 */ }
|
|
1003
|
+
}
|
|
1004
|
+
const options = { top, maxSentences, curveSegments };
|
|
920
1005
|
if (chapterTexts.length > 0) options.chapterTexts = chapterTexts;
|
|
921
|
-
|
|
1006
|
+
const result = {
|
|
922
1007
|
book,
|
|
923
1008
|
scope,
|
|
924
1009
|
enabled: true,
|
|
925
1010
|
autoAnalyze: effective.autoAnalyze,
|
|
926
1011
|
...analyzeText(text, options)
|
|
927
1012
|
};
|
|
1013
|
+
try {
|
|
1014
|
+
await mkdir(cacheDir, { recursive: true });
|
|
1015
|
+
await writeFile(reportFile, JSON.stringify({ ...result, cachedAt: new Date().toISOString() }, null, 2), "utf8");
|
|
1016
|
+
} catch { /* 缓存写失败不影响结果 */ }
|
|
1017
|
+
return { ...result, cache: "miss", reportFile, cachedAt: new Date().toISOString() };
|
|
928
1018
|
}
|
|
929
1019
|
});
|
|
930
1020
|
}
|
|
@@ -992,6 +1082,229 @@ ${value.updated === true ? "(已保存到 state 文件)" : ""}
|
|
|
992
1082
|
});
|
|
993
1083
|
}
|
|
994
1084
|
|
|
1085
|
+
|
|
1086
|
+
/** v0.6.0 风格自检:某章节 vs 全书其余章节的指纹对比。 */
|
|
1087
|
+
function registerNovelStyleCheck(ctx, config) {
|
|
1088
|
+
ctx.tools.register({
|
|
1089
|
+
name: "novel_style_check",
|
|
1090
|
+
description: "风格自检:对比某章节与全书其他章节的风格指纹(句式分布/句长/情绪),输出相似度与偏差清单,用于检查续写是否偏离作者文风。",
|
|
1091
|
+
parameters: {
|
|
1092
|
+
type: "object",
|
|
1093
|
+
properties: {
|
|
1094
|
+
book: { type: "string", description: "书名。" },
|
|
1095
|
+
chapter: { type: "string", description: "要检查的章节(章号/文件名/标题)。" },
|
|
1096
|
+
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
1097
|
+
},
|
|
1098
|
+
required: ["book", "chapter"],
|
|
1099
|
+
additionalProperties: false
|
|
1100
|
+
},
|
|
1101
|
+
output: {
|
|
1102
|
+
schema: {
|
|
1103
|
+
type: "object",
|
|
1104
|
+
additionalProperties: false,
|
|
1105
|
+
properties: {
|
|
1106
|
+
book: { type: "string" },
|
|
1107
|
+
chapter: { type: "string" },
|
|
1108
|
+
baselineScope: { type: "string" },
|
|
1109
|
+
similarity: { type: "number" },
|
|
1110
|
+
verdict: { type: "string", enum: ["high", "medium", "low"] },
|
|
1111
|
+
diffs: {
|
|
1112
|
+
type: "array",
|
|
1113
|
+
items: {
|
|
1114
|
+
type: "object",
|
|
1115
|
+
additionalProperties: false,
|
|
1116
|
+
properties: {
|
|
1117
|
+
dimension: { type: "string" },
|
|
1118
|
+
diff: { type: "number" },
|
|
1119
|
+
note: { type: "string" }
|
|
1120
|
+
},
|
|
1121
|
+
required: ["dimension", "diff", "note"]
|
|
1122
|
+
}
|
|
1123
|
+
},
|
|
1124
|
+
chapterFingerprint: { type: "string" },
|
|
1125
|
+
baselineFingerprint: { type: "string" },
|
|
1126
|
+
advice: { type: "string" }
|
|
1127
|
+
},
|
|
1128
|
+
required: ["book", "chapter", "baselineScope", "similarity", "verdict", "diffs", "chapterFingerprint", "baselineFingerprint", "advice"]
|
|
1129
|
+
},
|
|
1130
|
+
render: (_args, value) => {
|
|
1131
|
+
const verdictLabel = value.verdict === "high" ? "高度一致" : value.verdict === "medium" ? "大体一致" : "偏离明显";
|
|
1132
|
+
const lines = [`<path>novels/${value.book}</path>`, "<type>novel-style-check</type>", "<content>", ""];
|
|
1133
|
+
lines.push(`检查章节: ${value.chapter}`);
|
|
1134
|
+
lines.push(`对比基线: ${value.baselineScope}`);
|
|
1135
|
+
lines.push(`风格相似度: ${(value.similarity * 100).toFixed(1)}% (${verdictLabel})`);
|
|
1136
|
+
if (value.diffs.length === 0) {
|
|
1137
|
+
lines.push("偏差: 无明显偏差");
|
|
1138
|
+
} else {
|
|
1139
|
+
lines.push("偏差清单:");
|
|
1140
|
+
for (const d of value.diffs) lines.push(` - ${d.dimension}: ${d.note} (${d.diff > 0 ? "+" : ""}${d.diff})`);
|
|
1141
|
+
}
|
|
1142
|
+
lines.push(`本章指纹: ${value.chapterFingerprint}`);
|
|
1143
|
+
lines.push(`基线指纹: ${value.baselineFingerprint}`);
|
|
1144
|
+
lines.push("", "【建议】");
|
|
1145
|
+
lines.push(value.advice);
|
|
1146
|
+
lines.push("", "</content>");
|
|
1147
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
async execute(args, exec) {
|
|
1151
|
+
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
1152
|
+
const chapterArg = requiredString(args, "chapter");
|
|
1153
|
+
const root = resolveRoot(config, args, exec);
|
|
1154
|
+
const dir = bookDir(root, book);
|
|
1155
|
+
const chapters = await scanChapters(dir);
|
|
1156
|
+
assert(chapters.length > 1, `book "${book}" 只有 ${chapters.length} 章,无法做风格对比(至少需要 2 章)`);
|
|
1157
|
+
const target = findChapter(chapters, chapterArg);
|
|
1158
|
+
assert(target !== void 0, `cannot find chapter "${chapterArg}" in book "${book}"`);
|
|
1159
|
+
const others = chapters.filter((ch) => ch.file !== target.file);
|
|
1160
|
+
const targetText = await readTextFile(join(dir, target.file), exec);
|
|
1161
|
+
let baselineText = "";
|
|
1162
|
+
for (const ch of others) baselineText += await readTextFile(join(dir, ch.file), exec);
|
|
1163
|
+
const baseline = analyzeText(baselineText, {});
|
|
1164
|
+
const chapter = analyzeText(targetText, {});
|
|
1165
|
+
const similarity = fingerprintSimilarity(baseline, chapter);
|
|
1166
|
+
const diffs = styleDiffs(baseline, chapter);
|
|
1167
|
+
const verdict = similarity >= 0.9 ? "high" : similarity >= 0.75 ? "medium" : "low";
|
|
1168
|
+
let advice = "";
|
|
1169
|
+
if (diffs.length === 0) {
|
|
1170
|
+
advice = "本章与全书风格高度一致,可以放心续写。";
|
|
1171
|
+
} else {
|
|
1172
|
+
advice = "续写时建议注意:" + diffs.slice(0, 4).map((d) => d.dimension + (d.note === "偏高" ? "略多" : "略少")).join("、") + "。若为情节需要(如章节情绪转折),可接受适度偏离,但不要持续漂移。";
|
|
1173
|
+
}
|
|
1174
|
+
return {
|
|
1175
|
+
book,
|
|
1176
|
+
chapter: target.file,
|
|
1177
|
+
baselineScope: others.length === 1 ? others[0].file : `全书除本章外的 ${others.length} 章`,
|
|
1178
|
+
similarity,
|
|
1179
|
+
verdict,
|
|
1180
|
+
diffs,
|
|
1181
|
+
chapterFingerprint: chapter.fingerprint,
|
|
1182
|
+
baselineFingerprint: baseline.fingerprint,
|
|
1183
|
+
advice
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** v0.6.0 伏笔/剧情线登记表。 */
|
|
1190
|
+
function plotsFile(root, book) {
|
|
1191
|
+
return join(root, ".novel-writer", sanitizeSegment(book, "book") + ".json");
|
|
1192
|
+
}
|
|
1193
|
+
async function readPlots(root, book) {
|
|
1194
|
+
try {
|
|
1195
|
+
const parsed = JSON.parse(await readFile(plotsFile(root, book), "utf8"));
|
|
1196
|
+
return Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
1197
|
+
} catch {
|
|
1198
|
+
return [];
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
async function writePlots(root, book, entries) {
|
|
1202
|
+
const file = plotsFile(root, book);
|
|
1203
|
+
await mkdir(dirname(file), { recursive: true });
|
|
1204
|
+
await writeFile(file, JSON.stringify({ book, entries }, null, 2), "utf8");
|
|
1205
|
+
}
|
|
1206
|
+
function registerNovelPlot(ctx, config) {
|
|
1207
|
+
ctx.tools.register({
|
|
1208
|
+
name: "novel_plot",
|
|
1209
|
+
description: "伏笔/剧情线登记表:维护某部作品的伏笔与剧情钩子(open 待回收 / done 已回收),续写前查看可提醒模型回收伏笔、保持剧情连贯。",
|
|
1210
|
+
parameters: {
|
|
1211
|
+
type: "object",
|
|
1212
|
+
properties: {
|
|
1213
|
+
book: { type: "string", description: "书名。" },
|
|
1214
|
+
action: { type: "string", enum: ["list", "add", "update", "done", "delete"], description: "list=查看(默认);add=登记新伏笔;update=修改;done=标记已回收;delete=删除。" },
|
|
1215
|
+
id: { type: "string", description: "伏笔 id(update/done/delete 时需要)。" },
|
|
1216
|
+
content: { type: "string", description: "add/update 时:伏笔内容描述。" },
|
|
1217
|
+
chapter: { type: "string", description: "可选:伏笔出现的章节。" },
|
|
1218
|
+
note: { type: "string", description: "可选:备注(如何回收/何时回收)。" },
|
|
1219
|
+
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
1220
|
+
},
|
|
1221
|
+
required: ["book"],
|
|
1222
|
+
additionalProperties: false
|
|
1223
|
+
},
|
|
1224
|
+
output: {
|
|
1225
|
+
schema: {
|
|
1226
|
+
type: "object",
|
|
1227
|
+
additionalProperties: false,
|
|
1228
|
+
properties: {
|
|
1229
|
+
book: { type: "string" },
|
|
1230
|
+
action: { type: "string" },
|
|
1231
|
+
entries: {
|
|
1232
|
+
type: "array",
|
|
1233
|
+
items: {
|
|
1234
|
+
type: "object",
|
|
1235
|
+
additionalProperties: false,
|
|
1236
|
+
properties: {
|
|
1237
|
+
id: { type: "string" },
|
|
1238
|
+
content: { type: "string" },
|
|
1239
|
+
chapter: { type: "string" },
|
|
1240
|
+
note: { type: "string" },
|
|
1241
|
+
status: { type: "string", enum: ["open", "done"] },
|
|
1242
|
+
createdAt: { type: "string" },
|
|
1243
|
+
updatedAt: { type: "string" }
|
|
1244
|
+
},
|
|
1245
|
+
required: ["id", "content", "status"]
|
|
1246
|
+
}
|
|
1247
|
+
},
|
|
1248
|
+
message: { type: "string" }
|
|
1249
|
+
},
|
|
1250
|
+
required: ["book", "action", "entries"]
|
|
1251
|
+
},
|
|
1252
|
+
render: (_args, value) => {
|
|
1253
|
+
const lines = [`<path>novels/${value.book}</path>`, "<type>novel-plot</type>", "<content>", ""];
|
|
1254
|
+
if (value.message) lines.push(value.message);
|
|
1255
|
+
const open = value.entries.filter((e) => e.status === "open");
|
|
1256
|
+
const done = value.entries.filter((e) => e.status === "done");
|
|
1257
|
+
lines.push(`未回收伏笔 ${open.length} 条:`);
|
|
1258
|
+
if (open.length === 0) lines.push(" (无)");
|
|
1259
|
+
for (const e of open) lines.push(` - [${e.id}] ${e.content}${e.chapter ? "(出自 " + e.chapter + ")" : ""}${e.note ? "|" + e.note : ""}`);
|
|
1260
|
+
if (done.length > 0) {
|
|
1261
|
+
lines.push(`已回收 ${done.length} 条:`);
|
|
1262
|
+
for (const e of done) lines.push(` - [${e.id}] ${e.content}`);
|
|
1263
|
+
}
|
|
1264
|
+
lines.push("", "</content>");
|
|
1265
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1268
|
+
async execute(args, exec) {
|
|
1269
|
+
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
1270
|
+
const action = ["add", "update", "done", "delete"].includes(args?.action) ? args.action : "list";
|
|
1271
|
+
const root = resolveRoot(config, args, exec);
|
|
1272
|
+
const entries = await readPlots(root, book);
|
|
1273
|
+
const now = new Date().toISOString();
|
|
1274
|
+
let message = "";
|
|
1275
|
+
if (action === "add") {
|
|
1276
|
+
const content = optionalString(args, "content");
|
|
1277
|
+
assert(content !== void 0, "novel_plot add 需要 content 参数");
|
|
1278
|
+
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
1279
|
+
entries.push({ id, content, chapter: optionalString(args, "chapter"), note: optionalString(args, "note"), status: "open", createdAt: now, updatedAt: now });
|
|
1280
|
+
message = `已登记伏笔 #${id}`;
|
|
1281
|
+
} else if (action === "update" || action === "done" || action === "delete") {
|
|
1282
|
+
const id = optionalString(args, "id");
|
|
1283
|
+
assert(id !== void 0, `novel_plot ${action} 需要 id 参数`);
|
|
1284
|
+
const index = entries.findIndex((e) => e.id === id);
|
|
1285
|
+
assert(index !== -1, `伏笔 #${id} 不存在(可用 novel_plot 查看列表)`);
|
|
1286
|
+
if (action === "delete") {
|
|
1287
|
+
entries.splice(index, 1);
|
|
1288
|
+
message = `已删除伏笔 #${id}`;
|
|
1289
|
+
} else if (action === "done") {
|
|
1290
|
+
entries[index].status = "done";
|
|
1291
|
+
entries[index].updatedAt = now;
|
|
1292
|
+
message = `已标记伏笔 #${id} 为已回收`;
|
|
1293
|
+
} else {
|
|
1294
|
+
if (args?.content !== void 0) entries[index].content = String(args.content);
|
|
1295
|
+
if (args?.note !== void 0) entries[index].note = String(args.note);
|
|
1296
|
+
if (args?.chapter !== void 0) entries[index].chapter = String(args.chapter);
|
|
1297
|
+
entries[index].updatedAt = now;
|
|
1298
|
+
message = `已更新伏笔 #${id}`;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
await writePlots(root, book, entries);
|
|
1302
|
+
entries.sort((a, b) => (a.status === b.status ? a.createdAt.localeCompare(b.createdAt) : a.status === "open" ? -1 : 1));
|
|
1303
|
+
return { book, action, entries, ...message ? { message } : {} };
|
|
1304
|
+
}
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
|
|
995
1308
|
// ---- 工具定义 ----
|
|
996
1309
|
|
|
997
1310
|
function registerNovelBooks(ctx, config) {
|
|
@@ -1294,7 +1607,7 @@ function registerNovelKeywords(ctx, config) {
|
|
|
1294
1607
|
properties: {
|
|
1295
1608
|
word: { type: "string" },
|
|
1296
1609
|
count: { type: "integer" },
|
|
1297
|
-
kind: { type: "string", enum: ["cjk-bigram", "word"] }
|
|
1610
|
+
kind: { type: "string", enum: ["cjk-bigram", "cjk-trigram", "name-candidate", "word"] }
|
|
1298
1611
|
},
|
|
1299
1612
|
required: ["word", "count", "kind"]
|
|
1300
1613
|
}
|
|
@@ -1343,7 +1656,7 @@ function formatKeywords(value) {
|
|
|
1343
1656
|
const lines = [`<path>novels/${value.book}</path>`, `<type>novel-keywords</type>`, `<content>`, `统计范围: ${value.scope} (共 ${value.totalChars} 字)`, ""];
|
|
1344
1657
|
if (value.keywords.length === 0) lines.push("(未提取到重复出现的关键词)");
|
|
1345
1658
|
for (const keyword of value.keywords) {
|
|
1346
|
-
const kind = keyword.kind === "cjk-bigram" ? "词组" : "英文词";
|
|
1659
|
+
const kind = keyword.kind === "cjk-bigram" ? "词组" : keyword.kind === "cjk-trigram" ? "三字组" : keyword.kind === "name-candidate" ? "疑似人名" : "英文词";
|
|
1347
1660
|
lines.push(`- ${keyword.word} × ${keyword.count} (${kind})`);
|
|
1348
1661
|
}
|
|
1349
1662
|
lines.push("", "</content>");
|
|
@@ -1437,7 +1750,9 @@ const WORKFLOW_TEXT = [
|
|
|
1437
1750
|
" 若发现异名同书(如\"萝莉/幼女\"实为同一本),用 book 参数强制合并后以 apply 模式导入到 novels/<书名>/ 分类存放。",
|
|
1438
1751
|
"6. 续写 / 辅助写作时:先通读最近的章节(保持人物、时间线、情节伏笔、文风与已提炼的关键词一致),再动笔。",
|
|
1439
1752
|
" 新章节写入 novels/<书名>/ 文件夹:用 novel_new_chapter 创建章节文件,或直接用 write 工具写文件。",
|
|
1440
|
-
"7. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。"
|
|
1753
|
+
"7. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。",
|
|
1754
|
+
"8. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔,保持剧情连贯;写完新章节可用 novel_style_check 做风格自检(相似度+偏差清单);",
|
|
1755
|
+
" novel_sentence_analysis 结果自动缓存到 ~/.dsh/dsh-novel-writer/cache/(返回值含 reportFile 字段),重复分析直接命中缓存,需要重算时传 fresh=true。"
|
|
1441
1756
|
].join("\n");
|
|
1442
1757
|
|
|
1443
1758
|
function apply(ctx, config = {}) {
|
|
@@ -1449,8 +1764,10 @@ function apply(ctx, config = {}) {
|
|
|
1449
1764
|
registerNovelImport(ctx, config);
|
|
1450
1765
|
registerNovelSentenceAnalysis(ctx, config);
|
|
1451
1766
|
registerNovelSentenceConfig(ctx, config);
|
|
1767
|
+
registerNovelStyleCheck(ctx, config);
|
|
1768
|
+
registerNovelPlot(ctx, config);
|
|
1452
1769
|
// 注册 UI 开关状态路由(可选注入,headless 自动跳过)
|
|
1453
|
-
registerStyleConfigRoute(ctx);
|
|
1770
|
+
registerStyleConfigRoute(ctx, config);
|
|
1454
1771
|
ctx.systemPrompt.section({
|
|
1455
1772
|
name: "novel-writing",
|
|
1456
1773
|
order: 150,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-writer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "小说写作助手插件(v0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "小说写作助手插件(v0.6.0 工程化版):章节库管理、句式模式分析(九类/情感曲线/风格指纹/缓存报告)、风格自检、伏笔登记表、批量导入、续写辅助(关键词含三字组与疑似人名、情感词典去噪、支持任意位置章号、UTF-8/16/GBK 编码探测)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -10,43 +10,41 @@
|
|
|
10
10
|
1. `novel_books` 找到用户的作品;
|
|
11
11
|
2. `novel_chapters` 查看章节结构与各章字数;
|
|
12
12
|
3. `novel_read` 逐章通读(长章节用 offset/limit 分段读完);
|
|
13
|
-
4. `novel_keywords`
|
|
14
|
-
5.
|
|
13
|
+
4. `novel_keywords` 提取高频关键词(二字组/三字组/疑似人名/英文词);
|
|
14
|
+
5. `novel_sentence_analysis` 做句式模式分析(九类分布、排列规律、情感曲线、章节节奏、风格指纹);
|
|
15
|
+
6. 输出分析,至少覆盖三方面:
|
|
15
16
|
- **剧情脉络**:冲突、转折、悬念、伏笔、人物关系与目标;
|
|
16
17
|
- **写作手法**:叙事视角、节奏、对话、环境与细节描写、比喻意象、留白;
|
|
17
18
|
- **关键词与意象**:结合 novel_keywords 的结果,指出作者反复使用的词汇与意象,并引用具体章节佐证。
|
|
18
19
|
|
|
19
|
-
## 句式模式分析(v0.5.0
|
|
20
|
+
## 句式模式分析(v0.5.0 合并 + v0.6.0 增强)
|
|
20
21
|
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- 主观情感:情感词典(喜/怒/哀/惧/惊)+ 强度副词加权 → 主导情绪、情绪分布、情感曲线、主观性指数;
|
|
26
|
-
- 风格指纹 + 节奏建议(guidance)。
|
|
27
|
-
- 工具 `novel_sentence_config`:查看/修改开关(`enabled` / `autoAnalyze`)。
|
|
28
|
-
- 开关默认开启;统一在 Web GUI 侧边栏「句式分析」面板调整(设置 > 插件配置 仅显示状态),也可用 novel_sentence_config。
|
|
29
|
-
- 使用规则:
|
|
30
|
-
- 动手前先调用 `novel_sentence_config` 查看状态;
|
|
31
|
-
- `enabled=false` 时 novel_sentence_analysis 会返回关闭状态,不要强行分析;
|
|
32
|
-
- `autoAnalyze=true` 时分析作品应主动附带句式分析;false 时仅在用户明确要求时调用;
|
|
33
|
-
- **风险约束**:句式模式是参考节奏而非模板套用;若机械复刻导致僵硬,必须优先回归自然表达。
|
|
22
|
+
- 九类句式:陈述/环境/心理/对话/疑问/反问/感叹/祈使/省略留白;
|
|
23
|
+
- 情感曲线(喜/怒/哀/惧/惊)、句长节奏、段落结构、风格指纹、节奏建议(guidance);
|
|
24
|
+
- **缓存**:结果自动缓存(返回 `reportFile` / `cache`),章节未变重复分析秒回,`fresh=true` 强制重算;
|
|
25
|
+
- 情感词典已去噪("叹气"不算"气"、"不害怕"不计)、祈使/环境分类已增强。
|
|
34
26
|
|
|
35
|
-
##
|
|
27
|
+
## 风格自检(v0.6.0 新增)
|
|
36
28
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
|
|
29
|
+
- 写完新章节后调用 `novel_style_check`:本章 vs 全书其余章节 → 相似度 + 偏差清单 + 建议;
|
|
30
|
+
- 偏差明显时主动调整文风(对话/心理占比、句长、情绪),防止漂移。
|
|
31
|
+
|
|
32
|
+
## 伏笔登记表(v0.6.0 新增)
|
|
33
|
+
|
|
34
|
+
- `novel_plot`:list / add / update / done / delete 管理伏笔与剧情钩子;
|
|
35
|
+
- 续写前先 `novel_plot list` 查看未回收伏笔,续写中主动回收;
|
|
36
|
+
- 存储于 `<书库根>/.novel-writer/<书名>.json`。
|
|
40
37
|
|
|
41
38
|
## 续写工作流
|
|
42
39
|
|
|
43
40
|
1. 先通读最近的章节(必要时读完全书),保持:人物设定、时间线、情节伏笔、文风、已提炼的关键词一致;
|
|
44
|
-
2.
|
|
45
|
-
3.
|
|
46
|
-
4.
|
|
47
|
-
5.
|
|
41
|
+
2. `novel_plot list` 查看未回收伏笔;
|
|
42
|
+
3. 动笔前简述:上一章结尾状态 → 本章目标 → 写作计划;
|
|
43
|
+
4. 用 `novel_new_chapter` 创建新章节文件,或直接用 `write` 工具写入;
|
|
44
|
+
5. 正文用中文,保持与原文一致的叙事风格与细节密度;句式分析开启时参考指纹与节奏序列,僵硬时回归自然表达;
|
|
45
|
+
6. 写完后 `novel_style_check` 自检文风,`novel_plot add` 登记新伏笔。
|
|
48
46
|
|
|
49
47
|
## 注意事项
|
|
50
48
|
|
|
51
|
-
-
|
|
49
|
+
- 用户放入章节文件的方式不限:自己复制、AI 用 `write`/`edit` 创建,或用 novel_import 批量导入。
|
|
52
50
|
- 分析结果与续写正文都应使用中文呈现。
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// 端到端测试:10 工具注册、缓存、伏笔、风格自检、路由(v0.6.0)
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { apply } from "../lib/index.js";
|
|
6
|
+
|
|
7
|
+
const testRoot = join(process.cwd(), ".e2e-test");
|
|
8
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
9
|
+
mkdirSync(join(testRoot, "novels", "测试"), { recursive: true });
|
|
10
|
+
writeFileSync(join(testRoot, "novels", "测试", "第01章.md"), "雨下了一整夜。她站在窗前,心里想着明天的事。\n“你真的要走吗?”他低声问。\n难道这就是结局?她不禁这样想。\n", "utf8");
|
|
11
|
+
writeFileSync(join(testRoot, "novels", "测试", "第02章.md"), "日子照旧。她习惯了独自吃饭。窗外风急,雨打芭蕉。\n", "utf8");
|
|
12
|
+
|
|
13
|
+
const registry = [];
|
|
14
|
+
const routes = [];
|
|
15
|
+
const ctx = {
|
|
16
|
+
tools: { register: (d) => { registry.push(d); return () => {}; } },
|
|
17
|
+
systemPrompt: { section: () => () => {} },
|
|
18
|
+
inject: (names, cb) => cb({ webServer: { register: (r) => { routes.push(r); return () => {}; } }, effect: (fn) => fn() }),
|
|
19
|
+
};
|
|
20
|
+
apply(ctx, { root: testRoot, sentenceAnalysis: { enabled: true, autoAnalyze: true }, allowLanState: true });
|
|
21
|
+
const defs = Object.fromEntries(registry.map((d) => [d.name, d]));
|
|
22
|
+
const names = registry.map((d) => d.name);
|
|
23
|
+
console.log("工具数:", names.length, names.join(", "));
|
|
24
|
+
if (names.length !== 10) throw new Error("expected 10 tools");
|
|
25
|
+
|
|
26
|
+
const exec = { agent: { session: { header: { cwd: testRoot } } } };
|
|
27
|
+
|
|
28
|
+
// 1) 分析 + 缓存(第一次 miss,第二次 hit)
|
|
29
|
+
const first = await defs.novel_sentence_analysis.execute({ book: "测试" }, exec);
|
|
30
|
+
console.log("首次分析 cache:", first.cache, "| reportFile:", first.reportFile?.includes("cache"));
|
|
31
|
+
if (first.cache !== "miss") throw new Error("first should be miss");
|
|
32
|
+
const second = await defs.novel_sentence_analysis.execute({ book: "测试" }, exec);
|
|
33
|
+
console.log("二次分析 cache:", second.cache, "| 指纹一致:", second.fingerprint === first.fingerprint);
|
|
34
|
+
if (second.cache !== "hit") throw new Error("second should be hit");
|
|
35
|
+
const fresh = await defs.novel_sentence_analysis.execute({ book: "测试", fresh: true }, exec);
|
|
36
|
+
console.log("fresh 重算 cache:", fresh.cache);
|
|
37
|
+
if (fresh.cache !== "miss") throw new Error("fresh should miss");
|
|
38
|
+
|
|
39
|
+
// 2) 风格自检
|
|
40
|
+
const check = await defs.novel_style_check.execute({ book: "测试", chapter: "第02章.md" }, exec);
|
|
41
|
+
console.log("风格自检: 相似度", check.similarity, "| verdict:", check.verdict, "| diffs:", check.diffs.length, "| advice:", !!check.advice);
|
|
42
|
+
if (typeof check.similarity !== "number" || !check.advice) throw new Error("style check broken");
|
|
43
|
+
|
|
44
|
+
// 3) 伏笔登记表
|
|
45
|
+
const add = await defs.novel_plot.execute({ book: "测试", action: "add", content: "母神名讳之谜", chapter: "第01章", note: "第二章回收" }, exec);
|
|
46
|
+
console.log("伏笔 add:", add.message, "| entries:", add.entries.length);
|
|
47
|
+
const id = add.entries[0].id;
|
|
48
|
+
await defs.novel_plot.execute({ book: "测试", action: "add", content: "代行者血脉", chapter: "第01章" }, exec);
|
|
49
|
+
const list = await defs.novel_plot.execute({ book: "测试", action: "list" }, exec);
|
|
50
|
+
console.log("伏笔 list:", list.entries.map((e) => e.content + ":" + e.status).join(" "));
|
|
51
|
+
await defs.novel_plot.execute({ book: "测试", action: "done", id }, exec);
|
|
52
|
+
const afterDone = await defs.novel_plot.execute({ book: "测试", action: "list" }, exec);
|
|
53
|
+
console.log("done 后:", afterDone.entries.map((e) => e.content + ":" + e.status).join(" "));
|
|
54
|
+
if (afterDone.entries.find((e) => e.id === id).status !== "done") throw new Error("plot done broken");
|
|
55
|
+
const plotFile = join(testRoot, ".novel-writer", "测试.json");
|
|
56
|
+
if (!existsSync(plotFile)) throw new Error("plot file not persisted");
|
|
57
|
+
console.log("伏笔文件:", plotFile, "存在 ✓");
|
|
58
|
+
|
|
59
|
+
// 4) 关键词(三字组/疑似人名)
|
|
60
|
+
writeFileSync(join(testRoot, "novels", "测试", "第03章.md"), "露西亚说着话,琉璃点了点头。露西亚问导师问题,琉璃笑道。\n", "utf8");
|
|
61
|
+
const kw = await defs.novel_keywords.execute({ book: "测试", top: 20 }, exec);
|
|
62
|
+
console.log("关键词:", kw.keywords.map((k) => k.word + "(" + k.kind + ")").join(" "));
|
|
63
|
+
if (!kw.keywords.some((k) => k.kind === "name-candidate" && (k.word.includes("露西") || k.word.includes("琉璃")))) throw new Error("name candidate missing");
|
|
64
|
+
|
|
65
|
+
// 5) 路由 allowLan:非 loopback 同源放行
|
|
66
|
+
const handler = routes.find((r) => r.path === "/api/dsh-novel-writer/state").handler;
|
|
67
|
+
const res = { status: 0, body: "", writeHead(s) { this.status = s; }, end(b) { this.body = String(b); } };
|
|
68
|
+
await handler({
|
|
69
|
+
method: "GET", url: "/api/dsh-novel-writer/state",
|
|
70
|
+
socket: { remoteAddress: "192.168.1.5" },
|
|
71
|
+
headers: { host: "192.168.1.5:3080", origin: "http://192.168.1.5:3080" },
|
|
72
|
+
}, res);
|
|
73
|
+
console.log("局域网 GET(allowLanState=true):", res.status);
|
|
74
|
+
if (res.status !== 200) throw new Error("allowLan route broken");
|
|
75
|
+
|
|
76
|
+
// 清理缓存文件(保留状态文件)
|
|
77
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
78
|
+
console.log("\nALL E2E TESTS PASSED");
|
package/test/pattern-test.mjs
CHANGED
|
@@ -1,52 +1,48 @@
|
|
|
1
|
-
// 句式模式分析引擎测试(v0.
|
|
2
|
-
import { analyzeText, classifySentence,
|
|
1
|
+
// 句式模式分析引擎测试(v0.6.0:九类 + 去噪 + 相似度)
|
|
2
|
+
import { analyzeText, classifySentence, fingerprintSimilarity, styleDiffs, splitSentences } from "../lib/analysis.js";
|
|
3
3
|
|
|
4
4
|
const SAMPLE = `雨下了一整夜。她站在窗前,心里想着明天的事。
|
|
5
5
|
“你真的要走吗?”他低声问。
|
|
6
|
-
她沉默了很久,终于开口:“嗯。”
|
|
7
|
-
他走了。门关上的声音很轻,却像重锤砸在她心上。
|
|
8
6
|
难道这就是结局?她不禁这样想。
|
|
9
|
-
她忽然觉得胸口发闷,眼泪毫无预兆地落了下来。
|
|
10
7
|
窗外风急,雨打芭蕉。
|
|
11
8
|
多安静啊!
|
|
12
9
|
快睡吧,明天还要上班。
|
|
13
10
|
……
|
|
14
11
|
`;
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
console.log("句子总数:", sentences.length);
|
|
18
|
-
|
|
13
|
+
let ok = true;
|
|
19
14
|
const checks = [
|
|
20
|
-
["雨下了一整夜。", "statement"],
|
|
21
15
|
["窗外风急,雨打芭蕉。", "environment"],
|
|
22
16
|
["她心里一紧,想起当年那场大火。", "psychology"],
|
|
23
17
|
["「小姐,先歇下吧。」", "dialogue"],
|
|
24
|
-
["“你真的要走吗?”", "dialogue"],
|
|
25
18
|
["难道这就是结局?", "rhetoric-question"],
|
|
26
|
-
["
|
|
27
|
-
["多安静啊!", "exclamation"],
|
|
28
|
-
["快睡吧,明天还要上班。", "imperative"]
|
|
19
|
+
["快睡吧,明天还要上班。", "imperative"], // v0.6.0:命令动词开头 + 吧
|
|
29
20
|
];
|
|
30
|
-
let ok = true;
|
|
31
21
|
for (const [text, expect] of checks) {
|
|
32
22
|
const got = classifySentence(text).type;
|
|
33
|
-
|
|
34
|
-
if (!pass) ok = false;
|
|
35
|
-
console.log((pass ? "✓" : "✗"), expect.padEnd(20), "<=", text.slice(0, 18), "实际:", got);
|
|
23
|
+
if (got !== expect) { ok = false; console.log("✗", expect, "<=", text, "实际:", got); }
|
|
36
24
|
}
|
|
25
|
+
console.log("九类分类抽查:", ok ? "PASS" : "FAIL");
|
|
26
|
+
|
|
27
|
+
// 情感去噪:"叹气"不算"气";"不害怕"不计
|
|
28
|
+
const t1 = "她叹了口气,笑着说:\"没事。\" 她一点都不害怕,反而觉得很温暖。";
|
|
29
|
+
const r1 = analyzeText(t1);
|
|
30
|
+
const angerCount = r1.emotion.scores.find((s) => s.emotion === "anger").count;
|
|
31
|
+
const fearCount = r1.emotion.scores.find((s) => s.emotion === "fear").count;
|
|
32
|
+
console.log("情感去噪: anger(叹气不算):", angerCount, "| fear(不害怕不计):", fearCount, angerCount === 0 && fearCount === 0 ? "PASS" : "FAIL");
|
|
33
|
+
if (angerCount !== 0 || fearCount !== 0) ok = false;
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
console.log("
|
|
42
|
-
console.log("
|
|
43
|
-
|
|
44
|
-
console.log("\n压缩序列:", compressSequence(result.categories.map((c) => TYPE_CODE[c.type])));
|
|
35
|
+
// 相似度与偏差
|
|
36
|
+
const r2 = analyzeText(SAMPLE);
|
|
37
|
+
const sim = fingerprintSimilarity(r1, r2);
|
|
38
|
+
console.log("指纹相似度(不同文本):", sim, sim >= 0 && sim <= 1 ? "PASS" : "FAIL");
|
|
39
|
+
console.log("styleDiffs(自比):", styleDiffs(r1, r1).length === 0 ? "PASS" : "FAIL");
|
|
40
|
+
if (styleDiffs(r1, r1).length !== 0) ok = false;
|
|
45
41
|
|
|
46
|
-
//
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
// 情感曲线分段可调
|
|
43
|
+
const r3 = analyzeText(SAMPLE.repeat(20), { curveSegments: 5 });
|
|
44
|
+
console.log("curveSegments=5 曲线段数:", r3.emotion.curve.length, r3.emotion.curve.length === 5 ? "PASS" : "FAIL");
|
|
45
|
+
if (r3.emotion.curve.length !== 5) ok = false;
|
|
50
46
|
|
|
51
47
|
console.log("\n" + (ok ? "ALL PATTERN TESTS PASSED" : "SOME TESTS FAILED"));
|
|
52
48
|
process.exit(ok ? 0 : 1);
|