status_reporter.py 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201
  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, Optional
  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. from data_modules.index_manager import IndexManager
  75. except ImportError:
  76. from scripts.data_modules.config import get_config, DataModulesConfig
  77. from scripts.data_modules.index_manager import IndexManager
  78. def _is_resolved_foreshadowing_status(raw_status: Any) -> bool:
  79. """判断伏笔是否已回收(兼容历史字段与同义词)。"""
  80. if raw_status is None:
  81. return False
  82. status = str(raw_status).strip()
  83. if not status:
  84. return False
  85. status_lower = status.lower()
  86. if status in {"已回收", "已完成", "已解决", "完成"}:
  87. return True
  88. if status_lower in {"resolved", "done", "complete"}:
  89. return True
  90. if "已回收" in status:
  91. return True
  92. return False
  93. def _enable_windows_utf8_stdio() -> None:
  94. """在 Windows 下启用 UTF-8 输出;pytest 环境跳过以避免捕获冲突。"""
  95. if sys.platform != "win32":
  96. return
  97. if os.environ.get("PYTEST_CURRENT_TEST"):
  98. return
  99. try:
  100. import io
  101. stdout_buffer = getattr(sys.stdout, "buffer", None)
  102. stderr_buffer = getattr(sys.stderr, "buffer", None)
  103. if stdout_buffer is not None:
  104. sys.stdout = io.TextIOWrapper(stdout_buffer, encoding="utf-8")
  105. if stderr_buffer is not None:
  106. sys.stderr = io.TextIOWrapper(stderr_buffer, encoding="utf-8")
  107. except Exception:
  108. pass
  109. class StatusReporter:
  110. """状态报告生成器"""
  111. def __init__(self, project_root: str):
  112. self.project_root = Path(project_root)
  113. self.config = get_config(self.project_root)
  114. self.state_file = self.project_root / ".webnovel/state.json"
  115. self.chapters_dir = self.project_root / "正文"
  116. self.state = None
  117. self.chapters_data = []
  118. self._reading_power_cache: Dict[int, Optional[Dict[str, Any]]] = {}
  119. # v5.1 引入: 使用 IndexManager 读取实体
  120. self._index_manager = IndexManager(self.config)
  121. def _extract_stats_field(self, content: str, field_name: str) -> str:
  122. """
  123. 从“本章统计”区块提取字段值,例如:
  124. - **主导Strand**: quest
  125. """
  126. pattern = rf"^\s*-\s*\*\*{re.escape(field_name)}\*\*\s*:\s*(.+?)\s*$"
  127. for line in content.splitlines():
  128. m = re.match(pattern, line)
  129. if m:
  130. return m.group(1).strip()
  131. return ""
  132. def load_state(self) -> bool:
  133. """加载 state.json"""
  134. if not self.state_file.exists():
  135. print(f"❌ 状态文件不存在: {self.state_file}")
  136. return False
  137. with open(self.state_file, 'r', encoding='utf-8') as f:
  138. self.state = json.load(f)
  139. return True
  140. def _to_positive_int(self, value: Any) -> Optional[int]:
  141. """将输入解析为正整数;解析失败返回 None。"""
  142. if value is None or isinstance(value, bool):
  143. return None
  144. try:
  145. number = int(value)
  146. return number if number > 0 else None
  147. except (TypeError, ValueError):
  148. if isinstance(value, str):
  149. match = re.search(r"\d+", value)
  150. if match:
  151. number = int(match.group(0))
  152. return number if number > 0 else None
  153. return None
  154. def _normalize_foreshadowing_tier(self, raw_tier: Any) -> Tuple[str, float]:
  155. """标准化伏笔层级并返回对应权重。"""
  156. text = str(raw_tier or "").strip()
  157. lower = text.lower()
  158. if text in {"核心", "主线"} or lower in {"core", "main"}:
  159. return "核心", self.config.foreshadowing_tier_weight_core
  160. if text in {"装饰", "次要"} or lower in {"decor", "decoration"}:
  161. return "装饰", self.config.foreshadowing_tier_weight_decor
  162. return "支线", self.config.foreshadowing_tier_weight_sub
  163. def _resolve_chapter_field(self, item: Dict[str, Any], keys: List[str]) -> Optional[int]:
  164. """按候选键顺序读取章节号。"""
  165. for key in keys:
  166. if key in item:
  167. chapter = self._to_positive_int(item.get(key))
  168. if chapter is not None:
  169. return chapter
  170. return None
  171. def _collect_foreshadowing_records(self) -> List[Dict[str, Any]]:
  172. """收集未回收伏笔,并基于真实字段构建分析记录。"""
  173. if not self.state:
  174. return []
  175. current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
  176. plot_threads = self.state.get("plot_threads", {}) if isinstance(self.state.get("plot_threads"), dict) else {}
  177. foreshadowing = plot_threads.get("foreshadowing", [])
  178. if not isinstance(foreshadowing, list):
  179. return []
  180. records: List[Dict[str, Any]] = []
  181. for item in foreshadowing:
  182. if not isinstance(item, dict):
  183. continue
  184. if _is_resolved_foreshadowing_status(item.get("status")):
  185. continue
  186. content = str(item.get("content") or "").strip() or "[未命名伏笔]"
  187. tier, weight = self._normalize_foreshadowing_tier(item.get("tier"))
  188. planted_chapter = self._resolve_chapter_field(
  189. item,
  190. [
  191. "planted_chapter",
  192. "added_chapter",
  193. "source_chapter",
  194. "start_chapter",
  195. "chapter",
  196. ],
  197. )
  198. target_chapter = self._resolve_chapter_field(
  199. item,
  200. [
  201. "target_chapter",
  202. "due_chapter",
  203. "deadline_chapter",
  204. "resolve_by_chapter",
  205. "target",
  206. ],
  207. )
  208. elapsed = None
  209. if planted_chapter is not None:
  210. elapsed = max(0, current_chapter - planted_chapter)
  211. remaining = None
  212. if target_chapter is not None:
  213. remaining = target_chapter - current_chapter
  214. if remaining is not None and remaining < 0:
  215. overtime_status = "🔴 已超期"
  216. elif elapsed is None:
  217. overtime_status = "⚪ 数据不足"
  218. else:
  219. overtime_status = self._get_foreshadowing_status(elapsed)
  220. urgency: Optional[float] = None
  221. if (
  222. planted_chapter is not None
  223. and target_chapter is not None
  224. and target_chapter > planted_chapter
  225. and elapsed is not None
  226. ):
  227. urgency = round((elapsed / (target_chapter - planted_chapter)) * weight, 2)
  228. elif (
  229. planted_chapter is not None
  230. and target_chapter is not None
  231. and target_chapter <= planted_chapter
  232. and elapsed is not None
  233. ):
  234. urgency = round(weight * 2.0, 2)
  235. if remaining is not None and remaining < 0:
  236. urgency_status = "🔴 已超期"
  237. elif urgency is None:
  238. urgency_status = "⚪ 数据不足"
  239. else:
  240. urgency_status = self._get_urgency_status(urgency, remaining if remaining is not None else 0)
  241. records.append(
  242. {
  243. "content": content,
  244. "tier": tier,
  245. "weight": weight,
  246. "planted_chapter": planted_chapter,
  247. "target_chapter": target_chapter,
  248. "elapsed": elapsed,
  249. "remaining": remaining,
  250. "status": overtime_status,
  251. "urgency": urgency,
  252. "urgency_status": urgency_status,
  253. }
  254. )
  255. return records
  256. def _get_chapter_meta(self, chapter: int) -> Dict[str, Any]:
  257. """读取指定章节的 chapter_meta(支持 0001/1 两种键)。"""
  258. if not self.state:
  259. return {}
  260. chapter_meta = self.state.get("chapter_meta", {})
  261. if not isinstance(chapter_meta, dict):
  262. return {}
  263. for key in (f"{chapter:04d}", str(chapter)):
  264. value = chapter_meta.get(key)
  265. if isinstance(value, dict):
  266. return value
  267. return {}
  268. def _parse_pattern_count(self, raw_value: Any) -> Optional[int]:
  269. """解析爽点模式数量,解析失败返回 None。"""
  270. if raw_value is None:
  271. return None
  272. if isinstance(raw_value, list):
  273. patterns = [str(x).strip() for x in raw_value if str(x).strip()]
  274. return len(set(patterns))
  275. if isinstance(raw_value, str):
  276. text = raw_value.strip()
  277. if not text:
  278. return None
  279. parts = [p.strip() for p in re.split(r"[、,,/|+;;]+", text) if p.strip()]
  280. if parts:
  281. return len(set(parts))
  282. return 1
  283. return None
  284. def _get_chapter_reading_power_cached(self, chapter: int) -> Optional[Dict[str, Any]]:
  285. """读取并缓存 chapter_reading_power。"""
  286. if chapter in self._reading_power_cache:
  287. return self._reading_power_cache[chapter]
  288. try:
  289. record = self._index_manager.get_chapter_reading_power(chapter)
  290. except Exception:
  291. record = None
  292. self._reading_power_cache[chapter] = record
  293. return record
  294. def _get_chapter_cool_points(self, chapter: int, chapter_data: Dict[str, Any]) -> Tuple[Optional[int], str]:
  295. """获取单章爽点数量(真实元数据优先)。"""
  296. reading_power = self._get_chapter_reading_power_cached(chapter)
  297. if isinstance(reading_power, dict):
  298. count = self._parse_pattern_count(reading_power.get("coolpoint_patterns"))
  299. if count is not None:
  300. return count, "chapter_reading_power"
  301. chapter_meta = self._get_chapter_meta(chapter)
  302. for key in ("coolpoint_patterns", "coolpoint_pattern", "cool_point_patterns", "cool_point_pattern", "patterns", "pattern"):
  303. count = self._parse_pattern_count(chapter_meta.get(key))
  304. if count is not None:
  305. return count, "chapter_meta"
  306. count = self._parse_pattern_count(chapter_data.get("cool_point"))
  307. if count is not None:
  308. return count, "chapter_stats"
  309. return None, "none"
  310. def scan_chapters(self):
  311. """扫描所有章节文件"""
  312. if not self.chapters_dir.exists():
  313. print(f"⚠️ 正文目录不存在: {self.chapters_dir}")
  314. return
  315. # 支持两种目录结构:
  316. # 1) 正文/第0001章.md
  317. # 2) 正文/第1卷/第001章-标题.md
  318. chapter_files = sorted(self.chapters_dir.rglob("第*.md"))
  319. # v5.1 引入: 从 SQLite 获取已知角色名
  320. known_character_names: List[str] = []
  321. protagonist_name = ""
  322. if self.state:
  323. protagonist_name = self.state.get("protagonist_state", {}).get("name", "") or ""
  324. # 从 SQLite 获取所有角色的 canonical_name
  325. try:
  326. characters_from_db = self._index_manager.get_entities_by_type("角色")
  327. known_character_names = [
  328. c.get("canonical_name", c.get("id", ""))
  329. for c in characters_from_db
  330. if c.get("canonical_name")
  331. ]
  332. except Exception:
  333. known_character_names = []
  334. for chapter_file in chapter_files:
  335. chapter_num = extract_chapter_num_from_filename(chapter_file.name)
  336. if not chapter_num:
  337. continue
  338. # 读取章节内容
  339. with open(chapter_file, 'r', encoding='utf-8') as f:
  340. content = f.read()
  341. # 统计字数(去除 Markdown 标记)
  342. text = re.sub(r'```[\s\S]*?```', '', content) # 去除代码块
  343. text = re.sub(r'#+ .+', '', text) # 去除标题
  344. text = re.sub(r'---', '', text) # 去除分隔线
  345. word_count = len(text.strip())
  346. # 主导 Strand / 爽点类型(优先从"本章统计"解析)
  347. dominant_strand = (self._extract_stats_field(content, "主导Strand") or "").lower()
  348. cool_point_type = self._extract_stats_field(content, "爽点")
  349. # v5.1 引入: 角色提取从 SQLite chapters 表读取
  350. characters: List[str] = []
  351. try:
  352. chapter_info = self._index_manager.get_chapter(chapter_num)
  353. if chapter_info and chapter_info.get("characters"):
  354. stored = chapter_info["characters"]
  355. if isinstance(stored, str):
  356. stored = json.loads(stored)
  357. if isinstance(stored, list):
  358. for entity_id in stored:
  359. entity_id = str(entity_id).strip()
  360. if not entity_id:
  361. continue
  362. # 尝试获取 canonical_name
  363. entity = self._index_manager.get_entity(entity_id)
  364. name = entity.get("canonical_name", entity_id) if entity else entity_id
  365. characters.append(name)
  366. except Exception:
  367. characters = []
  368. if not characters and (protagonist_name or known_character_names):
  369. # 限制候选规模,避免在超大角色库下过慢
  370. candidates = []
  371. if protagonist_name:
  372. candidates.append(protagonist_name)
  373. candidates.extend(known_character_names[:self.config.character_candidates_limit])
  374. seen = set()
  375. for name in candidates:
  376. if not name or name in seen:
  377. continue
  378. if name in content:
  379. characters.append(name)
  380. seen.add(name)
  381. self.chapters_data.append({
  382. "chapter": chapter_num,
  383. "file": chapter_file,
  384. "word_count": word_count,
  385. "characters": characters,
  386. "dominant": dominant_strand,
  387. "cool_point": cool_point_type,
  388. })
  389. def analyze_characters(self) -> Dict:
  390. """分析角色活跃度(v5.1 引入,v5.4 沿用)"""
  391. if not self.state:
  392. return {}
  393. current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
  394. # v5.1 引入: 从 SQLite 获取所有角色
  395. try:
  396. characters_list = self._index_manager.get_entities_by_type("角色")
  397. except Exception:
  398. characters_list = []
  399. # 统计每个角色的最后出场章节
  400. character_activity = {}
  401. for char in characters_list:
  402. char_name = char.get("canonical_name", char.get("id", ""))
  403. if not char_name:
  404. continue
  405. # 查找最后出场章节
  406. last_appearance = char.get("last_appearance", 0) or 0
  407. # 也从 chapters_data 中检查
  408. for ch_data in self.chapters_data:
  409. if char_name in ch_data.get("characters", []):
  410. last_appearance = max(last_appearance, ch_data["chapter"])
  411. absence = current_chapter - last_appearance
  412. character_activity[char_name] = {
  413. "last_appearance": last_appearance,
  414. "absence": absence,
  415. "status": self._get_absence_status(absence)
  416. }
  417. return character_activity
  418. def _get_absence_status(self, absence: int) -> str:
  419. """判断掉线状态"""
  420. if absence == 0:
  421. return "✅ 活跃"
  422. elif absence < self.config.character_absence_warning:
  423. return "🟢 正常"
  424. elif absence < self.config.character_absence_critical:
  425. return "🟡 轻度掉线"
  426. else:
  427. return "🔴 严重掉线"
  428. def analyze_foreshadowing(self) -> List[Dict]:
  429. """分析伏笔深度"""
  430. records = self._collect_foreshadowing_records()
  431. return [
  432. {
  433. "content": item["content"],
  434. "planted_chapter": item["planted_chapter"],
  435. "estimated_chapter": item["planted_chapter"],
  436. "target_chapter": item["target_chapter"],
  437. "elapsed": item["elapsed"],
  438. "status": item["status"],
  439. }
  440. for item in records
  441. ]
  442. def _get_foreshadowing_status(self, elapsed: int) -> str:
  443. """判断伏笔超时状态"""
  444. if elapsed < self.config.foreshadowing_urgency_pending_medium:
  445. return "🟢 正常"
  446. elif elapsed < self.config.foreshadowing_urgency_pending_high + 50:
  447. return "🟡 轻度超时"
  448. else:
  449. return "🔴 严重超时"
  450. def analyze_foreshadowing_urgency(self) -> List[Dict]:
  451. """
  452. 分析伏笔紧急度(基于三层级系统)
  453. 三层级权重:
  454. - 核心(Core): 权重 3.0 - 必须回收,否则剧情崩塌
  455. - 支线(Sub): 权重 2.0 - 应该回收,否则显得作者健忘
  456. - 装饰(Decor): 权重 1.0 - 可回收可不回收,仅增加真实感
  457. 紧急度计算公式:
  458. urgency = (已过章节 / 目标回收章节) × 层级权重
  459. """
  460. records = self._collect_foreshadowing_records()
  461. urgency_list = [
  462. {
  463. "content": item["content"],
  464. "tier": item["tier"],
  465. "weight": item["weight"],
  466. "planted_chapter": item["planted_chapter"],
  467. "target_chapter": item["target_chapter"],
  468. "elapsed": item["elapsed"],
  469. "remaining": item["remaining"],
  470. "urgency": item["urgency"],
  471. "status": item["urgency_status"],
  472. }
  473. for item in records
  474. ]
  475. # 先按“是否可计算”,再按紧急度降序
  476. return sorted(
  477. urgency_list,
  478. key=lambda x: (x["urgency"] is None, -(x["urgency"] if x["urgency"] is not None else -1)),
  479. )
  480. def _get_urgency_status(self, urgency: float, remaining: int) -> str:
  481. """判断紧急度状态"""
  482. if remaining < 0:
  483. return "🔴 已超期"
  484. elif urgency >= self.config.foreshadowing_tier_weight_sub:
  485. return "🔴 紧急"
  486. elif urgency >= 1.0:
  487. return "🟡 警告"
  488. else:
  489. return "🟢 正常"
  490. def analyze_strand_weave(self) -> Dict:
  491. """
  492. 分析 Strand Weave 节奏分布
  493. 三线定义:
  494. - Quest(主线): 战斗、任务、升级 - 目标 55-65%
  495. - Fire(感情): 感情线、人际互动 - 目标 20-30%
  496. - Constellation(世界观): 世界观展开、势力背景 - 目标 10-20%
  497. 检查规则:
  498. - Quest 线连续不超过 5 章
  499. - Fire 线缺失不超过 10 章
  500. - Constellation 线缺失不超过 15 章
  501. """
  502. if not self.state:
  503. return {}
  504. strand_tracker = self.state.get("strand_tracker", {})
  505. history = strand_tracker.get("history", [])
  506. if not history:
  507. return {
  508. "has_data": False,
  509. "message": "暂无 Strand Weave 数据"
  510. }
  511. # 统计各线占比
  512. quest_count = 0
  513. fire_count = 0
  514. constellation_count = 0
  515. total = len(history)
  516. for entry in history:
  517. strand = (entry.get("strand") or entry.get("dominant") or "").lower()
  518. if strand in ["quest", "主线", "战斗", "任务"]:
  519. quest_count += 1
  520. elif strand in ["fire", "感情", "感情线", "互动"]:
  521. fire_count += 1
  522. elif strand in ["constellation", "世界观", "背景", "势力"]:
  523. constellation_count += 1
  524. # 计算占比
  525. quest_ratio = (quest_count / total * 100) if total > 0 else 0
  526. fire_ratio = (fire_count / total * 100) if total > 0 else 0
  527. constellation_ratio = (constellation_count / total * 100) if total > 0 else 0
  528. # 检查违规
  529. violations = []
  530. # 检查 Quest 连续超过 5 章
  531. quest_streak = 0
  532. max_quest_streak = 0
  533. for entry in history:
  534. strand = (entry.get("strand") or entry.get("dominant") or "").lower()
  535. if strand in ["quest", "主线", "战斗", "任务"]:
  536. quest_streak += 1
  537. max_quest_streak = max(max_quest_streak, quest_streak)
  538. else:
  539. quest_streak = 0
  540. if max_quest_streak > self.config.strand_quest_max_consecutive:
  541. violations.append(f"Quest 线连续 {max_quest_streak} 章(超过 {self.config.strand_quest_max_consecutive} 章限制)")
  542. # 检查 Fire 缺失超过 10 章
  543. fire_gap = 0
  544. max_fire_gap = 0
  545. for entry in history:
  546. strand = (entry.get("strand") or entry.get("dominant") or "").lower()
  547. if strand in ["fire", "感情", "感情线", "互动"]:
  548. max_fire_gap = max(max_fire_gap, fire_gap)
  549. fire_gap = 0
  550. else:
  551. fire_gap += 1
  552. max_fire_gap = max(max_fire_gap, fire_gap)
  553. if max_fire_gap > self.config.strand_fire_max_gap:
  554. violations.append(f"Fire 线缺失 {max_fire_gap} 章(超过 {self.config.strand_fire_max_gap} 章限制)")
  555. # 检查 Constellation 缺失超过 15 章
  556. const_gap = 0
  557. max_const_gap = 0
  558. for entry in history:
  559. strand = (entry.get("strand") or entry.get("dominant") or "").lower()
  560. if strand in ["constellation", "世界观", "背景", "势力"]:
  561. max_const_gap = max(max_const_gap, const_gap)
  562. const_gap = 0
  563. else:
  564. const_gap += 1
  565. max_const_gap = max(max_const_gap, const_gap)
  566. if max_const_gap > self.config.strand_constellation_max_gap:
  567. violations.append(f"Constellation 线缺失 {max_const_gap} 章(超过 {self.config.strand_constellation_max_gap} 章限制)")
  568. # 检查占比是否在合理范围
  569. cfg = self.config
  570. if quest_ratio < cfg.strand_quest_ratio_min:
  571. violations.append(f"Quest 占比 {quest_ratio:.1f}% 偏低(目标 {cfg.strand_quest_ratio_min}-{cfg.strand_quest_ratio_max}%)")
  572. elif quest_ratio > cfg.strand_quest_ratio_max:
  573. violations.append(f"Quest 占比 {quest_ratio:.1f}% 偏高(目标 {cfg.strand_quest_ratio_min}-{cfg.strand_quest_ratio_max}%)")
  574. if fire_ratio < cfg.strand_fire_ratio_min:
  575. violations.append(f"Fire 占比 {fire_ratio:.1f}% 偏低(目标 {cfg.strand_fire_ratio_min}-{cfg.strand_fire_ratio_max}%)")
  576. elif fire_ratio > cfg.strand_fire_ratio_max:
  577. violations.append(f"Fire 占比 {fire_ratio:.1f}% 偏高(目标 {cfg.strand_fire_ratio_min}-{cfg.strand_fire_ratio_max}%)")
  578. if constellation_ratio < cfg.strand_constellation_ratio_min:
  579. violations.append(f"Constellation 占比 {constellation_ratio:.1f}% 偏低(目标 {cfg.strand_constellation_ratio_min}-{cfg.strand_constellation_ratio_max}%)")
  580. elif constellation_ratio > cfg.strand_constellation_ratio_max:
  581. violations.append(f"Constellation 占比 {constellation_ratio:.1f}% 偏高(目标 {cfg.strand_constellation_ratio_min}-{cfg.strand_constellation_ratio_max}%)")
  582. return {
  583. "has_data": True,
  584. "total_chapters": total,
  585. "quest": {"count": quest_count, "ratio": quest_ratio},
  586. "fire": {"count": fire_count, "ratio": fire_ratio},
  587. "constellation": {"count": constellation_count, "ratio": constellation_ratio},
  588. "violations": violations,
  589. "max_quest_streak": max_quest_streak,
  590. "max_fire_gap": max_fire_gap,
  591. "max_const_gap": max_const_gap,
  592. "health": "✅ 健康" if not violations else f"⚠️ {len(violations)} 个问题"
  593. }
  594. def analyze_pacing(self) -> List[Dict]:
  595. """分析爽点节奏分布(每 N 章为一段)"""
  596. segment_size = self.config.pacing_segment_size
  597. segments = []
  598. for i in range(0, len(self.chapters_data), segment_size):
  599. segment_chapters = self.chapters_data[i:i+segment_size]
  600. if not segment_chapters:
  601. continue
  602. start_ch = segment_chapters[0]["chapter"]
  603. end_ch = segment_chapters[-1]["chapter"]
  604. total_words = sum(ch["word_count"] for ch in segment_chapters)
  605. cool_points = 0
  606. chapters_with_data = 0
  607. source_counter: Dict[str, int] = {}
  608. for chapter_data in segment_chapters:
  609. chapter = chapter_data["chapter"]
  610. count, source = self._get_chapter_cool_points(chapter, chapter_data)
  611. source_counter[source] = source_counter.get(source, 0) + 1
  612. if count is None:
  613. continue
  614. chapters_with_data += 1
  615. cool_points += count
  616. words_per_point = None
  617. if cool_points > 0:
  618. words_per_point = total_words / cool_points
  619. rating = self._get_pacing_rating(words_per_point)
  620. missing_chapters = len(segment_chapters) - chapters_with_data
  621. dominant_source = "none"
  622. if source_counter:
  623. dominant_source = max(source_counter.items(), key=lambda x: x[1])[0]
  624. segments.append({
  625. "start": start_ch,
  626. "end": end_ch,
  627. "total_words": total_words,
  628. "cool_points": cool_points,
  629. "words_per_point": words_per_point,
  630. "rating": rating,
  631. "missing_chapters": missing_chapters,
  632. "data_coverage": (chapters_with_data / len(segment_chapters)) if segment_chapters else 0.0,
  633. "dominant_source": dominant_source,
  634. })
  635. return segments
  636. def _get_pacing_rating(self, words_per_point: Optional[float]) -> str:
  637. """判断节奏评级"""
  638. if words_per_point is None:
  639. return "数据不足"
  640. if words_per_point < self.config.pacing_words_per_point_excellent:
  641. return "优秀"
  642. elif words_per_point < self.config.pacing_words_per_point_good:
  643. return "良好"
  644. elif words_per_point < self.config.pacing_words_per_point_acceptable:
  645. return "及格"
  646. else:
  647. return "偏低⚠️"
  648. def generate_relationship_graph(self) -> str:
  649. """生成人际关系 Mermaid 图"""
  650. if not self.state:
  651. return ""
  652. relationships = self.state.get("relationships", {})
  653. protagonist_name = self.state.get("protagonist_state", {}).get("name", "主角")
  654. lines = ["```mermaid", "graph LR"]
  655. # 支持两种格式:
  656. # 格式1(新): {"allies": [...], "enemies": [...]}
  657. # 格式2(旧): {"角色名": {"affection": X, "hatred": Y}}
  658. allies = relationships.get("allies", [])
  659. enemies = relationships.get("enemies", [])
  660. if allies or enemies:
  661. # 新格式
  662. for ally in allies:
  663. if isinstance(ally, dict):
  664. name = ally.get("name", "未知")
  665. relation = ally.get("relation", "友好")
  666. lines.append(f" {protagonist_name} -->|{relation}| {name}")
  667. for enemy in enemies:
  668. if isinstance(enemy, dict):
  669. name = enemy.get("name", "未知")
  670. relation = enemy.get("relation", "敌对")
  671. lines.append(f" {protagonist_name} -.->|{relation}| {name}")
  672. else:
  673. # 旧格式兼容
  674. for char_name, rel_data in relationships.items():
  675. if isinstance(rel_data, dict):
  676. affection = rel_data.get("affection", 0)
  677. hatred = rel_data.get("hatred", 0)
  678. if affection > 0:
  679. lines.append(f" {protagonist_name} -->|好感度{affection}| {char_name}")
  680. if hatred > 0:
  681. lines.append(f" {protagonist_name} -.->|仇恨度{hatred}| {char_name}")
  682. lines.append("```")
  683. return "\n".join(lines)
  684. def generate_report(self, focus: str = "all") -> str:
  685. """生成健康报告(Markdown 格式)"""
  686. report_lines = [
  687. "# 全书健康报告",
  688. "",
  689. f"> **生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
  690. "",
  691. "---",
  692. ""
  693. ]
  694. # 基本数据
  695. if focus in ["all", "basic"]:
  696. report_lines.extend(self._generate_basic_stats())
  697. # 角色活跃度
  698. if focus in ["all", "characters"]:
  699. report_lines.extend(self._generate_character_section())
  700. # 伏笔深度
  701. if focus in ["all", "foreshadowing"]:
  702. report_lines.extend(self._generate_foreshadowing_section())
  703. # 伏笔紧急度(新增)
  704. if focus in ["all", "foreshadowing", "urgency"]:
  705. report_lines.extend(self._generate_urgency_section())
  706. # 爽点节奏
  707. if focus in ["all", "pacing"]:
  708. report_lines.extend(self._generate_pacing_section())
  709. # Strand Weave 节奏(新增)
  710. if focus in ["all", "strand", "pacing"]:
  711. report_lines.extend(self._generate_strand_section())
  712. # 人际关系
  713. if focus in ["all", "relationships"]:
  714. report_lines.extend(self._generate_relationship_section())
  715. return "\n".join(report_lines)
  716. def _generate_basic_stats(self) -> List[str]:
  717. """生成基本统计"""
  718. if not self.state:
  719. return []
  720. progress = self.state.get("progress", {})
  721. current_chapter = progress.get("current_chapter", 0)
  722. total_words = progress.get("total_words", 0)
  723. target_words = self.state.get("project_info", {}).get("target_words", 2000000)
  724. avg_words = total_words / current_chapter if current_chapter > 0 else 0
  725. completion = (total_words / target_words * 100) if target_words > 0 else 0
  726. return [
  727. "## 📊 基本数据",
  728. "",
  729. f"- **总章节数**: {current_chapter} 章",
  730. f"- **总字数**: {total_words:,} 字",
  731. f"- **平均章节字数**: {avg_words:,.0f} 字",
  732. f"- **创作进度**: {completion:.1f}%(目标 {target_words:,} 字)",
  733. "",
  734. "---",
  735. ""
  736. ]
  737. def _generate_character_section(self) -> List[str]:
  738. """生成角色分析章节"""
  739. activity = self.analyze_characters()
  740. if not activity:
  741. return []
  742. # 筛选掉线角色
  743. dropped = {name: data for name, data in activity.items()
  744. if "掉线" in data["status"]}
  745. lines = [
  746. f"## ⚠️ 角色掉线({len(dropped)}人)",
  747. ""
  748. ]
  749. if dropped:
  750. lines.extend([
  751. "| 角色 | 最后出场 | 缺席章节 | 状态 |",
  752. "|------|---------|---------|------|"
  753. ])
  754. for char_name, data in sorted(dropped.items(),
  755. key=lambda x: x[1]["absence"],
  756. reverse=True):
  757. lines.append(
  758. f"| {char_name} | 第 {data['last_appearance']} 章 | "
  759. f"{data['absence']} 章 | {data['status']} |"
  760. )
  761. else:
  762. lines.append("✅ 所有角色活跃度正常")
  763. lines.extend(["", "---", ""])
  764. return lines
  765. def _generate_foreshadowing_section(self) -> List[str]:
  766. """生成伏笔分析章节"""
  767. overdue = self.analyze_foreshadowing()
  768. # 筛选超时伏笔
  769. overdue_items = [
  770. item for item in overdue if "超时" in item["status"] or "超期" in item["status"]
  771. ]
  772. unknown_items = [item for item in overdue if item["status"] == "⚪ 数据不足"]
  773. lines = [
  774. f"## ⚠️ 伏笔超时({len(overdue_items)}条)",
  775. ""
  776. ]
  777. if overdue_items:
  778. lines.extend([
  779. "| 伏笔内容 | 埋设章节 | 已过章节 | 状态 |",
  780. "|---------|---------|---------|------|"
  781. ])
  782. for item in sorted(overdue_items, key=lambda x: (x["elapsed"] if x["elapsed"] is not None else -1), reverse=True):
  783. planted = item["planted_chapter"] if item["planted_chapter"] is not None else "未知"
  784. elapsed = item["elapsed"] if item["elapsed"] is not None else "未知"
  785. lines.append(
  786. f"| {item['content'][:30]}... | 第 {planted} 章 | "
  787. f"{elapsed} 章 | {item['status']} |"
  788. )
  789. else:
  790. lines.append("✅ 所有伏笔进度正常")
  791. if unknown_items:
  792. lines.append("")
  793. lines.append(f"⚪ 另有 {len(unknown_items)} 条伏笔缺少章节信息,无法判断是否超时")
  794. lines.extend(["", "---", ""])
  795. return lines
  796. def _generate_urgency_section(self) -> List[str]:
  797. """生成伏笔紧急度章节(基于三层级系统)"""
  798. urgency_list = self.analyze_foreshadowing_urgency()
  799. # 筛选紧急伏笔
  800. urgent_items = [
  801. item
  802. for item in urgency_list
  803. if (item["urgency"] is not None and item["urgency"] >= 1.0) or item["status"] == "🔴 已超期"
  804. ]
  805. lines = [
  806. f"## 🚨 伏笔紧急度排序({len(urgent_items)}条需关注)",
  807. "",
  808. "> 基于三层级系统:核心(×3) / 支线(×2) / 装饰(×1)",
  809. "> 紧急度 = (已过章节 / (目标章节-埋设章节)) × 层级权重",
  810. ""
  811. ]
  812. unknown_items = [item for item in urgency_list if item["urgency"] is None]
  813. if unknown_items:
  814. lines.append(f"> {len(unknown_items)} 条伏笔缺少埋设/目标章节,紧急度记为 N/A")
  815. lines.append("")
  816. if urgency_list:
  817. lines.extend([
  818. "| 伏笔内容 | 层级 | 埋设 | 目标 | 紧急度 | 状态 |",
  819. "|---------|------|------|------|--------|------|"
  820. ])
  821. for item in urgency_list[:10]: # 只显示前10条
  822. planted = f"第{item['planted_chapter']}章" if item["planted_chapter"] is not None else "未知"
  823. target = f"第{item['target_chapter']}章" if item["target_chapter"] is not None else "未知"
  824. urgency_text = f"{item['urgency']:.2f}" if item["urgency"] is not None else "N/A"
  825. lines.append(
  826. f"| {item['content'][:20]}... | {item['tier']} | "
  827. f"{planted} | {target} | "
  828. f"{urgency_text} | {item['status']} |"
  829. )
  830. else:
  831. lines.append("✅ 暂无伏笔数据")
  832. lines.extend(["", "---", ""])
  833. return lines
  834. def _generate_strand_section(self) -> List[str]:
  835. """生成 Strand Weave 节奏章节"""
  836. strand_data = self.analyze_strand_weave()
  837. lines = [
  838. "## 🎭 Strand Weave 节奏分析",
  839. ""
  840. ]
  841. if not strand_data.get("has_data"):
  842. lines.append(f"⚠️ {strand_data.get('message', '暂无数据')}")
  843. lines.extend(["", "---", ""])
  844. return lines
  845. # 占比统计
  846. cfg = self.config
  847. lines.extend([
  848. "### 三线占比",
  849. "",
  850. "| Strand | 章节数 | 占比 | 目标范围 | 状态 |",
  851. "|--------|--------|------|----------|------|"
  852. ])
  853. q = strand_data["quest"]
  854. q_status = "✅" if cfg.strand_quest_ratio_min <= q["ratio"] <= cfg.strand_quest_ratio_max else "⚠️"
  855. lines.append(f"| Quest(主线) | {q['count']} | {q['ratio']:.1f}% | {cfg.strand_quest_ratio_min}-{cfg.strand_quest_ratio_max}% | {q_status} |")
  856. f = strand_data["fire"]
  857. f_status = "✅" if cfg.strand_fire_ratio_min <= f["ratio"] <= cfg.strand_fire_ratio_max else "⚠️"
  858. lines.append(f"| Fire(感情) | {f['count']} | {f['ratio']:.1f}% | {cfg.strand_fire_ratio_min}-{cfg.strand_fire_ratio_max}% | {f_status} |")
  859. c = strand_data["constellation"]
  860. c_status = "✅" if cfg.strand_constellation_ratio_min <= c["ratio"] <= cfg.strand_constellation_ratio_max else "⚠️"
  861. lines.append(f"| Constellation(世界观) | {c['count']} | {c['ratio']:.1f}% | {cfg.strand_constellation_ratio_min}-{cfg.strand_constellation_ratio_max}% | {c_status} |")
  862. lines.append("")
  863. # 连续性检查
  864. lines.extend([
  865. "### 连续性检查",
  866. "",
  867. f"- Quest 最大连续: {strand_data['max_quest_streak']} 章(限制 ≤5)",
  868. f"- Fire 最大缺失: {strand_data['max_fire_gap']} 章(限制 ≤10)",
  869. f"- Constellation 最大缺失: {strand_data['max_const_gap']} 章(限制 ≤15)",
  870. ""
  871. ])
  872. # 违规清单
  873. if strand_data["violations"]:
  874. lines.extend([
  875. "### ⚠️ 违规清单",
  876. ""
  877. ])
  878. for v in strand_data["violations"]:
  879. lines.append(f"- {v}")
  880. else:
  881. lines.append("### ✅ 无违规")
  882. lines.extend(["", f"**综合健康度**: {strand_data['health']}", "", "---", ""])
  883. return lines
  884. def _generate_pacing_section(self) -> List[str]:
  885. """生成节奏分析章节"""
  886. segments = self.analyze_pacing()
  887. lines = [
  888. "## 📈 爽点节奏分布",
  889. "",
  890. "```"
  891. ]
  892. for seg in segments:
  893. words_per_point = seg["words_per_point"]
  894. if words_per_point is None:
  895. lines.append(
  896. f"第 {seg['start']}-{seg['end']}章 ░ 数据不足"
  897. f"(缺少爽点数据 {seg['missing_chapters']} 章)"
  898. )
  899. continue
  900. bar_length = int(12 - (words_per_point / 2000 * 12))
  901. bar_length = max(1, min(12, bar_length))
  902. bar = "█" * bar_length
  903. suffix = ""
  904. if seg["missing_chapters"] > 0:
  905. suffix = f",缺少爽点数据 {seg['missing_chapters']} 章"
  906. lines.append(
  907. f"第 {seg['start']}-{seg['end']}章 {bar} {seg['rating']}"
  908. f"({words_per_point:.0f}字/爽点,记录 {seg['cool_points']} 个爽点{suffix})"
  909. )
  910. lines.extend(["```", "", "---", ""])
  911. return lines
  912. def _generate_relationship_section(self) -> List[str]:
  913. """生成人际关系章节"""
  914. graph = self.generate_relationship_graph()
  915. lines = [
  916. "## 💑 人际关系趋势",
  917. "",
  918. graph,
  919. "",
  920. "---",
  921. ""
  922. ]
  923. return lines
  924. def main():
  925. import argparse
  926. _enable_windows_utf8_stdio()
  927. parser = argparse.ArgumentParser(
  928. description="可视化状态报告生成器",
  929. formatter_class=argparse.RawDescriptionHelpFormatter,
  930. epilog="""
  931. 示例:
  932. # 生成完整健康报告
  933. python status_reporter.py --output .webnovel/health_report.md
  934. # 仅分析角色活跃度
  935. python status_reporter.py --focus characters
  936. # 仅分析伏笔
  937. python status_reporter.py --focus foreshadowing
  938. # 仅分析爽点节奏
  939. python status_reporter.py --focus pacing
  940. """
  941. )
  942. parser.add_argument('--output', default='.webnovel/health_report.md',
  943. help='输出文件路径')
  944. parser.add_argument('--focus', choices=['all', 'basic', 'characters',
  945. 'foreshadowing', 'urgency', 'pacing',
  946. 'strand', 'relationships'],
  947. default='all', help='分析焦点(新增 urgency, strand)')
  948. parser.add_argument('--project-root', default='.', help='项目根目录')
  949. args = parser.parse_args()
  950. # 解析项目根目录(支持从仓库根目录运行)
  951. project_root = args.project_root
  952. if project_root == '.' and not (Path('.') / '.webnovel' / 'state.json').exists():
  953. try:
  954. project_root = str(resolve_project_root())
  955. except FileNotFoundError:
  956. project_root = args.project_root
  957. # 创建报告生成器
  958. reporter = StatusReporter(project_root)
  959. # 加载状态
  960. if not reporter.load_state():
  961. sys.exit(1)
  962. print("📖 正在扫描章节文件...")
  963. reporter.scan_chapters()
  964. print(f"✅ 已扫描 {len(reporter.chapters_data)} 个章节")
  965. print("\n📊 正在分析...")
  966. # 生成报告
  967. report = reporter.generate_report(args.focus)
  968. # 保存报告
  969. output_file = Path(args.output)
  970. if args.output == '.webnovel/health_report.md' and project_root != '.':
  971. output_file = Path(project_root) / '.webnovel' / 'health_report.md'
  972. output_file.parent.mkdir(parents=True, exist_ok=True)
  973. with open(output_file, 'w', encoding='utf-8') as f:
  974. f.write(report)
  975. print(f"\n✅ 健康报告已生成: {output_file}")
  976. # 预览报告(前 30 行)
  977. print("\n" + "="*60)
  978. print("📄 报告预览:\n")
  979. print("\n".join(report.split("\n")[:30]))
  980. print("\n...")
  981. print("="*60)
  982. if __name__ == "__main__":
  983. main()