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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/bin/novel-maker.js +229 -0
- package/package.json +33 -0
- package/skill/CHANGELOG.md +82 -0
- package/skill/QUICK-REF.md +168 -0
- package/skill/README.md +75 -0
- package/skill/SKILL.md +715 -0
- package/skill/agents/README.md +59 -0
- package/skill/agents/auditor.md +234 -0
- package/skill/agents/coordinator.md +150 -0
- package/skill/agents/planner.md +220 -0
- package/skill/agents/reviewer.md +249 -0
- package/skill/agents/reviser.md +144 -0
- package/skill/agents/writer.md +213 -0
- package/skill/arc-templates/README.md +43 -0
- package/skill/arc-templates/apocalypse/ability.md +81 -0
- package/skill/arc-templates/apocalypse/faction.md +81 -0
- package/skill/arc-templates/apocalypse/humanity.md +81 -0
- package/skill/arc-templates/apocalypse/survival.md +81 -0
- package/skill/arc-templates/game/competition.md +81 -0
- package/skill/arc-templates/game/dungeon.md +81 -0
- package/skill/arc-templates/game/guild-war.md +81 -0
- package/skill/arc-templates/game/leveling.md +80 -0
- package/skill/arc-templates/general/challenge.md +44 -0
- package/skill/arc-templates/general/conflict.md +71 -0
- package/skill/arc-templates/general/explore.md +71 -0
- package/skill/arc-templates/general/growth.md +71 -0
- package/skill/arc-templates/general/mystery.md +71 -0
- package/skill/arc-templates/general/relation.md +71 -0
- package/skill/arc-templates/history/battle.md +71 -0
- package/skill/arc-templates/history/politics.md +71 -0
- package/skill/arc-templates/history/reform.md +71 -0
- package/skill/arc-templates/infinite-flow/boss.md +71 -0
- package/skill/arc-templates/infinite-flow/dungeon.md +71 -0
- package/skill/arc-templates/infinite-flow/enhance.md +71 -0
- package/skill/arc-templates/infinite-flow/team.md +71 -0
- package/skill/arc-templates/mystery/case.md +71 -0
- package/skill/arc-templates/mystery/deduction.md +71 -0
- package/skill/arc-templates/mystery/twist.md +71 -0
- package/skill/arc-templates/romance/angst.md +80 -0
- package/skill/arc-templates/romance/chase.md +71 -0
- package/skill/arc-templates/romance/slow-burn.md +71 -0
- package/skill/arc-templates/romance/sweet.md +80 -0
- package/skill/arc-templates/sci-fi/awakening.md +81 -0
- package/skill/arc-templates/sci-fi/breakthrough.md +81 -0
- package/skill/arc-templates/sci-fi/contact.md +81 -0
- package/skill/arc-templates/sci-fi/exploration.md +81 -0
- package/skill/arc-templates/urban/business.md +71 -0
- package/skill/arc-templates/urban/revenge.md +71 -0
- package/skill/arc-templates/urban/rise.md +71 -0
- package/skill/arc-templates/urban/romance.md +71 -0
- package/skill/arc-templates/western-fantasy/adventure.md +80 -0
- package/skill/arc-templates/western-fantasy/kingdom.md +71 -0
- package/skill/arc-templates/western-fantasy/magic-awakening.md +71 -0
- package/skill/arc-templates/western-fantasy/racial-conflict.md +71 -0
- package/skill/arc-templates/wuxia/breakthrough.md +71 -0
- package/skill/arc-templates/wuxia/grudge.md +71 -0
- package/skill/arc-templates/wuxia/hero-path.md +71 -0
- package/skill/arc-templates/wuxia/sect-war.md +71 -0
- package/skill/arc-templates/xianxia/breakthrough.md +71 -0
- package/skill/arc-templates/xianxia/dungeon.md +71 -0
- package/skill/arc-templates/xianxia/tournament.md +71 -0
- package/skill/arc-templates/xianxia/tribulation.md +71 -0
- package/skill/docs/examples.md +61 -0
- package/skill/docs/faq.md +81 -0
- package/skill/docs/installation.md +87 -0
- package/skill/docs/quickstart.md +83 -0
- package/skill/genre-packs/README.md +47 -0
- package/skill/genre-packs/_default/arc-types.md +153 -0
- package/skill/genre-packs/_default/rules.md +56 -0
- package/skill/genre-packs/_default/templates.md +135 -0
- package/skill/genre-packs/apocalypse/arc-types.md +109 -0
- package/skill/genre-packs/apocalypse/rules.md +113 -0
- package/skill/genre-packs/apocalypse/settings.md +106 -0
- package/skill/genre-packs/apocalypse/templates.md +192 -0
- package/skill/genre-packs/game/arc-types.md +109 -0
- package/skill/genre-packs/game/rules.md +113 -0
- package/skill/genre-packs/game/settings.md +103 -0
- package/skill/genre-packs/game/templates.md +173 -0
- package/skill/genre-packs/history/arc-types.md +109 -0
- package/skill/genre-packs/history/rules.md +107 -0
- package/skill/genre-packs/history/settings.md +126 -0
- package/skill/genre-packs/history/templates.md +179 -0
- package/skill/genre-packs/infinite-flow/arc-types.md +101 -0
- package/skill/genre-packs/infinite-flow/rules.md +75 -0
- package/skill/genre-packs/infinite-flow/settings.md +102 -0
- package/skill/genre-packs/infinite-flow/templates.md +226 -0
- package/skill/genre-packs/mystery/arc-types.md +109 -0
- package/skill/genre-packs/mystery/rules.md +107 -0
- package/skill/genre-packs/mystery/settings.md +103 -0
- package/skill/genre-packs/mystery/templates.md +178 -0
- package/skill/genre-packs/romance/arc-types.md +130 -0
- package/skill/genre-packs/romance/rules.md +88 -0
- package/skill/genre-packs/romance/settings.md +146 -0
- package/skill/genre-packs/romance/templates.md +245 -0
- package/skill/genre-packs/sci-fi/arc-types.md +109 -0
- package/skill/genre-packs/sci-fi/rules.md +113 -0
- package/skill/genre-packs/sci-fi/settings.md +99 -0
- package/skill/genre-packs/sci-fi/templates.md +170 -0
- package/skill/genre-packs/urban/arc-types.md +101 -0
- package/skill/genre-packs/urban/rules.md +75 -0
- package/skill/genre-packs/urban/settings.md +82 -0
- package/skill/genre-packs/urban/templates.md +212 -0
- package/skill/genre-packs/western-fantasy/arc-types.md +128 -0
- package/skill/genre-packs/western-fantasy/rules.md +88 -0
- package/skill/genre-packs/western-fantasy/settings.md +160 -0
- package/skill/genre-packs/western-fantasy/templates.md +225 -0
- package/skill/genre-packs/wuxia/arc-types.md +126 -0
- package/skill/genre-packs/wuxia/rules.md +86 -0
- package/skill/genre-packs/wuxia/settings.md +150 -0
- package/skill/genre-packs/wuxia/templates.md +195 -0
- package/skill/genre-packs/xianxia/arc-types.md +101 -0
- package/skill/genre-packs/xianxia/rules.md +74 -0
- package/skill/genre-packs/xianxia/settings.md +107 -0
- package/skill/genre-packs/xianxia/templates.md +202 -0
- package/skill/hooks/README.md +102 -0
- package/skill/hooks/chapter-complete.md +176 -0
- package/skill/hooks/context-injection.md +152 -0
- package/skill/hooks/intent-detection.md +183 -0
- package/skill/hooks/review-trigger.md +219 -0
- package/skill/hooks/summary-trigger.md +185 -0
- package/skill/references/act-guidance.md +228 -0
- package/skill/references/audit-core.md +130 -0
- package/skill/references/audit-dimensions.md +202 -0
- package/skill/references/character-voice-card.md +196 -0
- package/skill/references/consistency-checker.md +209 -0
- package/skill/references/content-expansion.md +68 -0
- package/skill/references/creative-constraints.md +200 -0
- package/skill/references/data-agent.md +286 -0
- package/skill/references/dialogue-writing.md +104 -0
- package/skill/references/editorial-perspective.md +166 -0
- package/skill/references/emotion-curve.md +127 -0
- package/skill/references/genre-rules.md +389 -0
- package/skill/references/golden-opening.md +81 -0
- package/skill/references/memory-system.md +288 -0
- package/skill/references/pacing-analysis.md +201 -0
- package/skill/references/platform-rules.md +244 -0
- package/skill/references/plot-structures.md +108 -0
- package/skill/references/reader-feedback.md +119 -0
- package/skill/references/rhythm-system.md +204 -0
- package/skill/references/style-imitation.md +193 -0
- package/skill/references/sweet-spot-tracking.md +182 -0
- package/skill/references/usage-guide.md +174 -0
- package/skill/references/writing-methods.md +169 -0
- package/skill/rules/anti-ai-expressions.md +206 -0
- package/skill/rules/character-voice.md +184 -0
- package/skill/rules/consistency-check.md +232 -0
- package/skill/rules/smart-query.md +263 -0
- package/skill/scripts/README.md +380 -0
- package/skill/scripts/auditor/chapter_transition.py +217 -0
- package/skill/scripts/auditor/consistency_scan.py +194 -0
- package/skill/scripts/auditor/dialogue_checker.py +194 -0
- package/skill/scripts/auditor/hook_report.py +115 -0
- package/skill/scripts/auditor/pacing_optimizer.py +303 -0
- package/skill/scripts/auditor/pacing_report.py +275 -0
- package/skill/scripts/auditor/pre_audit.py +203 -0
- package/skill/scripts/auditor/style_check.py +158 -0
- package/skill/scripts/auditor/worldbuilding_checker.py +637 -0
- package/skill/scripts/common/analyze.py +129 -0
- package/skill/scripts/common/init_guide.py +796 -0
- package/skill/scripts/common/install.py +169 -0
- package/skill/scripts/common/nm_utils.py +296 -0
- package/skill/scripts/common/validate.py +215 -0
- package/skill/scripts/coordinator/stats_report.py +165 -0
- package/skill/scripts/coordinator/volume_batch.py +121 -0
- package/skill/scripts/planner/outline_extractor.py +89 -0
- package/skill/scripts/planner/planner_context.py +220 -0
- package/skill/scripts/planner/query_engine.py +289 -0
- package/skill/scripts/reviewer/chapter_diff.py +143 -0
- package/skill/scripts/reviewer/character_arc_tracker.py +191 -0
- package/skill/scripts/reviewer/emotion_curve.py +340 -0
- package/skill/scripts/reviewer/foreshadowing_tracker.py +286 -0
- package/skill/scripts/reviewer/subplot_tracker.py +207 -0
- package/skill/scripts/reviewer/summary_generator.py +130 -0
- package/skill/scripts/reviewer/truth_diff.py +227 -0
- package/skill/scripts/reviewer/truth_manager.py +120 -0
- package/skill/scripts/test_scripts.py +366 -0
- package/skill/scripts/writer/build_write_context.py +255 -0
- package/skill/scripts/writer/chapter_info.py +67 -0
- package/skill/scripts/writer/check_wordcount.py +115 -0
- package/skill/scripts/writer/scene_builder.py +227 -0
- package/skill/scripts/writer/style_anchor.py +135 -0
- package/skill/styles/author-styles.md +53 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/skill/templates/INDEX.md +48 -0
- package/skill/templates/act-plan.md +72 -0
- package/skill/templates/chapter.md +53 -0
- package/skill/templates/character-profile.md +107 -0
- package/skill/templates/character-voice.md +106 -0
- package/skill/templates/constitution.md +90 -0
- package/skill/templates/emotional-arcs.md +37 -0
- package/skill/templates/hook-template.md +68 -0
- package/skill/templates/outline.md +155 -0
- package/skill/templates/plot-card.md +432 -0
- package/skill/templates/power-system.md +124 -0
- package/skill/templates/presets.json +69 -0
- package/skill/templates/review-report.md +135 -0
- package/skill/templates/scene-plan.md +221 -0
- package/skill/templates/scene-template.md +78 -0
- package/skill/templates/subplot-board.md +48 -0
- package/skill/templates/summary-10chapters.md +79 -0
- package/skill/templates/summary-50chapters.md +131 -0
- package/skill/templates/summary-volume.md +148 -0
- package/skill/templates/timeline.md +37 -0
- package/skill/templates/volume-plan.md +44 -0
- package/skill/templates/world-setting.md +151 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
单元测试脚本
|
|
5
|
+
测试所有辅助脚本的基本功能。
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
python scripts/test_scripts.py
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import json
|
|
14
|
+
import tempfile
|
|
15
|
+
import shutil
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Add common to path
|
|
19
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common'))
|
|
20
|
+
|
|
21
|
+
# Test results
|
|
22
|
+
test_results = {
|
|
23
|
+
'passed': 0,
|
|
24
|
+
'failed': 0,
|
|
25
|
+
'errors': []
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_passed(test_name):
|
|
30
|
+
test_results['passed'] += 1
|
|
31
|
+
print(f" ✓ {test_name}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_failed(test_name, error):
|
|
35
|
+
test_results['failed'] += 1
|
|
36
|
+
test_results['errors'].append({'test': test_name, 'error': str(error)})
|
|
37
|
+
print(f" ✗ {test_name}: {error}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_test_chapter(filepath, title="测试章节", content=None):
|
|
41
|
+
"""Create a test chapter file."""
|
|
42
|
+
if content is None:
|
|
43
|
+
content = f"""# {title}
|
|
44
|
+
|
|
45
|
+
林风站在山巅,望着远方的城市。
|
|
46
|
+
|
|
47
|
+
"苏婉,你来了。"林风转身说道。
|
|
48
|
+
|
|
49
|
+
苏婉微微一笑:"我当然会来。"
|
|
50
|
+
|
|
51
|
+
两人相视一笑,心中都明白彼此的心意。
|
|
52
|
+
|
|
53
|
+
突然,远处传来一声巨响,天空中出现了一道裂缝。
|
|
54
|
+
|
|
55
|
+
"不好!"林风脸色大变,"危机降临了!"
|
|
56
|
+
|
|
57
|
+
苏婉握紧手中的宝剑:"我们一起面对!"
|
|
58
|
+
|
|
59
|
+
林风从怀中掏出一本古老的秘籍,这是师父留给他的传承。
|
|
60
|
+
|
|
61
|
+
"据说这本秘籍具有强大的力量,但需要足够的修为才能使用。"林风说道。
|
|
62
|
+
|
|
63
|
+
苏婉点头:"我相信你一定能突破瓶颈。"
|
|
64
|
+
|
|
65
|
+
林风闭上眼睛,开始运功修炼。他的修为从筑基期开始提升...
|
|
66
|
+
|
|
67
|
+
一刻钟后,林风睁开眼睛,眼中闪过一丝精光。
|
|
68
|
+
|
|
69
|
+
"我突破了!现在是金丹期!"林风兴奋地说道。
|
|
70
|
+
|
|
71
|
+
苏婉为他高兴:"太好了!"
|
|
72
|
+
|
|
73
|
+
但就在这时,一个神秘人出现在他们面前。
|
|
74
|
+
|
|
75
|
+
"你们以为这样就结束了吗?"神秘人冷笑道,"真正的危险才刚刚开始。"
|
|
76
|
+
|
|
77
|
+
林风和苏婉对视一眼,都看到了对方眼中的决心。
|
|
78
|
+
|
|
79
|
+
无论前方有什么危险,他们都会一起面对。
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
**下章预告**:神秘人的真实身份究竟是什么?林风能否保护苏婉?"""
|
|
84
|
+
|
|
85
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
86
|
+
f.write(content)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_test_truth_file(filepath, content):
|
|
90
|
+
"""Create a test truth file."""
|
|
91
|
+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
92
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
93
|
+
f.write(content)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_scene_builder():
|
|
97
|
+
"""Test scene_builder.py"""
|
|
98
|
+
print("\n=== 测试 scene_builder.py ===")
|
|
99
|
+
try:
|
|
100
|
+
from writer.scene_builder import build_scene, list_scene_types
|
|
101
|
+
|
|
102
|
+
# Test list_scene_types
|
|
103
|
+
result = list_scene_types()
|
|
104
|
+
assert 'available_types' in result
|
|
105
|
+
assert len(result['available_types']) > 0
|
|
106
|
+
test_passed('list_scene_types')
|
|
107
|
+
|
|
108
|
+
# Test build_scene
|
|
109
|
+
result = build_scene('冲突', ['林风', '苏婉'], '紧张')
|
|
110
|
+
assert 'scene_type' in result
|
|
111
|
+
assert result['scene_type'] == '冲突'
|
|
112
|
+
assert 'structure' in result
|
|
113
|
+
assert len(result['structure']) > 0
|
|
114
|
+
test_passed('build_scene with type, chars, emotion')
|
|
115
|
+
|
|
116
|
+
# Test invalid type
|
|
117
|
+
result = build_scene('invalid_type')
|
|
118
|
+
assert 'error' in result
|
|
119
|
+
test_passed('build_scene with invalid type')
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
test_failed('scene_builder', e)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_dialogue_checker():
|
|
126
|
+
"""Test dialogue_checker.py"""
|
|
127
|
+
print("\n=== 测试 dialogue_checker.py ===")
|
|
128
|
+
try:
|
|
129
|
+
from auditor.dialogue_checker import check_dialogue_quality, extract_dialogues
|
|
130
|
+
|
|
131
|
+
# Test extract_dialogues (using ASCII quotes which the regex also matches)
|
|
132
|
+
text = '林风说道:"你好。"苏婉回答:"我很好。"'
|
|
133
|
+
dialogues = extract_dialogues(text)
|
|
134
|
+
assert len(dialogues) == 2
|
|
135
|
+
test_passed('extract_dialogues')
|
|
136
|
+
|
|
137
|
+
# Test check_dialogue_quality
|
|
138
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as f:
|
|
139
|
+
f.write('# 测试章节\n\n林风说道:"你好。"苏婉回答:"我很好。"')
|
|
140
|
+
temp_file = f.name
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
result = check_dialogue_quality(temp_file)
|
|
144
|
+
assert 'dialogue_count' in result
|
|
145
|
+
assert 'checks' in result
|
|
146
|
+
test_passed('check_dialogue_quality')
|
|
147
|
+
finally:
|
|
148
|
+
os.unlink(temp_file)
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
test_failed('dialogue_checker', e)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_character_arc_tracker():
|
|
155
|
+
"""Test character_arc_tracker.py"""
|
|
156
|
+
print("\n=== 测试 character_arc_tracker.py ===")
|
|
157
|
+
try:
|
|
158
|
+
from reviewer.character_arc_tracker import build_character_arc
|
|
159
|
+
|
|
160
|
+
# Create temp directory with test chapters
|
|
161
|
+
temp_dir = tempfile.mkdtemp()
|
|
162
|
+
try:
|
|
163
|
+
for i in range(3):
|
|
164
|
+
create_test_chapter(os.path.join(temp_dir, f'第{i+1:02d}章.md'), f'第{i+1}章')
|
|
165
|
+
|
|
166
|
+
result = build_character_arc(temp_dir, ['林风'])
|
|
167
|
+
assert 'total_chapters_analyzed' in result
|
|
168
|
+
assert result['total_chapters_analyzed'] == 3
|
|
169
|
+
assert 'characters_tracked' in result
|
|
170
|
+
test_passed('build_character_arc')
|
|
171
|
+
finally:
|
|
172
|
+
shutil.rmtree(temp_dir)
|
|
173
|
+
|
|
174
|
+
except Exception as e:
|
|
175
|
+
test_failed('character_arc_tracker', e)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_subplot_tracker():
|
|
179
|
+
"""Test subplot_tracker.py"""
|
|
180
|
+
print("\n=== 测试 subplot_tracker.py ===")
|
|
181
|
+
try:
|
|
182
|
+
from reviewer.subplot_tracker import track_subplot_across_chapters
|
|
183
|
+
|
|
184
|
+
# Create temp directory with test chapters
|
|
185
|
+
temp_dir = tempfile.mkdtemp()
|
|
186
|
+
try:
|
|
187
|
+
for i in range(3):
|
|
188
|
+
create_test_chapter(os.path.join(temp_dir, f'第{i+1:02d}章.md'), f'第{i+1}章')
|
|
189
|
+
|
|
190
|
+
result = track_subplot_across_chapters(temp_dir)
|
|
191
|
+
assert 'total_chapters_analyzed' in result
|
|
192
|
+
assert 'subplots_detected' in result
|
|
193
|
+
test_passed('track_subplot_across_chapters')
|
|
194
|
+
finally:
|
|
195
|
+
shutil.rmtree(temp_dir)
|
|
196
|
+
|
|
197
|
+
except Exception as e:
|
|
198
|
+
test_failed('subplot_tracker', e)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_chapter_transition():
|
|
202
|
+
"""Test chapter_transition.py"""
|
|
203
|
+
print("\n=== 测试 chapter_transition.py ===")
|
|
204
|
+
try:
|
|
205
|
+
from auditor.chapter_transition import check_transition, list_chapters
|
|
206
|
+
|
|
207
|
+
# Create temp directory with test chapters
|
|
208
|
+
temp_dir = tempfile.mkdtemp()
|
|
209
|
+
try:
|
|
210
|
+
for i in range(3):
|
|
211
|
+
create_test_chapter(os.path.join(temp_dir, f'第{i+1:02d}章.md'), f'第{i+1}章')
|
|
212
|
+
|
|
213
|
+
chapter_files = list_chapters(temp_dir)
|
|
214
|
+
assert len(chapter_files) == 3
|
|
215
|
+
|
|
216
|
+
transitions = check_transition(chapter_files)
|
|
217
|
+
assert len(transitions) == 2 # 3 chapters = 2 transitions
|
|
218
|
+
test_passed('check_transition')
|
|
219
|
+
finally:
|
|
220
|
+
shutil.rmtree(temp_dir)
|
|
221
|
+
|
|
222
|
+
except Exception as e:
|
|
223
|
+
test_failed('chapter_transition', e)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_emotion_curve():
|
|
227
|
+
"""Test emotion_curve.py"""
|
|
228
|
+
print("\n=== 测试 emotion_curve.py ===")
|
|
229
|
+
try:
|
|
230
|
+
from reviewer.emotion_curve import analyze_emotion_in_text, build_emotion_curve
|
|
231
|
+
|
|
232
|
+
# Test analyze_emotion_in_text
|
|
233
|
+
text = '林风很开心,苏婉很悲伤,两人心情复杂。'
|
|
234
|
+
result = analyze_emotion_in_text(text)
|
|
235
|
+
assert 'dominant' in result
|
|
236
|
+
assert 'scores' in result
|
|
237
|
+
test_passed('analyze_emotion_in_text')
|
|
238
|
+
|
|
239
|
+
# Test build_emotion_curve
|
|
240
|
+
temp_dir = tempfile.mkdtemp()
|
|
241
|
+
try:
|
|
242
|
+
for i in range(3):
|
|
243
|
+
create_test_chapter(os.path.join(temp_dir, f'第{i+1:02d}章.md'), f'第{i+1}章')
|
|
244
|
+
|
|
245
|
+
result = build_emotion_curve(temp_dir)
|
|
246
|
+
assert 'total_chapters' in result
|
|
247
|
+
assert 'curve' in result
|
|
248
|
+
test_passed('build_emotion_curve')
|
|
249
|
+
finally:
|
|
250
|
+
shutil.rmtree(temp_dir)
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
test_failed('emotion_curve', e)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_foreshadowing_tracker():
|
|
257
|
+
"""Test foreshadowing_tracker.py"""
|
|
258
|
+
print("\n=== 测试 foreshadowing_tracker.py ===")
|
|
259
|
+
try:
|
|
260
|
+
from reviewer.foreshadowing_tracker import track_foreshadowing_across_chapters
|
|
261
|
+
|
|
262
|
+
# Create temp directory with test chapters
|
|
263
|
+
temp_dir = tempfile.mkdtemp()
|
|
264
|
+
try:
|
|
265
|
+
for i in range(3):
|
|
266
|
+
create_test_chapter(os.path.join(temp_dir, f'第{i+1:02d}章.md'), f'第{i+1}章')
|
|
267
|
+
|
|
268
|
+
result = track_foreshadowing_across_chapters(temp_dir)
|
|
269
|
+
assert 'total_chapters' in result
|
|
270
|
+
assert 'total_setups' in result
|
|
271
|
+
assert 'total_resolutions' in result
|
|
272
|
+
test_passed('track_foreshadowing_across_chapters')
|
|
273
|
+
finally:
|
|
274
|
+
shutil.rmtree(temp_dir)
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
test_failed('foreshadowing_tracker', e)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_worldbuilding_checker():
|
|
281
|
+
"""Test worldbuilding_checker.py"""
|
|
282
|
+
print("\n=== 测试 worldbuilding_checker.py ===")
|
|
283
|
+
try:
|
|
284
|
+
from auditor.worldbuilding_checker import check_worldbuilding_consistency
|
|
285
|
+
|
|
286
|
+
# Create temp directory with test chapters
|
|
287
|
+
temp_dir = tempfile.mkdtemp()
|
|
288
|
+
truth_dir = tempfile.mkdtemp()
|
|
289
|
+
try:
|
|
290
|
+
for i in range(3):
|
|
291
|
+
create_test_chapter(os.path.join(temp_dir, f'第{i+1:02d}章.md'), f'第{i+1}章')
|
|
292
|
+
|
|
293
|
+
# Create truth file
|
|
294
|
+
create_test_truth_file(
|
|
295
|
+
os.path.join(truth_dir, 'power_system.md'),
|
|
296
|
+
'# 力量体系\n\n- 筑基期\n- 金丹期\n- 元婴期'
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
result = check_worldbuilding_consistency(temp_dir, truth_dir)
|
|
300
|
+
assert 'total_chapters' in result
|
|
301
|
+
assert 'dimensions_checked' in result
|
|
302
|
+
assert 'issues' in result
|
|
303
|
+
test_passed('check_worldbuilding_consistency')
|
|
304
|
+
finally:
|
|
305
|
+
shutil.rmtree(temp_dir)
|
|
306
|
+
shutil.rmtree(truth_dir)
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
test_failed('worldbuilding_checker', e)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_pacing_optimizer():
|
|
313
|
+
"""Test pacing_optimizer.py"""
|
|
314
|
+
print("\n=== 测试 pacing_optimizer.py ===")
|
|
315
|
+
try:
|
|
316
|
+
from auditor.pacing_optimizer import analyze_pacing_with_suggestions
|
|
317
|
+
|
|
318
|
+
# Create temp directory with test chapters
|
|
319
|
+
temp_dir = tempfile.mkdtemp()
|
|
320
|
+
try:
|
|
321
|
+
for i in range(5):
|
|
322
|
+
create_test_chapter(os.path.join(temp_dir, f'第{i+1:02d}章.md'), f'第{i+1}章')
|
|
323
|
+
|
|
324
|
+
result = analyze_pacing_with_suggestions(temp_dir)
|
|
325
|
+
assert 'total_chapters' in result
|
|
326
|
+
assert 'pacing_distribution' in result
|
|
327
|
+
assert 'suggestions' in result
|
|
328
|
+
test_passed('analyze_pacing_with_suggestions')
|
|
329
|
+
finally:
|
|
330
|
+
shutil.rmtree(temp_dir)
|
|
331
|
+
|
|
332
|
+
except Exception as e:
|
|
333
|
+
test_failed('pacing_optimizer', e)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main():
|
|
337
|
+
print("=" * 60)
|
|
338
|
+
print("NovelMaker 辅助脚本单元测试")
|
|
339
|
+
print("=" * 60)
|
|
340
|
+
|
|
341
|
+
# Run all tests
|
|
342
|
+
test_scene_builder()
|
|
343
|
+
test_dialogue_checker()
|
|
344
|
+
test_character_arc_tracker()
|
|
345
|
+
test_subplot_tracker()
|
|
346
|
+
test_chapter_transition()
|
|
347
|
+
test_emotion_curve()
|
|
348
|
+
test_foreshadowing_tracker()
|
|
349
|
+
test_worldbuilding_checker()
|
|
350
|
+
test_pacing_optimizer()
|
|
351
|
+
|
|
352
|
+
# Print summary
|
|
353
|
+
print("\n" + "=" * 60)
|
|
354
|
+
print(f"测试完成: {test_results['passed']}通过, {test_results['failed']}失败")
|
|
355
|
+
print("=" * 60)
|
|
356
|
+
|
|
357
|
+
if test_results['errors']:
|
|
358
|
+
print("\n失败详情:")
|
|
359
|
+
for error in test_results['errors']:
|
|
360
|
+
print(f" - {error['test']}: {error['error']}")
|
|
361
|
+
|
|
362
|
+
return 0 if test_results['failed'] == 0 else 1
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if __name__ == '__main__':
|
|
366
|
+
sys.exit(main())
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
写手上下文构建器 — 一键生成写作所需的精简上下文
|
|
5
|
+
|
|
6
|
+
将写手需要读取的 15+ 个文件压缩为一个结构化 JSON(~3000 token),
|
|
7
|
+
每章节省约 40,000-60,000 token 的文件读取消耗。
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python scripts/writer/build_write_context.py novels/volume-01/chapters/ch15.md
|
|
11
|
+
python scripts/writer/build_write_context.py --chapter 15 --volume 01
|
|
12
|
+
python scripts/writer/build_write_context.py --chapter 15 --json
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
|
|
21
|
+
from nm_utils import (
|
|
22
|
+
read_truth_section, list_chapters, read_chapter,
|
|
23
|
+
extract_characters, generate_summary, detect_hook,
|
|
24
|
+
parse_outline_headings
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
NOVEL_MAKER_DIR = '.novel-maker'
|
|
28
|
+
TRUTH_DIR = os.path.join(NOVEL_MAKER_DIR, 'truth-files')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _find_project_root(start_path):
|
|
32
|
+
"""向上查找包含 .novel-maker 的项目根目录"""
|
|
33
|
+
path = os.path.abspath(start_path)
|
|
34
|
+
for _ in range(10):
|
|
35
|
+
if os.path.isdir(os.path.join(path, NOVEL_MAKER_DIR)):
|
|
36
|
+
return path
|
|
37
|
+
parent = os.path.dirname(path)
|
|
38
|
+
if parent == path:
|
|
39
|
+
break
|
|
40
|
+
path = parent
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_truth_compact(filepath, max_chars=800):
|
|
45
|
+
"""读取真相文件并压缩为精简摘要"""
|
|
46
|
+
sections = read_truth_section(filepath)
|
|
47
|
+
if not sections:
|
|
48
|
+
return None
|
|
49
|
+
result = {}
|
|
50
|
+
for title, content in sections.items():
|
|
51
|
+
if title == 'header':
|
|
52
|
+
continue
|
|
53
|
+
lines = [l.strip() for l in content.split('\n') if l.strip() and not l.startswith('#')]
|
|
54
|
+
compact = '\n'.join(lines[:15]) # 每个section最多15行
|
|
55
|
+
if len(compact) > max_chars:
|
|
56
|
+
compact = compact[:max_chars] + '...'
|
|
57
|
+
if compact:
|
|
58
|
+
result[title] = compact
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_prev_chapters_info(chapters_dir, current_chapter_num, count=2):
|
|
63
|
+
"""获取前N章的结构化摘要"""
|
|
64
|
+
all_chapters = list_chapters(chapters_dir)
|
|
65
|
+
prev = []
|
|
66
|
+
for ch_path in all_chapters:
|
|
67
|
+
raw, title, clean, wc = read_chapter(ch_path)
|
|
68
|
+
# 判断是否在当前章之前
|
|
69
|
+
from nm_utils import chapter_sort_key
|
|
70
|
+
ch_num = chapter_sort_key(ch_path)
|
|
71
|
+
if ch_num >= current_chapter_num:
|
|
72
|
+
break
|
|
73
|
+
prev.append((ch_num, ch_path, raw, title, clean, wc))
|
|
74
|
+
|
|
75
|
+
# 只取最近N章
|
|
76
|
+
prev = prev[-count:]
|
|
77
|
+
results = []
|
|
78
|
+
for ch_num, ch_path, raw, title, clean, wc in prev:
|
|
79
|
+
chars = extract_characters(clean)[:8]
|
|
80
|
+
summary = generate_summary(raw, first_n=100, last_n=100)
|
|
81
|
+
hook = detect_hook(raw)
|
|
82
|
+
results.append({
|
|
83
|
+
'chapter': os.path.basename(ch_path),
|
|
84
|
+
'number': ch_num,
|
|
85
|
+
'title': title,
|
|
86
|
+
'word_count': wc,
|
|
87
|
+
'characters': chars,
|
|
88
|
+
'hook': hook['type'],
|
|
89
|
+
'summary': summary
|
|
90
|
+
})
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_outline_context(outline_path, chapter_num):
|
|
95
|
+
"""从大纲中提取当前章节的目标"""
|
|
96
|
+
if not os.path.exists(outline_path):
|
|
97
|
+
return None
|
|
98
|
+
with open(outline_path, encoding='utf-8') as f:
|
|
99
|
+
text = f.read()
|
|
100
|
+
headings = parse_outline_headings(text)
|
|
101
|
+
# 找到当前章节附近的大纲节点
|
|
102
|
+
result = []
|
|
103
|
+
found_current = False
|
|
104
|
+
for h in headings:
|
|
105
|
+
if f'第{chapter_num}章' in h['title'] or f'第{_to_cn(chapter_num)}章' in h['title']:
|
|
106
|
+
found_current = True
|
|
107
|
+
if found_current:
|
|
108
|
+
result.append(h)
|
|
109
|
+
if len(result) >= 5:
|
|
110
|
+
break
|
|
111
|
+
# 如果没找到精确匹配,返回最后几个节点
|
|
112
|
+
if not result:
|
|
113
|
+
result = headings[-5:] if len(headings) > 5 else headings
|
|
114
|
+
return [{'level': h['level'], 'title': h['title']} for h in result]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _to_cn(n):
|
|
118
|
+
"""数字转中文"""
|
|
119
|
+
cn = '零一二三四五六七八九十'
|
|
120
|
+
if n <= 10:
|
|
121
|
+
return cn[n]
|
|
122
|
+
if n < 20:
|
|
123
|
+
return '十' + (cn[n - 10] if n > 10 else '')
|
|
124
|
+
return str(n)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _get_constitution_summary(constitution_path):
|
|
128
|
+
"""提取创作宪法要点"""
|
|
129
|
+
if not os.path.exists(constitution_path):
|
|
130
|
+
return None
|
|
131
|
+
sections = read_truth_section(constitution_path)
|
|
132
|
+
if not sections:
|
|
133
|
+
return None
|
|
134
|
+
key_sections = ['字数要求', '文风配置', '核心创作原则', '禁忌清单']
|
|
135
|
+
result = {}
|
|
136
|
+
for title, content in sections.items():
|
|
137
|
+
if any(k in title for k in key_sections):
|
|
138
|
+
lines = [l.strip() for l in content.split('\n') if l.strip() and not l.startswith('#')]
|
|
139
|
+
result[title] = '\n'.join(lines[:8])
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_context(chapter_path=None, chapter_num=None, volume=None,
|
|
144
|
+
novel_root=None, truth_dir=None, recent_count=2):
|
|
145
|
+
"""构建写手上下文"""
|
|
146
|
+
|
|
147
|
+
# 确定项目根目录
|
|
148
|
+
if novel_root:
|
|
149
|
+
root = novel_root
|
|
150
|
+
elif chapter_path:
|
|
151
|
+
root = _find_project_root(os.path.dirname(chapter_path))
|
|
152
|
+
else:
|
|
153
|
+
root = _find_project_root('.')
|
|
154
|
+
|
|
155
|
+
if not root:
|
|
156
|
+
return {'error': '未找到 .novel-maker 目录,请先运行 /novel-maker init'}
|
|
157
|
+
|
|
158
|
+
truth_path = truth_dir or os.path.join(root, TRUTH_DIR)
|
|
159
|
+
context = {'meta': {}, 'truth_files': {}, 'prev_chapters': [], 'outline': None, 'constitution': None}
|
|
160
|
+
|
|
161
|
+
# 确定章节号和卷号
|
|
162
|
+
if chapter_path and os.path.exists(chapter_path):
|
|
163
|
+
from nm_utils import chapter_sort_key
|
|
164
|
+
chapter_num = chapter_num or chapter_sort_key(chapter_path)
|
|
165
|
+
chapters_dir = os.path.dirname(chapter_path)
|
|
166
|
+
volume = volume or os.path.basename(os.path.dirname(chapters_dir))
|
|
167
|
+
elif chapter_num and volume:
|
|
168
|
+
chapters_dir = os.path.join(root, 'novels', f'volume-{volume.zfill(2)}', 'chapters')
|
|
169
|
+
chapter_path = None
|
|
170
|
+
else:
|
|
171
|
+
return {'error': '请指定章节路径或章节号+卷号'}
|
|
172
|
+
|
|
173
|
+
context['meta'] = {
|
|
174
|
+
'chapter_num': chapter_num,
|
|
175
|
+
'volume': volume,
|
|
176
|
+
'chapters_dir': chapters_dir
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# 1. 读取精简版真相文件
|
|
180
|
+
truth_files = {
|
|
181
|
+
'characters': 'characters.md',
|
|
182
|
+
'current_state': 'current-state.md',
|
|
183
|
+
'world_setting': 'world-setting.md',
|
|
184
|
+
'power_system': 'power-system.md',
|
|
185
|
+
'pending_hooks': 'pending-hooks.md',
|
|
186
|
+
'emotional_arcs': 'emotional-arcs.md',
|
|
187
|
+
}
|
|
188
|
+
for key, filename in truth_files.items():
|
|
189
|
+
fpath = os.path.join(truth_path, filename)
|
|
190
|
+
compact = _read_truth_compact(fpath)
|
|
191
|
+
if compact:
|
|
192
|
+
context['truth_files'][key] = compact
|
|
193
|
+
|
|
194
|
+
# 2. 前N章摘要
|
|
195
|
+
if os.path.isdir(chapters_dir):
|
|
196
|
+
context['prev_chapters'] = _get_prev_chapters_info(chapters_dir, chapter_num, recent_count)
|
|
197
|
+
|
|
198
|
+
# 3. 大纲目标
|
|
199
|
+
outline_path = os.path.join(root, 'novels', f'volume-{volume.zfill(2) if volume else "01"}', 'outline.md')
|
|
200
|
+
if not os.path.exists(outline_path):
|
|
201
|
+
outline_path = os.path.join(root, 'novels', 'outline.md')
|
|
202
|
+
context['outline'] = _get_outline_context(outline_path, chapter_num)
|
|
203
|
+
|
|
204
|
+
# 4. 创作宪法要点
|
|
205
|
+
constitution_path = os.path.join(root, NOVEL_MAKER_DIR, 'constitution.md')
|
|
206
|
+
if not os.path.exists(constitution_path):
|
|
207
|
+
constitution_path = os.path.join(root, 'constitution.md')
|
|
208
|
+
context['constitution'] = _get_constitution_summary(constitution_path)
|
|
209
|
+
|
|
210
|
+
return context
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main():
|
|
214
|
+
parser = argparse.ArgumentParser(description='写手上下文构建器')
|
|
215
|
+
parser.add_argument('chapter', nargs='?', help='章节文件路径')
|
|
216
|
+
parser.add_argument('--chapter-num', '-n', type=int, help='章节号')
|
|
217
|
+
parser.add_argument('--volume', '-v', help='卷号')
|
|
218
|
+
parser.add_argument('--root', help='项目根目录')
|
|
219
|
+
parser.add_argument('--truth-dir', help='真相文件目录')
|
|
220
|
+
parser.add_argument('--recent', type=int, default=2, help='前N章摘要(默认2)')
|
|
221
|
+
parser.add_argument('--json', action='store_true', help='JSON输出')
|
|
222
|
+
args = parser.parse_args()
|
|
223
|
+
|
|
224
|
+
ctx = build_context(
|
|
225
|
+
chapter_path=args.chapter,
|
|
226
|
+
chapter_num=args.chapter_num,
|
|
227
|
+
volume=args.volume,
|
|
228
|
+
novel_root=args.root,
|
|
229
|
+
truth_dir=args.truth_dir,
|
|
230
|
+
recent_count=args.recent
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if args.json:
|
|
234
|
+
print(json.dumps(ctx, ensure_ascii=False, indent=2))
|
|
235
|
+
else:
|
|
236
|
+
if 'error' in ctx:
|
|
237
|
+
print(f"错误: {ctx['error']}")
|
|
238
|
+
return
|
|
239
|
+
print(f"=== 写手上下文: 第{ctx['meta']['chapter_num']}章 ===\n")
|
|
240
|
+
if ctx['truth_files']:
|
|
241
|
+
print("【真相文件摘要】")
|
|
242
|
+
for name, data in ctx['truth_files'].items():
|
|
243
|
+
print(f" {name}: {len(data)}个section")
|
|
244
|
+
if ctx['prev_chapters']:
|
|
245
|
+
print(f"\n【前{len(ctx['prev_chapters'])}章摘要】")
|
|
246
|
+
for ch in ctx['prev_chapters']:
|
|
247
|
+
print(f" {ch['chapter']}: {ch['title']} ({ch['word_count']}字) 钩子:{ch['hook']}")
|
|
248
|
+
if ctx['outline']:
|
|
249
|
+
print(f"\n【大纲目标】")
|
|
250
|
+
for h in ctx['outline']:
|
|
251
|
+
print(f" {' ' * (h['level'] - 1)}{h['title']}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == '__main__':
|
|
255
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
章节结构化信息提取
|
|
5
|
+
将章节全文压缩为结构化数据,AI 可直接读取此输出代替全文,大幅减少 token 消耗。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
|
|
13
|
+
from nm_utils import (
|
|
14
|
+
clean_markdown, count_chinese, extract_title, read_chapter,
|
|
15
|
+
extract_characters, extract_locations, detect_structure,
|
|
16
|
+
detect_hook, generate_summary
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def analyze_chapter(filepath):
|
|
21
|
+
raw, title, clean, wc = read_chapter(filepath)
|
|
22
|
+
return {
|
|
23
|
+
'file': os.path.basename(filepath),
|
|
24
|
+
'title': title,
|
|
25
|
+
'word_count': wc,
|
|
26
|
+
'characters': extract_characters(clean),
|
|
27
|
+
'locations': extract_locations(clean),
|
|
28
|
+
'structure': detect_structure(raw),
|
|
29
|
+
'hook': detect_hook(raw),
|
|
30
|
+
'summary': generate_summary(raw)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main():
|
|
35
|
+
if len(sys.argv) < 2:
|
|
36
|
+
print('用法: python chapter_info.py <章节文件路径> [--json]')
|
|
37
|
+
print(' python chapter_info.py <文件> --json # 纯JSON输出给AI用')
|
|
38
|
+
return
|
|
39
|
+
filepath = sys.argv[1]
|
|
40
|
+
json_only = '--json' in sys.argv
|
|
41
|
+
|
|
42
|
+
if not os.path.exists(filepath):
|
|
43
|
+
result = {'error': f'文件不存在: {filepath}'}
|
|
44
|
+
else:
|
|
45
|
+
result = analyze_chapter(filepath)
|
|
46
|
+
|
|
47
|
+
if json_only:
|
|
48
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
49
|
+
else:
|
|
50
|
+
info = result
|
|
51
|
+
if 'error' in info:
|
|
52
|
+
print(info['error'])
|
|
53
|
+
return
|
|
54
|
+
print(f"章节: {info['title'] or info['file']}")
|
|
55
|
+
print(f"字数: {info['word_count']}")
|
|
56
|
+
print(f"角色: {', '.join(info['characters'][:8]) if info['characters'] else '未检测到'}")
|
|
57
|
+
print(f"地点: {', '.join(info['locations'][:5]) if info['locations'] else '未检测到'}")
|
|
58
|
+
s = info['structure']
|
|
59
|
+
print(f"结构: 对话{s['dialogue_pct']}% / 动作{s['action_pct']}% / 描写{s['description_pct']}% ({s['total_paragraphs']}段)")
|
|
60
|
+
h = info['hook']
|
|
61
|
+
print(f"钩子: {h['type']} (结尾预览: {h['tail_preview'][:40]}...)")
|
|
62
|
+
print(f"摘要: 开头={info['summary']['开头'][:50]}...")
|
|
63
|
+
print(f" 结尾={info['summary']['结尾'][-50:]}...")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == '__main__':
|
|
67
|
+
main()
|