status_reporter.py 43 KB

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