extract_chapter_context.py 22 KB

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