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,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()
|