context_manager.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737
  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. import sys
  10. from pathlib import Path
  11. from typing import Any, Dict, List, Optional
  12. from .config import get_config
  13. from .index_manager import IndexManager, WritingChecklistScoreMeta
  14. from .context_ranker import ContextRanker
  15. from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch
  16. from .context_weights import (
  17. DEFAULT_TEMPLATE as CONTEXT_DEFAULT_TEMPLATE,
  18. TEMPLATE_WEIGHTS as CONTEXT_TEMPLATE_WEIGHTS,
  19. TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT as CONTEXT_TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT,
  20. )
  21. from .genre_aliases import normalize_genre_token
  22. from .genre_profile_builder import (
  23. build_composite_genre_hints,
  24. extract_genre_section,
  25. extract_markdown_refs,
  26. parse_genre_tokens,
  27. )
  28. from .writing_guidance_builder import (
  29. build_guidance_items,
  30. build_writing_checklist,
  31. is_checklist_item_completed,
  32. )
  33. class ContextManager:
  34. DEFAULT_TEMPLATE = CONTEXT_DEFAULT_TEMPLATE
  35. TEMPLATE_WEIGHTS = CONTEXT_TEMPLATE_WEIGHTS
  36. TEMPLATE_WEIGHTS_DYNAMIC = CONTEXT_TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT
  37. EXTRA_SECTIONS = {
  38. "story_skeleton",
  39. "memory",
  40. "preferences",
  41. "alerts",
  42. "reader_signal",
  43. "genre_profile",
  44. "writing_guidance",
  45. }
  46. SECTION_ORDER = [
  47. "core",
  48. "scene",
  49. "global",
  50. "reader_signal",
  51. "genre_profile",
  52. "writing_guidance",
  53. "story_skeleton",
  54. "memory",
  55. "preferences",
  56. "alerts",
  57. ]
  58. SUMMARY_SECTION_RE = re.compile(r"##\s*剧情摘要\s*\r?\n(.*?)(?=\r?\n##|\Z)", re.DOTALL)
  59. def __init__(self, config=None, snapshot_manager: Optional[SnapshotManager] = None):
  60. self.config = config or get_config()
  61. self.snapshot_manager = snapshot_manager or SnapshotManager(self.config)
  62. self.index_manager = IndexManager(self.config)
  63. self.context_ranker = ContextRanker(self.config)
  64. def _is_snapshot_compatible(self, cached: Dict[str, Any], template: str) -> bool:
  65. """判断快照是否可用于当前模板。"""
  66. if not isinstance(cached, dict):
  67. return False
  68. meta = cached.get("meta")
  69. if not isinstance(meta, dict):
  70. # 兼容旧快照:未记录 template 时仅允许默认模板复用
  71. return template == self.DEFAULT_TEMPLATE
  72. cached_template = meta.get("template")
  73. if not isinstance(cached_template, str):
  74. return template == self.DEFAULT_TEMPLATE
  75. return cached_template == template
  76. def build_context(
  77. self,
  78. chapter: int,
  79. template: str | None = None,
  80. use_snapshot: bool = True,
  81. save_snapshot: bool = True,
  82. max_chars: Optional[int] = None,
  83. ) -> Dict[str, Any]:
  84. template = template or self.DEFAULT_TEMPLATE
  85. self._active_template = template
  86. if template not in self.TEMPLATE_WEIGHTS:
  87. template = self.DEFAULT_TEMPLATE
  88. self._active_template = template
  89. if use_snapshot:
  90. try:
  91. cached = self.snapshot_manager.load_snapshot(chapter)
  92. if cached and self._is_snapshot_compatible(cached, template):
  93. return cached.get("payload", cached)
  94. except SnapshotVersionMismatch:
  95. # Snapshot incompatible; rebuild below.
  96. pass
  97. pack = self._build_pack(chapter)
  98. if getattr(self.config, "context_ranker_enabled", True):
  99. pack = self.context_ranker.rank_pack(pack, chapter)
  100. assembled = self.assemble_context(pack, template=template, max_chars=max_chars)
  101. if save_snapshot:
  102. meta = {"template": template}
  103. self.snapshot_manager.save_snapshot(chapter, assembled, meta=meta)
  104. return assembled
  105. def assemble_context(
  106. self,
  107. pack: Dict[str, Any],
  108. template: str = DEFAULT_TEMPLATE,
  109. max_chars: Optional[int] = None,
  110. ) -> Dict[str, Any]:
  111. chapter = int((pack.get("meta") or {}).get("chapter") or 0)
  112. weights = self._resolve_template_weights(template=template, chapter=chapter)
  113. max_chars = max_chars or 8000
  114. extra_budget = int(self.config.context_extra_section_budget or 0)
  115. sections = {}
  116. for section_name in self.SECTION_ORDER:
  117. if section_name in pack:
  118. sections[section_name] = pack[section_name]
  119. assembled: Dict[str, Any] = {"meta": pack.get("meta", {}), "sections": {}}
  120. for name, content in sections.items():
  121. weight = weights.get(name, 0.0)
  122. if weight > 0:
  123. budget = int(max_chars * weight)
  124. elif name in self.EXTRA_SECTIONS and extra_budget > 0:
  125. budget = extra_budget
  126. else:
  127. budget = None
  128. text = self._compact_json_text(content, budget)
  129. assembled["sections"][name] = {"content": content, "text": text, "budget": budget}
  130. assembled["template"] = template
  131. assembled["weights"] = weights
  132. if chapter > 0:
  133. assembled.setdefault("meta", {})["context_weight_stage"] = self._resolve_context_stage(chapter)
  134. return assembled
  135. def filter_invalid_items(self, items: List[Dict[str, Any]], source_type: str, id_key: str) -> List[Dict[str, Any]]:
  136. confirmed = self.index_manager.get_invalid_ids(source_type, status="confirmed")
  137. pending = self.index_manager.get_invalid_ids(source_type, status="pending")
  138. result = []
  139. for item in items:
  140. item_id = str(item.get(id_key, ""))
  141. if item_id in confirmed:
  142. continue
  143. if item_id in pending:
  144. item = dict(item)
  145. item["warning"] = "pending_invalid"
  146. result.append(item)
  147. return result
  148. def apply_confidence_filter(self, items: List[Dict[str, Any]], min_confidence: float) -> List[Dict[str, Any]]:
  149. filtered: List[Dict[str, Any]] = []
  150. for item in items:
  151. conf = item.get("confidence")
  152. if conf is None or conf >= min_confidence:
  153. filtered.append(item)
  154. return filtered
  155. def _build_pack(self, chapter: int) -> Dict[str, Any]:
  156. state = self._load_state()
  157. core = {
  158. "chapter_outline": self._load_outline(chapter),
  159. "protagonist_snapshot": state.get("protagonist_state", {}),
  160. "recent_summaries": self._load_recent_summaries(
  161. chapter,
  162. window=self.config.context_recent_summaries_window,
  163. ),
  164. "recent_meta": self._load_recent_meta(
  165. state,
  166. chapter,
  167. window=self.config.context_recent_meta_window,
  168. ),
  169. }
  170. scene = {
  171. "location_context": state.get("protagonist_state", {}).get("location", {}),
  172. "appearing_characters": self._load_recent_appearances(
  173. limit=self.config.context_max_appearing_characters,
  174. ),
  175. }
  176. scene["appearing_characters"] = self.filter_invalid_items(
  177. scene["appearing_characters"], source_type="entity", id_key="entity_id"
  178. )
  179. global_ctx = {
  180. "worldview_skeleton": self._load_setting("世界观"),
  181. "power_system_skeleton": self._load_setting("力量体系"),
  182. "style_contract_ref": self._load_setting("风格契约"),
  183. }
  184. preferences = self._load_json_optional(self.config.webnovel_dir / "preferences.json")
  185. memory = self._load_json_optional(self.config.webnovel_dir / "project_memory.json")
  186. story_skeleton = self._load_story_skeleton(chapter)
  187. alert_slice = max(0, int(self.config.context_alerts_slice))
  188. reader_signal = self._load_reader_signal(chapter)
  189. genre_profile = self._load_genre_profile(state)
  190. writing_guidance = self._build_writing_guidance(chapter, reader_signal, genre_profile)
  191. return {
  192. "meta": {"chapter": chapter},
  193. "core": core,
  194. "scene": scene,
  195. "global": global_ctx,
  196. "reader_signal": reader_signal,
  197. "genre_profile": genre_profile,
  198. "writing_guidance": writing_guidance,
  199. "story_skeleton": story_skeleton,
  200. "preferences": preferences,
  201. "memory": memory,
  202. "alerts": {
  203. "disambiguation_warnings": (
  204. state.get("disambiguation_warnings", [])[-alert_slice:] if alert_slice else []
  205. ),
  206. "disambiguation_pending": (
  207. state.get("disambiguation_pending", [])[-alert_slice:] if alert_slice else []
  208. ),
  209. },
  210. }
  211. def _load_reader_signal(self, chapter: int) -> Dict[str, Any]:
  212. if not getattr(self.config, "context_reader_signal_enabled", True):
  213. return {}
  214. recent_limit = max(1, int(getattr(self.config, "context_reader_signal_recent_limit", 5)))
  215. pattern_window = max(1, int(getattr(self.config, "context_reader_signal_window_chapters", 20)))
  216. review_window = max(1, int(getattr(self.config, "context_reader_signal_review_window", 5)))
  217. include_debt = bool(getattr(self.config, "context_reader_signal_include_debt", False))
  218. recent_power = self.index_manager.get_recent_reading_power(limit=recent_limit)
  219. pattern_stats = self.index_manager.get_pattern_usage_stats(last_n_chapters=pattern_window)
  220. hook_stats = self.index_manager.get_hook_type_stats(last_n_chapters=pattern_window)
  221. review_trend = self.index_manager.get_review_trend_stats(last_n=review_window)
  222. low_score_ranges: List[Dict[str, Any]] = []
  223. for row in review_trend.get("recent_ranges", []):
  224. score = row.get("overall_score")
  225. if isinstance(score, (int, float)) and float(score) < 75:
  226. low_score_ranges.append(
  227. {
  228. "start_chapter": row.get("start_chapter"),
  229. "end_chapter": row.get("end_chapter"),
  230. "overall_score": score,
  231. }
  232. )
  233. signal: Dict[str, Any] = {
  234. "recent_reading_power": recent_power,
  235. "pattern_usage": pattern_stats,
  236. "hook_type_usage": hook_stats,
  237. "review_trend": review_trend,
  238. "low_score_ranges": low_score_ranges,
  239. "next_chapter": chapter,
  240. }
  241. if include_debt:
  242. signal["debt_summary"] = self.index_manager.get_debt_summary()
  243. return signal
  244. def _load_genre_profile(self, state: Dict[str, Any]) -> Dict[str, Any]:
  245. if not getattr(self.config, "context_genre_profile_enabled", True):
  246. return {}
  247. fallback = str(getattr(self.config, "context_genre_profile_fallback", "shuangwen") or "shuangwen")
  248. genre_raw = str((state.get("project") or {}).get("genre") or fallback)
  249. genres = self._parse_genre_tokens(genre_raw)
  250. if not genres:
  251. genres = [fallback]
  252. max_genres = max(1, int(getattr(self.config, "context_genre_profile_max_genres", 2)))
  253. genres = genres[:max_genres]
  254. primary_genre = genres[0]
  255. secondary_genres = genres[1:]
  256. composite = len(genres) > 1
  257. profile_path = self.config.project_root / ".claude" / "references" / "genre-profiles.md"
  258. taxonomy_path = self.config.project_root / ".claude" / "references" / "reading-power-taxonomy.md"
  259. profile_text = profile_path.read_text(encoding="utf-8") if profile_path.exists() else ""
  260. taxonomy_text = taxonomy_path.read_text(encoding="utf-8") if taxonomy_path.exists() else ""
  261. profile_excerpt = self._extract_genre_section(profile_text, primary_genre)
  262. taxonomy_excerpt = self._extract_genre_section(taxonomy_text, primary_genre)
  263. secondary_profiles: List[str] = []
  264. secondary_taxonomies: List[str] = []
  265. for extra in secondary_genres:
  266. secondary_profiles.append(self._extract_genre_section(profile_text, extra))
  267. secondary_taxonomies.append(self._extract_genre_section(taxonomy_text, extra))
  268. refs = self._extract_markdown_refs(
  269. "\n".join([profile_excerpt] + secondary_profiles),
  270. max_items=int(getattr(self.config, "context_genre_profile_max_refs", 8)),
  271. )
  272. composite_hints = self._build_composite_genre_hints(genres, refs)
  273. return {
  274. "genre": primary_genre,
  275. "genre_raw": genre_raw,
  276. "genres": genres,
  277. "composite": composite,
  278. "secondary_genres": secondary_genres,
  279. "profile_excerpt": profile_excerpt,
  280. "taxonomy_excerpt": taxonomy_excerpt,
  281. "secondary_profile_excerpts": secondary_profiles,
  282. "secondary_taxonomy_excerpts": secondary_taxonomies,
  283. "reference_hints": refs,
  284. "composite_hints": composite_hints,
  285. }
  286. def _build_writing_guidance(
  287. self,
  288. chapter: int,
  289. reader_signal: Dict[str, Any],
  290. genre_profile: Dict[str, Any],
  291. ) -> Dict[str, Any]:
  292. if not getattr(self.config, "context_writing_guidance_enabled", True):
  293. return {}
  294. limit = max(1, int(getattr(self.config, "context_writing_guidance_max_items", 6)))
  295. low_score_threshold = float(
  296. getattr(self.config, "context_writing_guidance_low_score_threshold", 75.0)
  297. )
  298. guidance_bundle = build_guidance_items(
  299. chapter=chapter,
  300. reader_signal=reader_signal,
  301. genre_profile=genre_profile,
  302. low_score_threshold=low_score_threshold,
  303. hook_diversify_enabled=bool(
  304. getattr(self.config, "context_writing_guidance_hook_diversify", True)
  305. ),
  306. )
  307. guidance = list(guidance_bundle.get("guidance") or [])
  308. checklist = self._build_writing_checklist(
  309. chapter=chapter,
  310. guidance_items=guidance,
  311. reader_signal=reader_signal,
  312. genre_profile=genre_profile,
  313. )
  314. checklist_score = self._compute_writing_checklist_score(
  315. chapter=chapter,
  316. checklist=checklist,
  317. reader_signal=reader_signal,
  318. )
  319. if getattr(self.config, "context_writing_score_persist_enabled", True):
  320. self._persist_writing_checklist_score(checklist_score)
  321. low_ranges = guidance_bundle.get("low_ranges") or []
  322. hook_usage = guidance_bundle.get("hook_usage") or {}
  323. pattern_usage = guidance_bundle.get("pattern_usage") or {}
  324. genre = str(guidance_bundle.get("genre") or genre_profile.get("genre") or "").strip()
  325. hook_types = list(hook_usage.keys())[:3] if isinstance(hook_usage, dict) else []
  326. top_patterns = (
  327. sorted(pattern_usage, key=pattern_usage.get, reverse=True)[:3]
  328. if isinstance(pattern_usage, dict)
  329. else []
  330. )
  331. return {
  332. "chapter": chapter,
  333. "guidance_items": guidance[:limit],
  334. "checklist": checklist,
  335. "checklist_score": checklist_score,
  336. "signals_used": {
  337. "has_low_score_ranges": bool(low_ranges),
  338. "hook_types": hook_types,
  339. "top_patterns": top_patterns,
  340. "genre": genre,
  341. },
  342. }
  343. def _compute_writing_checklist_score(
  344. self,
  345. chapter: int,
  346. checklist: List[Dict[str, Any]],
  347. reader_signal: Dict[str, Any],
  348. ) -> Dict[str, Any]:
  349. total_items = len(checklist)
  350. required_items = 0
  351. completed_items = 0
  352. completed_required = 0
  353. total_weight = 0.0
  354. completed_weight = 0.0
  355. pending_labels: List[str] = []
  356. for item in checklist:
  357. if not isinstance(item, dict):
  358. continue
  359. required = bool(item.get("required"))
  360. weight = float(item.get("weight") or 1.0)
  361. total_weight += weight
  362. if required:
  363. required_items += 1
  364. completed = self._is_checklist_item_completed(item, reader_signal)
  365. if completed:
  366. completed_items += 1
  367. completed_weight += weight
  368. if required:
  369. completed_required += 1
  370. else:
  371. pending_labels.append(str(item.get("label") or item.get("id") or "未命名项"))
  372. completion_rate = (completed_items / total_items) if total_items > 0 else 1.0
  373. weighted_rate = (completed_weight / total_weight) if total_weight > 0 else completion_rate
  374. required_rate = (completed_required / required_items) if required_items > 0 else 1.0
  375. score = 100.0 * (0.5 * weighted_rate + 0.3 * required_rate + 0.2 * completion_rate)
  376. if getattr(self.config, "context_writing_score_include_reader_trend", True):
  377. trend_window = max(1, int(getattr(self.config, "context_writing_score_trend_window", 10)))
  378. trend = self.index_manager.get_writing_checklist_score_trend(last_n=trend_window)
  379. baseline = float(trend.get("score_avg") or 0.0)
  380. if baseline > 0:
  381. score += max(-10.0, min(10.0, (score - baseline) * 0.1))
  382. score = round(max(0.0, min(100.0, score)), 2)
  383. return {
  384. "chapter": chapter,
  385. "score": score,
  386. "completion_rate": round(completion_rate, 4),
  387. "weighted_completion_rate": round(weighted_rate, 4),
  388. "required_completion_rate": round(required_rate, 4),
  389. "total_items": total_items,
  390. "required_items": required_items,
  391. "completed_items": completed_items,
  392. "completed_required": completed_required,
  393. "total_weight": round(total_weight, 2),
  394. "completed_weight": round(completed_weight, 2),
  395. "pending_items": pending_labels,
  396. "trend_window": int(getattr(self.config, "context_writing_score_trend_window", 10)),
  397. }
  398. def _is_checklist_item_completed(self, item: Dict[str, Any], reader_signal: Dict[str, Any]) -> bool:
  399. return is_checklist_item_completed(item, reader_signal)
  400. def _persist_writing_checklist_score(self, checklist_score: Dict[str, Any]) -> None:
  401. if not checklist_score:
  402. return
  403. try:
  404. self.index_manager.save_writing_checklist_score(
  405. WritingChecklistScoreMeta(
  406. chapter=int(checklist_score.get("chapter") or 0),
  407. template=str(getattr(self, "_active_template", self.DEFAULT_TEMPLATE) or self.DEFAULT_TEMPLATE),
  408. total_items=int(checklist_score.get("total_items") or 0),
  409. required_items=int(checklist_score.get("required_items") or 0),
  410. completed_items=int(checklist_score.get("completed_items") or 0),
  411. completed_required=int(checklist_score.get("completed_required") or 0),
  412. total_weight=float(checklist_score.get("total_weight") or 0.0),
  413. completed_weight=float(checklist_score.get("completed_weight") or 0.0),
  414. completion_rate=float(checklist_score.get("completion_rate") or 0.0),
  415. score=float(checklist_score.get("score") or 0.0),
  416. score_breakdown={
  417. "weighted_completion_rate": checklist_score.get("weighted_completion_rate"),
  418. "required_completion_rate": checklist_score.get("required_completion_rate"),
  419. "trend_window": checklist_score.get("trend_window"),
  420. },
  421. pending_items=list(checklist_score.get("pending_items") or []),
  422. source="context_manager",
  423. )
  424. )
  425. except Exception as exc:
  426. print(
  427. f"[context_manager] failed to persist writing checklist score: {exc}",
  428. file=sys.stderr,
  429. )
  430. def _resolve_context_stage(self, chapter: int) -> str:
  431. early = max(1, int(getattr(self.config, "context_dynamic_budget_early_chapter", 30)))
  432. late = max(early + 1, int(getattr(self.config, "context_dynamic_budget_late_chapter", 120)))
  433. if chapter <= early:
  434. return "early"
  435. if chapter >= late:
  436. return "late"
  437. return "mid"
  438. def _resolve_template_weights(self, template: str, chapter: int) -> Dict[str, float]:
  439. template_key = template if template in self.TEMPLATE_WEIGHTS else self.DEFAULT_TEMPLATE
  440. base = dict(self.TEMPLATE_WEIGHTS.get(template_key, self.TEMPLATE_WEIGHTS[self.DEFAULT_TEMPLATE]))
  441. if not getattr(self.config, "context_dynamic_budget_enabled", True):
  442. return base
  443. stage = self._resolve_context_stage(chapter)
  444. dynamic_weights = getattr(self.config, "context_template_weights_dynamic", None)
  445. if not isinstance(dynamic_weights, dict):
  446. dynamic_weights = self.TEMPLATE_WEIGHTS_DYNAMIC
  447. stage_weights = dynamic_weights.get(stage, {}) if isinstance(dynamic_weights.get(stage, {}), dict) else {}
  448. staged = stage_weights.get(template_key)
  449. if isinstance(staged, dict):
  450. return dict(staged)
  451. return base
  452. def _parse_genre_tokens(self, genre_raw: str) -> List[str]:
  453. support_composite = bool(getattr(self.config, "context_genre_profile_support_composite", True))
  454. separators_raw = getattr(self.config, "context_genre_profile_separators", ("+", "/", "|", ","))
  455. separators = tuple(str(token) for token in separators_raw if str(token))
  456. return parse_genre_tokens(
  457. genre_raw,
  458. support_composite=support_composite,
  459. separators=separators,
  460. )
  461. def _normalize_genre_token(self, token: str) -> str:
  462. return normalize_genre_token(token)
  463. def _build_composite_genre_hints(self, genres: List[str], refs: List[str]) -> List[str]:
  464. return build_composite_genre_hints(genres, refs)
  465. def _build_writing_checklist(
  466. self,
  467. chapter: int,
  468. guidance_items: List[str],
  469. reader_signal: Dict[str, Any],
  470. genre_profile: Dict[str, Any],
  471. ) -> List[Dict[str, Any]]:
  472. _ = chapter
  473. if not getattr(self.config, "context_writing_checklist_enabled", True):
  474. return []
  475. min_items = max(1, int(getattr(self.config, "context_writing_checklist_min_items", 3)))
  476. max_items = max(min_items, int(getattr(self.config, "context_writing_checklist_max_items", 6)))
  477. default_weight = float(getattr(self.config, "context_writing_checklist_default_weight", 1.0))
  478. if default_weight <= 0:
  479. default_weight = 1.0
  480. return build_writing_checklist(
  481. guidance_items=guidance_items,
  482. reader_signal=reader_signal,
  483. genre_profile=genre_profile,
  484. min_items=min_items,
  485. max_items=max_items,
  486. default_weight=default_weight,
  487. )
  488. def _compact_json_text(self, content: Any, budget: Optional[int]) -> str:
  489. raw = json.dumps(content, ensure_ascii=False)
  490. if budget is None or len(raw) <= budget:
  491. return raw
  492. if not getattr(self.config, "context_compact_text_enabled", True):
  493. return raw[:budget]
  494. min_budget = max(1, int(getattr(self.config, "context_compact_min_budget", 120)))
  495. if budget <= min_budget:
  496. return raw[:budget]
  497. head_ratio = float(getattr(self.config, "context_compact_head_ratio", 0.65))
  498. head_budget = int(budget * max(0.2, min(0.9, head_ratio)))
  499. tail_budget = max(0, budget - head_budget - 10)
  500. compact = f"{raw[:head_budget]}…[TRUNCATED]{raw[-tail_budget:] if tail_budget else ''}"
  501. return compact[:budget]
  502. def _extract_genre_section(self, text: str, genre: str) -> str:
  503. return extract_genre_section(text, genre)
  504. def _extract_markdown_refs(self, text: str, max_items: int = 8) -> List[str]:
  505. return extract_markdown_refs(text, max_items=max_items)
  506. def _load_state(self) -> Dict[str, Any]:
  507. path = self.config.state_file
  508. if not path.exists():
  509. return {}
  510. return json.loads(path.read_text(encoding="utf-8"))
  511. def _load_outline(self, chapter: int) -> str:
  512. outline_dir = self.config.outline_dir
  513. patterns = [
  514. f"第{chapter}章*.md",
  515. f"第{chapter:02d}章*.md",
  516. f"第{chapter:03d}章*.md",
  517. f"第{chapter:04d}章*.md",
  518. ]
  519. for pattern in patterns:
  520. matches = list(outline_dir.glob(pattern))
  521. if matches:
  522. return matches[0].read_text(encoding="utf-8")
  523. return f"[大纲未找到: 第{chapter}章]"
  524. def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  525. summaries = []
  526. for ch in range(max(1, chapter - window), chapter):
  527. summary = self._load_summary_text(ch)
  528. if summary:
  529. summaries.append(summary)
  530. return summaries
  531. def _load_recent_meta(self, state: Dict[str, Any], chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  532. meta = state.get("chapter_meta", {}) or {}
  533. results = []
  534. for ch in range(max(1, chapter - window), chapter):
  535. for key in (f"{ch:04d}", str(ch)):
  536. if key in meta:
  537. results.append({"chapter": ch, **meta.get(key, {})})
  538. break
  539. return results
  540. def _load_recent_appearances(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
  541. appearances = self.index_manager.get_recent_appearances(limit=limit)
  542. return appearances or []
  543. def _load_setting(self, keyword: str) -> str:
  544. settings_dir = self.config.settings_dir
  545. candidates = [
  546. settings_dir / f"{keyword}.md",
  547. ]
  548. for path in candidates:
  549. if path.exists():
  550. return path.read_text(encoding="utf-8")
  551. # fallback: any file containing keyword
  552. matches = list(settings_dir.glob(f"*{keyword}*.md"))
  553. if matches:
  554. return matches[0].read_text(encoding="utf-8")
  555. return f"[{keyword}设定未找到]"
  556. def _extract_summary_excerpt(self, text: str, max_chars: int) -> str:
  557. if not text:
  558. return ""
  559. match = self.SUMMARY_SECTION_RE.search(text)
  560. excerpt = match.group(1).strip() if match else text.strip()
  561. if max_chars > 0 and len(excerpt) > max_chars:
  562. return excerpt[:max_chars].rstrip()
  563. return excerpt
  564. def _load_summary_text(self, chapter: int, snippet_chars: Optional[int] = None) -> Optional[Dict[str, Any]]:
  565. summary_path = self.config.webnovel_dir / "summaries" / f"ch{chapter:04d}.md"
  566. if not summary_path.exists():
  567. return None
  568. text = summary_path.read_text(encoding="utf-8")
  569. if snippet_chars:
  570. summary_text = self._extract_summary_excerpt(text, snippet_chars)
  571. else:
  572. summary_text = text
  573. return {"chapter": chapter, "summary": summary_text}
  574. def _load_story_skeleton(self, chapter: int) -> List[Dict[str, Any]]:
  575. interval = max(1, int(self.config.context_story_skeleton_interval))
  576. max_samples = max(0, int(self.config.context_story_skeleton_max_samples))
  577. snippet_chars = int(self.config.context_story_skeleton_snippet_chars)
  578. if max_samples <= 0 or chapter <= interval:
  579. return []
  580. samples: List[Dict[str, Any]] = []
  581. cursor = chapter - interval
  582. while cursor >= 1 and len(samples) < max_samples:
  583. summary = self._load_summary_text(cursor, snippet_chars=snippet_chars)
  584. if summary and summary.get("summary"):
  585. samples.append(summary)
  586. cursor -= interval
  587. samples.reverse()
  588. return samples
  589. def _load_json_optional(self, path: Path) -> Dict[str, Any]:
  590. if not path.exists():
  591. return {}
  592. try:
  593. return json.loads(path.read_text(encoding="utf-8"))
  594. except json.JSONDecodeError:
  595. return {}
  596. def main():
  597. import argparse
  598. from .cli_output import print_success, print_error
  599. parser = argparse.ArgumentParser(description="Context Manager CLI")
  600. parser.add_argument("--project-root", type=str, help="项目根目录")
  601. parser.add_argument("--chapter", type=int, required=True)
  602. parser.add_argument("--template", type=str, default=ContextManager.DEFAULT_TEMPLATE)
  603. parser.add_argument("--no-snapshot", action="store_true")
  604. parser.add_argument("--max-chars", type=int, default=8000)
  605. args = parser.parse_args()
  606. config = None
  607. if args.project_root:
  608. from .config import DataModulesConfig
  609. config = DataModulesConfig.from_project_root(args.project_root)
  610. manager = ContextManager(config)
  611. try:
  612. payload = manager.build_context(
  613. chapter=args.chapter,
  614. template=args.template,
  615. use_snapshot=not args.no_snapshot,
  616. save_snapshot=True,
  617. max_chars=args.max_chars,
  618. )
  619. print_success(payload, message="context_built")
  620. try:
  621. manager.index_manager.log_tool_call("context_manager:build", True, chapter=args.chapter)
  622. except Exception as exc:
  623. print(f"[context_manager] failed to log successful tool call: {exc}", file=sys.stderr)
  624. except Exception as exc:
  625. print_error("CONTEXT_BUILD_FAILED", str(exc), suggestion="请检查项目结构与依赖文件")
  626. try:
  627. manager.index_manager.log_tool_call(
  628. "context_manager:build", False, error_code="CONTEXT_BUILD_FAILED", error_message=str(exc), chapter=args.chapter
  629. )
  630. except Exception as log_exc:
  631. print(f"[context_manager] failed to log failed tool call: {log_exc}", file=sys.stderr)
  632. if __name__ == "__main__":
  633. import sys
  634. if sys.platform == "win32":
  635. import io
  636. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
  637. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
  638. main()