extract_chapter_context.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  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 asyncio
  14. import json
  15. import re
  16. import sys
  17. from pathlib import Path
  18. from typing import Any, Dict, List
  19. from runtime_compat import enable_windows_utf8_stdio
  20. try:
  21. from chapter_paths import find_chapter_file, volume_num_for_chapter
  22. except ImportError: # pragma: no cover
  23. from scripts.chapter_paths import find_chapter_file, volume_num_for_chapter
  24. def _ensure_scripts_path():
  25. scripts_dir = Path(__file__).resolve().parent
  26. if str(scripts_dir) not in sys.path:
  27. sys.path.insert(0, str(scripts_dir))
  28. _CHAPTER_RANGE_RE = re.compile(r"^\s*(\d+)\s*-\s*(\d+)\s*$")
  29. _RAG_TRIGGER_KEYWORDS = (
  30. "关系",
  31. "恩怨",
  32. "冲突",
  33. "敌对",
  34. "同盟",
  35. "师徒",
  36. "身份",
  37. "线索",
  38. "伏笔",
  39. "回收",
  40. "地点",
  41. "势力",
  42. "真相",
  43. "来历",
  44. )
  45. def _parse_chapters_range(value: Any) -> tuple[int, int] | None:
  46. if not isinstance(value, str):
  47. return None
  48. m = _CHAPTER_RANGE_RE.match(value)
  49. if not m:
  50. return None
  51. try:
  52. start = int(m.group(1))
  53. end = int(m.group(2))
  54. except ValueError:
  55. return None
  56. if start <= 0 or end <= 0 or start > end:
  57. return None
  58. return start, end
  59. def _volume_num_for_chapter_from_state(project_root: Path, chapter_num: int) -> int | None:
  60. """
  61. Prefer `.webnovel/state.json.progress.volumes_planned[].chapters_range` mapping.
  62. Fallback is handled by caller (typically 50 chapters per volume).
  63. """
  64. state_path = project_root / ".webnovel" / "state.json"
  65. if not state_path.exists():
  66. return None
  67. try:
  68. state = json.loads(state_path.read_text(encoding="utf-8"))
  69. except Exception:
  70. return None
  71. if not isinstance(state, dict):
  72. return None
  73. progress = state.get("progress")
  74. if not isinstance(progress, dict):
  75. return None
  76. volumes_planned = progress.get("volumes_planned")
  77. if not isinstance(volumes_planned, list):
  78. return None
  79. best: tuple[int, int] | None = None # (start, volume) - prefer the latest start if overlaps exist
  80. for item in volumes_planned:
  81. if not isinstance(item, dict):
  82. continue
  83. volume = item.get("volume")
  84. if not isinstance(volume, int) or volume <= 0:
  85. continue
  86. parsed = _parse_chapters_range(item.get("chapters_range"))
  87. if not parsed:
  88. continue
  89. start, end = parsed
  90. if start <= chapter_num <= end:
  91. cand = (start, volume)
  92. if best is None or cand[0] > best[0] or (cand[0] == best[0] and cand[1] < best[1]):
  93. best = cand
  94. return best[1] if best else None
  95. def find_project_root(start_path: Path | None = None) -> Path:
  96. """解析真实书项目根(包含 `.webnovel/state.json` 的目录)。"""
  97. from project_locator import resolve_project_root
  98. if start_path is None:
  99. return resolve_project_root()
  100. return resolve_project_root(str(start_path))
  101. def extract_chapter_outline(project_root: Path, chapter_num: int) -> str:
  102. """Extract chapter outline segment from volume outline file."""
  103. volume_num = _volume_num_for_chapter_from_state(project_root, chapter_num) or volume_num_for_chapter(chapter_num)
  104. outline_candidates = [
  105. project_root / "大纲" / f"第{volume_num}卷-详细大纲.md",
  106. project_root / "大纲" / f"第{volume_num}卷 详细大纲.md",
  107. project_root / "大纲" / f"第{volume_num}卷详细大纲.md",
  108. ]
  109. outline_file = next((p for p in outline_candidates if p.exists()), None)
  110. if outline_file is None:
  111. tried = " / ".join(str(p) for p in outline_candidates)
  112. return f"⚠️ 大纲文件不存在,已尝试: {tried}"
  113. content = outline_file.read_text(encoding="utf-8")
  114. pattern = rf"###\s*第\s*{chapter_num}\s*章[::]\s*(.+?)(?=###\s*第\s*\d+\s*章|##\s|$)"
  115. match = re.search(pattern, content, re.DOTALL)
  116. if not match:
  117. pattern2 = rf"###\s*第{chapter_num}章[::]\s*(.+?)(?=###\s*第\d+章|##\s|$)"
  118. match = re.search(pattern2, content, re.DOTALL)
  119. if match:
  120. outline = match.group(0).strip()
  121. if len(outline) > 1500:
  122. outline = outline[:1500] + "\n...(已截断)"
  123. return outline
  124. return f"⚠️ 未找到第 {chapter_num} 章的大纲"
  125. def _load_summary_file(project_root: Path, chapter_num: int) -> str:
  126. """Load summary section from `.webnovel/summaries/chNNNN.md`."""
  127. summary_path = project_root / ".webnovel" / "summaries" / f"ch{chapter_num:04d}.md"
  128. if not summary_path.exists():
  129. return ""
  130. text = summary_path.read_text(encoding="utf-8")
  131. summary_match = re.search(r"##\s*剧情摘要\s*\r?\n(.+?)(?=\r?\n##|$)", text, re.DOTALL)
  132. if summary_match:
  133. return summary_match.group(1).strip()
  134. return ""
  135. def extract_chapter_summary(project_root: Path, chapter_num: int) -> str:
  136. """Extract chapter summary, fallback to chapter body head."""
  137. summary = _load_summary_file(project_root, chapter_num)
  138. if summary:
  139. return summary
  140. chapter_file = find_chapter_file(project_root, chapter_num)
  141. if not chapter_file or not chapter_file.exists():
  142. return f"⚠️ 第{chapter_num}章文件不存在"
  143. content = chapter_file.read_text(encoding="utf-8")
  144. summary_match = re.search(r"##\s*本章摘要\s*\r?\n(.+?)(?=\r?\n##|$)", content, re.DOTALL)
  145. if summary_match:
  146. return summary_match.group(1).strip()
  147. stats_match = re.search(r"##\s*本章统计\s*\r?\n(.+?)(?=\r?\n##|$)", content, re.DOTALL)
  148. if stats_match:
  149. return f"[无摘要,仅统计]\n{stats_match.group(1).strip()}"
  150. lines = content.split("\n")
  151. text_lines = [line for line in lines if not line.startswith("#") and line.strip()]
  152. text = "\n".join(text_lines)[:500]
  153. return f"[自动截取前500字]\n{text}..."
  154. def extract_state_summary(project_root: Path) -> str:
  155. """Extract key fields from `.webnovel/state.json`."""
  156. state_file = project_root / ".webnovel" / "state.json"
  157. if not state_file.exists():
  158. return "⚠️ state.json 不存在"
  159. state = json.loads(state_file.read_text(encoding="utf-8"))
  160. summary_parts: List[str] = []
  161. if "progress" in state:
  162. progress = state["progress"]
  163. summary_parts.append(
  164. f"**进度**: 第{progress.get('current_chapter', '?')}章 / {progress.get('total_words', '?')}字"
  165. )
  166. if "protagonist_state" in state:
  167. ps = state["protagonist_state"]
  168. power = ps.get("power", {})
  169. summary_parts.append(f"**主角实力**: {power.get('realm', '?')} {power.get('layer', '?')}层")
  170. summary_parts.append(f"**当前位置**: {ps.get('location', '?')}")
  171. golden_finger = ps.get("golden_finger", {})
  172. summary_parts.append(
  173. f"**金手指**: {golden_finger.get('name', '?')} Lv.{golden_finger.get('level', '?')}"
  174. )
  175. if "strand_tracker" in state:
  176. tracker = state["strand_tracker"]
  177. history = tracker.get("history", [])[-5:]
  178. if history:
  179. items: List[str] = []
  180. for row in history:
  181. if not isinstance(row, dict):
  182. continue
  183. chapter = row.get("chapter", "?")
  184. strand = row.get("strand") or row.get("dominant") or "unknown"
  185. items.append(f"Ch{chapter}:{strand}")
  186. if items:
  187. summary_parts.append(f"**近5章Strand**: {', '.join(items)}")
  188. plot_threads = state.get("plot_threads", {}) if isinstance(state.get("plot_threads"), dict) else {}
  189. foreshadowing = plot_threads.get("foreshadowing", [])
  190. if isinstance(foreshadowing, list) and foreshadowing:
  191. active = [row for row in foreshadowing if row.get("status") in {"active", "未回收"}]
  192. urgent = [row for row in active if row.get("urgency", 0) > 50]
  193. if urgent:
  194. urgent_list = [
  195. f"{row.get('content', '?')[:30]}... (紧急度:{row.get('urgency')})"
  196. for row in urgent[:3]
  197. ]
  198. summary_parts.append(f"**紧急伏笔**: {'; '.join(urgent_list)}")
  199. return "\n".join(summary_parts)
  200. def _normalize_outline_text(outline: str) -> str:
  201. text = str(outline or "")
  202. if not text or text.startswith("⚠️"):
  203. return ""
  204. text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)
  205. text = re.sub(r"\s+", " ", text).strip()
  206. return text
  207. def _build_rag_query(outline: str, chapter_num: int, min_chars: int, max_chars: int) -> str:
  208. plain = _normalize_outline_text(outline)
  209. if not plain or len(plain) < min_chars:
  210. return ""
  211. if not any(keyword in plain for keyword in _RAG_TRIGGER_KEYWORDS):
  212. return ""
  213. if "关系" in plain or "师徒" in plain or "敌对" in plain or "同盟" in plain:
  214. topic = "人物关系与动机"
  215. elif "地点" in plain or "势力" in plain:
  216. topic = "地点势力与场景约束"
  217. elif "伏笔" in plain or "线索" in plain or "回收" in plain:
  218. topic = "伏笔与线索"
  219. else:
  220. topic = "剧情关键线索"
  221. clean_max = max(40, int(max_chars))
  222. return f"第{chapter_num}章 {topic}:{plain[:clean_max]}"
  223. def _search_with_rag(
  224. project_root: Path,
  225. chapter_num: int,
  226. query: str,
  227. top_k: int,
  228. ) -> Dict[str, Any]:
  229. _ensure_scripts_path()
  230. from data_modules.config import DataModulesConfig
  231. from data_modules.rag_adapter import RAGAdapter
  232. config = DataModulesConfig.from_project_root(project_root)
  233. adapter = RAGAdapter(config)
  234. intent_payload = adapter.query_router.route_intent(query)
  235. center_entities = list(intent_payload.get("entities") or [])
  236. results = []
  237. mode = "auto"
  238. fallback_reason = ""
  239. has_embed_key = bool(str(getattr(config, "embed_api_key", "") or "").strip())
  240. if has_embed_key:
  241. try:
  242. results = asyncio.run(
  243. adapter.search(
  244. query=query,
  245. top_k=top_k,
  246. strategy="auto",
  247. chapter=chapter_num,
  248. center_entities=center_entities,
  249. )
  250. )
  251. except Exception as exc:
  252. fallback_reason = f"auto_failed:{exc.__class__.__name__}"
  253. mode = "bm25_fallback"
  254. results = adapter.bm25_search(query=query, top_k=top_k, chapter=chapter_num)
  255. else:
  256. mode = "bm25_fallback"
  257. fallback_reason = "missing_embed_api_key"
  258. results = adapter.bm25_search(query=query, top_k=top_k, chapter=chapter_num)
  259. hits: List[Dict[str, Any]] = []
  260. for row in results:
  261. content = re.sub(r"\s+", " ", str(getattr(row, "content", "") or "")).strip()
  262. hits.append(
  263. {
  264. "chunk_id": str(getattr(row, "chunk_id", "") or ""),
  265. "chapter": int(getattr(row, "chapter", 0) or 0),
  266. "scene_index": int(getattr(row, "scene_index", 0) or 0),
  267. "score": round(float(getattr(row, "score", 0.0) or 0.0), 6),
  268. "source": str(getattr(row, "source", "") or mode),
  269. "source_file": str(getattr(row, "source_file", "") or ""),
  270. "content": content[:180],
  271. }
  272. )
  273. return {
  274. "invoked": True,
  275. "query": query,
  276. "mode": mode,
  277. "reason": fallback_reason or ("ok" if hits else "no_hit"),
  278. "intent": intent_payload.get("intent"),
  279. "needs_graph": bool(intent_payload.get("needs_graph")),
  280. "center_entities": center_entities,
  281. "hits": hits,
  282. }
  283. def _load_rag_assist(project_root: Path, chapter_num: int, outline: str) -> Dict[str, Any]:
  284. _ensure_scripts_path()
  285. from data_modules.config import DataModulesConfig
  286. config = DataModulesConfig.from_project_root(project_root)
  287. enabled = bool(getattr(config, "context_rag_assist_enabled", True))
  288. top_k = max(1, int(getattr(config, "context_rag_assist_top_k", 4)))
  289. min_chars = max(20, int(getattr(config, "context_rag_assist_min_outline_chars", 40)))
  290. max_chars = max(40, int(getattr(config, "context_rag_assist_max_query_chars", 120)))
  291. base_payload = {"enabled": enabled, "invoked": False, "reason": "", "query": "", "hits": []}
  292. if not enabled:
  293. base_payload["reason"] = "disabled_by_config"
  294. return base_payload
  295. query = _build_rag_query(outline, chapter_num=chapter_num, min_chars=min_chars, max_chars=max_chars)
  296. if not query:
  297. base_payload["reason"] = "outline_not_actionable"
  298. return base_payload
  299. vector_db = config.vector_db
  300. if not vector_db.exists() or vector_db.stat().st_size <= 0:
  301. base_payload["reason"] = "vector_db_missing_or_empty"
  302. return base_payload
  303. try:
  304. rag_payload = _search_with_rag(project_root=project_root, chapter_num=chapter_num, query=query, top_k=top_k)
  305. rag_payload["enabled"] = True
  306. return rag_payload
  307. except Exception as exc:
  308. base_payload["reason"] = f"rag_error:{exc.__class__.__name__}"
  309. return base_payload
  310. def _load_contract_context(project_root: Path, chapter_num: int) -> Dict[str, Any]:
  311. """Build context via ContextManager and return selected sections."""
  312. _ensure_scripts_path()
  313. from data_modules.config import DataModulesConfig
  314. from data_modules.context_manager import ContextManager
  315. config = DataModulesConfig.from_project_root(project_root)
  316. manager = ContextManager(config)
  317. payload = manager.build_context(
  318. chapter=chapter_num,
  319. template="plot",
  320. use_snapshot=True,
  321. save_snapshot=True,
  322. max_chars=8000,
  323. )
  324. sections = payload.get("sections", {})
  325. return {
  326. "context_contract_version": (payload.get("meta") or {}).get("context_contract_version"),
  327. "context_weight_stage": (payload.get("meta") or {}).get("context_weight_stage"),
  328. "reader_signal": (sections.get("reader_signal") or {}).get("content", {}),
  329. "genre_profile": (sections.get("genre_profile") or {}).get("content", {}),
  330. "writing_guidance": (sections.get("writing_guidance") or {}).get("content", {}),
  331. }
  332. def build_chapter_context_payload(project_root: Path, chapter_num: int) -> Dict[str, Any]:
  333. """Assemble full chapter context payload for text/json output."""
  334. outline = extract_chapter_outline(project_root, chapter_num)
  335. prev_summaries = []
  336. for prev_ch in range(max(1, chapter_num - 2), chapter_num):
  337. summary = extract_chapter_summary(project_root, prev_ch)
  338. prev_summaries.append(f"### 第{prev_ch}章摘要\n{summary}")
  339. state_summary = extract_state_summary(project_root)
  340. contract_context = _load_contract_context(project_root, chapter_num)
  341. rag_assist = _load_rag_assist(project_root, chapter_num, outline)
  342. return {
  343. "chapter": chapter_num,
  344. "outline": outline,
  345. "previous_summaries": prev_summaries,
  346. "state_summary": state_summary,
  347. "context_contract_version": contract_context.get("context_contract_version"),
  348. "context_weight_stage": contract_context.get("context_weight_stage"),
  349. "reader_signal": contract_context.get("reader_signal", {}),
  350. "genre_profile": contract_context.get("genre_profile", {}),
  351. "writing_guidance": contract_context.get("writing_guidance", {}),
  352. "rag_assist": rag_assist,
  353. }
  354. def _render_text(payload: Dict[str, Any]) -> str:
  355. chapter_num = payload.get("chapter")
  356. lines: List[str] = []
  357. lines.append(f"# 第 {chapter_num} 章创作上下文")
  358. lines.append("")
  359. lines.append("## 本章大纲")
  360. lines.append("")
  361. lines.append(str(payload.get("outline", "")))
  362. lines.append("")
  363. lines.append("---")
  364. lines.append("")
  365. lines.append("## 前文摘要")
  366. lines.append("")
  367. for item in payload.get("previous_summaries", []):
  368. lines.append(item)
  369. lines.append("")
  370. lines.append("---")
  371. lines.append("")
  372. lines.append("## 当前状态")
  373. lines.append("")
  374. lines.append(str(payload.get("state_summary", "")))
  375. lines.append("")
  376. contract_version = payload.get("context_contract_version")
  377. if contract_version:
  378. lines.append(f"## Contract ({contract_version})")
  379. lines.append("")
  380. stage = payload.get("context_weight_stage")
  381. if stage:
  382. lines.append(f"- 上下文阶段权重: {stage}")
  383. lines.append("")
  384. writing_guidance = payload.get("writing_guidance") or {}
  385. guidance_items = writing_guidance.get("guidance_items") or []
  386. checklist = writing_guidance.get("checklist") or []
  387. checklist_score = writing_guidance.get("checklist_score") or {}
  388. methodology = writing_guidance.get("methodology") or {}
  389. if guidance_items or checklist:
  390. lines.append("## 写作执行建议")
  391. lines.append("")
  392. for idx, item in enumerate(guidance_items, start=1):
  393. lines.append(f"{idx}. {item}")
  394. if checklist:
  395. total_weight = 0.0
  396. required_count = 0
  397. for row in checklist:
  398. if isinstance(row, dict):
  399. try:
  400. total_weight += float(row.get("weight") or 0)
  401. except (TypeError, ValueError):
  402. pass
  403. if row.get("required"):
  404. required_count += 1
  405. lines.append("")
  406. lines.append("### 执行检查清单(可评分)")
  407. lines.append("")
  408. lines.append(f"- 项目数: {len(checklist)}")
  409. lines.append(f"- 总权重: {total_weight:.2f}")
  410. lines.append(f"- 必做项: {required_count}")
  411. lines.append("")
  412. for idx, row in enumerate(checklist, start=1):
  413. if not isinstance(row, dict):
  414. lines.append(f"{idx}. {row}")
  415. continue
  416. label = str(row.get("label") or "").strip() or "未命名项"
  417. weight = row.get("weight")
  418. required_tag = "必做" if row.get("required") else "可选"
  419. verify_hint = str(row.get("verify_hint") or "").strip()
  420. lines.append(f"{idx}. [{required_tag}][w={weight}] {label}")
  421. if verify_hint:
  422. lines.append(f" - 验收: {verify_hint}")
  423. if checklist_score:
  424. lines.append("")
  425. lines.append("### 执行评分")
  426. lines.append("")
  427. lines.append(f"- 评分: {checklist_score.get('score')}")
  428. lines.append(f"- 完成率: {checklist_score.get('completion_rate')}")
  429. lines.append(f"- 必做完成率: {checklist_score.get('required_completion_rate')}")
  430. lines.append("")
  431. if isinstance(methodology, dict) and methodology.get("enabled"):
  432. lines.append("## 长篇方法论策略")
  433. lines.append("")
  434. lines.append(f"- 框架: {methodology.get('framework')}")
  435. methodology_scope = methodology.get("genre_profile_key") or methodology.get("pilot") or "general"
  436. lines.append(f"- 适用题材: {methodology_scope}")
  437. lines.append(f"- 章节阶段: {methodology.get('chapter_stage')}")
  438. observability = methodology.get("observability") or {}
  439. if observability:
  440. lines.append(
  441. "- 指标: "
  442. f"next_reason={observability.get('next_reason_clarity')}, "
  443. f"anchor={observability.get('anchor_effectiveness')}, "
  444. f"rhythm={observability.get('rhythm_naturalness')}"
  445. )
  446. signals = methodology.get("signals") or {}
  447. risk_flags = list(signals.get("risk_flags") or [])
  448. if risk_flags:
  449. lines.append(f"- 风险标记: {', '.join(str(flag) for flag in risk_flags)}")
  450. lines.append("")
  451. reader_signal = payload.get("reader_signal") or {}
  452. review_trend = reader_signal.get("review_trend") or {}
  453. if review_trend:
  454. overall_avg = review_trend.get("overall_avg")
  455. lines.append("## 追读信号")
  456. lines.append("")
  457. lines.append(f"- 最近审查均分: {overall_avg}")
  458. low_ranges = reader_signal.get("low_score_ranges") or []
  459. if low_ranges:
  460. lines.append(f"- 低分区间数: {len(low_ranges)}")
  461. lines.append("")
  462. genre_profile = payload.get("genre_profile") or {}
  463. if genre_profile.get("genre"):
  464. lines.append("## 题材锚定")
  465. lines.append("")
  466. lines.append(f"- 题材: {genre_profile.get('genre')}")
  467. genres = genre_profile.get("genres") or []
  468. if len(genres) > 1:
  469. lines.append(f"- 复合题材: {' + '.join(str(token) for token in genres)}")
  470. composite_hints = genre_profile.get("composite_hints") or []
  471. for row in composite_hints[:2]:
  472. lines.append(f"- {row}")
  473. refs = genre_profile.get("reference_hints") or []
  474. for row in refs[:3]:
  475. lines.append(f"- {row}")
  476. lines.append("")
  477. rag_assist = payload.get("rag_assist") or {}
  478. hits = rag_assist.get("hits") or []
  479. if rag_assist.get("invoked") and hits:
  480. lines.append("## RAG 检索线索")
  481. lines.append("")
  482. lines.append(f"- 模式: {rag_assist.get('mode')}")
  483. lines.append(f"- 意图: {rag_assist.get('intent')}")
  484. lines.append(f"- 查询: {rag_assist.get('query')}")
  485. lines.append("")
  486. for idx, row in enumerate(hits[:5], start=1):
  487. chapter = row.get("chapter", "?")
  488. scene_index = row.get("scene_index", "?")
  489. score = row.get("score", 0)
  490. source = row.get("source", "unknown")
  491. content = row.get("content", "")
  492. lines.append(f"{idx}. [Ch{chapter}-S{scene_index}][{source}][score={score}] {content}")
  493. lines.append("")
  494. return "\n".join(lines).rstrip() + "\n"
  495. def main():
  496. parser = argparse.ArgumentParser(description="提取章节创作所需的精简上下文")
  497. parser.add_argument("--chapter", type=int, required=True, help="目标章节号")
  498. parser.add_argument("--project-root", type=str, help="项目根目录")
  499. parser.add_argument("--format", choices=["text", "json"], default="text", help="输出格式")
  500. args = parser.parse_args()
  501. try:
  502. project_root = (
  503. find_project_root(Path(args.project_root))
  504. if args.project_root
  505. else find_project_root()
  506. )
  507. payload = build_chapter_context_payload(project_root, args.chapter)
  508. if args.format == "json":
  509. print(json.dumps(payload, ensure_ascii=False, indent=2))
  510. else:
  511. print(_render_text(payload), end="")
  512. except Exception as exc:
  513. print(f"❌ 错误: {exc}", file=sys.stderr)
  514. sys.exit(1)
  515. if __name__ == "__main__":
  516. if sys.platform == "win32":
  517. enable_windows_utf8_stdio()
  518. main()