dsh-novel-writer 0.9.0 → 1.0.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/lib/index.js CHANGED
@@ -581,6 +581,23 @@ const TOOL_LABELS = Object.freeze({
581
581
  novel_sentence_config: "开关配置", novel_style_check: "风格自检", novel_plot: "伏笔登记",
582
582
  novel_settings: "设定管理", novel_summary: "章节摘要", novel_continuity_check: "连贯性审计"
583
583
  });
584
+
585
+ /**
586
+ * v1.0.0 语用级规范:说话方式特征(称谓/客套/仪式/语气)。
587
+ * 不只管"词",还管"怎么说话"——对应 speechStyle 扫描。
588
+ */
589
+ const SPEECH_STYLE_RULES = {
590
+ western: {
591
+ titleGuideline: "贵族/名门女性称 Miss+名或直呼名;仆人/街坊用中性敬语;不用'小姐XXX'式的官宦称谓",
592
+ honorBad: ["提点", "赐教", "见教", "承蒙", "拜见", "叩见", "启禀", "告退", "大人", "官人", "娘子", "在下", "敝人", "草民", "小人", "老朽", "赏脸", "不敢当", "哪里哪里"],
593
+ honorGood: { "提点": "提醒", "赐教": "请教", "承蒙": "多谢", "在下": "我", "大人": "先生/阁下" },
594
+ ritualBadPatterns: [/上[一二三四五六七八九十百千]*柱?香/g, /[一二三四五六七八九十百千]+柱香/g, /烧香/g, /磕头/g, /跪拜/g],
595
+ ritualGoodNote: "欧式宗教仪式=在祭坛点蜡烛(light candle)并跪祷;禁烧香/上香/上X柱香/磕头",
596
+ toneGuideline: "对话口语化、带停顿词(Ah/well/罢了少见);禁文言四字客套"
597
+ },
598
+ eastern: null
599
+ };
600
+
584
601
  /**
585
602
  * v0.9.0 世界观文化标记词表(detect 用)。
586
603
  * 按"时代错置(anachronism)"分类组织:器物 / 称谓 / 计量 / 宗教仪式 / 市井风貌 / 服饰 / 食物 / 制度。
@@ -1446,6 +1463,16 @@ function normalizeSettingEntry(entry) {
1446
1463
  if (entry.recommended !== void 0 && entry.recommended !== null && typeof entry.recommended === "object") {
1447
1464
  out.recommended = { ...entry.recommended };
1448
1465
  }
1466
+ if (entry.speechStyle !== void 0 && entry.speechStyle !== null && typeof entry.speechStyle === "object") {
1467
+ const speech = {};
1468
+ if (typeof entry.speechStyle.title === "string") speech.title = entry.speechStyle.title;
1469
+ if (typeof entry.speechStyle.tone === "string") speech.tone = entry.speechStyle.tone;
1470
+ if (Array.isArray(entry.speechStyle.honorBad)) speech.honorBad = entry.speechStyle.honorBad.map(String);
1471
+ if (entry.speechStyle.honorGood !== void 0 && typeof entry.speechStyle.honorGood === "object") speech.honorGood = { ...entry.speechStyle.honorGood };
1472
+ if (Array.isArray(entry.speechStyle.ritualBadPatterns)) speech.ritualBadPatterns = entry.speechStyle.ritualBadPatterns.map((x) => String(x));
1473
+ if (typeof entry.speechStyle.ritualGoodNote === "string") speech.ritualGoodNote = entry.speechStyle.ritualGoodNote;
1474
+ out.speechStyle = speech;
1475
+ }
1449
1476
  return out;
1450
1477
  }
1451
1478
  function registerNovelSettings(ctx, config) {
@@ -1471,6 +1498,9 @@ function registerNovelSettings(ctx, config) {
1471
1498
  event: { type: "string", description: "timeline 专用:事件。" },
1472
1499
  chapter: { type: "string", description: "timeline 专用:对应章节。" },
1473
1500
  notes: { type: "string", description: "可选:备注。" },
1501
+ basis: { type: "string", description: "worldview 专用:判断依据/文化基准说明。" },
1502
+ ritual: { type: "string", description: "worldview 专用:仪式规范(如'点烛不烧香')。" },
1503
+ speechStyle: { type: "object", additionalProperties: true, description: "worldview 专用:说话方式规范(title 称谓/honorBad 客套禁词/ritualBadPatterns 仪式禁式/tone 语气·整体为 JSON 对象)。" },
1474
1504
  root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
1475
1505
  },
1476
1506
  required: ["book"],
@@ -1543,6 +1573,21 @@ function registerNovelSettings(ctx, config) {
1543
1573
  if (Array.isArray(args?.alias)) entry.alias = args.alias.map(String);
1544
1574
  if (Array.isArray(args?.bannedWords)) entry.bannedWords = args.bannedWords.map(String);
1545
1575
  if (args?.recommended !== void 0 && typeof args?.recommended === "object") entry.recommended = { ...args.recommended };
1576
+ if (args?.ritual !== void 0) entry.ritual = String(args.ritual);
1577
+ if (args?.speechStyle !== void 0 && typeof args?.speechStyle === "object") {
1578
+ entry.speechStyle = { ...args.speechStyle };
1579
+ } else if (args?.category === "worldview" && args?.basis?.toString().includes("west")) {
1580
+ // 便捷:欧式基准自动带默认语用规范
1581
+ entry.speechStyle = {
1582
+ title: SPEECH_STYLE_RULES.western.titleGuideline,
1583
+ honorBad: SPEECH_STYLE_RULES.western.honorBad,
1584
+ honorGood: { ...SPEECH_STYLE_RULES.western.honorGood },
1585
+ ritualBadPatterns: SPEECH_STYLE_RULES.western.ritualBadPatterns.map((x) => x.source),
1586
+ ritualGoodNote: SPEECH_STYLE_RULES.western.ritualGoodNote,
1587
+ tone: SPEECH_STYLE_RULES.western.toneGuideline
1588
+ };
1589
+ if (!entry.ritual) entry.ritual = SPEECH_STYLE_RULES.western.ritualGoodNote;
1590
+ }
1546
1591
  list.push(entry);
1547
1592
  message = `已登记 ${categoryLabel}:${name}`;
1548
1593
  await writeSettings(root, book, data);
@@ -1838,7 +1883,10 @@ function registerNovelContinuityCheck(ctx, config) {
1838
1883
  if (dup.length > 0) candidates.push({ type: "设定重复", detail: `${key} 中存在重复条目:${[...new Set(dup)].join("、")}`, chapters: [] });
1839
1884
  }
1840
1885
  // v0.9.0 用语风格扫描:对照 worldview 禁用词表(无登记则用默认欧式基准)
1841
- const worldviewEntry = Array.isArray(settings.worldview) ? settings.worldview[0] : void 0;
1886
+ // v1.0.0:取最近登记且含 speechStyle 的 worldview(避免被旧/首条无 speechStyle 覆盖)
1887
+ const worldviewEntry = Array.isArray(settings.worldview)
1888
+ ? settings.worldview.slice().reverse().find((e) => e && e.speechStyle) ?? settings.worldview[0]
1889
+ : void 0;
1842
1890
  const styleRule = (worldviewEntry && Array.isArray(worldviewEntry.bannedWords) && worldviewEntry.bannedWords.length > 0) ? worldviewEntry : DEFAULT_BANNED_WORDS;
1843
1891
  const cultureName = (worldviewEntry && worldviewEntry.name) || styleRule.culture;
1844
1892
  for (const word of styleRule.bannedWords) {
@@ -1852,6 +1900,52 @@ function registerNovelContinuityCheck(ctx, config) {
1852
1900
  });
1853
1901
  }
1854
1902
  }
1903
+ // v1.0.0 语用扫描:词级之上盯"说话方式"(称谓/客套/仪式通配)
1904
+ const speech = worldviewEntry?.speechStyle;
1905
+ if (speech && Array.isArray(speech.honorBad)) {
1906
+ const honorHits = new Map();
1907
+ for (const { file, text } of chapterTexts) {
1908
+ for (const word of speech.honorBad) {
1909
+ if (text.includes(word)) {
1910
+ if (!honorHits.has(word)) honorHits.set(word, []);
1911
+ if (!honorHits.get(word).includes(file)) honorHits.get(word).push(file);
1912
+ }
1913
+ }
1914
+ }
1915
+ for (const [word, files] of honorHits) {
1916
+ const rec = speech.honorGood?.[word] ? `(建议改「${speech.honorGood[word]}」)` : "";
1917
+ candidates.push({ type: "语用冲突·客套", detail: `「${word}」×${files.length}章 为中式客套,与当前世界观不符${rec}`, chapters: files });
1918
+ }
1919
+ }
1920
+ if (speech && Array.isArray(speech.ritualBadPatterns)) {
1921
+ for (const pattern of speech.ritualBadPatterns) {
1922
+ const regex = new RegExp(pattern, "g");
1923
+ const ritualHits = [];
1924
+ for (const { file, text } of chapterTexts) {
1925
+ if (regex.test(text)) ritualHits.push(file);
1926
+ regex.lastIndex = 0;
1927
+ }
1928
+ if (ritualHits.length > 0) {
1929
+ candidates.push({
1930
+ type: "语用冲突·仪式",
1931
+ detail: `检测到烧香/上香类仪式表达(${speech.ritualGoodNote || "应为点烛"})`,
1932
+ chapters: ritualHits
1933
+ });
1934
+ }
1935
+ }
1936
+ }
1937
+ if (speech && typeof speech.title === "string") {
1938
+ // 称谓:登记了"小姐XXX"禁用规则则扫描(title 含"小姐"时)
1939
+ if (speech.title.includes("小姐") || speech.title.includes("不用")) {
1940
+ let count = 0;
1941
+ const titleHits = [];
1942
+ for (const { file, text } of chapterTexts) {
1943
+ const m = text.match(/[A-Za-z\u4e00-\u9fff]+小姐/g);
1944
+ if (m) { count += m.length; if (!titleHits.includes(file)) titleHits.push(file); }
1945
+ }
1946
+ if (count > 0) candidates.push({ type: "语用冲突·称谓", detail: `「XX小姐」×${count} 与称谓规范不符(${speech.title})`, chapters: titleHits });
1947
+ }
1948
+ }
1855
1949
  const advice = candidates.length === 0
1856
1950
  ? "设定表与章节基本一致。建议继续登记新章节出现的新人物/新地点。"
1857
1951
  : "以上为规则候选,请逐条人工判断:确认为矛盾则更新设定表(novel_settings update)或修正正文;非矛盾(如回忆/刻意缺席)可忽略。";
@@ -2495,6 +2589,7 @@ const WORKFLOW_TEXT = [
2495
2589
  "7. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。",
2496
2590
  "8. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔(open 条目,含类型/优先级/提及章节);写完新章节可用 novel_style_check 做风格自检(相似度+偏差+细节密度),novel_plot scan 自动更新伏笔提及记录;",
2497
2591
  " 世界观一致性:续写前先确认文化基准——novel_settings category=worldview action=detect 自动判断(或人工 add/update),动笔时对照其 bannedWords/recommended 用词,避免中西意象混搭(如欧式背景不写老夫/上香/时辰);novel_continuity_check 会扫描用语冲突候选;",
2592
+ " 语用一致性:不只管词,还要管'怎么说话'——对照 worldview 的 speechStyle(title 称谓规范/honorBad 客套禁词/ritualBadPatterns 仪式禁式/tone 语气),人物开口前检查称谓是否欧式(Miss+名)、客套是否避免'提点/承蒙/在下'、宗教仪式是否点烛而非烧香/上X柱香、对话是否口语化不文言;",
2498
2593
  " novel_settings 维护四张设定表(人物/地点/道具/时间线),novel_summary 保存每章摘要(长书续写先读摘要再按需细读),novel_continuity_check 输出设定矛盾候选;",
2499
2594
  " novel_sentence_analysis 结果自动缓存到书库数据目录 <root>/.novel-writer/analysis/(reportFile 字段),novel_keywords 结果同样落盘,需要重算时传 fresh=true。"
2500
2595
  ].join("\n");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-novel-writer",
3
- "version": "0.9.0",
4
- "description": "小说写作助手插件(v0.9.0 世界观层):novel_settings 第五张表「世界观/用语规范」(detect 自动判断文化基准 + 禁用词表 + 推荐替代 + 仪式规范)、continuity 用语风格扫描、续写提示词用语一致性检查",
3
+ "version": "1.0.0",
4
+ "description": "小说写作助手插件(v1.0.0 语用级世界观):worldview 表加 speechStyle 说话方式规范(称谓/客套/仪式/语气)、continuity 语用扫描(含上X柱香通配)、续写语用一致性检查",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -64,7 +64,9 @@
64
64
 
65
65
  - **文化基准自动判断**:§BT§novel_settings§BT§(category=worldview,action=detect)扫描全书,按中西词表命中自动判断文化基准(western/eastern/mixed/unknown)并给出证据与置信度;
66
66
  - **用语规范登记**:detect 后可用 §BT§novel_settings§BT§ add(category=worldview)登记:name(基准名)、basis(判断依据)、bannedWords(禁用词表)、recommended(替代词映射)、ritual(仪式规范,如"点烛不烧香");
67
- - **续写前检查**:动笔前先确认 worldview(detect 或人工指定),对照 bannedWords/recommended 用词;
67
+ - **续写前检查**:动笔前先确认 worldview(detect 或人工指定),对照 bannedWords/recommended 用词,**并对照 speechStyle 检查说话方式**(称谓/客套/仪式/语气——不只管"词",还管"怎么说话");
68
+ - **语用级检查(v1.0.0)**:worldview 的 speechStyle 定义 title(称谓规范)/ honorBad(中式客套禁词)/ ritualBadPatterns(仪式通配,如"上X柱香")/ tone(语气);novel_continuity_check 会输出「语用冲突·客套 / 语用冲突·仪式 / 语用冲突·称谓」三类候选;
69
+ - **示例**(欧式中世纪):Miss+名(不用"XX小姐")、不写"提点/承蒙/在下"、宗教点烛不烧香/不"上柱香"、对话口语化不文言;
68
70
  - **自动化审计**:§BT§novel_continuity_check§BT§ 会自动扫描禁用词命中,输出「用语冲突」候选(含建议替换词);
69
71
  - **时代错置分类**(词表按此组织):器物 / 称谓 / 计量 / 宗教仪式 / 市井风貌 / 服饰 / 食物 / 制度;
70
72
  - **参考资料**(写作时校对):
package/test/e2e-test.mjs CHANGED
@@ -84,7 +84,7 @@ if (!existsSync(plotFile)) throw new Error("plot file not persisted");
84
84
  console.log("伏笔文件:", plotFile, "存在 ✓");
85
85
 
86
86
  // 4) 关键词(三字组/疑似人名)
87
- writeFileSync(join(testRoot, "novels", "测试", "第03章.md"), "露西亚说着话,琉璃点了点头。露西亚问导师问题,琉璃笑道。\n", "utf8");
87
+ writeFileSync(join(testRoot, "novels", "测试", "第03章.md"), "露西亚说着话,琉璃点了点头。露西亚问导师问题,琉璃笑道:\"多谢导师提点,承蒙关照。\" 她又给母神上了一柱香。\n", "utf8");
88
88
  const kw = await defs.novel_keywords.execute({ book: "测试", top: 20 }, exec);
89
89
  console.log("关键词:", kw.keywords.map((k) => k.word + "(" + k.kind + ")").join(" "));
90
90
  if (!kw.keywords.some((k) => k.kind === "name-candidate" && (k.word.includes("露西") || k.word.includes("琉璃")))) throw new Error("name candidate missing");
@@ -96,6 +96,13 @@ if (typeof det.culture !== "string") throw new Error("detect broken");
96
96
  await defs.novel_settings.execute({ book: "测试", category: "worldview", action: "add", name: "欧式中世纪", basis: "教堂/神甫/马车", bannedWords: ["上香", "老夫"], recommended: { "上香": "点烛" }, ritual: "点烛不烧香" }, exec);
97
97
  const wv = await defs.novel_settings.execute({ book: "测试", category: "worldview", action: "list" }, exec);
98
98
  if (!wv.worldview.some((e) => e.name === "欧式中世纪")) throw new Error("worldview add broken");
99
+ // v1.0.0 语用级:worldview 带 speechStyle(欧式)→ 扫描客套/仪式/称谓
100
+ await defs.novel_settings.execute({ book: "测试", category: "worldview", action: "add", name: "欧式基准", basis: "western 风格", bannedWords: [], recommended: {}, speechStyle: { title: "Miss+名,不用XX小姐", honorBad: ["提点", "承蒙"], honorGood: { "提点": "提醒" }, ritualBadPatterns: ["上[一二三四五六七八九十百千]*柱?香"], ritualGoodNote: "点烛", tone: "口语化" } }, exec);
101
+ const cont3 = await defs.novel_continuity_check.execute({ book: "测试" }, exec);
102
+ const prag = cont3.candidates.filter((x) => x.type.startsWith("语用"));
103
+ console.log("v1.0.0 语用扫描:", prag.map((x) => x.type + ":" + x.detail.slice(0, 24)).join(" | ") || "(无)");
104
+ if (prag.length === 0) throw new Error("pragmatic scan broken (test text lacks markers)");
105
+
99
106
  const cont2 = await defs.novel_continuity_check.execute({ book: "测试" }, exec);
100
107
  console.log("continuity 用语扫描:", cont2.candidates.filter((x) => x.type === "用语冲突").map((x) => x.detail.slice(0, 30)).join(" | ") || "(无)");
101
108