context_manager.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959
  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. import logging
  11. import hashlib
  12. from pathlib import Path
  13. from runtime_compat import enable_windows_utf8_stdio
  14. from typing import Any, Dict, List, Optional
  15. try:
  16. from chapter_outline_loader import (
  17. load_chapter_outline,
  18. load_chapter_plot_structure,
  19. )
  20. except ImportError: # pragma: no cover
  21. from scripts.chapter_outline_loader import (
  22. load_chapter_outline,
  23. load_chapter_plot_structure,
  24. )
  25. from .config import get_config
  26. from .index_manager import IndexManager, WritingChecklistScoreMeta
  27. from .context_ranker import ContextRanker
  28. from .prewrite_validator import PrewriteValidator
  29. from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch
  30. from .story_contracts import read_json_if_exists
  31. from .story_runtime_sources import RuntimeSourceSnapshot, load_runtime_sources
  32. from .context_weights import (
  33. DEFAULT_TEMPLATE as CONTEXT_DEFAULT_TEMPLATE,
  34. TEMPLATE_WEIGHTS as CONTEXT_TEMPLATE_WEIGHTS,
  35. TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT as CONTEXT_TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT,
  36. )
  37. from .genre_aliases import normalize_genre_token, to_profile_key
  38. from .genre_profile_builder import (
  39. build_composite_genre_hints,
  40. extract_genre_section,
  41. extract_markdown_refs,
  42. parse_genre_tokens,
  43. )
  44. from .writing_guidance_builder import (
  45. build_methodology_guidance_items,
  46. build_methodology_strategy_card,
  47. build_guidance_items,
  48. build_writing_checklist,
  49. is_checklist_item_completed,
  50. )
  51. logger = logging.getLogger(__name__)
  52. class ContextManager:
  53. DEFAULT_TEMPLATE = CONTEXT_DEFAULT_TEMPLATE
  54. TEMPLATE_WEIGHTS = CONTEXT_TEMPLATE_WEIGHTS
  55. TEMPLATE_WEIGHTS_DYNAMIC = CONTEXT_TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT
  56. EXTRA_SECTIONS = {
  57. "story_skeleton",
  58. "memory",
  59. "long_term_memory",
  60. "preferences",
  61. "alerts",
  62. "reader_signal",
  63. "genre_profile",
  64. "writing_guidance",
  65. "plot_structure",
  66. "story_contract",
  67. "runtime_status",
  68. "latest_commit",
  69. "prewrite_validation",
  70. }
  71. SECTION_ORDER = [
  72. "core",
  73. "story_contract",
  74. "runtime_status",
  75. "latest_commit",
  76. "prewrite_validation",
  77. "scene",
  78. "global",
  79. "reader_signal",
  80. "genre_profile",
  81. "writing_guidance",
  82. "plot_structure",
  83. "story_skeleton",
  84. "memory",
  85. "long_term_memory",
  86. "preferences",
  87. "alerts",
  88. ]
  89. SUMMARY_SECTION_RE = re.compile(r"##\s*剧情摘要\s*\r?\n(.*?)(?=\r?\n##|\Z)", re.DOTALL)
  90. def __init__(self, config=None, snapshot_manager: Optional[SnapshotManager] = None):
  91. self.config = config or get_config()
  92. self.snapshot_manager = snapshot_manager or SnapshotManager(self.config)
  93. self.index_manager = IndexManager(self.config)
  94. self.context_ranker = ContextRanker(self.config)
  95. def _is_snapshot_compatible(self, cached: Dict[str, Any], template: str) -> bool:
  96. """判断快照是否可用于当前模板。"""
  97. if not isinstance(cached, dict):
  98. return False
  99. meta = cached.get("meta")
  100. if not isinstance(meta, dict):
  101. # 兼容旧快照:未记录 template 时仅允许默认模板复用
  102. return template == self.DEFAULT_TEMPLATE
  103. cached_template = meta.get("template")
  104. if not isinstance(cached_template, str):
  105. return template == self.DEFAULT_TEMPLATE
  106. if cached_template != template:
  107. return False
  108. payload = cached.get("payload", cached)
  109. if not isinstance(payload, dict):
  110. return False
  111. sections = payload.get("sections")
  112. if not isinstance(sections, dict):
  113. return False
  114. required_sections = {
  115. "plot_structure",
  116. "long_term_memory",
  117. "story_contract",
  118. "runtime_status",
  119. "latest_commit",
  120. "prewrite_validation",
  121. }
  122. if not required_sections.issubset(set(sections.keys())):
  123. return False
  124. chapter = int(cached.get("chapter") or (payload.get("meta") or {}).get("chapter") or 0)
  125. if chapter <= 0:
  126. return False
  127. snapshot_signature = meta.get("story_contract_signature")
  128. current_signature = self._story_contract_signature(chapter)
  129. return snapshot_signature == current_signature
  130. def build_context(
  131. self,
  132. chapter: int,
  133. template: str | None = None,
  134. use_snapshot: bool = True,
  135. save_snapshot: bool = True,
  136. max_chars: Optional[int] = None,
  137. ) -> Dict[str, Any]:
  138. template = template or self.DEFAULT_TEMPLATE
  139. self._active_template = template
  140. if template not in self.TEMPLATE_WEIGHTS:
  141. template = self.DEFAULT_TEMPLATE
  142. self._active_template = template
  143. if use_snapshot:
  144. try:
  145. cached = self.snapshot_manager.load_snapshot(chapter)
  146. if cached and self._is_snapshot_compatible(cached, template):
  147. return cached.get("payload", cached)
  148. except SnapshotVersionMismatch:
  149. # Snapshot incompatible; rebuild below.
  150. pass
  151. pack = self._build_pack(chapter)
  152. if getattr(self.config, "context_ranker_enabled", True):
  153. pack = self.context_ranker.rank_pack(pack, chapter)
  154. assembled = self.assemble_context(pack, template=template, max_chars=max_chars)
  155. if save_snapshot:
  156. meta = {
  157. "template": template,
  158. "story_contract_signature": self._story_contract_signature(chapter),
  159. }
  160. self.snapshot_manager.save_snapshot(chapter, assembled, meta=meta)
  161. return assembled
  162. def assemble_context(
  163. self,
  164. pack: Dict[str, Any],
  165. template: str = DEFAULT_TEMPLATE,
  166. max_chars: Optional[int] = None,
  167. ) -> Dict[str, Any]:
  168. chapter = int((pack.get("meta") or {}).get("chapter") or 0)
  169. weights = self._resolve_template_weights(template=template, chapter=chapter)
  170. max_chars = max_chars or 8000
  171. extra_budget = int(self.config.context_extra_section_budget or 0)
  172. sections = {}
  173. for section_name in self.SECTION_ORDER:
  174. if section_name in pack:
  175. sections[section_name] = pack[section_name]
  176. assembled: Dict[str, Any] = {"meta": pack.get("meta", {}), "sections": {}}
  177. for name, content in sections.items():
  178. weight = weights.get(name, 0.0)
  179. if weight > 0:
  180. budget = int(max_chars * weight)
  181. elif name in self.EXTRA_SECTIONS and extra_budget > 0:
  182. budget = extra_budget
  183. else:
  184. budget = None
  185. text = self._compact_json_text(content, budget)
  186. assembled["sections"][name] = {"content": content, "text": text, "budget": budget}
  187. assembled["template"] = template
  188. assembled["weights"] = weights
  189. if chapter > 0:
  190. assembled.setdefault("meta", {})["context_weight_stage"] = self._resolve_context_stage(chapter)
  191. return assembled
  192. def filter_invalid_items(self, items: List[Dict[str, Any]], source_type: str, id_key: str) -> List[Dict[str, Any]]:
  193. confirmed = self.index_manager.get_invalid_ids(source_type, status="confirmed")
  194. pending = self.index_manager.get_invalid_ids(source_type, status="pending")
  195. result = []
  196. for item in items:
  197. item_id = str(item.get(id_key, ""))
  198. if item_id in confirmed:
  199. continue
  200. if item_id in pending:
  201. item = dict(item)
  202. item["warning"] = "pending_invalid"
  203. result.append(item)
  204. return result
  205. def apply_confidence_filter(self, items: List[Dict[str, Any]], min_confidence: float) -> List[Dict[str, Any]]:
  206. filtered: List[Dict[str, Any]] = []
  207. for item in items:
  208. conf = item.get("confidence")
  209. if conf is None or conf >= min_confidence:
  210. filtered.append(item)
  211. return filtered
  212. def _build_pack(self, chapter: int) -> Dict[str, Any]:
  213. state = self._load_state()
  214. runtime_sources = load_runtime_sources(self.config.project_root, chapter)
  215. use_orchestrator = bool(getattr(self.config, "context_use_memory_orchestrator", False))
  216. orchestrator_pack: Dict[str, Any] = {}
  217. if use_orchestrator:
  218. try:
  219. from .memory.orchestrator import MemoryOrchestrator
  220. orchestrator = MemoryOrchestrator(self.config)
  221. orchestrator_pack = orchestrator.build_memory_pack(chapter)
  222. except Exception as exc:
  223. logger.warning("memory_orchestrator_failed: %s", exc)
  224. core = {
  225. "chapter_outline": self._load_outline(chapter),
  226. "protagonist_snapshot": state.get("protagonist_state", {}),
  227. "recent_summaries": self._load_recent_summaries(
  228. chapter,
  229. window=self.config.context_recent_summaries_window,
  230. ),
  231. "recent_meta": self._load_recent_meta(
  232. state,
  233. chapter,
  234. window=self.config.context_recent_meta_window,
  235. ),
  236. }
  237. if use_orchestrator and orchestrator_pack:
  238. working_items = list(orchestrator_pack.get("working_memory") or [])
  239. outline_item = next((x for x in working_items if x.get("source") == "outline"), None)
  240. state_item = next((x for x in working_items if x.get("source") == "state_export"), None)
  241. summary_items = [
  242. {"chapter": x.get("chapter"), "summary": x.get("content")}
  243. for x in working_items
  244. if x.get("source") == "summary"
  245. ]
  246. core["chapter_outline"] = str(outline_item.get("content", "")) if outline_item else core["chapter_outline"]
  247. if isinstance(state_item, dict) and isinstance(state_item.get("content"), dict):
  248. state_export = dict(state_item.get("content") or {})
  249. core["protagonist_snapshot"] = state_export.get("protagonist_state", core["protagonist_snapshot"])
  250. if summary_items:
  251. core["recent_summaries"] = summary_items
  252. scene = {
  253. "location_context": state.get("protagonist_state", {}).get("location", {}),
  254. "appearing_characters": self._load_recent_appearances(
  255. limit=self.config.context_max_appearing_characters,
  256. ),
  257. }
  258. scene["appearing_characters"] = self.filter_invalid_items(
  259. scene["appearing_characters"], source_type="entity", id_key="entity_id"
  260. )
  261. story_contract = self._build_story_contract_from_runtime(runtime_sources)
  262. runtime_status = runtime_sources.to_dict()
  263. latest_commit = runtime_sources.latest_commit or {}
  264. global_ctx = {
  265. "worldview_skeleton": self._load_setting("世界观"),
  266. "power_system_skeleton": self._load_setting("力量体系"),
  267. "style_contract_ref": self._load_setting("风格契约"),
  268. }
  269. preferences = self._load_json_optional(self.config.webnovel_dir / "preferences.json")
  270. memory = self._load_json_optional(self.config.webnovel_dir / "project_memory.json")
  271. long_term_memory: Dict[str, Any] = orchestrator_pack if orchestrator_pack else {}
  272. story_skeleton = self._load_story_skeleton(chapter)
  273. alert_slice = max(0, int(self.config.context_alerts_slice))
  274. reader_signal = self._load_reader_signal(chapter)
  275. genre_profile = self._build_runtime_genre_profile(state, story_contract)
  276. writing_guidance = self._build_writing_guidance(chapter, reader_signal, genre_profile)
  277. plot_structure = self._load_plot_structure(chapter)
  278. prewrite_validation = PrewriteValidator(self.config.project_root).build(
  279. chapter=chapter,
  280. review_contract=story_contract.get("review_contract") or {},
  281. plot_structure=plot_structure,
  282. story_contract=story_contract,
  283. )
  284. return {
  285. "meta": {"chapter": chapter},
  286. "core": core,
  287. "story_contract": story_contract,
  288. "runtime_status": runtime_status,
  289. "latest_commit": latest_commit,
  290. "prewrite_validation": prewrite_validation,
  291. "scene": scene,
  292. "global": global_ctx,
  293. "reader_signal": reader_signal,
  294. "genre_profile": genre_profile,
  295. "writing_guidance": writing_guidance,
  296. "plot_structure": plot_structure,
  297. "story_skeleton": story_skeleton,
  298. "preferences": preferences,
  299. "memory": memory,
  300. "long_term_memory": long_term_memory,
  301. "alerts": {
  302. "disambiguation_warnings": (
  303. state.get("disambiguation_warnings", [])[-alert_slice:] if alert_slice else []
  304. ),
  305. "disambiguation_pending": (
  306. state.get("disambiguation_pending", [])[-alert_slice:] if alert_slice else []
  307. ),
  308. },
  309. }
  310. def _load_reader_signal(self, chapter: int) -> Dict[str, Any]:
  311. if not getattr(self.config, "context_reader_signal_enabled", True):
  312. return {}
  313. recent_limit = max(1, int(getattr(self.config, "context_reader_signal_recent_limit", 5)))
  314. pattern_window = max(1, int(getattr(self.config, "context_reader_signal_window_chapters", 20)))
  315. review_window = max(1, int(getattr(self.config, "context_reader_signal_review_window", 5)))
  316. include_debt = bool(getattr(self.config, "context_reader_signal_include_debt", False))
  317. recent_power = self.index_manager.get_recent_reading_power(limit=recent_limit)
  318. pattern_stats = self.index_manager.get_pattern_usage_stats(last_n_chapters=pattern_window)
  319. hook_stats = self.index_manager.get_hook_type_stats(last_n_chapters=pattern_window)
  320. review_trend = self.index_manager.get_review_trend_stats(last_n=review_window)
  321. low_score_ranges: List[Dict[str, Any]] = []
  322. for row in review_trend.get("recent_ranges", []):
  323. score = row.get("overall_score")
  324. notes = row.get("notes", "")
  325. has_blocking = "blocking=" in notes and "blocking=0" not in notes
  326. is_low_score = isinstance(score, (int, float)) and float(score) < 75
  327. if is_low_score or has_blocking:
  328. low_score_ranges.append(
  329. {
  330. "start_chapter": row.get("start_chapter"),
  331. "end_chapter": row.get("end_chapter"),
  332. "overall_score": score if isinstance(score, (int, float)) else 0.0,
  333. "notes": notes,
  334. }
  335. )
  336. signal: Dict[str, Any] = {
  337. "recent_reading_power": recent_power,
  338. "pattern_usage": pattern_stats,
  339. "hook_type_usage": hook_stats,
  340. "review_trend": review_trend,
  341. "low_score_ranges": low_score_ranges,
  342. "next_chapter": chapter,
  343. }
  344. if include_debt:
  345. signal["debt_summary"] = self.index_manager.get_debt_summary()
  346. return signal
  347. def _load_genre_profile(self, state: Dict[str, Any]) -> Dict[str, Any]:
  348. if not getattr(self.config, "context_genre_profile_enabled", True):
  349. return {}
  350. fallback = str(getattr(self.config, "context_genre_profile_fallback", "shuangwen") or "shuangwen")
  351. project = state.get("project") or {}
  352. project_info = state.get("project_info") or {}
  353. genre_raw = str(project.get("genre") or project_info.get("genre") or fallback)
  354. genres = self._parse_genre_tokens(genre_raw)
  355. if not genres:
  356. genres = [fallback]
  357. max_genres = max(1, int(getattr(self.config, "context_genre_profile_max_genres", 2)))
  358. genres = genres[:max_genres]
  359. primary_genre = genres[0]
  360. secondary_genres = genres[1:]
  361. composite = len(genres) > 1
  362. profile_path = self.config.project_root / ".claude" / "references" / "genre-profiles.md"
  363. taxonomy_path = self.config.project_root / ".claude" / "references" / "reading-power-taxonomy.md"
  364. profile_text = profile_path.read_text(encoding="utf-8") if profile_path.exists() else ""
  365. taxonomy_text = taxonomy_path.read_text(encoding="utf-8") if taxonomy_path.exists() else ""
  366. profile_excerpt = self._extract_genre_section(profile_text, primary_genre)
  367. taxonomy_excerpt = self._extract_genre_section(taxonomy_text, primary_genre)
  368. secondary_profiles: List[str] = []
  369. secondary_taxonomies: List[str] = []
  370. for extra in secondary_genres:
  371. secondary_profiles.append(self._extract_genre_section(profile_text, extra))
  372. secondary_taxonomies.append(self._extract_genre_section(taxonomy_text, extra))
  373. refs = self._extract_markdown_refs(
  374. "\n".join([profile_excerpt] + secondary_profiles),
  375. max_items=int(getattr(self.config, "context_genre_profile_max_refs", 8)),
  376. )
  377. composite_hints = self._build_composite_genre_hints(genres, refs)
  378. return {
  379. "genre": primary_genre,
  380. "genre_raw": genre_raw,
  381. "genres": genres,
  382. "composite": composite,
  383. "secondary_genres": secondary_genres,
  384. "profile_excerpt": profile_excerpt,
  385. "taxonomy_excerpt": taxonomy_excerpt,
  386. "secondary_profile_excerpts": secondary_profiles,
  387. "secondary_taxonomy_excerpts": secondary_taxonomies,
  388. "reference_hints": refs,
  389. "composite_hints": composite_hints,
  390. }
  391. def _build_runtime_genre_profile(
  392. self,
  393. state: Dict[str, Any],
  394. story_contract: Dict[str, Any],
  395. ) -> Dict[str, Any]:
  396. legacy_profile = self._load_genre_profile(state)
  397. if legacy_profile:
  398. legacy_profile = dict(legacy_profile)
  399. legacy_profile["mode"] = "fallback_only"
  400. primary_genre = str(
  401. (
  402. ((story_contract.get("master_setting") or {}).get("route") or {}).get("primary_genre")
  403. or ""
  404. )
  405. ).strip()
  406. if not primary_genre:
  407. return legacy_profile or {}
  408. runtime_profile = self._load_genre_profile({"project": {"genre": primary_genre}})
  409. runtime_profile = dict(runtime_profile or {})
  410. runtime_profile.setdefault("genre", primary_genre)
  411. runtime_profile.setdefault("genre_raw", primary_genre)
  412. runtime_profile.setdefault("genres", [primary_genre])
  413. runtime_profile.setdefault("secondary_genres", [])
  414. runtime_profile.setdefault("composite", len(runtime_profile.get("genres") or []) > 1)
  415. runtime_profile.setdefault("reference_hints", [])
  416. runtime_profile.setdefault("composite_hints", [])
  417. runtime_profile["mode"] = "contract_first"
  418. if legacy_profile:
  419. runtime_profile["legacy_genre"] = legacy_profile.get("genre")
  420. runtime_profile["legacy_genre_raw"] = legacy_profile.get("genre_raw")
  421. runtime_profile["legacy_genres"] = list(legacy_profile.get("genres") or [])
  422. return runtime_profile
  423. def _build_writing_guidance(
  424. self,
  425. chapter: int,
  426. reader_signal: Dict[str, Any],
  427. genre_profile: Dict[str, Any],
  428. ) -> Dict[str, Any]:
  429. if not getattr(self.config, "context_writing_guidance_enabled", True):
  430. return {}
  431. limit = max(1, int(getattr(self.config, "context_writing_guidance_max_items", 6)))
  432. low_score_threshold = float(
  433. getattr(self.config, "context_writing_guidance_low_score_threshold", 75.0)
  434. )
  435. guidance_bundle = build_guidance_items(
  436. chapter=chapter,
  437. reader_signal=reader_signal,
  438. genre_profile=genre_profile,
  439. low_score_threshold=low_score_threshold,
  440. hook_diversify_enabled=bool(
  441. getattr(self.config, "context_writing_guidance_hook_diversify", True)
  442. ),
  443. )
  444. guidance = list(guidance_bundle.get("guidance") or [])
  445. methodology_strategy: Dict[str, Any] = {}
  446. if self._is_methodology_enabled_for_genre(genre_profile):
  447. methodology_strategy = build_methodology_strategy_card(
  448. chapter=chapter,
  449. reader_signal=reader_signal,
  450. genre_profile=genre_profile,
  451. label=str(getattr(self.config, "context_methodology_label", "digital-serial-v1")),
  452. )
  453. guidance.extend(build_methodology_guidance_items(methodology_strategy))
  454. checklist = self._build_writing_checklist(
  455. chapter=chapter,
  456. guidance_items=guidance,
  457. reader_signal=reader_signal,
  458. genre_profile=genre_profile,
  459. strategy_card=methodology_strategy,
  460. )
  461. checklist_score = self._compute_writing_checklist_score(
  462. chapter=chapter,
  463. checklist=checklist,
  464. reader_signal=reader_signal,
  465. )
  466. if getattr(self.config, "context_writing_score_persist_enabled", True):
  467. self._persist_writing_checklist_score(checklist_score)
  468. low_ranges = guidance_bundle.get("low_ranges") or []
  469. hook_usage = guidance_bundle.get("hook_usage") or {}
  470. pattern_usage = guidance_bundle.get("pattern_usage") or {}
  471. genre = str(guidance_bundle.get("genre") or genre_profile.get("genre") or "").strip()
  472. hook_types = list(hook_usage.keys())[:3] if isinstance(hook_usage, dict) else []
  473. top_patterns = (
  474. sorted(pattern_usage, key=pattern_usage.get, reverse=True)[:3]
  475. if isinstance(pattern_usage, dict)
  476. else []
  477. )
  478. return {
  479. "chapter": chapter,
  480. "guidance_items": guidance[:limit],
  481. "checklist": checklist,
  482. "checklist_score": checklist_score,
  483. "methodology": methodology_strategy,
  484. "signals_used": {
  485. "has_low_score_ranges": bool(low_ranges),
  486. "hook_types": hook_types,
  487. "top_patterns": top_patterns,
  488. "genre": genre,
  489. "methodology_enabled": bool(methodology_strategy.get("enabled")),
  490. },
  491. }
  492. def _compute_writing_checklist_score(
  493. self,
  494. chapter: int,
  495. checklist: List[Dict[str, Any]],
  496. reader_signal: Dict[str, Any],
  497. ) -> Dict[str, Any]:
  498. total_items = len(checklist)
  499. required_items = 0
  500. completed_items = 0
  501. completed_required = 0
  502. total_weight = 0.0
  503. completed_weight = 0.0
  504. pending_labels: List[str] = []
  505. for item in checklist:
  506. if not isinstance(item, dict):
  507. continue
  508. required = bool(item.get("required"))
  509. weight = float(item.get("weight") or 1.0)
  510. total_weight += weight
  511. if required:
  512. required_items += 1
  513. completed = self._is_checklist_item_completed(item, reader_signal)
  514. if completed:
  515. completed_items += 1
  516. completed_weight += weight
  517. if required:
  518. completed_required += 1
  519. else:
  520. pending_labels.append(str(item.get("label") or item.get("id") or "未命名项"))
  521. completion_rate = (completed_items / total_items) if total_items > 0 else 1.0
  522. weighted_rate = (completed_weight / total_weight) if total_weight > 0 else completion_rate
  523. required_rate = (completed_required / required_items) if required_items > 0 else 1.0
  524. score = 100.0 * (0.5 * weighted_rate + 0.3 * required_rate + 0.2 * completion_rate)
  525. if getattr(self.config, "context_writing_score_include_reader_trend", True):
  526. trend_window = max(1, int(getattr(self.config, "context_writing_score_trend_window", 10)))
  527. trend = self.index_manager.get_writing_checklist_score_trend(last_n=trend_window)
  528. baseline = float(trend.get("score_avg") or 0.0)
  529. if baseline > 0:
  530. score += max(-10.0, min(10.0, (score - baseline) * 0.1))
  531. score = round(max(0.0, min(100.0, score)), 2)
  532. return {
  533. "chapter": chapter,
  534. "score": score,
  535. "completion_rate": round(completion_rate, 4),
  536. "weighted_completion_rate": round(weighted_rate, 4),
  537. "required_completion_rate": round(required_rate, 4),
  538. "total_items": total_items,
  539. "required_items": required_items,
  540. "completed_items": completed_items,
  541. "completed_required": completed_required,
  542. "total_weight": round(total_weight, 2),
  543. "completed_weight": round(completed_weight, 2),
  544. "pending_items": pending_labels,
  545. "trend_window": int(getattr(self.config, "context_writing_score_trend_window", 10)),
  546. }
  547. def _is_checklist_item_completed(self, item: Dict[str, Any], reader_signal: Dict[str, Any]) -> bool:
  548. return is_checklist_item_completed(item, reader_signal)
  549. def _persist_writing_checklist_score(self, checklist_score: Dict[str, Any]) -> None:
  550. if not checklist_score:
  551. return
  552. try:
  553. self.index_manager.save_writing_checklist_score(
  554. WritingChecklistScoreMeta(
  555. chapter=int(checklist_score.get("chapter") or 0),
  556. template=str(getattr(self, "_active_template", self.DEFAULT_TEMPLATE) or self.DEFAULT_TEMPLATE),
  557. total_items=int(checklist_score.get("total_items") or 0),
  558. required_items=int(checklist_score.get("required_items") or 0),
  559. completed_items=int(checklist_score.get("completed_items") or 0),
  560. completed_required=int(checklist_score.get("completed_required") or 0),
  561. total_weight=float(checklist_score.get("total_weight") or 0.0),
  562. completed_weight=float(checklist_score.get("completed_weight") or 0.0),
  563. completion_rate=float(checklist_score.get("completion_rate") or 0.0),
  564. score=float(checklist_score.get("score") or 0.0),
  565. score_breakdown={
  566. "weighted_completion_rate": checklist_score.get("weighted_completion_rate"),
  567. "required_completion_rate": checklist_score.get("required_completion_rate"),
  568. "trend_window": checklist_score.get("trend_window"),
  569. },
  570. pending_items=list(checklist_score.get("pending_items") or []),
  571. source="context_manager",
  572. )
  573. )
  574. except Exception as exc:
  575. logger.warning("failed to persist writing checklist score: %s", exc)
  576. def _resolve_context_stage(self, chapter: int) -> str:
  577. early = max(1, int(getattr(self.config, "context_dynamic_budget_early_chapter", 30)))
  578. late = max(early + 1, int(getattr(self.config, "context_dynamic_budget_late_chapter", 120)))
  579. if chapter <= early:
  580. return "early"
  581. if chapter >= late:
  582. return "late"
  583. return "mid"
  584. def _resolve_template_weights(self, template: str, chapter: int) -> Dict[str, float]:
  585. template_key = template if template in self.TEMPLATE_WEIGHTS else self.DEFAULT_TEMPLATE
  586. base = dict(self.TEMPLATE_WEIGHTS.get(template_key, self.TEMPLATE_WEIGHTS[self.DEFAULT_TEMPLATE]))
  587. if not getattr(self.config, "context_dynamic_budget_enabled", True):
  588. return base
  589. stage = self._resolve_context_stage(chapter)
  590. dynamic_weights = getattr(self.config, "context_template_weights_dynamic", None)
  591. if not isinstance(dynamic_weights, dict):
  592. dynamic_weights = self.TEMPLATE_WEIGHTS_DYNAMIC
  593. stage_weights = dynamic_weights.get(stage, {}) if isinstance(dynamic_weights.get(stage, {}), dict) else {}
  594. staged = stage_weights.get(template_key)
  595. if isinstance(staged, dict):
  596. return dict(staged)
  597. return base
  598. def _parse_genre_tokens(self, genre_raw: str) -> List[str]:
  599. support_composite = bool(getattr(self.config, "context_genre_profile_support_composite", True))
  600. separators_raw = getattr(self.config, "context_genre_profile_separators", ("+", "/", "|", ","))
  601. separators = tuple(str(token) for token in separators_raw if str(token))
  602. return parse_genre_tokens(
  603. genre_raw,
  604. support_composite=support_composite,
  605. separators=separators,
  606. )
  607. def _normalize_genre_token(self, token: str) -> str:
  608. return normalize_genre_token(token)
  609. def _build_composite_genre_hints(self, genres: List[str], refs: List[str]) -> List[str]:
  610. return build_composite_genre_hints(genres, refs)
  611. def _build_writing_checklist(
  612. self,
  613. chapter: int,
  614. guidance_items: List[str],
  615. reader_signal: Dict[str, Any],
  616. genre_profile: Dict[str, Any],
  617. strategy_card: Dict[str, Any] | None = None,
  618. ) -> List[Dict[str, Any]]:
  619. _ = chapter
  620. if not getattr(self.config, "context_writing_checklist_enabled", True):
  621. return []
  622. min_items = max(1, int(getattr(self.config, "context_writing_checklist_min_items", 3)))
  623. max_items = max(min_items, int(getattr(self.config, "context_writing_checklist_max_items", 6)))
  624. default_weight = float(getattr(self.config, "context_writing_checklist_default_weight", 1.0))
  625. if default_weight <= 0:
  626. default_weight = 1.0
  627. return build_writing_checklist(
  628. guidance_items=guidance_items,
  629. reader_signal=reader_signal,
  630. genre_profile=genre_profile,
  631. strategy_card=strategy_card,
  632. min_items=min_items,
  633. max_items=max_items,
  634. default_weight=default_weight,
  635. )
  636. def _is_methodology_enabled_for_genre(self, genre_profile: Dict[str, Any]) -> bool:
  637. if not bool(getattr(self.config, "context_methodology_enabled", False)):
  638. return False
  639. whitelist_raw = getattr(self.config, "context_methodology_genre_whitelist", ("*",))
  640. if isinstance(whitelist_raw, str):
  641. whitelist_iter = [whitelist_raw]
  642. else:
  643. whitelist_iter = list(whitelist_raw or [])
  644. whitelist = {str(token).strip().lower() for token in whitelist_iter if str(token).strip()}
  645. if not whitelist:
  646. return True
  647. if "*" in whitelist or "all" in whitelist:
  648. return True
  649. genre = str((genre_profile or {}).get("genre") or "").strip()
  650. if not genre:
  651. return False
  652. profile_key = to_profile_key(genre)
  653. return profile_key in whitelist
  654. def _compact_json_text(self, content: Any, budget: Optional[int]) -> str:
  655. raw = json.dumps(content, ensure_ascii=False)
  656. if budget is None or len(raw) <= budget:
  657. return raw
  658. if not getattr(self.config, "context_compact_text_enabled", True):
  659. return raw[:budget]
  660. min_budget = max(1, int(getattr(self.config, "context_compact_min_budget", 120)))
  661. if budget <= min_budget:
  662. return raw[:budget]
  663. head_ratio = float(getattr(self.config, "context_compact_head_ratio", 0.65))
  664. head_budget = int(budget * max(0.2, min(0.9, head_ratio)))
  665. tail_budget = max(0, budget - head_budget - 10)
  666. compact = f"{raw[:head_budget]}…[TRUNCATED]{raw[-tail_budget:] if tail_budget else ''}"
  667. return compact[:budget]
  668. def _extract_genre_section(self, text: str, genre: str) -> str:
  669. return extract_genre_section(text, genre)
  670. def _extract_markdown_refs(self, text: str, max_items: int = 8) -> List[str]:
  671. return extract_markdown_refs(text, max_items=max_items)
  672. def _load_state(self) -> Dict[str, Any]:
  673. path = self.config.state_file
  674. if not path.exists():
  675. return {}
  676. return json.loads(path.read_text(encoding="utf-8"))
  677. def _load_outline(self, chapter: int) -> str:
  678. return load_chapter_outline(self.config.project_root, chapter, max_chars=1500)
  679. def _load_plot_structure(self, chapter: int) -> Dict[str, Any]:
  680. return load_chapter_plot_structure(self.config.project_root, chapter)
  681. def _build_story_contract_from_runtime(self, runtime_sources: RuntimeSourceSnapshot) -> Dict[str, Any]:
  682. story_root = self.config.story_system_dir
  683. return {
  684. "master_setting": runtime_sources.contracts.get("master") or {},
  685. "chapter_brief": runtime_sources.contracts.get("chapter") or {},
  686. "volume_brief": runtime_sources.contracts.get("volume") or {},
  687. "review_contract": runtime_sources.contracts.get("review") or {},
  688. "anti_patterns": read_json_if_exists(story_root / "anti_patterns.json") or [],
  689. }
  690. def _story_contract_signature(self, chapter: int) -> Dict[str, str]:
  691. story_root = self.config.story_system_dir
  692. runtime_sources = load_runtime_sources(self.config.project_root, chapter)
  693. volume_path = story_root / "volumes"
  694. volume_ref = runtime_sources.contracts.get("volume") or {}
  695. volume_num = int((volume_ref.get("meta") or {}).get("volume") or 0)
  696. volume_path = volume_path / f"volume_{max(volume_num, 1):03d}.json"
  697. paths = {
  698. "master_setting": story_root / "MASTER_SETTING.json",
  699. "chapter_brief": story_root / "chapters" / f"chapter_{chapter:03d}.json",
  700. "volume_brief": volume_path,
  701. "review_contract": story_root / "reviews" / f"chapter_{chapter:03d}.review.json",
  702. "anti_patterns": story_root / "anti_patterns.json",
  703. }
  704. signature: Dict[str, str] = {}
  705. for name, path in paths.items():
  706. if not path.is_file():
  707. signature[name] = "missing"
  708. continue
  709. digest = hashlib.sha1(path.read_bytes()).hexdigest()
  710. signature[name] = digest
  711. signature["latest_commit"] = self._payload_signature(runtime_sources.latest_commit)
  712. signature["latest_accepted_commit"] = self._payload_signature(runtime_sources.latest_accepted_commit)
  713. return signature
  714. def _payload_signature(self, payload: Any) -> str:
  715. if not payload:
  716. return "missing"
  717. encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
  718. return hashlib.sha1(encoded).hexdigest()
  719. def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  720. summaries = []
  721. for ch in range(max(1, chapter - window), chapter):
  722. summary = self._load_summary_text(ch)
  723. if summary:
  724. summaries.append(summary)
  725. return summaries
  726. def _load_recent_meta(self, state: Dict[str, Any], chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  727. meta = state.get("chapter_meta", {}) or {}
  728. results = []
  729. for ch in range(max(1, chapter - window), chapter):
  730. for key in (f"{ch:04d}", str(ch)):
  731. if key in meta:
  732. results.append({"chapter": ch, **meta.get(key, {})})
  733. break
  734. return results
  735. def _load_recent_appearances(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
  736. appearances = self.index_manager.get_recent_appearances(limit=limit)
  737. return appearances or []
  738. def _load_setting(self, keyword: str) -> str:
  739. settings_dir = self.config.settings_dir
  740. candidates = [
  741. settings_dir / f"{keyword}.md",
  742. ]
  743. for path in candidates:
  744. if path.exists():
  745. return path.read_text(encoding="utf-8")
  746. # fallback: any file containing keyword
  747. matches = list(settings_dir.glob(f"*{keyword}*.md"))
  748. if matches:
  749. return matches[0].read_text(encoding="utf-8")
  750. return f"[{keyword}设定未找到]"
  751. def _extract_summary_excerpt(self, text: str, max_chars: int) -> str:
  752. if not text:
  753. return ""
  754. match = self.SUMMARY_SECTION_RE.search(text)
  755. excerpt = match.group(1).strip() if match else text.strip()
  756. if max_chars > 0 and len(excerpt) > max_chars:
  757. return excerpt[:max_chars].rstrip()
  758. return excerpt
  759. def _load_summary_text(self, chapter: int, snippet_chars: Optional[int] = None) -> Optional[Dict[str, Any]]:
  760. summary_path = self.config.webnovel_dir / "summaries" / f"ch{chapter:04d}.md"
  761. if not summary_path.exists():
  762. return None
  763. text = summary_path.read_text(encoding="utf-8")
  764. if snippet_chars:
  765. summary_text = self._extract_summary_excerpt(text, snippet_chars)
  766. else:
  767. summary_text = text
  768. return {"chapter": chapter, "summary": summary_text}
  769. def _load_story_skeleton(self, chapter: int) -> List[Dict[str, Any]]:
  770. interval = max(1, int(self.config.context_story_skeleton_interval))
  771. max_samples = max(0, int(self.config.context_story_skeleton_max_samples))
  772. snippet_chars = int(self.config.context_story_skeleton_snippet_chars)
  773. if max_samples <= 0 or chapter <= interval:
  774. return []
  775. samples: List[Dict[str, Any]] = []
  776. cursor = chapter - interval
  777. while cursor >= 1 and len(samples) < max_samples:
  778. summary = self._load_summary_text(cursor, snippet_chars=snippet_chars)
  779. if summary and summary.get("summary"):
  780. samples.append(summary)
  781. cursor -= interval
  782. samples.reverse()
  783. return samples
  784. def _load_json_optional(self, path: Path) -> Dict[str, Any]:
  785. if not path.exists():
  786. return {}
  787. try:
  788. return json.loads(path.read_text(encoding="utf-8"))
  789. except json.JSONDecodeError:
  790. return {}
  791. def main():
  792. import argparse
  793. from .cli_output import print_success, print_error
  794. parser = argparse.ArgumentParser(description="Context Manager CLI")
  795. parser.add_argument("--project-root", type=str, help="项目根目录")
  796. parser.add_argument("--chapter", type=int, required=True)
  797. parser.add_argument("--template", type=str, default=ContextManager.DEFAULT_TEMPLATE)
  798. parser.add_argument("--no-snapshot", action="store_true")
  799. parser.add_argument("--max-chars", type=int, default=8000)
  800. args = parser.parse_args()
  801. config = None
  802. if args.project_root:
  803. # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
  804. from project_locator import resolve_project_root
  805. from .config import DataModulesConfig
  806. resolved_root = resolve_project_root(args.project_root)
  807. config = DataModulesConfig.from_project_root(resolved_root)
  808. manager = ContextManager(config)
  809. try:
  810. payload = manager.build_context(
  811. chapter=args.chapter,
  812. template=args.template,
  813. use_snapshot=not args.no_snapshot,
  814. save_snapshot=True,
  815. max_chars=args.max_chars,
  816. )
  817. print_success(payload, message="context_built")
  818. try:
  819. manager.index_manager.log_tool_call("context_manager:build", True, chapter=args.chapter)
  820. except Exception as exc:
  821. logger.warning("failed to log successful tool call: %s", exc)
  822. except Exception as exc:
  823. print_error("CONTEXT_BUILD_FAILED", str(exc), suggestion="请检查项目结构与依赖文件")
  824. try:
  825. manager.index_manager.log_tool_call(
  826. "context_manager:build", False, error_code="CONTEXT_BUILD_FAILED", error_message=str(exc), chapter=args.chapter
  827. )
  828. except Exception as log_exc:
  829. logger.warning("failed to log failed tool call: %s", log_exc)
  830. if __name__ == "__main__":
  831. import sys
  832. if sys.platform == "win32":
  833. enable_windows_utf8_stdio()
  834. main()