orchestrator.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 长期记忆编排器。
  5. """
  6. from __future__ import annotations
  7. import json
  8. from typing import Any, Dict, List
  9. from ..config import DataModulesConfig, get_config
  10. from ..index_manager import IndexManager
  11. from .schema import MemoryItem
  12. from .store import ScratchpadManager
  13. from .budget import allocate_limits
  14. try:
  15. from chapter_outline_loader import load_chapter_outline
  16. except ImportError: # pragma: no cover
  17. from scripts.chapter_outline_loader import load_chapter_outline
  18. class MemoryOrchestrator:
  19. PRIORITY = {
  20. "world_rule": 0,
  21. "character_state": 1,
  22. "relationship": 2,
  23. "story_fact": 3,
  24. "open_loop": 4,
  25. "reader_promise": 5,
  26. "timeline": 6,
  27. }
  28. def __init__(self, config: DataModulesConfig | None = None):
  29. self.config = config or get_config()
  30. self.index_manager = IndexManager(self.config)
  31. self.store = ScratchpadManager(self.config)
  32. def build_memory_pack(self, chapter: int, task_type: str = "write") -> Dict[str, Any]:
  33. outline = load_chapter_outline(self.config.project_root, chapter, max_chars=1500)
  34. working = self._build_working_memory(chapter=chapter, outline=outline)
  35. episodic = self._build_episodic_memory(chapter=chapter)
  36. active_items = self.store.query(status="active")
  37. conflicts = self.store.conflicts()
  38. filtered = self._filter_relevant(active_items, chapter=chapter, outline=outline)
  39. max_items = max(1, int(getattr(self.config, "memory_orchestrator_max_items", 30)))
  40. limits = allocate_limits(max_items=max_items, task_type=task_type)
  41. semantic_items = self._apply_budget(filtered, max_items=limits["semantic"])
  42. working_items = working[: limits["working"]]
  43. episodic_items = episodic[: limits["episodic"]]
  44. semantic_payload = [item.to_dict() for item in semantic_items]
  45. recent_changes = self.index_manager.get_recent_state_changes(
  46. limit=max(1, int(getattr(self.config, "memory_orchestrator_recent_changes_limit", 10)))
  47. )
  48. active_constraints = [
  49. item.to_dict()
  50. for item in semantic_items
  51. if item.category in {"world_rule", "open_loop"}
  52. ]
  53. warnings = []
  54. if conflicts:
  55. warnings.append(
  56. {
  57. "type": "memory_conflict",
  58. "count": len(conflicts),
  59. "sample": conflicts[:5],
  60. }
  61. )
  62. return {
  63. "working_memory": working_items,
  64. "episodic_memory": episodic_items,
  65. "semantic_memory": semantic_payload,
  66. # long_term_facts 保持对外 contract:仅包含可直接注入的长期语义事实。
  67. "long_term_facts": semantic_payload,
  68. "active_constraints": active_constraints,
  69. "recent_changes": list(recent_changes),
  70. "warnings": warnings,
  71. "stats": {
  72. "total": len(active_items),
  73. "working_total": len(working),
  74. "episodic_total": len(episodic),
  75. "semantic_total": len(filtered),
  76. "injected": len(semantic_payload),
  77. "layered_total_injected": len(working_items) + len(episodic_items) + len(semantic_payload),
  78. "filtered": max(0, len(active_items) - len(filtered)),
  79. "conflicts": len(conflicts),
  80. },
  81. }
  82. def _filter_relevant(self, items: List[MemoryItem], chapter: int, outline: str) -> List[MemoryItem]:
  83. if not items:
  84. return []
  85. if not outline:
  86. return sorted(items, key=lambda x: (x.source_chapter, x.updated_at), reverse=True)
  87. keep: List[MemoryItem] = []
  88. source_window = max(1, int(getattr(self.config, "memory_orchestrator_source_window", 20)))
  89. for item in items:
  90. if item.subject and item.subject in outline:
  91. keep.append(item)
  92. continue
  93. if item.field and item.field in outline:
  94. keep.append(item)
  95. continue
  96. if item.value and item.value[:20] in outline:
  97. keep.append(item)
  98. continue
  99. if item.source_chapter > 0 and chapter - item.source_chapter <= source_window:
  100. keep.append(item)
  101. return sorted(keep, key=lambda x: (self.PRIORITY.get(x.category, 99), -x.source_chapter))
  102. def _apply_budget(self, items: List[MemoryItem], max_items: int) -> List[MemoryItem]:
  103. if max_items <= 0:
  104. return []
  105. if len(items) <= max_items:
  106. return list(items)
  107. return list(items[:max_items])
  108. def _load_state(self) -> Dict[str, Any]:
  109. path = self.config.state_file
  110. if not path.exists():
  111. return {}
  112. try:
  113. return json.loads(path.read_text(encoding="utf-8"))
  114. except Exception:
  115. return {}
  116. def _load_recent_summaries(self, chapter: int, window: int) -> List[Dict[str, Any]]:
  117. result: List[Dict[str, Any]] = []
  118. summary_dir = self.config.webnovel_dir / "summaries"
  119. if not summary_dir.exists():
  120. return result
  121. for ch in range(max(1, chapter - window), chapter):
  122. path = summary_dir / f"ch{ch:04d}.md"
  123. if not path.exists():
  124. continue
  125. text = path.read_text(encoding="utf-8")
  126. if text:
  127. result.append({"layer": "working", "source": "summary", "chapter": ch, "content": text[:800]})
  128. return result
  129. def _build_working_memory(self, chapter: int, outline: str) -> List[Dict[str, Any]]:
  130. state = self._load_state()
  131. result: List[Dict[str, Any]] = []
  132. if outline:
  133. result.append({"layer": "working", "source": "outline", "chapter": chapter, "content": outline})
  134. summary_window = max(1, int(getattr(self.config, "context_recent_summaries_window", 3)))
  135. result.extend(self._load_recent_summaries(chapter=chapter, window=summary_window))
  136. state_export = {
  137. "protagonist_state": state.get("protagonist_state", {}),
  138. "plot_threads": state.get("plot_threads", {}),
  139. "disambiguation_pending": state.get("disambiguation_pending", []),
  140. }
  141. result.append(
  142. {
  143. "layer": "working",
  144. "source": "state_export",
  145. "chapter": chapter,
  146. "content": state_export,
  147. }
  148. )
  149. return result
  150. def _build_episodic_memory(self, chapter: int) -> List[Dict[str, Any]]:
  151. _ = chapter
  152. changes_limit = max(1, int(getattr(self.config, "memory_orchestrator_recent_changes_limit", 10)))
  153. rel_limit = max(1, min(20, changes_limit))
  154. recent_changes = self.index_manager.get_recent_state_changes(limit=changes_limit)
  155. recent_relationships = self.index_manager.get_recent_relationships(limit=rel_limit)
  156. recent_appearances = self.index_manager.get_recent_appearances(limit=rel_limit)
  157. result: List[Dict[str, Any]] = []
  158. for row in recent_changes:
  159. result.append(
  160. {
  161. "layer": "episodic",
  162. "source": "state_change",
  163. "chapter": int(row.get("chapter") or 0),
  164. "entity_id": row.get("entity_id", ""),
  165. "field": row.get("field", ""),
  166. "content": row,
  167. }
  168. )
  169. for row in recent_relationships:
  170. result.append(
  171. {
  172. "layer": "episodic",
  173. "source": "relationship",
  174. "chapter": int(row.get("chapter") or 0),
  175. "entity_id": row.get("from_entity", ""),
  176. "field": row.get("to_entity", ""),
  177. "content": row,
  178. }
  179. )
  180. for row in recent_appearances:
  181. result.append(
  182. {
  183. "layer": "episodic",
  184. "source": "appearance",
  185. "chapter": int(row.get("chapter") or 0),
  186. "entity_id": row.get("entity_id", ""),
  187. "field": "appearance",
  188. "content": row,
  189. }
  190. )
  191. result.sort(key=lambda x: int(x.get("chapter") or 0), reverse=True)
  192. return result