context_manager.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  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. from pathlib import Path
  9. from typing import Any, Dict, List, Optional
  10. from .config import get_config
  11. from .index_manager import IndexManager
  12. from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch
  13. class ContextManager:
  14. DEFAULT_TEMPLATE = "plot"
  15. TEMPLATE_WEIGHTS = {
  16. "plot": {"core": 0.40, "scene": 0.35, "global": 0.25},
  17. "battle": {"core": 0.35, "scene": 0.45, "global": 0.20},
  18. "emotion": {"core": 0.45, "scene": 0.35, "global": 0.20},
  19. "transition": {"core": 0.50, "scene": 0.25, "global": 0.25},
  20. }
  21. def __init__(self, config=None, snapshot_manager: Optional[SnapshotManager] = None):
  22. self.config = config or get_config()
  23. self.snapshot_manager = snapshot_manager or SnapshotManager(self.config)
  24. self.index_manager = IndexManager(self.config)
  25. def build_context(
  26. self,
  27. chapter: int,
  28. template: str | None = None,
  29. use_snapshot: bool = True,
  30. save_snapshot: bool = True,
  31. max_chars: Optional[int] = None,
  32. ) -> Dict[str, Any]:
  33. template = template or self.DEFAULT_TEMPLATE
  34. if template not in self.TEMPLATE_WEIGHTS:
  35. template = self.DEFAULT_TEMPLATE
  36. if use_snapshot:
  37. try:
  38. cached = self.snapshot_manager.load_snapshot(chapter)
  39. if cached:
  40. return cached.get("payload", cached)
  41. except SnapshotVersionMismatch:
  42. # Snapshot incompatible; rebuild below.
  43. pass
  44. pack = self._build_pack(chapter)
  45. assembled = self.assemble_context(pack, template=template, max_chars=max_chars)
  46. if save_snapshot:
  47. meta = {"template": template}
  48. self.snapshot_manager.save_snapshot(chapter, assembled, meta=meta)
  49. return assembled
  50. def assemble_context(
  51. self,
  52. pack: Dict[str, Any],
  53. template: str = DEFAULT_TEMPLATE,
  54. max_chars: Optional[int] = None,
  55. ) -> Dict[str, Any]:
  56. weights = self.TEMPLATE_WEIGHTS.get(template, self.TEMPLATE_WEIGHTS[self.DEFAULT_TEMPLATE])
  57. max_chars = max_chars or 8000
  58. sections = {}
  59. for section_name in ["core", "scene", "global", "memory", "preferences", "alerts"]:
  60. if section_name in pack:
  61. sections[section_name] = pack[section_name]
  62. assembled: Dict[str, Any] = {"meta": pack.get("meta", {}), "sections": {}}
  63. for name, content in sections.items():
  64. weight = weights.get(name, 0.0)
  65. budget = int(max_chars * weight) if weight > 0 else None
  66. text = json.dumps(content, ensure_ascii=False)
  67. if budget is not None and len(text) > budget:
  68. text = text[:budget]
  69. assembled["sections"][name] = {"content": content, "text": text, "budget": budget}
  70. assembled["template"] = template
  71. assembled["weights"] = weights
  72. return assembled
  73. def filter_invalid_items(self, items: List[Dict[str, Any]], source_type: str, id_key: str) -> List[Dict[str, Any]]:
  74. confirmed = self.index_manager.get_invalid_ids(source_type, status="confirmed")
  75. pending = self.index_manager.get_invalid_ids(source_type, status="pending")
  76. result = []
  77. for item in items:
  78. item_id = str(item.get(id_key, ""))
  79. if item_id in confirmed:
  80. continue
  81. if item_id in pending:
  82. item = dict(item)
  83. item["warning"] = "pending_invalid"
  84. result.append(item)
  85. return result
  86. def apply_confidence_filter(self, items: List[Dict[str, Any]], min_confidence: float) -> List[Dict[str, Any]]:
  87. filtered: List[Dict[str, Any]] = []
  88. for item in items:
  89. conf = item.get("confidence")
  90. if conf is None or conf >= min_confidence:
  91. filtered.append(item)
  92. return filtered
  93. def _build_pack(self, chapter: int) -> Dict[str, Any]:
  94. state = self._load_state()
  95. core = {
  96. "chapter_outline": self._load_outline(chapter),
  97. "protagonist_snapshot": state.get("protagonist_state", {}),
  98. "recent_summaries": self._load_recent_summaries(chapter, window=3),
  99. "recent_meta": self._load_recent_meta(state, chapter, window=3),
  100. }
  101. scene = {
  102. "location_context": state.get("protagonist_state", {}).get("location", {}),
  103. "appearing_characters": self._load_recent_appearances(),
  104. }
  105. scene["appearing_characters"] = self.filter_invalid_items(
  106. scene["appearing_characters"], source_type="entity", id_key="entity_id"
  107. )
  108. global_ctx = {
  109. "worldview_skeleton": self._load_setting("世界观"),
  110. "power_system_skeleton": self._load_setting("力量体系"),
  111. "style_contract_ref": self._load_setting("风格契约"),
  112. }
  113. preferences = self._load_json_optional(self.config.webnovel_dir / "preferences.json")
  114. memory = self._load_json_optional(self.config.webnovel_dir / "project_memory.json")
  115. return {
  116. "meta": {"chapter": chapter},
  117. "core": core,
  118. "scene": scene,
  119. "global": global_ctx,
  120. "preferences": preferences,
  121. "memory": memory,
  122. "alerts": {
  123. "disambiguation_warnings": state.get("disambiguation_warnings", [])[-10:],
  124. "disambiguation_pending": state.get("disambiguation_pending", [])[-10:],
  125. },
  126. }
  127. def _load_state(self) -> Dict[str, Any]:
  128. path = self.config.state_file
  129. if not path.exists():
  130. return {}
  131. return json.loads(path.read_text(encoding="utf-8"))
  132. def _load_outline(self, chapter: int) -> str:
  133. outline_dir = self.config.outline_dir
  134. patterns = [
  135. f"第{chapter}章*.md",
  136. f"第{chapter:02d}章*.md",
  137. f"第{chapter:03d}章*.md",
  138. f"第{chapter:04d}章*.md",
  139. ]
  140. for pattern in patterns:
  141. matches = list(outline_dir.glob(pattern))
  142. if matches:
  143. return matches[0].read_text(encoding="utf-8")
  144. return f"[大纲未找到: 第{chapter}章]"
  145. def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  146. summaries = []
  147. summaries_dir = self.config.webnovel_dir / "summaries"
  148. for ch in range(max(1, chapter - window), chapter):
  149. path = summaries_dir / f"ch{ch:04d}.md"
  150. if path.exists():
  151. summaries.append({"chapter": ch, "summary": path.read_text(encoding="utf-8")})
  152. return summaries
  153. def _load_recent_meta(self, state: Dict[str, Any], chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  154. meta = state.get("chapter_meta", {}) or {}
  155. results = []
  156. for ch in range(max(1, chapter - window), chapter):
  157. for key in (f"{ch:04d}", str(ch)):
  158. if key in meta:
  159. results.append({"chapter": ch, **meta.get(key, {})})
  160. break
  161. return results
  162. def _load_recent_appearances(self) -> List[Dict[str, Any]]:
  163. appearances = self.index_manager.get_recent_appearances()
  164. return appearances or []
  165. def _load_setting(self, keyword: str) -> str:
  166. settings_dir = self.config.settings_dir
  167. candidates = [
  168. settings_dir / f"{keyword}.md",
  169. ]
  170. for path in candidates:
  171. if path.exists():
  172. return path.read_text(encoding="utf-8")
  173. # fallback: any file containing keyword
  174. matches = list(settings_dir.glob(f"*{keyword}*.md"))
  175. if matches:
  176. return matches[0].read_text(encoding="utf-8")
  177. return f"[{keyword}设定未找到]"
  178. def _load_json_optional(self, path: Path) -> Dict[str, Any]:
  179. if not path.exists():
  180. return {}
  181. try:
  182. return json.loads(path.read_text(encoding="utf-8"))
  183. except json.JSONDecodeError:
  184. return {}
  185. def main():
  186. import argparse
  187. from .cli_output import print_success, print_error
  188. parser = argparse.ArgumentParser(description="Context Manager CLI")
  189. parser.add_argument("--project-root", type=str, help="项目根目录")
  190. parser.add_argument("--chapter", type=int, required=True)
  191. parser.add_argument("--template", type=str, default=ContextManager.DEFAULT_TEMPLATE)
  192. parser.add_argument("--no-snapshot", action="store_true")
  193. parser.add_argument("--max-chars", type=int, default=8000)
  194. args = parser.parse_args()
  195. config = None
  196. if args.project_root:
  197. from .config import DataModulesConfig
  198. config = DataModulesConfig.from_project_root(args.project_root)
  199. manager = ContextManager(config)
  200. try:
  201. payload = manager.build_context(
  202. chapter=args.chapter,
  203. template=args.template,
  204. use_snapshot=not args.no_snapshot,
  205. save_snapshot=True,
  206. max_chars=args.max_chars,
  207. )
  208. print_success(payload, message="context_built")
  209. try:
  210. manager.index_manager.log_tool_call("context_manager:build", True, chapter=args.chapter)
  211. except Exception:
  212. pass
  213. except Exception as exc:
  214. print_error("CONTEXT_BUILD_FAILED", str(exc), suggestion="请检查项目结构与依赖文件")
  215. try:
  216. manager.index_manager.log_tool_call(
  217. "context_manager:build", False, error_code="CONTEXT_BUILD_FAILED", error_message=str(exc), chapter=args.chapter
  218. )
  219. except Exception:
  220. pass
  221. if __name__ == "__main__":
  222. main()