context_manager.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. ContextManager - assemble context packs with weighted priorities.
  5. """
  6. from __future__ import annotations
  7. import json
  8. import re
  9. from pathlib import Path
  10. from typing import Any, Dict, List, Optional
  11. from .config import get_config
  12. from .index_manager import IndexManager
  13. from .context_ranker import ContextRanker
  14. from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch
  15. class ContextManager:
  16. DEFAULT_TEMPLATE = "plot"
  17. TEMPLATE_WEIGHTS = {
  18. "plot": {"core": 0.40, "scene": 0.35, "global": 0.25},
  19. "battle": {"core": 0.35, "scene": 0.45, "global": 0.20},
  20. "emotion": {"core": 0.45, "scene": 0.35, "global": 0.20},
  21. "transition": {"core": 0.50, "scene": 0.25, "global": 0.25},
  22. }
  23. EXTRA_SECTIONS = {
  24. "story_skeleton",
  25. "memory",
  26. "preferences",
  27. "alerts",
  28. "reader_signal",
  29. "genre_profile",
  30. "writing_guidance",
  31. }
  32. SECTION_ORDER = [
  33. "core",
  34. "scene",
  35. "global",
  36. "reader_signal",
  37. "genre_profile",
  38. "writing_guidance",
  39. "story_skeleton",
  40. "memory",
  41. "preferences",
  42. "alerts",
  43. ]
  44. SUMMARY_SECTION_RE = re.compile(r"##\s*剧情摘要\s*\r?\n(.*?)(?=\r?\n##|\Z)", re.DOTALL)
  45. def __init__(self, config=None, snapshot_manager: Optional[SnapshotManager] = None):
  46. self.config = config or get_config()
  47. self.snapshot_manager = snapshot_manager or SnapshotManager(self.config)
  48. self.index_manager = IndexManager(self.config)
  49. self.context_ranker = ContextRanker(self.config)
  50. def _is_snapshot_compatible(self, cached: Dict[str, Any], template: str) -> bool:
  51. """判断快照是否可用于当前模板。"""
  52. if not isinstance(cached, dict):
  53. return False
  54. meta = cached.get("meta")
  55. if not isinstance(meta, dict):
  56. # 兼容旧快照:未记录 template 时仅允许默认模板复用
  57. return template == self.DEFAULT_TEMPLATE
  58. cached_template = meta.get("template")
  59. if not isinstance(cached_template, str):
  60. return template == self.DEFAULT_TEMPLATE
  61. return cached_template == template
  62. def build_context(
  63. self,
  64. chapter: int,
  65. template: str | None = None,
  66. use_snapshot: bool = True,
  67. save_snapshot: bool = True,
  68. max_chars: Optional[int] = None,
  69. ) -> Dict[str, Any]:
  70. template = template or self.DEFAULT_TEMPLATE
  71. if template not in self.TEMPLATE_WEIGHTS:
  72. template = self.DEFAULT_TEMPLATE
  73. if use_snapshot:
  74. try:
  75. cached = self.snapshot_manager.load_snapshot(chapter)
  76. if cached and self._is_snapshot_compatible(cached, template):
  77. return cached.get("payload", cached)
  78. except SnapshotVersionMismatch:
  79. # Snapshot incompatible; rebuild below.
  80. pass
  81. pack = self._build_pack(chapter)
  82. if getattr(self.config, "context_ranker_enabled", True):
  83. pack = self.context_ranker.rank_pack(pack, chapter)
  84. assembled = self.assemble_context(pack, template=template, max_chars=max_chars)
  85. if save_snapshot:
  86. meta = {"template": template}
  87. self.snapshot_manager.save_snapshot(chapter, assembled, meta=meta)
  88. return assembled
  89. def assemble_context(
  90. self,
  91. pack: Dict[str, Any],
  92. template: str = DEFAULT_TEMPLATE,
  93. max_chars: Optional[int] = None,
  94. ) -> Dict[str, Any]:
  95. weights = self.TEMPLATE_WEIGHTS.get(template, self.TEMPLATE_WEIGHTS[self.DEFAULT_TEMPLATE])
  96. max_chars = max_chars or 8000
  97. extra_budget = int(self.config.context_extra_section_budget or 0)
  98. sections = {}
  99. for section_name in self.SECTION_ORDER:
  100. if section_name in pack:
  101. sections[section_name] = pack[section_name]
  102. assembled: Dict[str, Any] = {"meta": pack.get("meta", {}), "sections": {}}
  103. for name, content in sections.items():
  104. weight = weights.get(name, 0.0)
  105. if weight > 0:
  106. budget = int(max_chars * weight)
  107. elif name in self.EXTRA_SECTIONS and extra_budget > 0:
  108. budget = extra_budget
  109. else:
  110. budget = None
  111. text = self._compact_json_text(content, budget)
  112. assembled["sections"][name] = {"content": content, "text": text, "budget": budget}
  113. assembled["template"] = template
  114. assembled["weights"] = weights
  115. return assembled
  116. def filter_invalid_items(self, items: List[Dict[str, Any]], source_type: str, id_key: str) -> List[Dict[str, Any]]:
  117. confirmed = self.index_manager.get_invalid_ids(source_type, status="confirmed")
  118. pending = self.index_manager.get_invalid_ids(source_type, status="pending")
  119. result = []
  120. for item in items:
  121. item_id = str(item.get(id_key, ""))
  122. if item_id in confirmed:
  123. continue
  124. if item_id in pending:
  125. item = dict(item)
  126. item["warning"] = "pending_invalid"
  127. result.append(item)
  128. return result
  129. def apply_confidence_filter(self, items: List[Dict[str, Any]], min_confidence: float) -> List[Dict[str, Any]]:
  130. filtered: List[Dict[str, Any]] = []
  131. for item in items:
  132. conf = item.get("confidence")
  133. if conf is None or conf >= min_confidence:
  134. filtered.append(item)
  135. return filtered
  136. def _build_pack(self, chapter: int) -> Dict[str, Any]:
  137. state = self._load_state()
  138. core = {
  139. "chapter_outline": self._load_outline(chapter),
  140. "protagonist_snapshot": state.get("protagonist_state", {}),
  141. "recent_summaries": self._load_recent_summaries(
  142. chapter,
  143. window=self.config.context_recent_summaries_window,
  144. ),
  145. "recent_meta": self._load_recent_meta(
  146. state,
  147. chapter,
  148. window=self.config.context_recent_meta_window,
  149. ),
  150. }
  151. scene = {
  152. "location_context": state.get("protagonist_state", {}).get("location", {}),
  153. "appearing_characters": self._load_recent_appearances(
  154. limit=self.config.context_max_appearing_characters,
  155. ),
  156. }
  157. scene["appearing_characters"] = self.filter_invalid_items(
  158. scene["appearing_characters"], source_type="entity", id_key="entity_id"
  159. )
  160. global_ctx = {
  161. "worldview_skeleton": self._load_setting("世界观"),
  162. "power_system_skeleton": self._load_setting("力量体系"),
  163. "style_contract_ref": self._load_setting("风格契约"),
  164. }
  165. preferences = self._load_json_optional(self.config.webnovel_dir / "preferences.json")
  166. memory = self._load_json_optional(self.config.webnovel_dir / "project_memory.json")
  167. story_skeleton = self._load_story_skeleton(chapter)
  168. alert_slice = max(0, int(self.config.context_alerts_slice))
  169. reader_signal = self._load_reader_signal(chapter)
  170. genre_profile = self._load_genre_profile(state)
  171. writing_guidance = self._build_writing_guidance(chapter, reader_signal, genre_profile)
  172. return {
  173. "meta": {"chapter": chapter},
  174. "core": core,
  175. "scene": scene,
  176. "global": global_ctx,
  177. "reader_signal": reader_signal,
  178. "genre_profile": genre_profile,
  179. "writing_guidance": writing_guidance,
  180. "story_skeleton": story_skeleton,
  181. "preferences": preferences,
  182. "memory": memory,
  183. "alerts": {
  184. "disambiguation_warnings": (
  185. state.get("disambiguation_warnings", [])[-alert_slice:] if alert_slice else []
  186. ),
  187. "disambiguation_pending": (
  188. state.get("disambiguation_pending", [])[-alert_slice:] if alert_slice else []
  189. ),
  190. },
  191. }
  192. def _load_reader_signal(self, chapter: int) -> Dict[str, Any]:
  193. if not getattr(self.config, "context_reader_signal_enabled", True):
  194. return {}
  195. recent_limit = max(1, int(getattr(self.config, "context_reader_signal_recent_limit", 5)))
  196. pattern_window = max(1, int(getattr(self.config, "context_reader_signal_window_chapters", 20)))
  197. review_window = max(1, int(getattr(self.config, "context_reader_signal_review_window", 5)))
  198. include_debt = bool(getattr(self.config, "context_reader_signal_include_debt", False))
  199. recent_power = self.index_manager.get_recent_reading_power(limit=recent_limit)
  200. pattern_stats = self.index_manager.get_pattern_usage_stats(last_n_chapters=pattern_window)
  201. hook_stats = self.index_manager.get_hook_type_stats(last_n_chapters=pattern_window)
  202. review_trend = self.index_manager.get_review_trend_stats(last_n=review_window)
  203. low_score_ranges: List[Dict[str, Any]] = []
  204. for row in review_trend.get("recent_ranges", []):
  205. score = row.get("overall_score")
  206. if isinstance(score, (int, float)) and float(score) < 75:
  207. low_score_ranges.append(
  208. {
  209. "start_chapter": row.get("start_chapter"),
  210. "end_chapter": row.get("end_chapter"),
  211. "overall_score": score,
  212. }
  213. )
  214. signal: Dict[str, Any] = {
  215. "recent_reading_power": recent_power,
  216. "pattern_usage": pattern_stats,
  217. "hook_type_usage": hook_stats,
  218. "review_trend": review_trend,
  219. "low_score_ranges": low_score_ranges,
  220. "next_chapter": chapter,
  221. }
  222. if include_debt:
  223. signal["debt_summary"] = self.index_manager.get_debt_summary()
  224. return signal
  225. def _load_genre_profile(self, state: Dict[str, Any]) -> Dict[str, Any]:
  226. if not getattr(self.config, "context_genre_profile_enabled", True):
  227. return {}
  228. fallback = str(getattr(self.config, "context_genre_profile_fallback", "shuangwen") or "shuangwen")
  229. genre = str((state.get("project") or {}).get("genre") or fallback)
  230. profile_path = self.config.project_root / ".claude" / "references" / "genre-profiles.md"
  231. taxonomy_path = self.config.project_root / ".claude" / "references" / "reading-power-taxonomy.md"
  232. profile_text = profile_path.read_text(encoding="utf-8") if profile_path.exists() else ""
  233. taxonomy_text = taxonomy_path.read_text(encoding="utf-8") if taxonomy_path.exists() else ""
  234. profile_excerpt = self._extract_genre_section(profile_text, genre)
  235. taxonomy_excerpt = self._extract_genre_section(taxonomy_text, genre)
  236. refs = self._extract_markdown_refs(
  237. profile_excerpt,
  238. max_items=int(getattr(self.config, "context_genre_profile_max_refs", 8)),
  239. )
  240. return {
  241. "genre": genre,
  242. "profile_excerpt": profile_excerpt,
  243. "taxonomy_excerpt": taxonomy_excerpt,
  244. "reference_hints": refs,
  245. }
  246. def _build_writing_guidance(
  247. self,
  248. chapter: int,
  249. reader_signal: Dict[str, Any],
  250. genre_profile: Dict[str, Any],
  251. ) -> Dict[str, Any]:
  252. if not getattr(self.config, "context_writing_guidance_enabled", True):
  253. return {}
  254. guidance: List[str] = []
  255. limit = max(1, int(getattr(self.config, "context_writing_guidance_max_items", 6)))
  256. low_score_threshold = float(
  257. getattr(self.config, "context_writing_guidance_low_score_threshold", 75.0)
  258. )
  259. low_ranges = reader_signal.get("low_score_ranges") or []
  260. if low_ranges:
  261. worst = min(
  262. low_ranges,
  263. key=lambda row: float(row.get("overall_score", 9999)),
  264. )
  265. guidance.append(
  266. f"第{chapter}章优先修复近期低分段问题:参考{worst.get('start_chapter')}-{worst.get('end_chapter')}章,强化冲突推进与结尾钩子。"
  267. )
  268. hook_usage = reader_signal.get("hook_type_usage") or {}
  269. if hook_usage and getattr(self.config, "context_writing_guidance_hook_diversify", True):
  270. dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
  271. guidance.append(
  272. f"近期钩子类型“{dominant_hook}”使用偏多,本章建议做钩子差异化,避免连续同构。"
  273. )
  274. pattern_usage = reader_signal.get("pattern_usage") or {}
  275. if pattern_usage:
  276. top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
  277. guidance.append(
  278. f"爽点模式“{top_pattern}”近期高频,本章可保留主爽点但叠加一个新爽点副轴。"
  279. )
  280. review_trend = reader_signal.get("review_trend") or {}
  281. overall_avg = review_trend.get("overall_avg")
  282. if isinstance(overall_avg, (int, float)) and float(overall_avg) < low_score_threshold:
  283. guidance.append(
  284. f"最近审查均分{overall_avg:.1f}低于阈值{low_score_threshold:.1f},建议先保稳:减少跳场、每段补动作结果闭环。"
  285. )
  286. genre = str(genre_profile.get("genre") or "").strip()
  287. refs = genre_profile.get("reference_hints") or []
  288. if genre:
  289. guidance.append(f"题材锚定:按“{genre}”叙事主线推进,保持题材读者预期稳定兑现。")
  290. if refs:
  291. guidance.append(f"题材策略可执行提示:{refs[0]}")
  292. if not guidance:
  293. guidance.append("本章执行默认高可读策略:冲突前置、信息后置、段末留钩。")
  294. checklist = self._build_writing_checklist(
  295. chapter=chapter,
  296. guidance_items=guidance,
  297. reader_signal=reader_signal,
  298. genre_profile=genre_profile,
  299. )
  300. return {
  301. "chapter": chapter,
  302. "guidance_items": guidance[:limit],
  303. "checklist": checklist,
  304. "signals_used": {
  305. "has_low_score_ranges": bool(low_ranges),
  306. "hook_types": list(hook_usage.keys())[:3],
  307. "top_patterns": sorted(
  308. pattern_usage,
  309. key=pattern_usage.get,
  310. reverse=True,
  311. )[:3],
  312. "genre": genre,
  313. },
  314. }
  315. def _build_writing_checklist(
  316. self,
  317. chapter: int,
  318. guidance_items: List[str],
  319. reader_signal: Dict[str, Any],
  320. genre_profile: Dict[str, Any],
  321. ) -> List[Dict[str, Any]]:
  322. if not getattr(self.config, "context_writing_checklist_enabled", True):
  323. return []
  324. min_items = max(1, int(getattr(self.config, "context_writing_checklist_min_items", 3)))
  325. max_items = max(min_items, int(getattr(self.config, "context_writing_checklist_max_items", 6)))
  326. default_weight = float(getattr(self.config, "context_writing_checklist_default_weight", 1.0))
  327. if default_weight <= 0:
  328. default_weight = 1.0
  329. items: List[Dict[str, Any]] = []
  330. def _add_item(
  331. item_id: str,
  332. label: str,
  333. *,
  334. weight: Optional[float] = None,
  335. required: bool = False,
  336. source: str = "writing_guidance",
  337. verify_hint: str = "",
  338. ) -> None:
  339. if len(items) >= max_items:
  340. return
  341. if any(row.get("id") == item_id for row in items):
  342. return
  343. item_weight = float(weight if weight is not None else default_weight)
  344. if item_weight <= 0:
  345. item_weight = default_weight
  346. items.append(
  347. {
  348. "id": item_id,
  349. "label": label,
  350. "weight": round(item_weight, 2),
  351. "required": bool(required),
  352. "source": source,
  353. "verify_hint": verify_hint,
  354. }
  355. )
  356. low_ranges = reader_signal.get("low_score_ranges") or []
  357. if low_ranges:
  358. worst = min(low_ranges, key=lambda row: float(row.get("overall_score", 9999)))
  359. span = f"{worst.get('start_chapter')}-{worst.get('end_chapter')}"
  360. _add_item(
  361. "fix_low_score_range",
  362. f"修复低分区间问题(参考第{span}章)",
  363. weight=max(default_weight, 1.4),
  364. required=True,
  365. source="reader_signal.low_score_ranges",
  366. verify_hint="至少完成1处冲突升级,并在段末留下钩子。",
  367. )
  368. hook_usage = reader_signal.get("hook_type_usage") or {}
  369. if hook_usage:
  370. dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
  371. _add_item(
  372. "hook_diversification",
  373. f"钩子差异化(避免继续单一“{dominant_hook}”)",
  374. weight=max(default_weight, 1.2),
  375. required=True,
  376. source="reader_signal.hook_type_usage",
  377. verify_hint="结尾钩子类型与近20章主类型至少有一处差异。",
  378. )
  379. pattern_usage = reader_signal.get("pattern_usage") or {}
  380. if pattern_usage:
  381. top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
  382. _add_item(
  383. "coolpoint_combo",
  384. f"主爽点+副爽点组合(主爽点:{top_pattern})",
  385. weight=default_weight,
  386. required=False,
  387. source="reader_signal.pattern_usage",
  388. verify_hint="新增至少1个副爽点,并与主爽点形成因果链。",
  389. )
  390. review_trend = reader_signal.get("review_trend") or {}
  391. overall_avg = review_trend.get("overall_avg")
  392. if isinstance(overall_avg, (int, float)):
  393. _add_item(
  394. "readability_loop",
  395. "段落可读性闭环(动作→结果→情绪)",
  396. weight=max(default_weight, 1.1),
  397. required=True,
  398. source="reader_signal.review_trend",
  399. verify_hint="抽查3段,均包含动作结果闭环。",
  400. )
  401. genre = str(genre_profile.get("genre") or "").strip()
  402. if genre:
  403. _add_item(
  404. "genre_anchor_consistency",
  405. f"题材锚定一致性({genre})",
  406. weight=max(default_weight, 1.1),
  407. required=True,
  408. source="genre_profile.genre",
  409. verify_hint="主冲突与题材核心承诺保持一致。",
  410. )
  411. for idx, text in enumerate(guidance_items, start=1):
  412. if len(items) >= max_items:
  413. break
  414. label = str(text).strip()
  415. if not label:
  416. continue
  417. _add_item(
  418. f"guidance_item_{idx}",
  419. label,
  420. weight=default_weight,
  421. required=False,
  422. source="writing_guidance.guidance_items",
  423. verify_hint="完成后可在正文中定位对应段落。",
  424. )
  425. fallback_items = [
  426. (
  427. "opening_conflict",
  428. "开篇300字内给出冲突触发",
  429. "开头段出现明确目标与阻力。",
  430. ),
  431. (
  432. "scene_goal_block",
  433. "场景目标与阻力清晰",
  434. "每个场景至少有1个可验证目标。",
  435. ),
  436. (
  437. "ending_hook",
  438. "段末留钩并引出下一问",
  439. "结尾出现未解问题或下一步行动。",
  440. ),
  441. ]
  442. for item_id, label, verify_hint in fallback_items:
  443. if len(items) >= min_items or len(items) >= max_items:
  444. break
  445. _add_item(
  446. item_id,
  447. label,
  448. weight=default_weight,
  449. required=False,
  450. source="fallback",
  451. verify_hint=verify_hint,
  452. )
  453. return items[:max_items]
  454. def _compact_json_text(self, content: Any, budget: Optional[int]) -> str:
  455. raw = json.dumps(content, ensure_ascii=False)
  456. if budget is None or len(raw) <= budget:
  457. return raw
  458. if not getattr(self.config, "context_compact_text_enabled", True):
  459. return raw[:budget]
  460. min_budget = max(1, int(getattr(self.config, "context_compact_min_budget", 120)))
  461. if budget <= min_budget:
  462. return raw[:budget]
  463. head_ratio = float(getattr(self.config, "context_compact_head_ratio", 0.65))
  464. head_budget = int(budget * max(0.2, min(0.9, head_ratio)))
  465. tail_budget = max(0, budget - head_budget - 10)
  466. compact = f"{raw[:head_budget]}…[TRUNCATED]{raw[-tail_budget:] if tail_budget else ''}"
  467. return compact[:budget]
  468. def _extract_genre_section(self, text: str, genre: str) -> str:
  469. if not text:
  470. return ""
  471. lines = text.splitlines()
  472. capture: List[str] = []
  473. active = False
  474. target = genre.strip().lower()
  475. for line in lines:
  476. normalized = line.strip().lower()
  477. if normalized.startswith("## "):
  478. if active:
  479. break
  480. active = target in normalized
  481. if active:
  482. capture.append(line)
  483. continue
  484. if active:
  485. capture.append(line)
  486. if capture:
  487. return "\n".join(capture).strip()
  488. return "\n".join(lines[:80]).strip()
  489. def _extract_markdown_refs(self, text: str, max_items: int = 8) -> List[str]:
  490. if not text:
  491. return []
  492. refs: List[str] = []
  493. for line in text.splitlines():
  494. row = line.strip().lstrip("-*").strip()
  495. if not row or row.startswith("#"):
  496. continue
  497. refs.append(row)
  498. if len(refs) >= max(1, max_items):
  499. break
  500. return refs
  501. def _load_state(self) -> Dict[str, Any]:
  502. path = self.config.state_file
  503. if not path.exists():
  504. return {}
  505. return json.loads(path.read_text(encoding="utf-8"))
  506. def _load_outline(self, chapter: int) -> str:
  507. outline_dir = self.config.outline_dir
  508. patterns = [
  509. f"第{chapter}章*.md",
  510. f"第{chapter:02d}章*.md",
  511. f"第{chapter:03d}章*.md",
  512. f"第{chapter:04d}章*.md",
  513. ]
  514. for pattern in patterns:
  515. matches = list(outline_dir.glob(pattern))
  516. if matches:
  517. return matches[0].read_text(encoding="utf-8")
  518. return f"[大纲未找到: 第{chapter}章]"
  519. def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  520. summaries = []
  521. for ch in range(max(1, chapter - window), chapter):
  522. summary = self._load_summary_text(ch)
  523. if summary:
  524. summaries.append(summary)
  525. return summaries
  526. def _load_recent_meta(self, state: Dict[str, Any], chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  527. meta = state.get("chapter_meta", {}) or {}
  528. results = []
  529. for ch in range(max(1, chapter - window), chapter):
  530. for key in (f"{ch:04d}", str(ch)):
  531. if key in meta:
  532. results.append({"chapter": ch, **meta.get(key, {})})
  533. break
  534. return results
  535. def _load_recent_appearances(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
  536. appearances = self.index_manager.get_recent_appearances(limit=limit)
  537. return appearances or []
  538. def _load_setting(self, keyword: str) -> str:
  539. settings_dir = self.config.settings_dir
  540. candidates = [
  541. settings_dir / f"{keyword}.md",
  542. ]
  543. for path in candidates:
  544. if path.exists():
  545. return path.read_text(encoding="utf-8")
  546. # fallback: any file containing keyword
  547. matches = list(settings_dir.glob(f"*{keyword}*.md"))
  548. if matches:
  549. return matches[0].read_text(encoding="utf-8")
  550. return f"[{keyword}设定未找到]"
  551. def _extract_summary_excerpt(self, text: str, max_chars: int) -> str:
  552. if not text:
  553. return ""
  554. match = self.SUMMARY_SECTION_RE.search(text)
  555. excerpt = match.group(1).strip() if match else text.strip()
  556. if max_chars > 0 and len(excerpt) > max_chars:
  557. return excerpt[:max_chars].rstrip()
  558. return excerpt
  559. def _load_summary_text(self, chapter: int, snippet_chars: Optional[int] = None) -> Optional[Dict[str, Any]]:
  560. summary_path = self.config.webnovel_dir / "summaries" / f"ch{chapter:04d}.md"
  561. if not summary_path.exists():
  562. return None
  563. text = summary_path.read_text(encoding="utf-8")
  564. if snippet_chars:
  565. summary_text = self._extract_summary_excerpt(text, snippet_chars)
  566. else:
  567. summary_text = text
  568. return {"chapter": chapter, "summary": summary_text}
  569. def _load_story_skeleton(self, chapter: int) -> List[Dict[str, Any]]:
  570. interval = max(1, int(self.config.context_story_skeleton_interval))
  571. max_samples = max(0, int(self.config.context_story_skeleton_max_samples))
  572. snippet_chars = int(self.config.context_story_skeleton_snippet_chars)
  573. if max_samples <= 0 or chapter <= interval:
  574. return []
  575. samples: List[Dict[str, Any]] = []
  576. cursor = chapter - interval
  577. while cursor >= 1 and len(samples) < max_samples:
  578. summary = self._load_summary_text(cursor, snippet_chars=snippet_chars)
  579. if summary and summary.get("summary"):
  580. samples.append(summary)
  581. cursor -= interval
  582. samples.reverse()
  583. return samples
  584. def _load_json_optional(self, path: Path) -> Dict[str, Any]:
  585. if not path.exists():
  586. return {}
  587. try:
  588. return json.loads(path.read_text(encoding="utf-8"))
  589. except json.JSONDecodeError:
  590. return {}
  591. def main():
  592. import argparse
  593. from .cli_output import print_success, print_error
  594. parser = argparse.ArgumentParser(description="Context Manager CLI")
  595. parser.add_argument("--project-root", type=str, help="项目根目录")
  596. parser.add_argument("--chapter", type=int, required=True)
  597. parser.add_argument("--template", type=str, default=ContextManager.DEFAULT_TEMPLATE)
  598. parser.add_argument("--no-snapshot", action="store_true")
  599. parser.add_argument("--max-chars", type=int, default=8000)
  600. args = parser.parse_args()
  601. config = None
  602. if args.project_root:
  603. from .config import DataModulesConfig
  604. config = DataModulesConfig.from_project_root(args.project_root)
  605. manager = ContextManager(config)
  606. try:
  607. payload = manager.build_context(
  608. chapter=args.chapter,
  609. template=args.template,
  610. use_snapshot=not args.no_snapshot,
  611. save_snapshot=True,
  612. max_chars=args.max_chars,
  613. )
  614. print_success(payload, message="context_built")
  615. try:
  616. manager.index_manager.log_tool_call("context_manager:build", True, chapter=args.chapter)
  617. except Exception:
  618. pass
  619. except Exception as exc:
  620. print_error("CONTEXT_BUILD_FAILED", str(exc), suggestion="请检查项目结构与依赖文件")
  621. try:
  622. manager.index_manager.log_tool_call(
  623. "context_manager:build", False, error_code="CONTEXT_BUILD_FAILED", error_message=str(exc), chapter=args.chapter
  624. )
  625. except Exception:
  626. pass
  627. if __name__ == "__main__":
  628. import sys
  629. if sys.platform == "win32":
  630. import io
  631. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
  632. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
  633. main()