@oh-story/dsh 0.1.9 → 0.1.10

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.
Files changed (197) hide show
  1. package/README.md +12 -12
  2. package/lib/client.js +204 -72
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +84 -74
  5. package/lib/drama/skills/short-drama/SKILL.md +2 -2
  6. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +14 -0
  7. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +10 -0
  8. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +6 -9
  9. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +28 -0
  10. package/lib/drama/skills/short-drama-edit/SKILL.md +27 -29
  11. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +2 -2
  12. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +24 -67
  13. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +27 -56
  14. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +37 -79
  15. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +39 -92
  16. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +12 -16
  17. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +126 -11
  18. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +64 -0
  19. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +2 -0
  20. package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +11 -0
  21. package/lib/drama/skills/short-drama-produce/SKILL.md +5 -1
  22. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +14 -20
  23. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +8 -26
  24. package/lib/drama/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
  25. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +142 -1
  26. package/lib/drama/skills/short-drama-review/SKILL.md +2 -3
  27. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +6 -19
  28. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +9 -35
  29. package/lib/drama/skills/short-drama-storyboard/SKILL.md +10 -6
  30. package/lib/drama/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
  31. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +58 -21
  32. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +1 -1
  33. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +10 -9
  34. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +17 -0
  35. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +12 -38
  36. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +4 -7
  37. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +8 -22
  38. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +6 -19
  39. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +2 -3
  40. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +34 -9
  41. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +2 -2
  42. package/lib/drama/skills/short-drama-write/SKILL.md +2 -4
  43. package/lib/index.js +289 -91
  44. package/lib/index.js.map +4 -4
  45. package/lib/novel-to-game/README.md +299 -59
  46. package/lib/novel-to-game/README_ZH.md +299 -56
  47. package/lib/novel-to-game/VERSION +1 -1
  48. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +3 -1
  49. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +11 -3
  50. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +397 -8
  51. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +0 -31
  52. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +71 -73
  53. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +78 -89
  54. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +37 -14
  55. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +5 -5
  56. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -1
  57. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +11 -16
  58. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +11 -11
  59. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +23 -0
  60. package/lib/novel-to-game/manifest.json +77 -87
  61. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +7 -12
  62. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +27 -106
  63. package/lib/novel-to-game/skills/game-build/SKILL.md +13 -14
  64. package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +24 -0
  65. package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +45 -82
  66. package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +14 -45
  67. package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +30 -65
  68. package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +13 -27
  69. package/lib/novel-to-game/skills/game-concept/SKILL.md +13 -15
  70. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +28 -75
  71. package/lib/novel-to-game/skills/game-qa/SKILL.md +6 -7
  72. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +9 -5
  73. package/lib/novel-to-game/skills/game-world-design/SKILL.md +12 -16
  74. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +23 -72
  75. package/lib/novel-to-game/skills/game-world-design/references/numeric-design-method.md +14 -48
  76. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +36 -103
  77. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +4 -10
  78. package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +4 -4
  79. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +9 -21
  80. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +5 -4
  81. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +5 -8
  82. package/lib/oh-story/manifest.json +230 -185
  83. package/lib/oh-story/roles/chapter-extractor.md +177 -237
  84. package/lib/oh-story/roles/consistency-checker.md +4 -4
  85. package/lib/oh-story/roles/narrative-writer.md +24 -25
  86. package/lib/oh-story/roles/story-architect.md +5 -5
  87. package/lib/oh-story/roles/story-explorer.md +11 -11
  88. package/lib/oh-story/skills/story/SKILL.md +13 -7
  89. package/lib/oh-story/skills/story/VERSION +1 -1
  90. package/lib/oh-story/skills/story/references/author-memory.md +54 -24
  91. package/lib/oh-story/skills/story/scripts/author_memory_commit.py +934 -170
  92. package/lib/oh-story/skills/story-cover/SKILL.md +10 -1
  93. package/lib/oh-story/skills/story-deslop/SKILL.md +40 -53
  94. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +11 -10
  95. package/lib/oh-story/skills/story-deslop/references/author-memory.md +54 -24
  96. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +1 -1
  97. package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +934 -170
  98. package/lib/oh-story/skills/story-import/SKILL.md +77 -81
  99. package/lib/oh-story/skills/story-import/references/character-state-reverse.md +1 -1
  100. package/lib/oh-story/skills/story-import/references/state-tracking.md +4 -69
  101. package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +10 -8
  102. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +8 -6
  103. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +34 -16
  104. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +210 -2
  105. package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +3 -0
  106. package/lib/oh-story/skills/story-long-analyze/SKILL.md +91 -258
  107. package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +245 -0
  108. package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +2 -2
  109. package/lib/oh-story/skills/story-long-analyze/references/inspiration-library.md +105 -0
  110. package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +113 -105
  111. package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +169 -125
  112. package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +172 -64
  113. package/lib/oh-story/skills/story-long-analyze/references/semantic-acceptance-fixtures.md +84 -0
  114. package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +42 -132
  115. package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +5 -5
  116. package/lib/oh-story/skills/story-long-analyze/scripts/build_chapter_index.py +466 -0
  117. package/lib/oh-story/skills/story-long-analyze/scripts/inspect_existing_assets.py +702 -0
  118. package/lib/oh-story/skills/story-long-analyze/scripts/inspiration_index.py +864 -0
  119. package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +1106 -0
  120. package/lib/oh-story/skills/story-long-analyze/scripts/render_relation_chart.py +425 -0
  121. package/lib/oh-story/skills/story-long-scan/SKILL.md +4 -1
  122. package/lib/oh-story/skills/story-long-write/SKILL.md +38 -29
  123. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +11 -10
  124. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +25 -20
  125. package/lib/oh-story/skills/story-long-write/references/author-memory.md +54 -24
  126. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +3 -3
  127. package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +26 -0
  128. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +4 -0
  129. package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +2 -2
  130. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +4 -13
  131. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +1 -1
  132. package/lib/oh-story/skills/story-long-write/references/project-files.md +9 -7
  133. package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +12 -4
  134. package/lib/oh-story/skills/story-long-write/references/reference-index.md +1 -2
  135. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +8 -6
  136. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +86 -115
  137. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +55 -120
  138. package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +15 -11
  139. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +66 -68
  140. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +3 -3
  141. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +4 -12
  142. package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +934 -170
  143. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +139 -18
  144. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +34 -16
  145. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +5 -3
  146. package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +58 -0
  147. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +210 -2
  148. package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +3 -0
  149. package/lib/oh-story/skills/story-review/SKILL.md +68 -85
  150. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +11 -10
  151. package/lib/oh-story/skills/story-review/references/author-memory.md +54 -24
  152. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +4 -0
  153. package/lib/oh-story/skills/story-review/references/review-quality.md +4 -10
  154. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +8 -6
  155. package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +934 -170
  156. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +210 -2
  157. package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +3 -0
  158. package/lib/oh-story/skills/story-setup/SKILL.md +50 -42
  159. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +11 -10
  160. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +1 -1
  161. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +4 -0
  162. package/lib/oh-story/skills/story-setup/references/agent-references/long-emotional-methods.md +2 -2
  163. package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +1 -1
  164. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +4 -12
  165. package/lib/oh-story/skills/story-setup/references/diagnostics.md +1 -1
  166. package/lib/oh-story/skills/story-short-analyze/SKILL.md +22 -3
  167. package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +5 -1
  168. package/lib/oh-story/skills/story-short-scan/SKILL.md +5 -1
  169. package/lib/oh-story/skills/story-short-write/SKILL.md +6 -147
  170. package/lib/oh-story/skills/story-short-write/references/author-memory.md +54 -24
  171. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +4 -0
  172. package/lib/oh-story/skills/story-short-write/references/reference-index.md +2 -0
  173. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +3 -2
  174. package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +126 -0
  175. package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +32 -0
  176. package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +934 -170
  177. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  178. package/lib/types/client/file-activity.d.ts +19 -1
  179. package/lib/types/client/file-activity.d.ts.map +1 -1
  180. package/lib/types/client/index.d.ts.map +1 -1
  181. package/lib/types/client/production-prompts.d.ts +7 -0
  182. package/lib/types/client/production-prompts.d.ts.map +1 -1
  183. package/lib/types/client/production-runtime.d.ts +16 -0
  184. package/lib/types/client/production-runtime.d.ts.map +1 -1
  185. package/lib/types/drama-adapters.d.ts +5 -3
  186. package/lib/types/drama-adapters.d.ts.map +1 -1
  187. package/lib/types/native-hooks.d.ts +52 -0
  188. package/lib/types/native-hooks.d.ts.map +1 -1
  189. package/lib/types/role-identity.d.ts +17 -0
  190. package/lib/types/role-identity.d.ts.map +1 -0
  191. package/lib/types/role-tool.d.ts.map +1 -1
  192. package/lib/types/skill-provider.d.ts.map +1 -1
  193. package/package.json +45 -45
  194. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +0 -58
  195. package/lib/novel-to-game/skills/game-world-design/references/game-writing-craft.md +0 -53
  196. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -38
  197. package/lib/oh-story/skills/story-long-write/references/state-tracking.md +0 -108
