extract_chapter_context.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. extract_chapter_context.py - extract chapter writing context
  5. Features:
  6. - chapter outline snippet
  7. - previous chapter summaries (prefers .webnovel/summaries)
  8. - compact state summary
  9. - ContextManager contract sections (reader_signal / genre_profile / writing_guidance)
  10. """
  11. from __future__ import annotations
  12. import argparse
  13. import json
  14. import re
  15. import sys
  16. from pathlib import Path
  17. from typing import Any, Dict, List
  18. from runtime_compat import enable_windows_utf8_stdio
  19. try:
  20. from chapter_paths import find_chapter_file, volume_num_for_chapter
  21. except ImportError: # pragma: no cover
  22. from scripts.chapter_paths import find_chapter_file, volume_num_for_chapter
  23. def _ensure_scripts_path():
  24. scripts_dir = Path(__file__).resolve().parent
  25. if str(scripts_dir) not in sys.path:
  26. sys.path.insert(0, str(scripts_dir))
  27. _CHAPTER_RANGE_RE = re.compile(r"^\s*(\d+)\s*-\s*(\d+)\s*$")
  28. def _parse_chapters_range(value: Any) -> tuple[int, int] | None:
  29. if not isinstance(value, str):
  30. return None
  31. m = _CHAPTER_RANGE_RE.match(value)
  32. if not m:
  33. return None
  34. try:
  35. start = int(m.group(1))
  36. end = int(m.group(2))
  37. except ValueError:
  38. return None
  39. if start <= 0 or end <= 0 or start > end:
  40. return None
  41. return start, end
  42. def _volume_num_for_chapter_from_state(project_root: Path, chapter_num: int) -> int | None:
  43. """
  44. Prefer `.webnovel/state.json.progress.volumes_planned[].chapters_range` mapping.
  45. Fallback is handled by caller (typically 50 chapters per volume).
  46. """
  47. state_path = project_root / ".webnovel" / "state.json"
  48. if not state_path.exists():
  49. return None
  50. try:
  51. state = json.loads(state_path.read_text(encoding="utf-8"))
  52. except Exception:
  53. return None
  54. if not isinstance(state, dict):
  55. return None
  56. progress = state.get("progress")
  57. if not isinstance(progress, dict):
  58. return None
  59. volumes_planned = progress.get("volumes_planned")
  60. if not isinstance(volumes_planned, list):
  61. return None
  62. best: tuple[int, int] | None = None # (start, volume) - prefer the latest start if overlaps exist
  63. for item in volumes_planned:
  64. if not isinstance(item, dict):
  65. continue
  66. volume = item.get("volume")
  67. if not isinstance(volume, int) or volume <= 0:
  68. continue
  69. parsed = _parse_chapters_range(item.get("chapters_range"))
  70. if not parsed:
  71. continue
  72. start, end = parsed
  73. if start <= chapter_num <= end:
  74. cand = (start, volume)
  75. if best is None or cand[0] > best[0] or (cand[0] == best[0] and cand[1] < best[1]):
  76. best = cand
  77. return best[1] if best else None
  78. def find_project_root(start_path: Path | None = None) -> Path:
  79. """Find project root containing `.webnovel` directory."""
  80. if start_path is None:
  81. start_path = Path.cwd()
  82. search_paths = [
  83. start_path,
  84. start_path / "webnovel-project",
  85. start_path.parent,
  86. ]
  87. for path in search_paths:
  88. if (path / ".webnovel").exists():
  89. return path
  90. raise FileNotFoundError("未找到 .webnovel 目录,请确认项目路径")
  91. def extract_chapter_outline(project_root: Path, chapter_num: int) -> str:
  92. """Extract chapter outline segment from volume outline file."""
  93. volume_num = _volume_num_for_chapter_from_state(project_root, chapter_num) or volume_num_for_chapter(chapter_num)
  94. outline_candidates = [
  95. project_root / "大纲" / f"第{volume_num}卷-详细大纲.md",
  96. project_root / "大纲" / f"第{volume_num}卷 详细大纲.md",
  97. project_root / "大纲" / f"第{volume_num}卷详细大纲.md",
  98. ]
  99. outline_file = next((p for p in outline_candidates if p.exists()), None)
  100. if outline_file is None:
  101. tried = " / ".join(str(p) for p in outline_candidates)
  102. return f"⚠️ 大纲文件不存在,已尝试: {tried}"
  103. content = outline_file.read_text(encoding="utf-8")
  104. pattern = rf"###\s*第\s*{chapter_num}\s*章[::]\s*(.+?)(?=###\s*第\s*\d+\s*章|##\s|$)"
  105. match = re.search(pattern, content, re.DOTALL)
  106. if not match:
  107. pattern2 = rf"###\s*第{chapter_num}章[::]\s*(.+?)(?=###\s*第\d+章|##\s|$)"
  108. match = re.search(pattern2, content, re.DOTALL)
  109. if match:
  110. outline = match.group(0).strip()
  111. if len(outline) > 1500:
  112. outline = outline[:1500] + "\n...(已截断)"
  113. return outline
  114. return f"⚠️ 未找到第 {chapter_num} 章的大纲"
  115. def _load_summary_file(project_root: Path, chapter_num: int) -> str:
  116. """Load summary section from `.webnovel/summaries/chNNNN.md`."""
  117. summary_path = project_root / ".webnovel" / "summaries" / f"ch{chapter_num:04d}.md"
  118. if not summary_path.exists():
  119. return ""
  120. text = summary_path.read_text(encoding="utf-8")
  121. summary_match = re.search(r"##\s*剧情摘要\s*\r?\n(.+?)(?=\r?\n##|$)", text, re.DOTALL)
  122. if summary_match:
  123. return summary_match.group(1).strip()
  124. return ""
  125. def extract_chapter_summary(project_root: Path, chapter_num: int) -> str:
  126. """Extract chapter summary, fallback to chapter body head."""
  127. summary = _load_summary_file(project_root, chapter_num)
  128. if summary:
  129. return summary
  130. chapter_file = find_chapter_file(project_root, chapter_num)
  131. if not chapter_file or not chapter_file.exists():
  132. return f"⚠️ 第{chapter_num}章文件不存在"
  133. content = chapter_file.read_text(encoding="utf-8")
  134. summary_match = re.search(r"##\s*本章摘要\s*\r?\n(.+?)(?=\r?\n##|$)", content, re.DOTALL)
  135. if summary_match:
  136. return summary_match.group(1).strip()
  137. stats_match = re.search(r"##\s*本章统计\s*\r?\n(.+?)(?=\r?\n##|$)", content, re.DOTALL)
  138. if stats_match:
  139. return f"[无摘要,仅统计]\n{stats_match.group(1).strip()}"
  140. lines = content.split("\n")
  141. text_lines = [line for line in lines if not line.startswith("#") and line.strip()]
  142. text = "\n".join(text_lines)[:500]
  143. return f"[自动截取前500字]\n{text}..."
  144. def extract_state_summary(project_root: Path) -> str:
  145. """Extract key fields from `.webnovel/state.json`."""
  146. state_file = project_root / ".webnovel" / "state.json"
  147. if not state_file.exists():
  148. return "⚠️ state.json 不存在"
  149. state = json.loads(state_file.read_text(encoding="utf-8"))
  150. summary_parts: List[str] = []
  151. if "progress" in state:
  152. progress = state["progress"]
  153. summary_parts.append(
  154. f"**进度**: 第{progress.get('current_chapter', '?')}章 / {progress.get('total_words', '?')}字"
  155. )
  156. if "protagonist_state" in state:
  157. ps = state["protagonist_state"]
  158. power = ps.get("power", {})
  159. summary_parts.append(f"**主角实力**: {power.get('realm', '?')} {power.get('layer', '?')}层")
  160. summary_parts.append(f"**当前位置**: {ps.get('location', '?')}")
  161. golden_finger = ps.get("golden_finger", {})
  162. summary_parts.append(
  163. f"**金手指**: {golden_finger.get('name', '?')} Lv.{golden_finger.get('level', '?')}"
  164. )
  165. if "strand_tracker" in state:
  166. tracker = state["strand_tracker"]
  167. history = tracker.get("history", [])[-5:]
  168. if history:
  169. items: List[str] = []
  170. for row in history:
  171. if not isinstance(row, dict):
  172. continue
  173. chapter = row.get("chapter", "?")
  174. strand = row.get("strand") or row.get("dominant") or "unknown"
  175. items.append(f"Ch{chapter}:{strand}")
  176. if items:
  177. summary_parts.append(f"**近5章Strand**: {', '.join(items)}")
  178. plot_threads = state.get("plot_threads", {}) if isinstance(state.get("plot_threads"), dict) else {}
  179. foreshadowing = plot_threads.get("foreshadowing", [])
  180. if isinstance(foreshadowing, list) and foreshadowing:
  181. active = [row for row in foreshadowing if row.get("status") in {"active", "未回收"}]
  182. urgent = [row for row in active if row.get("urgency", 0) > 50]
  183. if urgent:
  184. urgent_list = [
  185. f"{row.get('content', '?')[:30]}... (紧急度:{row.get('urgency')})"
  186. for row in urgent[:3]
  187. ]
  188. summary_parts.append(f"**紧急伏笔**: {'; '.join(urgent_list)}")
  189. return "\n".join(summary_parts)
  190. def _load_contract_context(project_root: Path, chapter_num: int) -> Dict[str, Any]:
  191. """Build context via ContextManager and return selected sections."""
  192. _ensure_scripts_path()
  193. from data_modules.config import DataModulesConfig
  194. from data_modules.context_manager import ContextManager
  195. config = DataModulesConfig.from_project_root(project_root)
  196. manager = ContextManager(config)
  197. payload = manager.build_context(
  198. chapter=chapter_num,
  199. template="plot",
  200. use_snapshot=True,
  201. save_snapshot=True,
  202. max_chars=8000,
  203. )
  204. sections = payload.get("sections", {})
  205. return {
  206. "context_contract_version": (payload.get("meta") or {}).get("context_contract_version"),
  207. "context_weight_stage": (payload.get("meta") or {}).get("context_weight_stage"),
  208. "reader_signal": (sections.get("reader_signal") or {}).get("content", {}),
  209. "genre_profile": (sections.get("genre_profile") or {}).get("content", {}),
  210. "writing_guidance": (sections.get("writing_guidance") or {}).get("content", {}),
  211. }
  212. def build_chapter_context_payload(project_root: Path, chapter_num: int) -> Dict[str, Any]:
  213. """Assemble full chapter context payload for text/json output."""
  214. outline = extract_chapter_outline(project_root, chapter_num)
  215. prev_summaries = []
  216. for prev_ch in range(max(1, chapter_num - 2), chapter_num):
  217. summary = extract_chapter_summary(project_root, prev_ch)
  218. prev_summaries.append(f"### 第{prev_ch}章摘要\n{summary}")
  219. state_summary = extract_state_summary(project_root)
  220. contract_context = _load_contract_context(project_root, chapter_num)
  221. return {
  222. "chapter": chapter_num,
  223. "outline": outline,
  224. "previous_summaries": prev_summaries,
  225. "state_summary": state_summary,
  226. "context_contract_version": contract_context.get("context_contract_version"),
  227. "context_weight_stage": contract_context.get("context_weight_stage"),
  228. "reader_signal": contract_context.get("reader_signal", {}),
  229. "genre_profile": contract_context.get("genre_profile", {}),
  230. "writing_guidance": contract_context.get("writing_guidance", {}),
  231. }
  232. def _render_text(payload: Dict[str, Any]) -> str:
  233. chapter_num = payload.get("chapter")
  234. lines: List[str] = []
  235. lines.append(f"# 第 {chapter_num} 章创作上下文")
  236. lines.append("")
  237. lines.append("## 本章大纲")
  238. lines.append("")
  239. lines.append(str(payload.get("outline", "")))
  240. lines.append("")
  241. lines.append("---")
  242. lines.append("")
  243. lines.append("## 前文摘要")
  244. lines.append("")
  245. for item in payload.get("previous_summaries", []):
  246. lines.append(item)
  247. lines.append("")
  248. lines.append("---")
  249. lines.append("")
  250. lines.append("## 当前状态")
  251. lines.append("")
  252. lines.append(str(payload.get("state_summary", "")))
  253. lines.append("")
  254. contract_version = payload.get("context_contract_version")
  255. if contract_version:
  256. lines.append(f"## Contract ({contract_version})")
  257. lines.append("")
  258. stage = payload.get("context_weight_stage")
  259. if stage:
  260. lines.append(f"- 上下文阶段权重: {stage}")
  261. lines.append("")
  262. writing_guidance = payload.get("writing_guidance") or {}
  263. guidance_items = writing_guidance.get("guidance_items") or []
  264. checklist = writing_guidance.get("checklist") or []
  265. checklist_score = writing_guidance.get("checklist_score") or {}
  266. if guidance_items or checklist:
  267. lines.append("## 写作执行建议")
  268. lines.append("")
  269. for idx, item in enumerate(guidance_items, start=1):
  270. lines.append(f"{idx}. {item}")
  271. if checklist:
  272. total_weight = 0.0
  273. required_count = 0
  274. for row in checklist:
  275. if isinstance(row, dict):
  276. try:
  277. total_weight += float(row.get("weight") or 0)
  278. except (TypeError, ValueError):
  279. pass
  280. if row.get("required"):
  281. required_count += 1
  282. lines.append("")
  283. lines.append("### 执行检查清单(可评分)")
  284. lines.append("")
  285. lines.append(f"- 项目数: {len(checklist)}")
  286. lines.append(f"- 总权重: {total_weight:.2f}")
  287. lines.append(f"- 必做项: {required_count}")
  288. lines.append("")
  289. for idx, row in enumerate(checklist, start=1):
  290. if not isinstance(row, dict):
  291. lines.append(f"{idx}. {row}")
  292. continue
  293. label = str(row.get("label") or "").strip() or "未命名项"
  294. weight = row.get("weight")
  295. required_tag = "必做" if row.get("required") else "可选"
  296. verify_hint = str(row.get("verify_hint") or "").strip()
  297. lines.append(f"{idx}. [{required_tag}][w={weight}] {label}")
  298. if verify_hint:
  299. lines.append(f" - 验收: {verify_hint}")
  300. if checklist_score:
  301. lines.append("")
  302. lines.append("### 执行评分")
  303. lines.append("")
  304. lines.append(f"- 评分: {checklist_score.get('score')}")
  305. lines.append(f"- 完成率: {checklist_score.get('completion_rate')}")
  306. lines.append(f"- 必做完成率: {checklist_score.get('required_completion_rate')}")
  307. lines.append("")
  308. reader_signal = payload.get("reader_signal") or {}
  309. review_trend = reader_signal.get("review_trend") or {}
  310. if review_trend:
  311. overall_avg = review_trend.get("overall_avg")
  312. lines.append("## 追读信号")
  313. lines.append("")
  314. lines.append(f"- 最近审查均分: {overall_avg}")
  315. low_ranges = reader_signal.get("low_score_ranges") or []
  316. if low_ranges:
  317. lines.append(f"- 低分区间数: {len(low_ranges)}")
  318. lines.append("")
  319. genre_profile = payload.get("genre_profile") or {}
  320. if genre_profile.get("genre"):
  321. lines.append("## 题材锚定")
  322. lines.append("")
  323. lines.append(f"- 题材: {genre_profile.get('genre')}")
  324. genres = genre_profile.get("genres") or []
  325. if len(genres) > 1:
  326. lines.append(f"- 复合题材: {' + '.join(str(token) for token in genres)}")
  327. composite_hints = genre_profile.get("composite_hints") or []
  328. for row in composite_hints[:2]:
  329. lines.append(f"- {row}")
  330. refs = genre_profile.get("reference_hints") or []
  331. for row in refs[:3]:
  332. lines.append(f"- {row}")
  333. lines.append("")
  334. return "\n".join(lines).rstrip() + "\n"
  335. def main():
  336. parser = argparse.ArgumentParser(description="提取章节创作所需的精简上下文")
  337. parser.add_argument("--chapter", type=int, required=True, help="目标章节号")
  338. parser.add_argument("--project-root", type=str, help="项目根目录")
  339. parser.add_argument("--format", choices=["text", "json"], default="text", help="输出格式")
  340. args = parser.parse_args()
  341. try:
  342. project_root = Path(args.project_root) if args.project_root else find_project_root()
  343. payload = build_chapter_context_payload(project_root, args.chapter)
  344. if args.format == "json":
  345. print(json.dumps(payload, ensure_ascii=False, indent=2))
  346. else:
  347. print(_render_text(payload), end="")
  348. except Exception as exc:
  349. print(f"❌ 错误: {exc}", file=sys.stderr)
  350. sys.exit(1)
  351. if __name__ == "__main__":
  352. if sys.platform == "win32":
  353. enable_windows_utf8_stdio()
  354. main()