status_reporter.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992
  1. #!/usr/bin/env python3
  2. """
  3. 可视化状态报告系统 (Status Reporter)
  4. 核心理念:面对 1000 个章节,作者会迷失。需要"宏观俯瞰"能力。
  5. 功能:
  6. 1. 角色活跃度分析:哪些角色太久没出场(掉线统计)
  7. 2. 伏笔深度分析:哪些坑挖得太久了(超过 20 万字未收)+ 紧急度排序
  8. 3. 爽点节奏分布:全书高潮点的分布频率(热力图)
  9. 4. 字数分布统计:各卷、各篇的字数分布
  10. 5. 人际关系图谱:好感度/仇恨度趋势
  11. 6. Strand Weave 节奏分析:Quest/Fire/Constellation 三线占比统计
  12. 7. 伏笔紧急度排序:基于三层级系统(核心/支线/装饰)的优先级计算
  13. 输出格式:
  14. - Markdown 报告(.webnovel/health_report.md)
  15. - 包含 Mermaid 图表(角色关系图、爽点热力图)
  16. 使用方式:
  17. # 生成完整健康报告
  18. python status_reporter.py --output .webnovel/health_report.md
  19. # 仅分析角色活跃度
  20. python status_reporter.py --focus characters
  21. # 仅分析伏笔
  22. python status_reporter.py --focus foreshadowing
  23. # 仅分析爽点节奏
  24. python status_reporter.py --focus pacing
  25. # 分析 Strand Weave 节奏
  26. python status_reporter.py --focus strand
  27. 报告示例:
  28. # 全书健康报告
  29. ## 📊 基本数据
  30. - **总章节数**: 450 章
  31. - **总字数**: 1,985,432 字
  32. - **平均章节字数**: 4,412 字
  33. - **创作进度**: 99.3%(目标 200万字)
  34. ## ⚠️ 角色掉线(3人)
  35. | 角色 | 最后出场 | 缺席章节 | 状态 |
  36. |------|---------|---------|------|
  37. | 李雪 | 第 350 章 | 100 章 | 🔴 严重掉线 |
  38. | 血煞门主 | 第 300 章 | 150 章 | 🔴 严重掉线 |
  39. | 天云宗宗主 | 第 400 章 | 50 章 | 🟡 轻度掉线 |
  40. ## ⚠️ 伏笔超时(2条)
  41. | 伏笔内容 | 埋设章节 | 已过章节 | 状态 |
  42. |---------|---------|---------|------|
  43. | "林家宝库铭文的秘密" | 第 200 章 | 250 章 | 🔴 严重超时 |
  44. | "神秘玉佩的来历" | 第 270 章 | 180 章 | 🟡 轻度超时 |
  45. ## 📈 爽点节奏分布
  46. ```
  47. 第 1-100 章 ████████████ 优秀(1200字/爽点)
  48. 第 101-200章 ██████████ 良好(1500字/爽点)
  49. 第 201-300章 ████████ 良好(1600字/爽点)
  50. 第 301-400章 ████ 偏低(2200字/爽点)⚠️
  51. 第 401-450章 ██████ 良好(1550字/爽点)
  52. ```
  53. ## 💑 人际关系趋势
  54. ```mermaid
  55. graph LR
  56. 主角 -->|好感度95| 李雪
  57. 主角 -->|好感度60| 慕容雪
  58. 主角 -->|仇恨度100| 血煞门
  59. ```
  60. """
  61. import json
  62. import os
  63. import re
  64. import sys
  65. from pathlib import Path
  66. from typing import Dict, List, Any, Tuple
  67. from datetime import datetime
  68. from collections import defaultdict
  69. from project_locator import resolve_project_root
  70. from chapter_paths import extract_chapter_num_from_filename
  71. # 导入配置
  72. try:
  73. from data_modules.config import get_config, DataModulesConfig
  74. except ImportError:
  75. from scripts.data_modules.config import get_config, DataModulesConfig
  76. def _is_resolved_foreshadowing_status(raw_status: Any) -> bool:
  77. """判断伏笔是否已回收(兼容历史字段与同义词)。"""
  78. if raw_status is None:
  79. return False
  80. status = str(raw_status).strip()
  81. if not status:
  82. return False
  83. status_lower = status.lower()
  84. if status in {"已回收", "已完成", "已解决", "完成"}:
  85. return True
  86. if status_lower in {"resolved", "done", "complete"}:
  87. return True
  88. if "已回收" in status:
  89. return True
  90. return False
  91. # Windows 编码兼容性修复
  92. if sys.platform == 'win32':
  93. import io
  94. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
  95. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
  96. class StatusReporter:
  97. """状态报告生成器"""
  98. def __init__(self, project_root: str):
  99. self.project_root = Path(project_root)
  100. self.config = get_config(self.project_root)
  101. self.state_file = self.project_root / ".webnovel/state.json"
  102. self.chapters_dir = self.project_root / "正文"
  103. self.state = None
  104. self.chapters_data = []
  105. # 可选:集成结构化索引(如果可用,角色统计更准)
  106. self.index = None
  107. try:
  108. from structured_index import StructuredIndex
  109. self.index = StructuredIndex(self.project_root)
  110. except Exception:
  111. self.index = None
  112. def _extract_stats_field(self, content: str, field_name: str) -> str:
  113. """
  114. 从“本章统计”区块提取字段值,例如:
  115. - **主导Strand**: quest
  116. """
  117. pattern = rf"^\s*-\s*\*\*{re.escape(field_name)}\*\*\s*:\s*(.+?)\s*$"
  118. for line in content.splitlines():
  119. m = re.match(pattern, line)
  120. if m:
  121. return m.group(1).strip()
  122. return ""
  123. def load_state(self) -> bool:
  124. """加载 state.json"""
  125. if not self.state_file.exists():
  126. print(f"❌ 状态文件不存在: {self.state_file}")
  127. return False
  128. with open(self.state_file, 'r', encoding='utf-8') as f:
  129. self.state = json.load(f)
  130. return True
  131. def scan_chapters(self):
  132. """扫描所有章节文件"""
  133. if not self.chapters_dir.exists():
  134. print(f"⚠️ 正文目录不存在: {self.chapters_dir}")
  135. return
  136. # 支持两种目录结构:
  137. # 1) 正文/第0001章.md
  138. # 2) 正文/第1卷/第001章-标题.md
  139. chapter_files = sorted(self.chapters_dir.rglob("第*.md"))
  140. # 角色候选(fallback 用):从 state.json 获取已知角色名 (v5.0 entities_v3 格式)
  141. known_character_names: List[str] = []
  142. protagonist_name = ""
  143. if self.state:
  144. protagonist_name = self.state.get("protagonist_state", {}).get("name", "") or ""
  145. # v5.0: 从 entities_v3.角色 获取角色名
  146. entities_v3 = self.state.get("entities_v3", {})
  147. characters_dict = entities_v3.get("角色", {})
  148. known_character_names = [
  149. c.get("canonical_name", char_id)
  150. for char_id, c in characters_dict.items()
  151. if c.get("canonical_name")
  152. ]
  153. for chapter_file in chapter_files:
  154. chapter_num = extract_chapter_num_from_filename(chapter_file.name)
  155. if not chapter_num:
  156. continue
  157. # 读取章节内容
  158. with open(chapter_file, 'r', encoding='utf-8') as f:
  159. content = f.read()
  160. # 统计字数(去除 Markdown 标记)
  161. text = re.sub(r'```[\s\S]*?```', '', content) # 去除代码块
  162. text = re.sub(r'#+ .+', '', text) # 去除标题
  163. text = re.sub(r'---', '', text) # 去除分隔线
  164. word_count = len(text.strip())
  165. # 主导 Strand / 爽点类型(优先从“本章统计”解析)
  166. dominant_strand = (self._extract_stats_field(content, "主导Strand") or "").lower()
  167. cool_point_type = self._extract_stats_field(content, "爽点")
  168. # 角色提取:优先从结构化索引读取(若有),否则 fallback 用“出现即算出场”
  169. characters: List[str] = []
  170. if self.index is not None:
  171. try:
  172. cursor = self.index.conn.execute(
  173. "SELECT characters FROM chapters WHERE chapter_num = ?",
  174. (chapter_num,),
  175. )
  176. row = cursor.fetchone()
  177. if row and row[0]:
  178. try:
  179. stored = json.loads(row[0])
  180. if isinstance(stored, list):
  181. # v4.0: chapters.characters 存 entity_id 列表,输出时尽量还原为 canonical_name
  182. for x in stored:
  183. entity_id = str(x).strip()
  184. if not entity_id:
  185. continue
  186. name = entity_id
  187. try:
  188. ent = self.index.query_entity_by_id(entity_id)
  189. if ent and ent.get("canonical_name"):
  190. name = str(ent["canonical_name"]).strip() or entity_id
  191. except Exception:
  192. name = entity_id
  193. characters.append(name)
  194. except json.JSONDecodeError:
  195. characters = []
  196. except Exception:
  197. characters = []
  198. if not characters and (protagonist_name or known_character_names):
  199. # 限制候选规模,避免在超大角色库下过慢
  200. candidates = []
  201. if protagonist_name:
  202. candidates.append(protagonist_name)
  203. candidates.extend(known_character_names[:self.config.character_candidates_limit])
  204. seen = set()
  205. for name in candidates:
  206. if not name or name in seen:
  207. continue
  208. if name in content:
  209. characters.append(name)
  210. seen.add(name)
  211. self.chapters_data.append({
  212. "chapter": chapter_num,
  213. "file": chapter_file,
  214. "word_count": word_count,
  215. "characters": characters,
  216. "dominant": dominant_strand,
  217. "cool_point": cool_point_type,
  218. })
  219. def analyze_characters(self) -> Dict:
  220. """分析角色活跃度 (v5.0 entities_v3 格式)"""
  221. if not self.state:
  222. return {}
  223. current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
  224. # v5.0: 从 entities_v3.角色 获取角色
  225. entities_v3 = self.state.get("entities_v3", {})
  226. characters_dict = entities_v3.get("角色", {})
  227. # 统计每个角色的最后出场章节
  228. character_activity = {}
  229. for char_id, char in characters_dict.items():
  230. char_name = char.get("canonical_name", char_id)
  231. if not char_name:
  232. continue
  233. # 查找最后出场章节
  234. last_appearance = 0
  235. for ch_data in self.chapters_data:
  236. if char_name in ch_data.get("characters", []):
  237. last_appearance = max(last_appearance, ch_data["chapter"])
  238. absence = current_chapter - last_appearance
  239. character_activity[char_name] = {
  240. "last_appearance": last_appearance,
  241. "absence": absence,
  242. "status": self._get_absence_status(absence)
  243. }
  244. return character_activity
  245. def _get_absence_status(self, absence: int) -> str:
  246. """判断掉线状态"""
  247. if absence == 0:
  248. return "✅ 活跃"
  249. elif absence < self.config.character_absence_warning:
  250. return "🟢 正常"
  251. elif absence < self.config.character_absence_critical:
  252. return "🟡 轻度掉线"
  253. else:
  254. return "🔴 严重掉线"
  255. def analyze_foreshadowing(self) -> List[Dict]:
  256. """分析伏笔深度"""
  257. if not self.state:
  258. return []
  259. current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
  260. plot_threads = self.state.get("plot_threads", {})
  261. foreshadowing = plot_threads.get("foreshadowing", [])
  262. overdue = []
  263. for item in foreshadowing:
  264. status = item.get("status")
  265. if _is_resolved_foreshadowing_status(status):
  266. continue
  267. # 假设每个伏笔记录了"added_chapter"(埋设章节)
  268. # 如果没有,使用 added_at 日期估算(粗略)
  269. # 这里简化:假设第 1 章开始,平均每天写 1 章
  270. # 简化:假设伏笔按添加顺序,第 N 个伏笔大约在第 N*10 章埋下
  271. # 实际项目应该在伏笔记录中加入 "埋设章节号" 字段
  272. # 这里使用 content 中的关键词匹配(极度简化)
  273. content = item.get("content", "")
  274. # 假设伏笔平均埋设时间 = 当前章节的一半(极度粗糙估算)
  275. estimated_chapter = current_chapter // 2
  276. elapsed = current_chapter - estimated_chapter
  277. overdue.append({
  278. "content": content,
  279. "estimated_chapter": estimated_chapter,
  280. "elapsed": elapsed,
  281. "status": self._get_foreshadowing_status(elapsed)
  282. })
  283. return overdue
  284. def _get_foreshadowing_status(self, elapsed: int) -> str:
  285. """判断伏笔超时状态"""
  286. if elapsed < self.config.foreshadowing_urgency_pending_medium:
  287. return "🟢 正常"
  288. elif elapsed < self.config.foreshadowing_urgency_pending_high + 50:
  289. return "🟡 轻度超时"
  290. else:
  291. return "🔴 严重超时"
  292. def analyze_foreshadowing_urgency(self) -> List[Dict]:
  293. """
  294. 分析伏笔紧急度(基于三层级系统)
  295. 三层级权重:
  296. - 核心(Core): 权重 3.0 - 必须回收,否则剧情崩塌
  297. - 支线(Sub): 权重 2.0 - 应该回收,否则显得作者健忘
  298. - 装饰(Decor): 权重 1.0 - 可回收可不回收,仅增加真实感
  299. 紧急度计算公式:
  300. urgency = (已过章节 / 目标回收章节) × 层级权重
  301. """
  302. if not self.state:
  303. return []
  304. current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
  305. plot_threads = self.state.get("plot_threads", {})
  306. foreshadowing = plot_threads.get("foreshadowing", [])
  307. # 层级权重映射
  308. tier_weights = {
  309. "核心": self.config.foreshadowing_tier_weight_core,
  310. "core": self.config.foreshadowing_tier_weight_core,
  311. "支线": self.config.foreshadowing_tier_weight_sub,
  312. "sub": self.config.foreshadowing_tier_weight_sub,
  313. "装饰": self.config.foreshadowing_tier_weight_decor,
  314. "decor": self.config.foreshadowing_tier_weight_decor
  315. }
  316. urgency_list = []
  317. for item in foreshadowing:
  318. if _is_resolved_foreshadowing_status(item.get("status")):
  319. continue
  320. content = item.get("content", "")
  321. tier = item.get("tier", "支线") # 默认支线
  322. planted_chapter = item.get("planted_chapter", 1)
  323. target_chapter = item.get("target_chapter", planted_chapter + 100)
  324. weight = tier_weights.get(tier.lower(), self.config.foreshadowing_tier_weight_sub)
  325. elapsed = current_chapter - planted_chapter
  326. remaining = target_chapter - current_chapter
  327. # 紧急度计算
  328. if target_chapter > planted_chapter:
  329. urgency = (elapsed / (target_chapter - planted_chapter)) * weight
  330. else:
  331. urgency = weight * 2 # 已超期
  332. urgency_list.append({
  333. "content": content,
  334. "tier": tier,
  335. "weight": weight,
  336. "planted_chapter": planted_chapter,
  337. "target_chapter": target_chapter,
  338. "elapsed": elapsed,
  339. "remaining": remaining,
  340. "urgency": round(urgency, 2),
  341. "status": self._get_urgency_status(urgency, remaining)
  342. })
  343. # 按紧急度排序(降序)
  344. return sorted(urgency_list, key=lambda x: x["urgency"], reverse=True)
  345. def _get_urgency_status(self, urgency: float, remaining: int) -> str:
  346. """判断紧急度状态"""
  347. if remaining < 0:
  348. return "🔴 已超期"
  349. elif urgency >= self.config.foreshadowing_tier_weight_sub:
  350. return "🔴 紧急"
  351. elif urgency >= 1.0:
  352. return "🟡 警告"
  353. else:
  354. return "🟢 正常"
  355. def analyze_strand_weave(self) -> Dict:
  356. """
  357. 分析 Strand Weave 节奏分布
  358. 三线定义:
  359. - Quest(主线): 战斗、任务、升级 - 目标 55-65%
  360. - Fire(感情): 感情线、人际互动 - 目标 20-30%
  361. - Constellation(世界观): 世界观展开、势力背景 - 目标 10-20%
  362. 检查规则:
  363. - Quest 线连续不超过 5 章
  364. - Fire 线缺失不超过 10 章
  365. - Constellation 线缺失不超过 15 章
  366. """
  367. if not self.state:
  368. return {}
  369. strand_tracker = self.state.get("strand_tracker", {})
  370. history = strand_tracker.get("history", [])
  371. if not history:
  372. return {
  373. "has_data": False,
  374. "message": "暂无 Strand Weave 数据"
  375. }
  376. # 统计各线占比
  377. quest_count = 0
  378. fire_count = 0
  379. constellation_count = 0
  380. total = len(history)
  381. for entry in history:
  382. strand = (entry.get("strand") or entry.get("dominant") or "").lower()
  383. if strand in ["quest", "主线", "战斗", "任务"]:
  384. quest_count += 1
  385. elif strand in ["fire", "感情", "感情线", "互动"]:
  386. fire_count += 1
  387. elif strand in ["constellation", "世界观", "背景", "势力"]:
  388. constellation_count += 1
  389. # 计算占比
  390. quest_ratio = (quest_count / total * 100) if total > 0 else 0
  391. fire_ratio = (fire_count / total * 100) if total > 0 else 0
  392. constellation_ratio = (constellation_count / total * 100) if total > 0 else 0
  393. # 检查违规
  394. violations = []
  395. # 检查 Quest 连续超过 5 章
  396. quest_streak = 0
  397. max_quest_streak = 0
  398. for entry in history:
  399. strand = (entry.get("strand") or entry.get("dominant") or "").lower()
  400. if strand in ["quest", "主线", "战斗", "任务"]:
  401. quest_streak += 1
  402. max_quest_streak = max(max_quest_streak, quest_streak)
  403. else:
  404. quest_streak = 0
  405. if max_quest_streak > self.config.strand_quest_max_consecutive:
  406. violations.append(f"Quest 线连续 {max_quest_streak} 章(超过 {self.config.strand_quest_max_consecutive} 章限制)")
  407. # 检查 Fire 缺失超过 10 章
  408. fire_gap = 0
  409. max_fire_gap = 0
  410. for entry in history:
  411. strand = (entry.get("strand") or entry.get("dominant") or "").lower()
  412. if strand in ["fire", "感情", "感情线", "互动"]:
  413. max_fire_gap = max(max_fire_gap, fire_gap)
  414. fire_gap = 0
  415. else:
  416. fire_gap += 1
  417. max_fire_gap = max(max_fire_gap, fire_gap)
  418. if max_fire_gap > self.config.strand_fire_max_gap:
  419. violations.append(f"Fire 线缺失 {max_fire_gap} 章(超过 {self.config.strand_fire_max_gap} 章限制)")
  420. # 检查 Constellation 缺失超过 15 章
  421. const_gap = 0
  422. max_const_gap = 0
  423. for entry in history:
  424. strand = (entry.get("strand") or entry.get("dominant") or "").lower()
  425. if strand in ["constellation", "世界观", "背景", "势力"]:
  426. max_const_gap = max(max_const_gap, const_gap)
  427. const_gap = 0
  428. else:
  429. const_gap += 1
  430. max_const_gap = max(max_const_gap, const_gap)
  431. if max_const_gap > self.config.strand_constellation_max_gap:
  432. violations.append(f"Constellation 线缺失 {max_const_gap} 章(超过 {self.config.strand_constellation_max_gap} 章限制)")
  433. # 检查占比是否在合理范围
  434. cfg = self.config
  435. if quest_ratio < cfg.strand_quest_ratio_min:
  436. violations.append(f"Quest 占比 {quest_ratio:.1f}% 偏低(目标 {cfg.strand_quest_ratio_min}-{cfg.strand_quest_ratio_max}%)")
  437. elif quest_ratio > cfg.strand_quest_ratio_max:
  438. violations.append(f"Quest 占比 {quest_ratio:.1f}% 偏高(目标 {cfg.strand_quest_ratio_min}-{cfg.strand_quest_ratio_max}%)")
  439. if fire_ratio < cfg.strand_fire_ratio_min:
  440. violations.append(f"Fire 占比 {fire_ratio:.1f}% 偏低(目标 {cfg.strand_fire_ratio_min}-{cfg.strand_fire_ratio_max}%)")
  441. elif fire_ratio > cfg.strand_fire_ratio_max:
  442. violations.append(f"Fire 占比 {fire_ratio:.1f}% 偏高(目标 {cfg.strand_fire_ratio_min}-{cfg.strand_fire_ratio_max}%)")
  443. if constellation_ratio < cfg.strand_constellation_ratio_min:
  444. violations.append(f"Constellation 占比 {constellation_ratio:.1f}% 偏低(目标 {cfg.strand_constellation_ratio_min}-{cfg.strand_constellation_ratio_max}%)")
  445. elif constellation_ratio > cfg.strand_constellation_ratio_max:
  446. violations.append(f"Constellation 占比 {constellation_ratio:.1f}% 偏高(目标 {cfg.strand_constellation_ratio_min}-{cfg.strand_constellation_ratio_max}%)")
  447. return {
  448. "has_data": True,
  449. "total_chapters": total,
  450. "quest": {"count": quest_count, "ratio": quest_ratio},
  451. "fire": {"count": fire_count, "ratio": fire_ratio},
  452. "constellation": {"count": constellation_count, "ratio": constellation_ratio},
  453. "violations": violations,
  454. "max_quest_streak": max_quest_streak,
  455. "max_fire_gap": max_fire_gap,
  456. "max_const_gap": max_const_gap,
  457. "health": "✅ 健康" if not violations else f"⚠️ {len(violations)} 个问题"
  458. }
  459. def analyze_pacing(self) -> List[Dict]:
  460. """分析爽点节奏分布(每 N 章为一段)"""
  461. segment_size = self.config.pacing_segment_size
  462. segments = []
  463. for i in range(0, len(self.chapters_data), segment_size):
  464. segment_chapters = self.chapters_data[i:i+segment_size]
  465. if not segment_chapters:
  466. continue
  467. start_ch = segment_chapters[0]["chapter"]
  468. end_ch = segment_chapters[-1]["chapter"]
  469. total_words = sum(ch["word_count"] for ch in segment_chapters)
  470. # 假设爽点数量 = 章节数(简化:每章至少 1 个爽点)
  471. # 实际项目应该在审查报告中记录爽点数量
  472. assumed_cool_points = len(segment_chapters)
  473. words_per_point = total_words / assumed_cool_points if assumed_cool_points > 0 else 0
  474. segments.append({
  475. "start": start_ch,
  476. "end": end_ch,
  477. "total_words": total_words,
  478. "cool_points": assumed_cool_points,
  479. "words_per_point": words_per_point,
  480. "rating": self._get_pacing_rating(words_per_point)
  481. })
  482. return segments
  483. def _get_pacing_rating(self, words_per_point: float) -> str:
  484. """判断节奏评级"""
  485. if words_per_point < self.config.pacing_words_per_point_excellent:
  486. return "优秀"
  487. elif words_per_point < self.config.pacing_words_per_point_good:
  488. return "良好"
  489. elif words_per_point < self.config.pacing_words_per_point_acceptable:
  490. return "及格"
  491. else:
  492. return "偏低⚠️"
  493. def generate_relationship_graph(self) -> str:
  494. """生成人际关系 Mermaid 图"""
  495. if not self.state:
  496. return ""
  497. relationships = self.state.get("relationships", {})
  498. protagonist_name = self.state.get("protagonist_state", {}).get("name", "主角")
  499. lines = ["```mermaid", "graph LR"]
  500. # 支持两种格式:
  501. # 格式1(新): {"allies": [...], "enemies": [...]}
  502. # 格式2(旧): {"角色名": {"affection": X, "hatred": Y}}
  503. allies = relationships.get("allies", [])
  504. enemies = relationships.get("enemies", [])
  505. if allies or enemies:
  506. # 新格式
  507. for ally in allies:
  508. if isinstance(ally, dict):
  509. name = ally.get("name", "未知")
  510. relation = ally.get("relation", "友好")
  511. lines.append(f" {protagonist_name} -->|{relation}| {name}")
  512. for enemy in enemies:
  513. if isinstance(enemy, dict):
  514. name = enemy.get("name", "未知")
  515. relation = enemy.get("relation", "敌对")
  516. lines.append(f" {protagonist_name} -.->|{relation}| {name}")
  517. else:
  518. # 旧格式兼容
  519. for char_name, rel_data in relationships.items():
  520. if isinstance(rel_data, dict):
  521. affection = rel_data.get("affection", 0)
  522. hatred = rel_data.get("hatred", 0)
  523. if affection > 0:
  524. lines.append(f" {protagonist_name} -->|好感度{affection}| {char_name}")
  525. if hatred > 0:
  526. lines.append(f" {protagonist_name} -.->|仇恨度{hatred}| {char_name}")
  527. lines.append("```")
  528. return "\n".join(lines)
  529. def generate_report(self, focus: str = "all") -> str:
  530. """生成健康报告(Markdown 格式)"""
  531. report_lines = [
  532. "# 全书健康报告",
  533. "",
  534. f"> **生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
  535. "",
  536. "---",
  537. ""
  538. ]
  539. # 基本数据
  540. if focus in ["all", "basic"]:
  541. report_lines.extend(self._generate_basic_stats())
  542. # 角色活跃度
  543. if focus in ["all", "characters"]:
  544. report_lines.extend(self._generate_character_section())
  545. # 伏笔深度
  546. if focus in ["all", "foreshadowing"]:
  547. report_lines.extend(self._generate_foreshadowing_section())
  548. # 伏笔紧急度(新增)
  549. if focus in ["all", "foreshadowing", "urgency"]:
  550. report_lines.extend(self._generate_urgency_section())
  551. # 爽点节奏
  552. if focus in ["all", "pacing"]:
  553. report_lines.extend(self._generate_pacing_section())
  554. # Strand Weave 节奏(新增)
  555. if focus in ["all", "strand", "pacing"]:
  556. report_lines.extend(self._generate_strand_section())
  557. # 人际关系
  558. if focus in ["all", "relationships"]:
  559. report_lines.extend(self._generate_relationship_section())
  560. return "\n".join(report_lines)
  561. def _generate_basic_stats(self) -> List[str]:
  562. """生成基本统计"""
  563. if not self.state:
  564. return []
  565. progress = self.state.get("progress", {})
  566. current_chapter = progress.get("current_chapter", 0)
  567. total_words = progress.get("total_words", 0)
  568. target_words = self.state.get("project_info", {}).get("target_words", 2000000)
  569. avg_words = total_words / current_chapter if current_chapter > 0 else 0
  570. completion = (total_words / target_words * 100) if target_words > 0 else 0
  571. return [
  572. "## 📊 基本数据",
  573. "",
  574. f"- **总章节数**: {current_chapter} 章",
  575. f"- **总字数**: {total_words:,} 字",
  576. f"- **平均章节字数**: {avg_words:,.0f} 字",
  577. f"- **创作进度**: {completion:.1f}%(目标 {target_words:,} 字)",
  578. "",
  579. "---",
  580. ""
  581. ]
  582. def _generate_character_section(self) -> List[str]:
  583. """生成角色分析章节"""
  584. activity = self.analyze_characters()
  585. if not activity:
  586. return []
  587. # 筛选掉线角色
  588. dropped = {name: data for name, data in activity.items()
  589. if "掉线" in data["status"]}
  590. lines = [
  591. f"## ⚠️ 角色掉线({len(dropped)}人)",
  592. ""
  593. ]
  594. if dropped:
  595. lines.extend([
  596. "| 角色 | 最后出场 | 缺席章节 | 状态 |",
  597. "|------|---------|---------|------|"
  598. ])
  599. for char_name, data in sorted(dropped.items(),
  600. key=lambda x: x[1]["absence"],
  601. reverse=True):
  602. lines.append(
  603. f"| {char_name} | 第 {data['last_appearance']} 章 | "
  604. f"{data['absence']} 章 | {data['status']} |"
  605. )
  606. else:
  607. lines.append("✅ 所有角色活跃度正常")
  608. lines.extend(["", "---", ""])
  609. return lines
  610. def _generate_foreshadowing_section(self) -> List[str]:
  611. """生成伏笔分析章节"""
  612. overdue = self.analyze_foreshadowing()
  613. # 筛选超时伏笔
  614. overdue_items = [item for item in overdue if "超时" in item["status"]]
  615. lines = [
  616. f"## ⚠️ 伏笔超时({len(overdue_items)}条)",
  617. ""
  618. ]
  619. if overdue_items:
  620. lines.extend([
  621. "| 伏笔内容 | 估计埋设 | 已过章节 | 状态 |",
  622. "|---------|---------|---------|------|"
  623. ])
  624. for item in sorted(overdue_items, key=lambda x: x["elapsed"], reverse=True):
  625. lines.append(
  626. f"| {item['content'][:30]}... | 第 {item['estimated_chapter']} 章 | "
  627. f"{item['elapsed']} 章 | {item['status']} |"
  628. )
  629. else:
  630. lines.append("✅ 所有伏笔进度正常")
  631. lines.extend(["", "---", ""])
  632. return lines
  633. def _generate_urgency_section(self) -> List[str]:
  634. """生成伏笔紧急度章节(基于三层级系统)"""
  635. urgency_list = self.analyze_foreshadowing_urgency()
  636. # 筛选紧急伏笔
  637. urgent_items = [item for item in urgency_list if item["urgency"] >= 1.0]
  638. lines = [
  639. f"## 🚨 伏笔紧急度排序({len(urgent_items)}条需关注)",
  640. "",
  641. "> 基于三层级系统:核心(×3) / 支线(×2) / 装饰(×1)",
  642. "> 紧急度 = (已过章节 / 目标回收章节) × 层级权重",
  643. ""
  644. ]
  645. if urgency_list:
  646. lines.extend([
  647. "| 伏笔内容 | 层级 | 埋设 | 目标 | 紧急度 | 状态 |",
  648. "|---------|------|------|------|--------|------|"
  649. ])
  650. for item in urgency_list[:10]: # 只显示前10条
  651. lines.append(
  652. f"| {item['content'][:20]}... | {item['tier']} | "
  653. f"第{item['planted_chapter']}章 | 第{item['target_chapter']}章 | "
  654. f"{item['urgency']:.2f} | {item['status']} |"
  655. )
  656. else:
  657. lines.append("✅ 暂无伏笔数据")
  658. lines.extend(["", "---", ""])
  659. return lines
  660. def _generate_strand_section(self) -> List[str]:
  661. """生成 Strand Weave 节奏章节"""
  662. strand_data = self.analyze_strand_weave()
  663. lines = [
  664. "## 🎭 Strand Weave 节奏分析",
  665. ""
  666. ]
  667. if not strand_data.get("has_data"):
  668. lines.append(f"⚠️ {strand_data.get('message', '暂无数据')}")
  669. lines.extend(["", "---", ""])
  670. return lines
  671. # 占比统计
  672. cfg = self.config
  673. lines.extend([
  674. "### 三线占比",
  675. "",
  676. "| Strand | 章节数 | 占比 | 目标范围 | 状态 |",
  677. "|--------|--------|------|----------|------|"
  678. ])
  679. q = strand_data["quest"]
  680. q_status = "✅" if cfg.strand_quest_ratio_min <= q["ratio"] <= cfg.strand_quest_ratio_max else "⚠️"
  681. lines.append(f"| Quest(主线) | {q['count']} | {q['ratio']:.1f}% | {cfg.strand_quest_ratio_min}-{cfg.strand_quest_ratio_max}% | {q_status} |")
  682. f = strand_data["fire"]
  683. f_status = "✅" if cfg.strand_fire_ratio_min <= f["ratio"] <= cfg.strand_fire_ratio_max else "⚠️"
  684. lines.append(f"| Fire(感情) | {f['count']} | {f['ratio']:.1f}% | {cfg.strand_fire_ratio_min}-{cfg.strand_fire_ratio_max}% | {f_status} |")
  685. c = strand_data["constellation"]
  686. c_status = "✅" if cfg.strand_constellation_ratio_min <= c["ratio"] <= cfg.strand_constellation_ratio_max else "⚠️"
  687. lines.append(f"| Constellation(世界观) | {c['count']} | {c['ratio']:.1f}% | {cfg.strand_constellation_ratio_min}-{cfg.strand_constellation_ratio_max}% | {c_status} |")
  688. lines.append("")
  689. # 连续性检查
  690. lines.extend([
  691. "### 连续性检查",
  692. "",
  693. f"- Quest 最大连续: {strand_data['max_quest_streak']} 章(限制 ≤5)",
  694. f"- Fire 最大缺失: {strand_data['max_fire_gap']} 章(限制 ≤10)",
  695. f"- Constellation 最大缺失: {strand_data['max_const_gap']} 章(限制 ≤15)",
  696. ""
  697. ])
  698. # 违规清单
  699. if strand_data["violations"]:
  700. lines.extend([
  701. "### ⚠️ 违规清单",
  702. ""
  703. ])
  704. for v in strand_data["violations"]:
  705. lines.append(f"- {v}")
  706. else:
  707. lines.append("### ✅ 无违规")
  708. lines.extend(["", f"**综合健康度**: {strand_data['health']}", "", "---", ""])
  709. return lines
  710. def _generate_pacing_section(self) -> List[str]:
  711. """生成节奏分析章节"""
  712. segments = self.analyze_pacing()
  713. lines = [
  714. "## 📈 爽点节奏分布",
  715. "",
  716. "```"
  717. ]
  718. for seg in segments:
  719. bar_length = int(12 - (seg["words_per_point"] / 2000 * 12))
  720. bar_length = max(1, min(12, bar_length))
  721. bar = "█" * bar_length
  722. lines.append(
  723. f"第 {seg['start']}-{seg['end']}章 {bar} "
  724. f"{seg['rating']}({seg['words_per_point']:.0f}字/爽点)"
  725. )
  726. lines.extend(["```", "", "---", ""])
  727. return lines
  728. def _generate_relationship_section(self) -> List[str]:
  729. """生成人际关系章节"""
  730. graph = self.generate_relationship_graph()
  731. lines = [
  732. "## 💑 人际关系趋势",
  733. "",
  734. graph,
  735. "",
  736. "---",
  737. ""
  738. ]
  739. return lines
  740. def main():
  741. import argparse
  742. parser = argparse.ArgumentParser(
  743. description="可视化状态报告生成器",
  744. formatter_class=argparse.RawDescriptionHelpFormatter,
  745. epilog="""
  746. 示例:
  747. # 生成完整健康报告
  748. python status_reporter.py --output .webnovel/health_report.md
  749. # 仅分析角色活跃度
  750. python status_reporter.py --focus characters
  751. # 仅分析伏笔
  752. python status_reporter.py --focus foreshadowing
  753. # 仅分析爽点节奏
  754. python status_reporter.py --focus pacing
  755. """
  756. )
  757. parser.add_argument('--output', default='.webnovel/health_report.md',
  758. help='输出文件路径')
  759. parser.add_argument('--focus', choices=['all', 'basic', 'characters',
  760. 'foreshadowing', 'urgency', 'pacing',
  761. 'strand', 'relationships'],
  762. default='all', help='分析焦点(新增 urgency, strand)')
  763. parser.add_argument('--project-root', default='.', help='项目根目录')
  764. args = parser.parse_args()
  765. # 解析项目根目录(支持从仓库根目录运行)
  766. project_root = args.project_root
  767. if project_root == '.' and not (Path('.') / '.webnovel' / 'state.json').exists():
  768. try:
  769. project_root = str(resolve_project_root())
  770. except FileNotFoundError:
  771. project_root = args.project_root
  772. # 创建报告生成器
  773. reporter = StatusReporter(project_root)
  774. # 加载状态
  775. if not reporter.load_state():
  776. sys.exit(1)
  777. print("📖 正在扫描章节文件...")
  778. reporter.scan_chapters()
  779. print(f"✅ 已扫描 {len(reporter.chapters_data)} 个章节")
  780. print("\n📊 正在分析...")
  781. # 生成报告
  782. report = reporter.generate_report(args.focus)
  783. # 保存报告
  784. output_file = Path(args.output)
  785. if args.output == '.webnovel/health_report.md' and project_root != '.':
  786. output_file = Path(project_root) / '.webnovel' / 'health_report.md'
  787. output_file.parent.mkdir(parents=True, exist_ok=True)
  788. with open(output_file, 'w', encoding='utf-8') as f:
  789. f.write(report)
  790. print(f"\n✅ 健康报告已生成: {output_file}")
  791. # 预览报告(前 30 行)
  792. print("\n" + "="*60)
  793. print("📄 报告预览:\n")
  794. print("\n".join(report.split("\n")[:30]))
  795. print("\n...")
  796. print("="*60)
  797. if __name__ == "__main__":
  798. main()