novel-maker 2.2.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.
Files changed (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/bin/novel-maker.js +229 -0
  4. package/package.json +33 -0
  5. package/skill/CHANGELOG.md +82 -0
  6. package/skill/QUICK-REF.md +168 -0
  7. package/skill/README.md +75 -0
  8. package/skill/SKILL.md +715 -0
  9. package/skill/agents/README.md +59 -0
  10. package/skill/agents/auditor.md +234 -0
  11. package/skill/agents/coordinator.md +150 -0
  12. package/skill/agents/planner.md +220 -0
  13. package/skill/agents/reviewer.md +249 -0
  14. package/skill/agents/reviser.md +144 -0
  15. package/skill/agents/writer.md +213 -0
  16. package/skill/arc-templates/README.md +43 -0
  17. package/skill/arc-templates/apocalypse/ability.md +81 -0
  18. package/skill/arc-templates/apocalypse/faction.md +81 -0
  19. package/skill/arc-templates/apocalypse/humanity.md +81 -0
  20. package/skill/arc-templates/apocalypse/survival.md +81 -0
  21. package/skill/arc-templates/game/competition.md +81 -0
  22. package/skill/arc-templates/game/dungeon.md +81 -0
  23. package/skill/arc-templates/game/guild-war.md +81 -0
  24. package/skill/arc-templates/game/leveling.md +80 -0
  25. package/skill/arc-templates/general/challenge.md +44 -0
  26. package/skill/arc-templates/general/conflict.md +71 -0
  27. package/skill/arc-templates/general/explore.md +71 -0
  28. package/skill/arc-templates/general/growth.md +71 -0
  29. package/skill/arc-templates/general/mystery.md +71 -0
  30. package/skill/arc-templates/general/relation.md +71 -0
  31. package/skill/arc-templates/history/battle.md +71 -0
  32. package/skill/arc-templates/history/politics.md +71 -0
  33. package/skill/arc-templates/history/reform.md +71 -0
  34. package/skill/arc-templates/infinite-flow/boss.md +71 -0
  35. package/skill/arc-templates/infinite-flow/dungeon.md +71 -0
  36. package/skill/arc-templates/infinite-flow/enhance.md +71 -0
  37. package/skill/arc-templates/infinite-flow/team.md +71 -0
  38. package/skill/arc-templates/mystery/case.md +71 -0
  39. package/skill/arc-templates/mystery/deduction.md +71 -0
  40. package/skill/arc-templates/mystery/twist.md +71 -0
  41. package/skill/arc-templates/romance/angst.md +80 -0
  42. package/skill/arc-templates/romance/chase.md +71 -0
  43. package/skill/arc-templates/romance/slow-burn.md +71 -0
  44. package/skill/arc-templates/romance/sweet.md +80 -0
  45. package/skill/arc-templates/sci-fi/awakening.md +81 -0
  46. package/skill/arc-templates/sci-fi/breakthrough.md +81 -0
  47. package/skill/arc-templates/sci-fi/contact.md +81 -0
  48. package/skill/arc-templates/sci-fi/exploration.md +81 -0
  49. package/skill/arc-templates/urban/business.md +71 -0
  50. package/skill/arc-templates/urban/revenge.md +71 -0
  51. package/skill/arc-templates/urban/rise.md +71 -0
  52. package/skill/arc-templates/urban/romance.md +71 -0
  53. package/skill/arc-templates/western-fantasy/adventure.md +80 -0
  54. package/skill/arc-templates/western-fantasy/kingdom.md +71 -0
  55. package/skill/arc-templates/western-fantasy/magic-awakening.md +71 -0
  56. package/skill/arc-templates/western-fantasy/racial-conflict.md +71 -0
  57. package/skill/arc-templates/wuxia/breakthrough.md +71 -0
  58. package/skill/arc-templates/wuxia/grudge.md +71 -0
  59. package/skill/arc-templates/wuxia/hero-path.md +71 -0
  60. package/skill/arc-templates/wuxia/sect-war.md +71 -0
  61. package/skill/arc-templates/xianxia/breakthrough.md +71 -0
  62. package/skill/arc-templates/xianxia/dungeon.md +71 -0
  63. package/skill/arc-templates/xianxia/tournament.md +71 -0
  64. package/skill/arc-templates/xianxia/tribulation.md +71 -0
  65. package/skill/docs/examples.md +61 -0
  66. package/skill/docs/faq.md +81 -0
  67. package/skill/docs/installation.md +87 -0
  68. package/skill/docs/quickstart.md +83 -0
  69. package/skill/genre-packs/README.md +47 -0
  70. package/skill/genre-packs/_default/arc-types.md +153 -0
  71. package/skill/genre-packs/_default/rules.md +56 -0
  72. package/skill/genre-packs/_default/templates.md +135 -0
  73. package/skill/genre-packs/apocalypse/arc-types.md +109 -0
  74. package/skill/genre-packs/apocalypse/rules.md +113 -0
  75. package/skill/genre-packs/apocalypse/settings.md +106 -0
  76. package/skill/genre-packs/apocalypse/templates.md +192 -0
  77. package/skill/genre-packs/game/arc-types.md +109 -0
  78. package/skill/genre-packs/game/rules.md +113 -0
  79. package/skill/genre-packs/game/settings.md +103 -0
  80. package/skill/genre-packs/game/templates.md +173 -0
  81. package/skill/genre-packs/history/arc-types.md +109 -0
  82. package/skill/genre-packs/history/rules.md +107 -0
  83. package/skill/genre-packs/history/settings.md +126 -0
  84. package/skill/genre-packs/history/templates.md +179 -0
  85. package/skill/genre-packs/infinite-flow/arc-types.md +101 -0
  86. package/skill/genre-packs/infinite-flow/rules.md +75 -0
  87. package/skill/genre-packs/infinite-flow/settings.md +102 -0
  88. package/skill/genre-packs/infinite-flow/templates.md +226 -0
  89. package/skill/genre-packs/mystery/arc-types.md +109 -0
  90. package/skill/genre-packs/mystery/rules.md +107 -0
  91. package/skill/genre-packs/mystery/settings.md +103 -0
  92. package/skill/genre-packs/mystery/templates.md +178 -0
  93. package/skill/genre-packs/romance/arc-types.md +130 -0
  94. package/skill/genre-packs/romance/rules.md +88 -0
  95. package/skill/genre-packs/romance/settings.md +146 -0
  96. package/skill/genre-packs/romance/templates.md +245 -0
  97. package/skill/genre-packs/sci-fi/arc-types.md +109 -0
  98. package/skill/genre-packs/sci-fi/rules.md +113 -0
  99. package/skill/genre-packs/sci-fi/settings.md +99 -0
  100. package/skill/genre-packs/sci-fi/templates.md +170 -0
  101. package/skill/genre-packs/urban/arc-types.md +101 -0
  102. package/skill/genre-packs/urban/rules.md +75 -0
  103. package/skill/genre-packs/urban/settings.md +82 -0
  104. package/skill/genre-packs/urban/templates.md +212 -0
  105. package/skill/genre-packs/western-fantasy/arc-types.md +128 -0
  106. package/skill/genre-packs/western-fantasy/rules.md +88 -0
  107. package/skill/genre-packs/western-fantasy/settings.md +160 -0
  108. package/skill/genre-packs/western-fantasy/templates.md +225 -0
  109. package/skill/genre-packs/wuxia/arc-types.md +126 -0
  110. package/skill/genre-packs/wuxia/rules.md +86 -0
  111. package/skill/genre-packs/wuxia/settings.md +150 -0
  112. package/skill/genre-packs/wuxia/templates.md +195 -0
  113. package/skill/genre-packs/xianxia/arc-types.md +101 -0
  114. package/skill/genre-packs/xianxia/rules.md +74 -0
  115. package/skill/genre-packs/xianxia/settings.md +107 -0
  116. package/skill/genre-packs/xianxia/templates.md +202 -0
  117. package/skill/hooks/README.md +102 -0
  118. package/skill/hooks/chapter-complete.md +176 -0
  119. package/skill/hooks/context-injection.md +152 -0
  120. package/skill/hooks/intent-detection.md +183 -0
  121. package/skill/hooks/review-trigger.md +219 -0
  122. package/skill/hooks/summary-trigger.md +185 -0
  123. package/skill/references/act-guidance.md +228 -0
  124. package/skill/references/audit-core.md +130 -0
  125. package/skill/references/audit-dimensions.md +202 -0
  126. package/skill/references/character-voice-card.md +196 -0
  127. package/skill/references/consistency-checker.md +209 -0
  128. package/skill/references/content-expansion.md +68 -0
  129. package/skill/references/creative-constraints.md +200 -0
  130. package/skill/references/data-agent.md +286 -0
  131. package/skill/references/dialogue-writing.md +104 -0
  132. package/skill/references/editorial-perspective.md +166 -0
  133. package/skill/references/emotion-curve.md +127 -0
  134. package/skill/references/genre-rules.md +389 -0
  135. package/skill/references/golden-opening.md +81 -0
  136. package/skill/references/memory-system.md +288 -0
  137. package/skill/references/pacing-analysis.md +201 -0
  138. package/skill/references/platform-rules.md +244 -0
  139. package/skill/references/plot-structures.md +108 -0
  140. package/skill/references/reader-feedback.md +119 -0
  141. package/skill/references/rhythm-system.md +204 -0
  142. package/skill/references/style-imitation.md +193 -0
  143. package/skill/references/sweet-spot-tracking.md +182 -0
  144. package/skill/references/usage-guide.md +174 -0
  145. package/skill/references/writing-methods.md +169 -0
  146. package/skill/rules/anti-ai-expressions.md +206 -0
  147. package/skill/rules/character-voice.md +184 -0
  148. package/skill/rules/consistency-check.md +232 -0
  149. package/skill/rules/smart-query.md +263 -0
  150. package/skill/scripts/README.md +380 -0
  151. package/skill/scripts/auditor/chapter_transition.py +217 -0
  152. package/skill/scripts/auditor/consistency_scan.py +194 -0
  153. package/skill/scripts/auditor/dialogue_checker.py +194 -0
  154. package/skill/scripts/auditor/hook_report.py +115 -0
  155. package/skill/scripts/auditor/pacing_optimizer.py +303 -0
  156. package/skill/scripts/auditor/pacing_report.py +275 -0
  157. package/skill/scripts/auditor/pre_audit.py +203 -0
  158. package/skill/scripts/auditor/style_check.py +158 -0
  159. package/skill/scripts/auditor/worldbuilding_checker.py +637 -0
  160. package/skill/scripts/common/analyze.py +129 -0
  161. package/skill/scripts/common/init_guide.py +796 -0
  162. package/skill/scripts/common/install.py +169 -0
  163. package/skill/scripts/common/nm_utils.py +296 -0
  164. package/skill/scripts/common/validate.py +215 -0
  165. package/skill/scripts/coordinator/stats_report.py +165 -0
  166. package/skill/scripts/coordinator/volume_batch.py +121 -0
  167. package/skill/scripts/planner/outline_extractor.py +89 -0
  168. package/skill/scripts/planner/planner_context.py +220 -0
  169. package/skill/scripts/planner/query_engine.py +289 -0
  170. package/skill/scripts/reviewer/chapter_diff.py +143 -0
  171. package/skill/scripts/reviewer/character_arc_tracker.py +191 -0
  172. package/skill/scripts/reviewer/emotion_curve.py +340 -0
  173. package/skill/scripts/reviewer/foreshadowing_tracker.py +286 -0
  174. package/skill/scripts/reviewer/subplot_tracker.py +207 -0
  175. package/skill/scripts/reviewer/summary_generator.py +130 -0
  176. package/skill/scripts/reviewer/truth_diff.py +227 -0
  177. package/skill/scripts/reviewer/truth_manager.py +120 -0
  178. package/skill/scripts/test_scripts.py +366 -0
  179. package/skill/scripts/writer/build_write_context.py +255 -0
  180. package/skill/scripts/writer/chapter_info.py +67 -0
  181. package/skill/scripts/writer/check_wordcount.py +115 -0
  182. package/skill/scripts/writer/scene_builder.py +227 -0
  183. package/skill/scripts/writer/style_anchor.py +135 -0
  184. package/skill/styles/author-styles.md +53 -0
  185. package/skill/styles/authors//344/270/245/350/260/250/350/256/276/345/256/232/346/265/201//345/277/230/350/257/255.md +110 -0
  186. package/skill/styles/authors//344/270/245/350/260/250/350/256/276/345/256/232/346/265/201//347/210/261/346/275/234/346/260/264/347/232/204/344/271/214/350/264/274.md +110 -0
  187. package/skill/styles/authors//344/270/245/350/260/250/350/256/276/345/256/232/346/265/201//350/250/200/345/275/222/346/255/243/344/274/240.md +110 -0
  188. package/skill/styles/authors//345/244/232/347/245/236/350/257/235/347/203/255/350/241/200/346/265/201//344/270/211/344/271/235/351/237/263/345/237/237.md +108 -0
  189. package/skill/styles/authors//346/202/254/347/226/221/346/216/250/347/220/206/346/265/201//346/235/200/350/231/253/351/230/237/351/230/237/345/221/230.md +108 -0
  190. package/skill/styles/authors//346/220/236/347/254/221/345/271/275/351/273/230/346/265/201//344/270/211/345/244/251/344/270/244/350/247/211.md +110 -0
  191. package/skill/styles/authors//346/220/236/347/254/221/345/271/275/351/273/230/346/265/201//344/274/232/350/257/264/350/257/235/347/232/204/350/202/230/345/255/220.md +110 -0
  192. package/skill/styles/authors//346/220/236/347/254/221/345/271/275/351/273/230/346/265/201//345/215/226/346/212/245/345/260/217/351/203/216/345/220/233.md +108 -0
  193. package/skill/styles/authors//346/220/236/347/254/221/345/271/275/351/273/230/346/265/201//345/274/210/351/235/222/345/263/260.md +123 -0
  194. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//345/224/220/345/256/266/344/270/211/345/260/221.md +109 -0
  195. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//345/244/251/350/232/225/345/234/237/350/261/206.md +110 -0
  196. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//346/210/221/345/220/203/350/245/277/347/272/242/346/237/277.md +108 -0
  197. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//346/273/232/345/274/200.md +109 -0
  198. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//350/276/260/344/270/234.md +109 -0
  199. package/skill/styles/authors//347/211/271/350/211/262/351/242/206/345/237/237/346/265/201//345/244/251/344/270/213/351/234/270/345/224/261.md +110 -0
  200. package/skill/styles/authors//347/211/271/350/211/262/351/242/206/345/237/237/346/265/201//346/234/210/345/205/263.md +108 -0
  201. package/skill/styles/authors//347/211/271/350/211/262/351/242/206/345/237/237/346/265/201//350/220/247/351/274/216.md +109 -0
  202. package/skill/styles/authors//347/211/271/350/211/262/351/242/206/345/237/237/346/265/201//350/235/264/350/235/266/350/223/235.md +112 -0
  203. package/skill/styles/authors//347/273/206/350/205/273/346/226/207/351/235/222/346/265/201//346/204/244/346/200/222/347/232/204/351/246/231/350/225/211.md +109 -0
  204. package/skill/styles/authors//347/273/206/350/205/273/346/226/207/351/235/222/346/265/201//347/203/275/347/201/253/346/210/217/350/257/270/344/276/257.md +109 -0
  205. package/skill/styles/authors//347/273/206/350/205/273/346/226/207/351/235/222/346/265/201//347/214/253/350/205/273.md +110 -0
  206. package/skill/styles/authors//347/273/206/350/205/273/346/226/207/351/235/222/346/265/201//350/200/263/346/240/271.md +109 -0
  207. package/skill/templates/INDEX.md +48 -0
  208. package/skill/templates/act-plan.md +72 -0
  209. package/skill/templates/chapter.md +53 -0
  210. package/skill/templates/character-profile.md +107 -0
  211. package/skill/templates/character-voice.md +106 -0
  212. package/skill/templates/constitution.md +90 -0
  213. package/skill/templates/emotional-arcs.md +37 -0
  214. package/skill/templates/hook-template.md +68 -0
  215. package/skill/templates/outline.md +155 -0
  216. package/skill/templates/plot-card.md +432 -0
  217. package/skill/templates/power-system.md +124 -0
  218. package/skill/templates/presets.json +69 -0
  219. package/skill/templates/review-report.md +135 -0
  220. package/skill/templates/scene-plan.md +221 -0
  221. package/skill/templates/scene-template.md +78 -0
  222. package/skill/templates/subplot-board.md +48 -0
  223. package/skill/templates/summary-10chapters.md +79 -0
  224. package/skill/templates/summary-50chapters.md +131 -0
  225. package/skill/templates/summary-volume.md +148 -0
  226. package/skill/templates/timeline.md +37 -0
  227. package/skill/templates/volume-plan.md +44 -0
  228. package/skill/templates/world-setting.md +151 -0
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 情绪曲线分析脚本
5
+ 分析章节或整卷的情绪变化曲线,检测情绪波动是否合理。
6
+ 供复盘师在卷末复盘时使用,或审计师在审查情绪节奏时参考。
7
+
8
+ 用法:
9
+ python scripts/reviewer/emotion_curve.py 章节文件 --json
10
+ python scripts/reviewer/emotion_curve.py 章节目录 --volume --json
11
+ python scripts/reviewer/emotion_curve.py 章节目录 --recent 10
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ from collections import defaultdict
20
+
21
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
22
+ from nm_utils import (
23
+ list_chapters, read_chapter, count_chinese, clean_markdown
24
+ )
25
+
26
+ # ─── 情绪分类 ───────────────────────────────
27
+ EMOTION_CATEGORIES = {
28
+ 'positive': {
29
+ 'name': '积极',
30
+ 'keywords': ['开心', '快乐', '喜悦', '兴奋', '激动', '幸福', '满足', '欣慰', '自豪', '感动',
31
+ '爱', '喜欢', '温暖', '希望', '期待', '惊喜', '欣慰', '释然', '轻松', '愉快']
32
+ },
33
+ 'negative': {
34
+ 'name': '消极',
35
+ 'keywords': ['悲伤', '难过', '痛苦', '绝望', '愤怒', '恐惧', '焦虑', '担忧', '失望', '沮丧',
36
+ '恨', '厌恶', '嫉妒', '后悔', '自责', '内疚', '羞愧', '孤独', '无助', '迷茫']
37
+ },
38
+ 'neutral': {
39
+ 'name': '中性',
40
+ 'keywords': ['平静', '淡然', '从容', '淡定', '思考', '观察', '分析', '计划', '准备', '等待']
41
+ },
42
+ 'tense': {
43
+ 'name': '紧张',
44
+ 'keywords': ['紧张', '危急', '危险', '紧迫', '焦虑', '不安', '忐忑', '慌张', '急促', '激烈']
45
+ }
46
+ }
47
+
48
+
49
+ def analyze_emotion_in_text(text):
50
+ """Analyze emotion distribution in text."""
51
+ emotion_scores = defaultdict(int)
52
+
53
+ for category, config in EMOTION_CATEGORIES.items():
54
+ for keyword in config['keywords']:
55
+ count = text.count(keyword)
56
+ if count > 0:
57
+ emotion_scores[category] += count
58
+
59
+ total = sum(emotion_scores.values())
60
+ if total == 0:
61
+ return {'dominant': 'neutral', 'scores': {k: 0 for k in EMOTION_CATEGORIES}, 'total': 0}
62
+
63
+ # Calculate percentages
64
+ percentages = {k: round(v / total * 100, 1) for k, v in emotion_scores.items()}
65
+
66
+ # Find dominant emotion
67
+ dominant = max(emotion_scores, key=emotion_scores.get)
68
+
69
+ return {
70
+ 'dominant': dominant,
71
+ 'dominant_name': EMOTION_CATEGORIES[dominant]['name'],
72
+ 'scores': dict(emotion_scores),
73
+ 'percentages': percentages,
74
+ 'total': total
75
+ }
76
+
77
+
78
+ def analyze_chapter_emotion(filepath):
79
+ """Analyze emotion in a single chapter."""
80
+ raw, title, clean, wc = read_chapter(filepath)
81
+
82
+ # Analyze different parts
83
+ head = clean[:1000] if len(clean) > 1000 else clean
84
+ middle = clean[len(clean)//3:2*len(clean)//3] if len(clean) > 3000 else clean
85
+ tail = clean[-1000:] if len(clean) > 1000 else clean
86
+
87
+ head_emotion = analyze_emotion_in_text(head)
88
+ middle_emotion = analyze_emotion_in_text(middle)
89
+ tail_emotion = analyze_emotion_in_text(tail)
90
+ full_emotion = analyze_emotion_in_text(clean)
91
+
92
+ return {
93
+ 'chapter': title,
94
+ 'word_count': wc,
95
+ 'full': full_emotion,
96
+ 'structure': {
97
+ 'head': head_emotion,
98
+ 'middle': middle_emotion,
99
+ 'tail': tail_emotion
100
+ }
101
+ }
102
+
103
+
104
+ def build_emotion_curve(chapters_dir, recent_n=None):
105
+ """Build emotion curve across multiple chapters."""
106
+ chapter_files = list_chapters(chapters_dir, recent_n)
107
+ if not chapter_files:
108
+ return {'error': '未找到章节文件'}
109
+
110
+ curve = []
111
+ for idx, cf in enumerate(chapter_files):
112
+ ch_data = analyze_chapter_emotion(cf)
113
+ curve.append({
114
+ 'chapter_num': idx + 1,
115
+ 'chapter_title': ch_data['chapter'],
116
+ 'dominant_emotion': ch_data['full']['dominant_name'],
117
+ 'emotion_scores': ch_data['full']['scores'],
118
+ 'word_count': ch_data['word_count']
119
+ })
120
+
121
+ # Analyze curve patterns
122
+ emotions_sequence = [c['dominant_emotion'] for c in curve]
123
+
124
+ # Detect monotony (same emotion for too long)
125
+ monotony_issues = []
126
+ current_emotion = emotions_sequence[0] if emotions_sequence else None
127
+ streak_start = 0
128
+ for i, emo in enumerate(emotions_sequence):
129
+ if emo != current_emotion:
130
+ streak_length = i - streak_start
131
+ if streak_length >= 3:
132
+ monotony_issues.append({
133
+ 'emotion': current_emotion,
134
+ 'chapters': f"第{streak_start+1}-{i}章",
135
+ 'length': streak_length
136
+ })
137
+ current_emotion = emo
138
+ streak_start = i
139
+
140
+ # Check final streak
141
+ if len(emotions_sequence) - streak_start >= 3:
142
+ monotony_issues.append({
143
+ 'emotion': current_emotion,
144
+ 'chapters': f"第{streak_start+1}-{len(emotions_sequence)}章",
145
+ 'length': len(emotions_sequence) - streak_start
146
+ })
147
+
148
+ # Emotion transition analysis
149
+ transitions = []
150
+ for i in range(len(emotions_sequence) - 1):
151
+ transitions.append({
152
+ 'from': emotions_sequence[i],
153
+ 'to': emotions_sequence[i+1],
154
+ 'chapter': i + 1
155
+ })
156
+
157
+ return {
158
+ 'total_chapters': len(curve),
159
+ 'curve': curve,
160
+ 'emotions_sequence': emotions_sequence,
161
+ 'monotony_issues': monotony_issues,
162
+ 'transitions': transitions,
163
+ 'summary': {
164
+ 'emotion_distribution': defaultdict(int),
165
+ 'most_common': max(set(emotions_sequence), key=emotions_sequence.count) if emotions_sequence else 'N/A'
166
+ }
167
+ }
168
+
169
+
170
+ def generate_ascii_curve(emotions_sequence, width=60):
171
+ """Generate ASCII art visualization of emotion curve."""
172
+ if not emotions_sequence:
173
+ return ""
174
+
175
+ # Map emotions to numeric values for visualization
176
+ emotion_values = {
177
+ '积极': 3,
178
+ '紧张': 2,
179
+ '中性': 1,
180
+ '消极': 0
181
+ }
182
+
183
+ # Convert to numeric sequence
184
+ values = [emotion_values.get(e, 1) for e in emotions_sequence]
185
+
186
+ # Generate ASCII curve
187
+ lines = []
188
+
189
+ # Header
190
+ lines.append("情绪曲线可视化:")
191
+ lines.append("")
192
+
193
+ # Y-axis labels
194
+ y_labels = ['积极', '紧张', '中性', '消极']
195
+
196
+ # Draw the curve
197
+ max_val = 3
198
+ for level in range(max_val, -1, -1):
199
+ label = y_labels[level] if level < len(y_labels) else ''
200
+ line = f"{label:4s} │"
201
+
202
+ for val in values:
203
+ if val == level:
204
+ line += "●"
205
+ elif val > level:
206
+ line += "│"
207
+ else:
208
+ line += " "
209
+ line += " "
210
+
211
+ lines.append(line)
212
+
213
+ # X-axis
214
+ lines.append(" └" + "─" * (len(values) * 2))
215
+
216
+ # Chapter numbers
217
+ x_axis = " "
218
+ for i in range(len(values)):
219
+ if i % 5 == 0 or i == len(values) - 1:
220
+ x_axis += f"{i+1:2d}"
221
+ else:
222
+ x_axis += " "
223
+ lines.append(x_axis)
224
+
225
+ return "\n".join(lines)
226
+
227
+
228
+ def generate_emotion_heatmap(curve_data):
229
+ """Generate emotion heatmap visualization."""
230
+ if not curve_data:
231
+ return ""
232
+
233
+ lines = []
234
+ lines.append("情绪热力图:")
235
+ lines.append("")
236
+
237
+ # Header
238
+ lines.append("章节 " + " ".join(f"{i+1:2d}" for i in range(min(len(curve_data), 20))))
239
+
240
+ # Emotion rows
241
+ for emotion_key in ['positive', 'negative', 'neutral', 'tense']:
242
+ emotion_name = EMOTION_CATEGORIES[emotion_key]['name']
243
+ row = f"{emotion_name:4s} "
244
+
245
+ for ch_data in curve_data[:20]:
246
+ scores = ch_data['emotion_scores']
247
+ score = scores.get(emotion_key, 0)
248
+
249
+ if score == 0:
250
+ row += "· "
251
+ elif score <= 2:
252
+ row += "▁ "
253
+ elif score <= 5:
254
+ row += "▃ "
255
+ elif score <= 10:
256
+ row += "▅ "
257
+ else:
258
+ row += "█ "
259
+
260
+ lines.append(row)
261
+
262
+ return "\n".join(lines)
263
+
264
+
265
+ def main():
266
+ parser = argparse.ArgumentParser(description='情绪曲线分析脚本')
267
+ parser.add_argument('path', help='章节文件或章节目录路径')
268
+ parser.add_argument('--volume', '-v', action='store_true', help='分析整卷(目录模式)')
269
+ parser.add_argument('--recent', type=int, help='仅分析最近N章')
270
+ parser.add_argument('--json', action='store_true', help='JSON输出')
271
+ parser.add_argument('--visualize', action='store_true', help='显示可视化图表')
272
+ args = parser.parse_args()
273
+
274
+ if not os.path.exists(args.path):
275
+ print(f"错误: 路径不存在: {args.path}", file=sys.stderr)
276
+ sys.exit(1)
277
+
278
+ if args.volume or os.path.isdir(args.path):
279
+ result = build_emotion_curve(args.path, args.recent)
280
+ else:
281
+ result = analyze_chapter_emotion(args.path)
282
+
283
+ if args.json:
284
+ print(json.dumps(result, ensure_ascii=False, indent=2))
285
+ else:
286
+ if 'error' in result:
287
+ print(result['error'])
288
+ return
289
+
290
+ if 'curve' in result:
291
+ # Volume mode
292
+ print(f"\n=== 情绪曲线分析 (最近{result['total_chapters']}章) ===\n")
293
+
294
+ print("情绪序列:")
295
+ print(" " + " → ".join(result['emotions_sequence']))
296
+
297
+ if result['monotony_issues']:
298
+ print(f"\n单调性问题:")
299
+ for issue in result['monotony_issues']:
300
+ print(f" {issue['chapters']}: 连续{issue['length']}章{issue['emotion']}")
301
+ else:
302
+ print(f"\n✓ 情绪变化丰富,无明显单调问题")
303
+
304
+ print(f"\n情绪分布:")
305
+ dist = result['summary']['emotion_distribution']
306
+ for emo in result['emotions_sequence']:
307
+ dist[emo] += 1
308
+ for emo, count in sorted(dist.items(), key=lambda x: -x[1]):
309
+ bar = "█" * count
310
+ print(f" {emo}: {bar} ({count}章)")
311
+
312
+ print(f"\n最常见情绪: {result['summary']['most_common']}")
313
+
314
+ # Show visualizations if requested
315
+ if args.visualize:
316
+ print(f"\n{'='*60}")
317
+ print(generate_ascii_curve(result['emotions_sequence']))
318
+ print(f"\n{'='*60}")
319
+ print(generate_emotion_heatmap(result['curve']))
320
+ else:
321
+ # Single chapter mode
322
+ print(f"\n=== 情绪分析: {result['chapter']} ===\n")
323
+ print(f"字数: {result['word_count']}")
324
+ print(f"主导情绪: {result['full']['dominant_name']}")
325
+
326
+ print(f"\n情绪得分:")
327
+ for cat, score in result['full']['scores'].items():
328
+ name = EMOTION_CATEGORIES[cat]['name']
329
+ pct = result['full']['percentages'].get(cat, 0)
330
+ bar = "█" * max(1, score // 2)
331
+ print(f" {name}: {bar} ({score}次, {pct}%)")
332
+
333
+ print(f"\n章节结构情绪:")
334
+ for part, data in result['structure'].items():
335
+ part_name = {'head': '开头', 'middle': '中间', 'tail': '结尾'}[part]
336
+ print(f" {part_name}: {data['dominant_name']}")
337
+
338
+
339
+ if __name__ == '__main__':
340
+ main()
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 伏笔追踪器
5
+ 追踪小说中伏笔的设置和回收情况,检测未回收的伏笔。
6
+ 供复盘师在卷末复盘时使用,或规划师在规划伏笔时参考。
7
+
8
+ 用法:
9
+ python scripts/reviewer/foreshadowing_tracker.py 章节目录 --json
10
+ python scripts/reviewer/foreshadowing_tracker.py 章节目录 --recent 20
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ from collections import defaultdict
19
+
20
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
21
+ from nm_utils import (
22
+ list_chapters, read_chapter, count_chinese, clean_markdown
23
+ )
24
+
25
+ # ─── 伏笔关键词模式 ───────────────────────────────
26
+ FORESHADOWING_PATTERNS = {
27
+ '悬念型': {
28
+ 'setup_keywords': ['秘密', '真相', '隐藏', '未知', '谜团', '疑问', '为什么', '怎么回事'],
29
+ 'resolution_keywords': ['原来', '竟然', '居然', '真相大白', '揭晓', '揭示', '发现']
30
+ },
31
+ '预言型': {
32
+ 'setup_keywords': ['预言', '传说', '古老', '命运', '注定', '将会', '总有一天'],
33
+ 'resolution_keywords': ['应验', '实现', '果然', '如预言般', '命中注定']
34
+ },
35
+ '物品型': {
36
+ 'setup_keywords': ['宝物', '神器', '秘籍', '钥匙', '信物', '遗物', '传承'],
37
+ 'resolution_keywords': ['使用', '激活', '开启', '获得', '继承', '发挥']
38
+ },
39
+ '人物型': {
40
+ 'setup_keywords': ['神秘人', '陌生人', '身份', '来历', '背景', '过去'],
41
+ 'resolution_keywords': ['身份揭晓', '原来是', '竟然是', '真实身份']
42
+ },
43
+ '危机型': {
44
+ 'setup_keywords': ['危险', '威胁', '隐患', '危机', '不安', '预感'],
45
+ 'resolution_keywords': ['爆发', '降临', '到来', '发生', '应验']
46
+ }
47
+ }
48
+
49
+
50
+ def detect_foreshadowing_in_chapter(filepath):
51
+ """Detect foreshadowing setups and resolutions in a chapter."""
52
+ raw, title, clean, wc = read_chapter(filepath)
53
+
54
+ setups = []
55
+ resolutions = []
56
+
57
+ for foreshadowing_type, config in FORESHADOWING_PATTERNS.items():
58
+ # Detect setups
59
+ for keyword in config['setup_keywords']:
60
+ positions = [m.start() for m in re.finditer(keyword, clean)]
61
+ for pos in positions:
62
+ # Get context around the keyword
63
+ start = max(0, pos - 50)
64
+ end = min(len(clean), pos + 50)
65
+ context = clean[start:end].replace('\n', ' ')
66
+
67
+ setups.append({
68
+ 'type': foreshadowing_type,
69
+ 'keyword': keyword,
70
+ 'context': context,
71
+ 'position': pos
72
+ })
73
+
74
+ # Detect resolutions
75
+ for keyword in config['resolution_keywords']:
76
+ positions = [m.start() for m in re.finditer(keyword, clean)]
77
+ for pos in positions:
78
+ start = max(0, pos - 50)
79
+ end = min(len(clean), pos + 50)
80
+ context = clean[start:end].replace('\n', ' ')
81
+
82
+ resolutions.append({
83
+ 'type': foreshadowing_type,
84
+ 'keyword': keyword,
85
+ 'context': context,
86
+ 'position': pos
87
+ })
88
+
89
+ return {
90
+ 'chapter': title,
91
+ 'word_count': wc,
92
+ 'setups': setups[:10], # Limit to 10 per chapter
93
+ 'resolutions': resolutions[:10]
94
+ }
95
+
96
+
97
+ def track_foreshadowing_across_chapters(chapters_dir, recent_n=None):
98
+ """Track foreshadowing across multiple chapters."""
99
+ chapter_files = list_chapters(chapters_dir, recent_n)
100
+ if not chapter_files:
101
+ return {'error': '未找到章节文件'}
102
+
103
+ all_setups = []
104
+ all_resolutions = []
105
+ chapter_data = []
106
+
107
+ for idx, cf in enumerate(chapter_files):
108
+ ch_data = detect_foreshadowing_in_chapter(cf)
109
+ chapter_data.append(ch_data)
110
+
111
+ for setup in ch_data['setups']:
112
+ setup['chapter_num'] = idx + 1
113
+ setup['chapter_title'] = ch_data['chapter']
114
+ all_setups.append(setup)
115
+
116
+ for resolution in ch_data['resolutions']:
117
+ resolution['chapter_num'] = idx + 1
118
+ resolution['chapter_title'] = ch_data['chapter']
119
+ all_resolutions.append(resolution)
120
+
121
+ # Analyze foreshadowing status
122
+ setup_types = defaultdict(int)
123
+ resolution_types = defaultdict(int)
124
+
125
+ for setup in all_setups:
126
+ setup_types[setup['type']] += 1
127
+
128
+ for resolution in all_resolutions:
129
+ resolution_types[resolution['type']] += 1
130
+
131
+ # Calculate resolution rate
132
+ total_setups = len(all_setups)
133
+ total_resolutions = len(all_resolutions)
134
+ resolution_rate = round(total_resolutions / max(total_setups, 1) * 100, 1)
135
+
136
+ # Detect unresolved foreshadowing (setups without matching resolutions)
137
+ unresolved_by_type = {}
138
+ for foreshadowing_type in FORESHADOWING_PATTERNS.keys():
139
+ setup_count = setup_types.get(foreshadowing_type, 0)
140
+ resolution_count = resolution_types.get(foreshadowing_type, 0)
141
+ unresolved = setup_count - resolution_count
142
+ if unresolved > 0:
143
+ unresolved_by_type[foreshadowing_type] = {
144
+ 'setups': setup_count,
145
+ 'resolutions': resolution_count,
146
+ 'unresolved': unresolved
147
+ }
148
+
149
+ # Generate auto-recovery suggestions
150
+ suggestions = generate_recovery_suggestions(unresolved_by_type, all_setups, len(chapter_files))
151
+
152
+ return {
153
+ 'total_chapters': len(chapter_files),
154
+ 'total_setups': total_setups,
155
+ 'total_resolutions': total_resolutions,
156
+ 'resolution_rate': resolution_rate,
157
+ 'setup_types': dict(setup_types),
158
+ 'resolution_types': dict(resolution_types),
159
+ 'unresolved_by_type': unresolved_by_type,
160
+ 'recent_setups': all_setups[-10:],
161
+ 'recent_resolutions': all_resolutions[-10:],
162
+ 'chapter_data': chapter_data[-5:], # Last 5 chapters
163
+ 'suggestions': suggestions
164
+ }
165
+
166
+
167
+ def generate_recovery_suggestions(unresolved_by_type, all_setups, total_chapters):
168
+ """Generate automatic recovery suggestions for unresolved foreshadowing."""
169
+ suggestions = []
170
+
171
+ # Recovery templates for each foreshadowing type
172
+ recovery_templates = {
173
+ '悬念型': {
174
+ 'suggestion': '在后续章节中安排真相揭露场景',
175
+ 'example': '可以安排一个关键角色揭示秘密,或者主角通过调查发现真相',
176
+ 'timing': '建议在5-10章内回收'
177
+ },
178
+ '预言型': {
179
+ 'suggestion': '让预言在关键时刻应验',
180
+ 'example': '可以在高潮章节让预言成真,增强戏剧性',
181
+ 'timing': '建议在卷末或重要转折点回收'
182
+ },
183
+ '物品型': {
184
+ 'suggestion': '让物品在关键时刻发挥作用',
185
+ 'example': '可以在危机时刻激活宝物/神器,扭转局势',
186
+ 'timing': '建议在3-8章内回收'
187
+ },
188
+ '人物型': {
189
+ 'suggestion': '揭示神秘人物的真实身份',
190
+ 'example': '可以安排身份揭露场景,制造意外转折',
191
+ 'timing': '建议在5-15章内回收'
192
+ },
193
+ '危机型': {
194
+ 'suggestion': '让预感的危机真正降临',
195
+ 'example': '可以在平静章节后突然爆发危机,制造紧张感',
196
+ 'timing': '建议在3-10章内回收'
197
+ }
198
+ }
199
+
200
+ for foreshadowing_type, data in unresolved_by_type.items():
201
+ if foreshadowing_type in recovery_templates:
202
+ template = recovery_templates[foreshadowing_type]
203
+
204
+ # Find the earliest setup for this type
205
+ earliest_setup = None
206
+ for setup in all_setups:
207
+ if setup['type'] == foreshadowing_type:
208
+ earliest_setup = setup
209
+ break
210
+
211
+ suggestion = {
212
+ 'type': foreshadowing_type,
213
+ 'unresolved_count': data['unresolved'],
214
+ 'suggestion': template['suggestion'],
215
+ 'example': template['example'],
216
+ 'timing': template['timing']
217
+ }
218
+
219
+ if earliest_setup:
220
+ suggestion['first_setup_chapter'] = earliest_setup.get('chapter_num', '未知')
221
+ suggestion['context_preview'] = earliest_setup.get('context', '')[:50]
222
+
223
+ suggestions.append(suggestion)
224
+
225
+ return suggestions
226
+
227
+
228
+ def main():
229
+ parser = argparse.ArgumentParser(description='伏笔追踪器')
230
+ parser.add_argument('chapters_dir', help='章节目录路径')
231
+ parser.add_argument('--recent', type=int, help='仅分析最近N章')
232
+ parser.add_argument('--json', action='store_true', help='JSON输出')
233
+ args = parser.parse_args()
234
+
235
+ if not os.path.isdir(args.chapters_dir):
236
+ print(f"错误: 目录不存在: {args.chapters_dir}", file=sys.stderr)
237
+ sys.exit(1)
238
+
239
+ result = track_foreshadowing_across_chapters(args.chapters_dir, args.recent)
240
+
241
+ if args.json:
242
+ print(json.dumps(result, ensure_ascii=False, indent=2))
243
+ else:
244
+ if 'error' in result:
245
+ print(result['error'])
246
+ return
247
+
248
+ print(f"\n=== 伏笔追踪报告 ===\n")
249
+ print(f"分析章节数: {result['total_chapters']}")
250
+ print(f"伏笔设置数: {result['total_setups']}")
251
+ print(f"伏笔回收数: {result['total_resolutions']}")
252
+ print(f"回收率: {result['resolution_rate']}%")
253
+
254
+ print(f"\n伏笔类型分布:")
255
+ for foreshadowing_type in FORESHADOWING_PATTERNS.keys():
256
+ setup_count = result['setup_types'].get(foreshadowing_type, 0)
257
+ resolution_count = result['resolution_types'].get(foreshadowing_type, 0)
258
+ if setup_count > 0:
259
+ print(f" {foreshadowing_type}: 设置{setup_count}次, 回收{resolution_count}次")
260
+
261
+ if result['unresolved_by_type']:
262
+ print(f"\n未回收伏笔:")
263
+ for foreshadowing_type, data in result['unresolved_by_type'].items():
264
+ print(f" ⚠ {foreshadowing_type}: {data['unresolved']}个未回收 (设置{data['setups']}次, 回收{data['resolutions']}次)")
265
+ else:
266
+ print(f"\n✓ 所有伏笔类型均有回收")
267
+
268
+ print(f"\n最近伏笔设置:")
269
+ for setup in result['recent_setups'][-5:]:
270
+ print(f" 第{setup['chapter_num']}章 [{setup['type']}]: {setup['context'][:60]}...")
271
+
272
+ print(f"\n最近伏笔回收:")
273
+ for resolution in result['recent_resolutions'][-5:]:
274
+ print(f" 第{resolution['chapter_num']}章 [{resolution['type']}]: {resolution['context'][:60]}...")
275
+
276
+ # 输出回收建议
277
+ if result.get('suggestions'):
278
+ print(f"\n回收建议:")
279
+ for suggestion in result['suggestions']:
280
+ print(f" [{suggestion['type']}] {suggestion['suggestion']}")
281
+ print(f" 示例: {suggestion['example']}")
282
+ print(f" 时机: {suggestion['timing']}")
283
+
284
+
285
+ if __name__ == '__main__':
286
+ main()