@@ -0,0 +1,864 @@
1
+ #!/usr/bin/env python3
2
+ """Register inspiration atoms from EM mechanism cards, then validate/query the index.
3
+
4
+ 三层灵感库的机械层。原子灵感(IA)不再生成独立文件:每个 IA 是
5
+ `灵感索引.csv` 里的一行登记,机制全文只在 `拆文库/{书}/剧情/情绪模块.md`
6
+ 的 EM 卡一处维护,需要时按 ID 回查。NM/CBA 卡内禁止路径引用,
7
+ 来源只记 `书名/EM-xxx` 式裸 ID;路径解析统一走索引。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import csv
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ import tempfile
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ COLUMNS = (
23
+ "item_id",
24
+ "layer",
25
+ "title",
26
+ "source_book",
27
+ "path",
28
+ "source_ids",
29
+ "novel_count",
30
+ "atom_count",
31
+ "grade",
32
+ "tags",
33
+ "status",
34
+ )
35
+ LAYERS = {"原子灵感": "IA-", "单小说灵感合并": "NM-", "跨书灵感聚合": "CBA-"}
36
+ TAG_AXES = {"题材", "读者需求", "情绪", "关系动作", "剧情功能", "节奏位置", "适用阶段", "风险"}
37
+ REQUIRED_CBA_AXES = {"题材", "读者需求", "情绪", "剧情功能", "适用阶段", "风险"}
38
+ CORE_QUERY_AXES = {"题材", "读者需求", "情绪", "剧情功能", "适用阶段"}
39
+ # EM 完整卡必须具备的五个灵感映射字段(缺失=Stage 3 卡片质量问题,报错回拆文侧修复)
40
+ EM_REQUIRED_FIELDS = ("读者想看什么", "情绪链", "戏剧单元", "可替换项", "不可照搬")
41
+ # 专名泄漏扫描范围:排除「不可照搬」——该字段的职责就是点名原书专名
42
+ EM_LEAK_SCAN_FIELDS = ("读者想看什么", "情绪链", "戏剧单元", "可替换项")
43
+ EM_HEADER_RE = re.compile(r"^###\s+[*_]*(EM-[0-9]{2,})(?![0-9])[*_]*\s*(?:[·\-—::||]\s*)?(.*?)[\s*_]*$")
44
+ # 以 EM 编号开头、却不是 `### EM-xxx` 的标题行:下一张卡会被静默并进上一张
45
+ EM_MISPLACED_HEADER_RE = re.compile(r"^#{1,6}\s+[*_【\[]*EM-[0-9]")
46
+ EM_TABLE_SEPARATOR_RE = re.compile(r"^:?-+:?$")
47
+ EM_INDEX_ID_RE = re.compile(r"(EM-[0-9]{2,})")
48
+ # 字段行的两种体裁:表格 `| 字段 | 值 |` 与粗体列表 `- **字段**:值`(前导 `- ` 可省)
49
+ EM_BOLD_FIELD_RE = re.compile(r"^[-*]?\s*\*{2,3}([^*]+?)(?:[::]\*{2,3}|\*{2,3}\s*[::])\s*(.*)$")
50
+ # 同义字段名归一;表头行的首列词不作为字段
51
+ EM_FIELD_ALIASES = {"不可照搬项": "不可照搬", "可替换项目": "可替换项"}
52
+ EM_TABLE_HEADER_KEYS = {"字段", "维度", "---", ""}
53
+ CARD_PATH_RE = re.compile(r"\]\(|\.md\)|原子灵感/|单小说灵感合并/|跨书灵感聚合/|拆文库/")
54
+
55
+
56
+ def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
59
+ try:
60
+ with os.fdopen(fd, "w", encoding=encoding, newline="") as handle:
61
+ handle.write(text)
62
+ handle.flush()
63
+ os.fsync(handle.fileno())
64
+ os.replace(temp_name, path)
65
+ except BaseException:
66
+ try:
67
+ os.unlink(temp_name)
68
+ except OSError:
69
+ pass
70
+ raise
71
+
72
+
73
+ def csv_text(rows: list[dict[str, str]]) -> str:
74
+ import io
75
+
76
+ buffer = io.StringIO(newline="")
77
+ writer = csv.DictWriter(buffer, fieldnames=COLUMNS, lineterminator="\n")
78
+ writer.writeheader()
79
+ writer.writerows(rows)
80
+ return "" + buffer.getvalue()
81
+
82
+
83
+ def normalize_em_field(key: str) -> str:
84
+ """去掉字段名上的粗体标记与空白,再按同义表归一。"""
85
+ return EM_FIELD_ALIASES.get(key.strip().strip("*").strip(), key.strip().strip("*").strip())
86
+
87
+
88
+ def parse_em_module(module_text: str) -> tuple[list[dict[str, str]], list[tuple[str, str]], list[str]]:
89
+ """Return (complete cards, index-only entries, structural problems) from 情绪模块.md text.
90
+
91
+ 完整卡=`### EM-xxx 名称` 小节内的字段行,表格 `|字段|内容|` 与粗体列表 `- **字段**:内容` 都接受;
92
+ 粗体字段同行没写值时,吸收其后到下一个字段/标题为止的列表或段落行作为多行值。
93
+ 索引条目=「其他机制索引」小节里出现 EM-xxx 的行(机制ID|名称|…)。
94
+ 认不出的 EM 标题行和同卡重复字段记为问题:它们意味着下一张卡被并进了上一张。
95
+ """
96
+ lines = module_text.split("\n")
97
+ cards: list[dict[str, str]] = []
98
+ index_entries: list[tuple[str, str]] = []
99
+ problems: list[str] = []
100
+ current: dict[str, str] | None = None
101
+ pending_field: str | None = None
102
+ in_index_section = False
103
+ for line in lines:
104
+ stripped = line.strip()
105
+ header = EM_HEADER_RE.match(stripped)
106
+ if header:
107
+ current = {"em_id": header.group(1), "title": header.group(2)}
108
+ cards.append(current)
109
+ in_index_section = False
110
+ pending_field = None
111
+ continue
112
+ if EM_MISPLACED_HEADER_RE.match(stripped):
113
+ problems.append(f"em_header_unrecognized:{stripped[:40]}——EM 卡标题须写成 `### EM-xxx 名称`")
114
+ current = None
115
+ pending_field = None
116
+ continue
117
+ if stripped.startswith("## "):
118
+ in_index_section = "其他机制索引" in stripped
119
+ current = None
120
+ pending_field = None
121
+ continue
122
+ if current is not None and stripped.startswith("|"):
123
+ pending_field = None
124
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
125
+ if len(cells) >= 2:
126
+ key = normalize_em_field(cells[0])
127
+ if key not in EM_TABLE_HEADER_KEYS and not EM_TABLE_SEPARATOR_RE.match(key):
128
+ if key in EM_REQUIRED_FIELDS and key in current:
129
+ problems.append(f"{current['em_id']}:em_field_duplicate:{key}")
130
+ current[key] = cells[1]
131
+ continue
132
+ if current is not None:
133
+ # 多行值:字段名行之后缩进的列表行一律是续行,哪怕它自己带粗体小标题
134
+ if pending_field is not None and stripped and line[:1] in (" ", "\t"):
135
+ appended = stripped.lstrip("-*").strip()
136
+ current[pending_field] = f"{current[pending_field]};{appended}" if current[pending_field] else appended
137
+ continue
138
+ bold = EM_BOLD_FIELD_RE.match(stripped)
139
+ if bold:
140
+ key = normalize_em_field(bold.group(1))
141
+ value = bold.group(2).strip()
142
+ if key in EM_REQUIRED_FIELDS and key in current:
143
+ problems.append(f"{current['em_id']}:em_field_duplicate:{key}")
144
+ current[key] = value
145
+ pending_field = None if value else key
146
+ continue
147
+ if pending_field is not None and stripped.startswith("#"):
148
+ pending_field = None
149
+ continue
150
+ if pending_field is not None and stripped:
151
+ appended = stripped.lstrip("-*").strip()
152
+ if appended:
153
+ current[pending_field] = (
154
+ f"{current[pending_field]};{appended}" if current[pending_field] else appended
155
+ )
156
+ continue
157
+ if in_index_section and stripped:
158
+ match = EM_INDEX_ID_RE.search(stripped)
159
+ if match:
160
+ parts = [part.strip() for part in re.split(r"[||]", stripped.strip("|| ")) if part.strip()]
161
+ name = parts[1] if len(parts) >= 2 and parts[0] == match.group(1) else (parts[0] if parts else match.group(1))
162
+ if name == match.group(1) and len(parts) >= 2:
163
+ name = parts[1]
164
+ index_entries.append((match.group(1), name))
165
+ return cards, index_entries, problems
166
+
167
+
168
+ def resolve_workspace(root: Path, explicit: Path | None = None) -> Path:
169
+ """定位含 `拆文库/` 的工作区——泄漏门名单与 EM 集合校验都依赖它。
170
+
171
+ 显式 `--workspace` 优先;否则从 `--root` 起向上最多探测 5 级。
172
+ 定位失败必须报错而不是回退空名单:名单为空集时泄漏检查等于没跑。
173
+ """
174
+ if explicit is not None:
175
+ if (explicit / "拆文库").is_dir():
176
+ return explicit
177
+ raise ValueError(f"workspace_invalid:{explicit} 下没有 拆文库/")
178
+ candidate = root.resolve()
179
+ for _ in range(6):
180
+ if (candidate / "拆文库").is_dir():
181
+ return candidate
182
+ if candidate.parent == candidate:
183
+ break
184
+ candidate = candidate.parent
185
+ raise ValueError("workspace_not_located:从 --root 向上未找到含 拆文库/ 的目录——用 --workspace 显式指定")
186
+
187
+
188
+ EM_ARROW_RE = re.compile(r"→|⟶|->")
189
+
190
+
191
+ def replaceable_antipattern_hits(value: str, names: set[str]) -> list[str]:
192
+ """「专名→任意X」反模式:箭头左侧命中角色名单即高置信真引用。"""
193
+ hits: set[str] = set()
194
+ for segment in re.split(r"[;;,,、||]", value):
195
+ if not EM_ARROW_RE.search(segment):
196
+ continue
197
+ left = EM_ARROW_RE.split(segment)[0]
198
+ hits.update(name for name in names if name in left)
199
+ return sorted(hits)
200
+
201
+
202
+ def character_names(workspace: Path, book: str) -> set[str]:
203
+ role_dir = workspace / "拆文库" / book / "角色"
204
+ names: set[str] = set()
205
+ if role_dir.is_dir():
206
+ for entry in role_dir.rglob("*.md"):
207
+ stem = re.sub(r"^\d+[-_.、\s]*", "", entry.stem)
208
+ if len(stem) >= 2 and stem not in {"角色关系", "README", "readme", "索引", "目录"}:
209
+ names.add(stem)
210
+ return names
211
+
212
+
213
+ def load_numbered_rows(root: Path) -> tuple[list[tuple[int, dict[str, str]]], list[str]]:
214
+ """索引行连同其文件行号;列数不对的行报错并剔除,行号仍按文件实际位置。"""
215
+ errors: list[str] = []
216
+ index_path = root / "灵感索引.csv"
217
+ try:
218
+ with index_path.open("r", encoding="utf-8-sig", newline="") as handle:
219
+ reader = csv.DictReader(handle)
220
+ rows: list[tuple[int, dict[str, str]]] = []
221
+ for row in reader:
222
+ if None in row or None in row.values():
223
+ errors.append(f"line_{reader.line_num}:column_count_mismatch")
224
+ continue
225
+ rows.append((reader.line_num, row))
226
+ if tuple(reader.fieldnames or ()) != COLUMNS:
227
+ errors.append("index_header_mismatch")
228
+ except (OSError, UnicodeError, csv.Error) as exc:
229
+ return [], [f"index_unreadable:{exc}"]
230
+ return rows, errors
231
+
232
+
233
+ def load_rows(root: Path) -> tuple[list[dict[str, str]], list[str]]:
234
+ numbered, errors = load_numbered_rows(root)
235
+ return [row for _, row in numbered], errors
236
+
237
+
238
+ class RegisterError(ValueError):
239
+ """携带完整问题清单的登记失败——一次报全,避免逐轮试跑。"""
240
+
241
+ def __init__(self, errors: list[str], warnings: list[str] | None = None) -> None:
242
+ super().__init__("\n".join(errors))
243
+ self.errors = errors
244
+ self.warnings = warnings or []
245
+
246
+
247
+ def analyze_module(root: Path, module_path: Path, book: str,
248
+ workspace: Path | None = None) -> dict[str, Any]:
249
+ """解析并检查一本书的 EM 卡,收集全部 errors/warnings,不写盘。"""
250
+ errors: list[str] = []
251
+ warnings: list[str] = []
252
+ if not book or book in {".", ".."} or "/" in book or "\\" in book:
253
+ raise RegisterError([f"book_name_invalid:{book!r}——--book 只写书名本身"])
254
+ try:
255
+ ws = resolve_workspace(root, workspace)
256
+ except ValueError as exc:
257
+ raise RegisterError([str(exc)]) from exc
258
+ try:
259
+ module_text = module_path.read_text(encoding="utf-8-sig")
260
+ except (OSError, UnicodeError) as exc:
261
+ raise RegisterError([f"emotion_module_unreadable:{exc}"]) from exc
262
+ cards, index_entries, problems = parse_em_module(module_text)
263
+ if not cards and not index_entries:
264
+ raise RegisterError(["emotion_module_has_no_em_cards"])
265
+ errors.extend(problems)
266
+
267
+ book_dir = ws / "拆文库" / book
268
+ if not book_dir.is_dir():
269
+ errors.append(f"book_dir_not_found:拆文库/{book}——工作区 {ws} 下没有这本书,泄漏门无法取角色名单")
270
+ elif book_dir.resolve() not in module_path.resolve().parents:
271
+ errors.append(f"module_book_mismatch:{module_path} 不在 拆文库/{book}/ 下——--book 与 --module 必须是同一本书")
272
+ names = character_names(ws, book)
273
+ if not names and book_dir.is_dir():
274
+ warnings.append(f"character_roster_missing:拆文库/{book}/角色/ 不存在或为空——泄漏门本次没有名单可查,请人工确认卡内无专名")
275
+
276
+ seen: set[str] = set()
277
+ atom_rows: list[dict[str, str]] = []
278
+ for card in cards:
279
+ em_id = card["em_id"]
280
+ if em_id in seen:
281
+ errors.append(f"em_id_duplicate:{em_id}")
282
+ continue
283
+ seen.add(em_id)
284
+ card_errors = 0
285
+ if not card["title"].strip():
286
+ errors.append(f"{em_id}:em_title_missing——EM 卡标题须写成 `### {em_id} 名称`")
287
+ card_errors += 1
288
+ absent = [field for field in EM_REQUIRED_FIELDS if field not in card]
289
+ empty = [field for field in EM_REQUIRED_FIELDS if field in card and not card[field].strip()]
290
+ if absent:
291
+ errors.append(f"{em_id}:em_fields_missing:{'|'.join(absent)}——请回 story-long-analyze Stage 3 补全该模块卡")
292
+ card_errors += 1
293
+ if empty:
294
+ errors.append(f"{em_id}:em_field_value_empty:{'|'.join(empty)}——字段在但值为空;多行值须紧跟字段名行(支持列表/段落)")
295
+ card_errors += 1
296
+
297
+ field_texts = {"标题": card.get("title", "")}
298
+ field_texts.update({field: card.get(field, "") for field in EM_LEAK_SCAN_FIELDS})
299
+ hit_locations = {
300
+ name: [field for field, text in field_texts.items() if name in text]
301
+ for name in sorted(names)
302
+ if any(name in text for text in field_texts.values())
303
+ }
304
+ antipattern = replaceable_antipattern_hits(card.get("可替换项", ""), names)
305
+ non_portable = card.get("不可照搬", "")
306
+ for name in antipattern:
307
+ errors.append(f"{em_id}:replaceable_antipattern:{name}——「专名→任意X」把专名写进抽象字段,改写成「功能位→任意X」")
308
+ card_errors += 1
309
+ for name, locations in hit_locations.items():
310
+ if name in antipattern:
311
+ continue
312
+ if name in non_portable:
313
+ errors.append(f"{em_id}:source_specific_name_in_mechanism:{name}@{'|'.join(locations)}——本卡「不可照搬」已点名该专名却仍在抽象字段使用,请回 Stage 3 去专名后重试")
314
+ card_errors += 1
315
+ else:
316
+ warnings.append(f"{em_id}:leak_suspect:{name}@{'|'.join(locations)}——角色卡名与通用职能词无法机械区分,请人工复核;确认是专名请回 Stage 3 修卡")
317
+ if card_errors == 0:
318
+ atom_rows.append(_ia_row(book, em_id, card["title"], grade="full"))
319
+ for em_id, title in index_entries:
320
+ if em_id in seen:
321
+ continue
322
+ seen.add(em_id)
323
+ for name in sorted(name for name in names if name in title):
324
+ warnings.append(f"{em_id}:leak_suspect:{name}@索引标题——请人工复核;确认是专名请回 Stage 3 修索引条目")
325
+ atom_rows.append(_ia_row(book, em_id, title, grade="index"))
326
+ return {
327
+ "book": book,
328
+ "cards_full": len(cards),
329
+ "cards_index": sum(1 for em_id, _ in index_entries if em_id not in {card["em_id"] for card in cards}),
330
+ "character_roster": len(names),
331
+ "errors": errors,
332
+ "warnings": warnings,
333
+ "atom_rows": atom_rows,
334
+ }
335
+
336
+
337
+ def check_atoms(root: Path, module_path: Path, book: str,
338
+ workspace: Path | None = None) -> dict[str, Any]:
339
+ """只读自检:报出一本书的卡数、名单规模与全部问题,不写任何文件。"""
340
+ try:
341
+ report = analyze_module(root, module_path, book, workspace)
342
+ except RegisterError as exc:
343
+ return {"ok": False, "book": book, "errors": exc.errors, "warnings": exc.warnings}
344
+ report.pop("atom_rows")
345
+ report["ok"] = not report["errors"]
346
+ return report
347
+
348
+
349
+ def register_atoms(root: Path, module_path: Path, book: str,
350
+ workspace: Path | None = None) -> dict[str, Any]:
351
+ report = analyze_module(root, module_path, book, workspace)
352
+ if report["errors"]:
353
+ raise RegisterError(report["errors"], report["warnings"])
354
+ atom_rows = report["atom_rows"]
355
+
356
+ existing, errors = load_rows(root) if (root / "灵感索引.csv").is_file() else ([], [])
357
+ if errors:
358
+ raise RegisterError(errors, report["warnings"])
359
+ preserved = [
360
+ row
361
+ for row in existing
362
+ if not (row.get("layer") == "原子灵感" and row.get("source_book") == book)
363
+ ]
364
+ layer_order = {"原子灵感": 0, "单小说灵感合并": 1, "跨书灵感聚合": 2}
365
+ combined = preserved + atom_rows
366
+ combined.sort(
367
+ key=lambda row: (
368
+ layer_order.get(row.get("layer", ""), 9),
369
+ row.get("source_book", ""),
370
+ row.get("item_id", ""),
371
+ )
372
+ )
373
+ atomic_write_text(root / "灵感索引.csv", csv_text(combined), encoding="utf-8")
374
+ return {
375
+ "ok": True,
376
+ "book": book,
377
+ "atoms_full": sum(1 for row in atom_rows if row["grade"] == "full"),
378
+ "atoms_index": sum(1 for row in atom_rows if row["grade"] == "index"),
379
+ "index_writes": 1,
380
+ "character_roster": report["character_roster"],
381
+ "warnings": report["warnings"],
382
+ }
383
+
384
+
385
+ def _ia_row(book: str, em_id: str, title: str, grade: str) -> dict[str, str]:
386
+ return {
387
+ "item_id": em_id.replace("EM-", "IA-"),
388
+ "layer": "原子灵感",
389
+ "title": title.strip(),
390
+ "source_book": book,
391
+ "path": "",
392
+ "source_ids": em_id,
393
+ "novel_count": "1",
394
+ "atom_count": "1",
395
+ "grade": grade,
396
+ "tags": "",
397
+ "status": "active",
398
+ }
399
+
400
+
401
+ def parse_tags(raw: str) -> tuple[dict[str, set[str]], list[str]]:
402
+ result: dict[str, set[str]] = {}
403
+ errors: list[str] = []
404
+ if not raw.strip():
405
+ return result, errors
406
+ for part in raw.split(";"):
407
+ if not part.strip():
408
+ continue
409
+ if "=" not in part:
410
+ errors.append(f"tag_missing_equals:{part}")
411
+ continue
412
+ axis, values = (piece.strip() for piece in part.split("=", 1))
413
+ if axis not in TAG_AXES:
414
+ errors.append(f"tag_axis_unknown:{axis}")
415
+ continue
416
+ parsed = {value.strip() for value in values.split("|") if value.strip()}
417
+ if not parsed:
418
+ errors.append(f"tag_value_empty:{axis}")
419
+ continue
420
+ result.setdefault(axis, set()).update(parsed)
421
+ return result, errors
422
+
423
+
424
+ def positive_int(raw: str) -> int | None:
425
+ try:
426
+ value = int(raw)
427
+ except (TypeError, ValueError):
428
+ return None
429
+ return value if value >= 1 else None
430
+
431
+
432
+ def source_ids(raw: str) -> list[str]:
433
+ return [item.strip() for item in re.split(r"[|;]", raw) if item.strip()]
434
+
435
+
436
+ def load_book_em_ids(workspace: Path, source_book: str) -> tuple[set[str], str | None]:
437
+ module_path = workspace / "拆文库" / source_book / "剧情" / "情绪模块.md"
438
+ try:
439
+ module_text = module_path.read_text(encoding="utf-8-sig")
440
+ except (OSError, UnicodeError):
441
+ return set(), "emotion_module_unreadable"
442
+ cards, index_entries, _ = parse_em_module(module_text)
443
+ ids = {card["em_id"] for card in cards} | {em_id for em_id, _ in index_entries}
444
+ return ids, None
445
+
446
+
447
+ def validate(root: Path, workspace: Path | None = None) -> list[str]:
448
+ numbered, errors = load_numbered_rows(root)
449
+ rows = [row for _, row in numbered]
450
+ try:
451
+ ws: Path | None = resolve_workspace(root, workspace)
452
+ except ValueError as exc:
453
+ ws = None
454
+ workspace_error = str(exc)
455
+ vocabulary = load_vocabulary(root)
456
+ if vocabulary is not None:
457
+ errors.extend(vocabulary_errors(vocabulary))
458
+ seen: set[tuple[str, str, str]] = set()
459
+ ia_by_book: dict[str, dict[str, dict[str, str]]] = {}
460
+ nm_by_book: dict[str, dict[str, dict[str, str]]] = {}
461
+ active_single_book_cba: dict[str, int] = {}
462
+ for number, row in numbered:
463
+ item_id = row.get("item_id", "").strip()
464
+ layer = row.get("layer", "").strip()
465
+ book = row.get("source_book", "").strip()
466
+ prefix = LAYERS.get(layer)
467
+ if not prefix:
468
+ errors.append(f"line_{number}:layer_invalid")
469
+ continue
470
+ unique_key = (layer, book if layer != "跨书灵感聚合" else "", item_id)
471
+ if not item_id.startswith(prefix) or unique_key in seen:
472
+ errors.append(f"line_{number}:item_id_invalid_or_duplicate")
473
+ seen.add(unique_key)
474
+ if layer == "原子灵感":
475
+ ia_by_book.setdefault(book, {})[item_id] = row
476
+ if row.get("path", "").strip():
477
+ errors.append(f"line_{number}:ia_must_not_have_card_file")
478
+ if row.get("grade", "").strip() not in {"full", "index"}:
479
+ errors.append(f"line_{number}:ia_grade_invalid")
480
+ else:
481
+ if layer == "单小说灵感合并":
482
+ nm_by_book.setdefault(book, {})[item_id] = row
483
+ relative = row.get("path", "").strip().replace("\\", "/")
484
+ if not relative.startswith(f"{layer}/") or ".." in relative.split("/"):
485
+ errors.append(f"line_{number}:path_outside_layer")
486
+ card_text = ""
487
+ elif not (root / Path(relative)).is_file():
488
+ errors.append(f"line_{number}:path_missing")
489
+ card_text = ""
490
+ else:
491
+ try:
492
+ card_text = (root / Path(relative)).read_text(encoding="utf-8")
493
+ except (OSError, UnicodeError):
494
+ card_text = ""
495
+ if card_text and CARD_PATH_RE.search(card_text):
496
+ errors.append(f"line_{number}:path_reference_in_card——卡内只允许裸 ID,路径按 ID 查灵感索引.csv")
497
+ if row.get("grade", "").strip():
498
+ errors.append(f"line_{number}:grade_reserved_for_ia")
499
+ tags, tag_errors = parse_tags(row.get("tags", ""))
500
+ errors.extend(f"line_{number}:{error}" for error in tag_errors)
501
+ if layer == "跨书灵感聚合":
502
+ missing = REQUIRED_CBA_AXES - set(tags)
503
+ if missing:
504
+ errors.append(f"line_{number}:cba_tags_missing:{'|'.join(sorted(missing))}")
505
+ if vocabulary is not None:
506
+ for axis, values in sorted(tags.items()):
507
+ if axis not in vocabulary:
508
+ continue
509
+ for value in sorted(values - set(vocabulary[axis])):
510
+ errors.append(f"line_{number}:tag_value_not_in_vocabulary:{axis}={value}——先对照 标签词表.md:同义就用表内值,确属新维度先受控扩表")
511
+ novel_count = positive_int(row.get("novel_count", ""))
512
+ if novel_count is None:
513
+ errors.append(f"line_{number}:novel_count_invalid")
514
+ if positive_int(row.get("atom_count", "")) is None:
515
+ errors.append(f"line_{number}:atom_count_invalid")
516
+ if not row.get("source_ids", "").strip():
517
+ errors.append(f"line_{number}:source_ids_missing")
518
+ if novel_count == 1 and "单书假设" not in card_text:
519
+ errors.append(f"line_{number}:single_book_hypothesis_marker_missing")
520
+ if novel_count is not None and novel_count >= 2 and "跨书重复验证" not in card_text:
521
+ errors.append(f"line_{number}:cross_book_validation_marker_missing")
522
+ elif tags:
523
+ errors.append(f"line_{number}:tags_reserved_for_cba")
524
+
525
+ for book, atoms in ia_by_book.items():
526
+ if ws is None:
527
+ errors.append(f"book_{book}:{workspace_error}")
528
+ module_error: str | None = "workspace_not_located"
529
+ em_ids = set()
530
+ else:
531
+ em_ids, module_error = load_book_em_ids(ws, book)
532
+ if module_error:
533
+ errors.append(f"book_{book}:{module_error}")
534
+ registered: set[str] = set()
535
+ for item_id, row in atoms.items():
536
+ refs = source_ids(row.get("source_ids", ""))
537
+ if len(refs) != 1 or not refs[0].startswith("EM-"):
538
+ errors.append(f"{book}/{item_id}:ia_source_em_invalid")
539
+ continue
540
+ if refs[0].replace("EM-", "IA-") != item_id:
541
+ errors.append(f"{book}/{item_id}:ia_id_must_mirror_em_id")
542
+ registered.add(refs[0])
543
+ if positive_int(row.get("novel_count", "")) != 1 or positive_int(row.get("atom_count", "")) != 1:
544
+ errors.append(f"{book}/{item_id}:ia_counts_must_equal_one")
545
+ if not module_error and registered != em_ids:
546
+ missing = sorted(em_ids - registered)
547
+ extra = sorted(registered - em_ids)
548
+ errors.append(f"book_{book}:ia_em_set_mismatch:missing={missing}:extra={extra}")
549
+
550
+ for book, merges in nm_by_book.items():
551
+ atoms = ia_by_book.get(book, {})
552
+ atom_em = {row.get("source_ids", "").strip() for row in atoms.values()}
553
+ for item_id, row in merges.items():
554
+ refs = source_ids(row.get("source_ids", ""))
555
+ if len(refs) < 2:
556
+ errors.append(f"{book}/{item_id}:nm_requires_at_least_two_sources")
557
+ continue
558
+ unknown = sorted(set(refs) - atom_em)
559
+ if unknown:
560
+ errors.append(f"{book}/{item_id}:nm_source_em_missing:{unknown}")
561
+ if positive_int(row.get("novel_count", "")) != 1:
562
+ errors.append(f"{book}/{item_id}:nm_novel_count_must_equal_one")
563
+ if positive_int(row.get("atom_count", "")) != len(set(refs) & atom_em):
564
+ errors.append(f"{book}/{item_id}:nm_atom_count_mismatch")
565
+
566
+ for row in rows:
567
+ if row.get("layer") != "跨书灵感聚合":
568
+ continue
569
+ cba_id = row.get("item_id", "").strip()
570
+ expanded: set[tuple[str, str]] = set()
571
+ unknown_refs: list[str] = []
572
+ for raw_ref in source_ids(row.get("source_ids", "")):
573
+ if "/" not in raw_ref:
574
+ unknown_refs.append(raw_ref)
575
+ continue
576
+ book, ref = raw_ref.rsplit("/", 1)
577
+ if ref.startswith("NM-") and ref in nm_by_book.get(book, {}):
578
+ for em_ref in source_ids(nm_by_book[book][ref].get("source_ids", "")):
579
+ expanded.add((book, em_ref))
580
+ elif ref.startswith("EM-") and ref.replace("EM-", "IA-") in ia_by_book.get(book, {}):
581
+ expanded.add((book, ref))
582
+ else:
583
+ unknown_refs.append(raw_ref)
584
+ if unknown_refs:
585
+ errors.append(f"{cba_id}:cba_source_missing:{sorted(unknown_refs)}")
586
+ if not expanded:
587
+ errors.append(f"{cba_id}:cba_requires_sources")
588
+ continue
589
+ novels = {book for book, _ in expanded}
590
+ if len(novels) == 1 and row.get("status", "").strip() == "active":
591
+ only = next(iter(novels))
592
+ active_single_book_cba[only] = active_single_book_cba.get(only, 0) + 1
593
+ if positive_int(row.get("novel_count", "")) != len(novels):
594
+ errors.append(f"{cba_id}:cba_novel_count_mismatch")
595
+ if positive_int(row.get("atom_count", "")) != len(expanded):
596
+ errors.append(f"{cba_id}:cba_atom_count_mismatch")
597
+ for book, count in sorted(active_single_book_cba.items()):
598
+ if count > 3:
599
+ errors.append(f"book_{book}:active_single_book_cba_limit_exceeded:{count}")
600
+ return errors
601
+
602
+
603
+ def load_vocabulary(root: Path) -> dict[str, list[str]] | None:
604
+ """读 `灵感库/标签词表.md`:`## 轴` 小节下的 `- 值` 行。文件不存在返回 None。"""
605
+ path = root / "标签词表.md"
606
+ if not path.is_file():
607
+ return None
608
+ vocabulary: dict[str, list[str]] = {}
609
+ current_axis: str | None = None
610
+ try:
611
+ text = path.read_text(encoding="utf-8-sig")
612
+ except (OSError, UnicodeError):
613
+ return None
614
+ for line in text.split("\n"):
615
+ stripped = line.strip()
616
+ if stripped.startswith("## "):
617
+ axis = stripped[3:].strip()
618
+ current_axis = axis if axis in TAG_AXES else None
619
+ if current_axis is not None:
620
+ vocabulary.setdefault(current_axis, [])
621
+ continue
622
+ if current_axis is not None and stripped[:2] in {"- ", "* "}:
623
+ value = stripped[2:].split("(")[0].split("(")[0].strip()
624
+ if value and value not in vocabulary[current_axis]:
625
+ vocabulary[current_axis].append(value)
626
+ return vocabulary
627
+
628
+
629
+ def vocabulary_errors(vocabulary: dict[str, list[str]]) -> list[str]:
630
+ """词表自身的健康检查:必填轴齐全。同义判定交给扩表时的人工比对。"""
631
+ return [
632
+ f"vocabulary_axis_missing:{axis}"
633
+ for axis in sorted(REQUIRED_CBA_AXES - set(vocabulary))
634
+ ]
635
+
636
+
637
+ def coverage(root: Path) -> dict[str, Any]:
638
+ """增量入库的机械导航:报出未进入任何 active CBA 闭包的原子。
639
+
640
+ 跨书聚合的 LLM 工作只需要看 uncovered 原子与现存单书假设卡,
641
+ 不必重读已覆盖的 EM 卡。只读,不写盘。
642
+ """
643
+ rows, errors = load_rows(root)
644
+ if errors:
645
+ raise ValueError(";".join(errors))
646
+ ia_by_book: dict[str, set[str]] = {}
647
+ nm_members: dict[tuple[str, str], set[str]] = {}
648
+ for row in rows:
649
+ book = row.get("source_book", "").strip()
650
+ if row.get("layer") == "原子灵感" and row.get("status", "").strip() == "active":
651
+ for ref in source_ids(row.get("source_ids", "")):
652
+ ia_by_book.setdefault(book, set()).add(ref)
653
+ elif row.get("layer") == "单小说灵感合并":
654
+ nm_members[(book, row.get("item_id", "").strip())] = set(source_ids(row.get("source_ids", "")))
655
+ covered: set[tuple[str, str]] = set()
656
+ single_book_hypotheses: list[str] = []
657
+ for row in rows:
658
+ if row.get("layer") != "跨书灵感聚合" or row.get("status", "").strip() != "active":
659
+ continue
660
+ if positive_int(row.get("novel_count", "")) == 1:
661
+ single_book_hypotheses.append(row.get("item_id", "").strip())
662
+ for raw_ref in source_ids(row.get("source_ids", "")):
663
+ if "/" not in raw_ref:
664
+ continue
665
+ book, ref = raw_ref.rsplit("/", 1)
666
+ if ref.startswith("NM-"):
667
+ covered.update((book, em_ref) for em_ref in nm_members.get((book, ref), set()))
668
+ else:
669
+ covered.add((book, ref))
670
+ books: dict[str, dict[str, Any]] = {}
671
+ uncovered_total = 0
672
+ for book in sorted(ia_by_book):
673
+ uncovered = sorted(em for em in ia_by_book[book] if (book, em) not in covered)
674
+ uncovered_total += len(uncovered)
675
+ books[book] = {
676
+ "atoms": len(ia_by_book[book]),
677
+ "covered": len(ia_by_book[book]) - len(uncovered),
678
+ "uncovered": uncovered,
679
+ }
680
+ return {
681
+ "ok": True,
682
+ "books": books,
683
+ "uncovered_total": uncovered_total,
684
+ "single_book_hypotheses": sorted(single_book_hypotheses),
685
+ }
686
+
687
+
688
+ def requested_tags(values: list[str]) -> dict[str, set[str]]:
689
+ tags, errors = parse_tags(";".join(values))
690
+ if errors:
691
+ raise ValueError(";".join(errors))
692
+ return tags
693
+
694
+
695
+ def query(root: Path, values: list[str], limit: int) -> dict[str, Any]:
696
+ """返回 matches 之外还带纠词元数据:请求值在全库零出现时点名
697
+ `unmatched_tags`,并给出所查各轴的现存值清单——零命中先纠词重查,
698
+ 而不是把词表漂移误记成「库里没有」。"""
699
+ rows, errors = load_rows(root)
700
+ if errors:
701
+ raise ValueError(";".join(errors))
702
+ wanted = requested_tags(values)
703
+ axis_values: dict[str, set[str]] = {axis: set() for axis in wanted}
704
+ matches: list[dict[str, Any]] = []
705
+ for row in rows:
706
+ if row.get("layer") != "跨书灵感聚合" or row.get("status") != "active":
707
+ continue
708
+ tags, tag_errors = parse_tags(row.get("tags", ""))
709
+ if tag_errors:
710
+ continue
711
+ for axis in axis_values:
712
+ axis_values[axis].update(tags.get(axis, set()))
713
+ score = 0
714
+ matched: list[str] = []
715
+ core_match = False
716
+ for axis, wanted_values in wanted.items():
717
+ overlap = wanted_values & tags.get(axis, set())
718
+ if overlap:
719
+ score += (2 if axis in CORE_QUERY_AXES else 1) * len(overlap)
720
+ matched.extend(f"{axis}={value}" for value in sorted(overlap))
721
+ if axis in CORE_QUERY_AXES:
722
+ core_match = True
723
+ if score <= 0 or not core_match:
724
+ continue
725
+ matches.append(
726
+ {
727
+ "item_id": row["item_id"],
728
+ "title": row["title"],
729
+ "path": row["path"],
730
+ "score": score,
731
+ "matched_tags": matched,
732
+ "novel_count": positive_int(row.get("novel_count", "")) or 0,
733
+ "atom_count": positive_int(row.get("atom_count", "")) or 0,
734
+ }
735
+ )
736
+ matches.sort(key=lambda item: (-item["score"], -item["novel_count"], -item["atom_count"], item["item_id"]))
737
+ unmatched = [
738
+ f"{axis}={value}"
739
+ for axis in sorted(wanted)
740
+ for value in sorted(wanted[axis] - axis_values[axis])
741
+ ]
742
+ return {
743
+ "matches": matches[: max(3, min(limit, 8))],
744
+ "unmatched_tags": unmatched,
745
+ "axis_inventory": {axis: sorted(found) for axis, found in sorted(axis_values.items())},
746
+ "vocabulary_loaded": load_vocabulary(root) is not None,
747
+ }
748
+
749
+
750
+ def resolve(root: Path, refs: list[str]) -> dict[str, Any]:
751
+ """按 `书名/EM-xxx`、`书名/NM-xxx` 或 `CBA-xxx` 裸 ID 解析可读位置——需要时才查。"""
752
+ rows, errors = load_rows(root)
753
+ if errors:
754
+ raise ValueError(";".join(errors))
755
+ by_key: dict[tuple[str, str], dict[str, str]] = {}
756
+ for row in rows:
757
+ book = row.get("source_book", "").strip() if row.get("layer") != "跨书灵感聚合" else ""
758
+ by_key[(book, row.get("item_id", "").strip())] = row
759
+ resolved: list[dict[str, str]] = []
760
+ missing: list[str] = []
761
+ for raw_ref in refs:
762
+ if "/" in raw_ref:
763
+ book, ref = raw_ref.rsplit("/", 1)
764
+ else:
765
+ book, ref = "", raw_ref
766
+ item = ref.replace("EM-", "IA-") if ref.startswith("EM-") else ref
767
+ row = by_key.get((book if not item.startswith("CBA-") else "", item))
768
+ if row is None:
769
+ missing.append(raw_ref)
770
+ continue
771
+ if row.get("layer") == "原子灵感":
772
+ location = f"拆文库/{book}/剧情/情绪模块.md#{row.get('source_ids', '')}"
773
+ else:
774
+ location = row.get("path", "")
775
+ resolved.append({"ref": raw_ref, "item_id": row.get("item_id", ""), "title": row.get("title", ""), "location": location})
776
+ return {"ok": not missing, "resolved": resolved, "missing": missing}
777
+
778
+
779
+ def parse_args() -> argparse.Namespace:
780
+ parser = argparse.ArgumentParser()
781
+ subparsers = parser.add_subparsers(dest="command", required=True)
782
+ register_parser = subparsers.add_parser("register-atoms")
783
+ register_parser.add_argument("--root", required=True, type=Path)
784
+ register_parser.add_argument("--module", required=True, type=Path)
785
+ register_parser.add_argument("--book", required=True)
786
+ register_parser.add_argument("--workspace", type=Path, default=None)
787
+ check_parser = subparsers.add_parser("check-atoms")
788
+ check_parser.add_argument("--root", required=True, type=Path)
789
+ check_parser.add_argument("--module", required=True, type=Path)
790
+ check_parser.add_argument("--book", required=True)
791
+ check_parser.add_argument("--workspace", type=Path, default=None)
792
+ validate_parser = subparsers.add_parser("validate")
793
+ validate_parser.add_argument("--root", required=True, type=Path)
794
+ validate_parser.add_argument("--workspace", type=Path, default=None)
795
+ coverage_parser = subparsers.add_parser("coverage")
796
+ coverage_parser.add_argument("--root", required=True, type=Path)
797
+ query_parser = subparsers.add_parser("query")
798
+ query_parser.add_argument("--root", required=True, type=Path)
799
+ query_parser.add_argument("--tag", action="append", default=[])
800
+ query_parser.add_argument("--limit", type=int, default=6)
801
+ resolve_parser = subparsers.add_parser("resolve")
802
+ resolve_parser.add_argument("--root", required=True, type=Path)
803
+ resolve_parser.add_argument("--ref", action="append", default=[], required=True)
804
+ return parser.parse_args()
805
+
806
+
807
+ def main() -> int:
808
+ if hasattr(sys.stdout, "reconfigure"):
809
+ sys.stdout.reconfigure(encoding="utf-8")
810
+ args = parse_args()
811
+ try:
812
+ return run(args)
813
+ except (OSError, UnicodeError) as exc:
814
+ message = f"io_error:{exc}"
815
+ print(json.dumps({"ok": False, "error": message, "errors": [message]}, ensure_ascii=False))
816
+ return 2
817
+
818
+
819
+ def run(args: argparse.Namespace) -> int:
820
+ if args.command == "register-atoms":
821
+ try:
822
+ payload = register_atoms(args.root, args.module, args.book.strip(), args.workspace)
823
+ except ValueError as exc:
824
+ errors = getattr(exc, "errors", None) or [str(exc)]
825
+ warnings = getattr(exc, "warnings", [])
826
+ print(json.dumps({"ok": False, "errors": errors, "warnings": warnings}, ensure_ascii=False))
827
+ return 2
828
+ print(json.dumps(payload, ensure_ascii=False))
829
+ return 0
830
+ if args.command == "check-atoms":
831
+ report = check_atoms(args.root, args.module, args.book.strip(), args.workspace)
832
+ print(json.dumps(report, ensure_ascii=False))
833
+ return 0 if report["ok"] else 1
834
+ if args.command == "validate":
835
+ errors = validate(args.root, args.workspace)
836
+ print(json.dumps({"ok": not errors, "errors": errors}, ensure_ascii=False))
837
+ return 0 if not errors else 1
838
+ if args.command == "coverage":
839
+ try:
840
+ payload = coverage(args.root)
841
+ except ValueError as exc:
842
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
843
+ return 2
844
+ print(json.dumps(payload, ensure_ascii=False))
845
+ return 0
846
+ if args.command == "resolve":
847
+ try:
848
+ payload = resolve(args.root, args.ref)
849
+ except ValueError as exc:
850
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
851
+ return 2
852
+ print(json.dumps(payload, ensure_ascii=False))
853
+ return 0 if payload["ok"] else 1
854
+ try:
855
+ payload = query(args.root, args.tag, args.limit)
856
+ except ValueError as exc:
857
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
858
+ return 2
859
+ print(json.dumps({"ok": True, **payload}, ensure_ascii=False))
860
+ return 0
861
+
862
+
863
+ if __name__ == "__main__":
864
+ sys.exit(main())