context_manager.py 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040
  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, WritingChecklistScoreMeta
  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. TEMPLATE_WEIGHTS_DYNAMIC = {
  24. "early": {
  25. "plot": {"core": 0.48, "scene": 0.39, "global": 0.13},
  26. "battle": {"core": 0.42, "scene": 0.50, "global": 0.08},
  27. "emotion": {"core": 0.52, "scene": 0.38, "global": 0.10},
  28. "transition": {"core": 0.56, "scene": 0.28, "global": 0.16},
  29. },
  30. "mid": {
  31. "plot": {"core": 0.40, "scene": 0.35, "global": 0.25},
  32. "battle": {"core": 0.35, "scene": 0.45, "global": 0.20},
  33. "emotion": {"core": 0.45, "scene": 0.35, "global": 0.20},
  34. "transition": {"core": 0.50, "scene": 0.25, "global": 0.25},
  35. },
  36. "late": {
  37. "plot": {"core": 0.36, "scene": 0.29, "global": 0.35},
  38. "battle": {"core": 0.31, "scene": 0.39, "global": 0.30},
  39. "emotion": {"core": 0.41, "scene": 0.29, "global": 0.30},
  40. "transition": {"core": 0.46, "scene": 0.21, "global": 0.33},
  41. },
  42. }
  43. EXTRA_SECTIONS = {
  44. "story_skeleton",
  45. "memory",
  46. "preferences",
  47. "alerts",
  48. "reader_signal",
  49. "genre_profile",
  50. "writing_guidance",
  51. }
  52. SECTION_ORDER = [
  53. "core",
  54. "scene",
  55. "global",
  56. "reader_signal",
  57. "genre_profile",
  58. "writing_guidance",
  59. "story_skeleton",
  60. "memory",
  61. "preferences",
  62. "alerts",
  63. ]
  64. SUMMARY_SECTION_RE = re.compile(r"##\s*剧情摘要\s*\r?\n(.*?)(?=\r?\n##|\Z)", re.DOTALL)
  65. def __init__(self, config=None, snapshot_manager: Optional[SnapshotManager] = None):
  66. self.config = config or get_config()
  67. self.snapshot_manager = snapshot_manager or SnapshotManager(self.config)
  68. self.index_manager = IndexManager(self.config)
  69. self.context_ranker = ContextRanker(self.config)
  70. def _is_snapshot_compatible(self, cached: Dict[str, Any], template: str) -> bool:
  71. """判断快照是否可用于当前模板。"""
  72. if not isinstance(cached, dict):
  73. return False
  74. meta = cached.get("meta")
  75. if not isinstance(meta, dict):
  76. # 兼容旧快照:未记录 template 时仅允许默认模板复用
  77. return template == self.DEFAULT_TEMPLATE
  78. cached_template = meta.get("template")
  79. if not isinstance(cached_template, str):
  80. return template == self.DEFAULT_TEMPLATE
  81. return cached_template == template
  82. def build_context(
  83. self,
  84. chapter: int,
  85. template: str | None = None,
  86. use_snapshot: bool = True,
  87. save_snapshot: bool = True,
  88. max_chars: Optional[int] = None,
  89. ) -> Dict[str, Any]:
  90. template = template or self.DEFAULT_TEMPLATE
  91. self._active_template = template
  92. if template not in self.TEMPLATE_WEIGHTS:
  93. template = self.DEFAULT_TEMPLATE
  94. self._active_template = template
  95. if use_snapshot:
  96. try:
  97. cached = self.snapshot_manager.load_snapshot(chapter)
  98. if cached and self._is_snapshot_compatible(cached, template):
  99. return cached.get("payload", cached)
  100. except SnapshotVersionMismatch:
  101. # Snapshot incompatible; rebuild below.
  102. pass
  103. pack = self._build_pack(chapter)
  104. if getattr(self.config, "context_ranker_enabled", True):
  105. pack = self.context_ranker.rank_pack(pack, chapter)
  106. assembled = self.assemble_context(pack, template=template, max_chars=max_chars)
  107. if save_snapshot:
  108. meta = {"template": template}
  109. self.snapshot_manager.save_snapshot(chapter, assembled, meta=meta)
  110. return assembled
  111. def assemble_context(
  112. self,
  113. pack: Dict[str, Any],
  114. template: str = DEFAULT_TEMPLATE,
  115. max_chars: Optional[int] = None,
  116. ) -> Dict[str, Any]:
  117. chapter = int((pack.get("meta") or {}).get("chapter") or 0)
  118. weights = self._resolve_template_weights(template=template, chapter=chapter)
  119. max_chars = max_chars or 8000
  120. extra_budget = int(self.config.context_extra_section_budget or 0)
  121. sections = {}
  122. for section_name in self.SECTION_ORDER:
  123. if section_name in pack:
  124. sections[section_name] = pack[section_name]
  125. assembled: Dict[str, Any] = {"meta": pack.get("meta", {}), "sections": {}}
  126. for name, content in sections.items():
  127. weight = weights.get(name, 0.0)
  128. if weight > 0:
  129. budget = int(max_chars * weight)
  130. elif name in self.EXTRA_SECTIONS and extra_budget > 0:
  131. budget = extra_budget
  132. else:
  133. budget = None
  134. text = self._compact_json_text(content, budget)
  135. assembled["sections"][name] = {"content": content, "text": text, "budget": budget}
  136. assembled["template"] = template
  137. assembled["weights"] = weights
  138. if chapter > 0:
  139. assembled.setdefault("meta", {})["context_weight_stage"] = self._resolve_context_stage(chapter)
  140. return assembled
  141. def filter_invalid_items(self, items: List[Dict[str, Any]], source_type: str, id_key: str) -> List[Dict[str, Any]]:
  142. confirmed = self.index_manager.get_invalid_ids(source_type, status="confirmed")
  143. pending = self.index_manager.get_invalid_ids(source_type, status="pending")
  144. result = []
  145. for item in items:
  146. item_id = str(item.get(id_key, ""))
  147. if item_id in confirmed:
  148. continue
  149. if item_id in pending:
  150. item = dict(item)
  151. item["warning"] = "pending_invalid"
  152. result.append(item)
  153. return result
  154. def apply_confidence_filter(self, items: List[Dict[str, Any]], min_confidence: float) -> List[Dict[str, Any]]:
  155. filtered: List[Dict[str, Any]] = []
  156. for item in items:
  157. conf = item.get("confidence")
  158. if conf is None or conf >= min_confidence:
  159. filtered.append(item)
  160. return filtered
  161. def _build_pack(self, chapter: int) -> Dict[str, Any]:
  162. state = self._load_state()
  163. core = {
  164. "chapter_outline": self._load_outline(chapter),
  165. "protagonist_snapshot": state.get("protagonist_state", {}),
  166. "recent_summaries": self._load_recent_summaries(
  167. chapter,
  168. window=self.config.context_recent_summaries_window,
  169. ),
  170. "recent_meta": self._load_recent_meta(
  171. state,
  172. chapter,
  173. window=self.config.context_recent_meta_window,
  174. ),
  175. }
  176. scene = {
  177. "location_context": state.get("protagonist_state", {}).get("location", {}),
  178. "appearing_characters": self._load_recent_appearances(
  179. limit=self.config.context_max_appearing_characters,
  180. ),
  181. }
  182. scene["appearing_characters"] = self.filter_invalid_items(
  183. scene["appearing_characters"], source_type="entity", id_key="entity_id"
  184. )
  185. global_ctx = {
  186. "worldview_skeleton": self._load_setting("世界观"),
  187. "power_system_skeleton": self._load_setting("力量体系"),
  188. "style_contract_ref": self._load_setting("风格契约"),
  189. }
  190. preferences = self._load_json_optional(self.config.webnovel_dir / "preferences.json")
  191. memory = self._load_json_optional(self.config.webnovel_dir / "project_memory.json")
  192. story_skeleton = self._load_story_skeleton(chapter)
  193. alert_slice = max(0, int(self.config.context_alerts_slice))
  194. reader_signal = self._load_reader_signal(chapter)
  195. genre_profile = self._load_genre_profile(state)
  196. writing_guidance = self._build_writing_guidance(chapter, reader_signal, genre_profile)
  197. return {
  198. "meta": {"chapter": chapter},
  199. "core": core,
  200. "scene": scene,
  201. "global": global_ctx,
  202. "reader_signal": reader_signal,
  203. "genre_profile": genre_profile,
  204. "writing_guidance": writing_guidance,
  205. "story_skeleton": story_skeleton,
  206. "preferences": preferences,
  207. "memory": memory,
  208. "alerts": {
  209. "disambiguation_warnings": (
  210. state.get("disambiguation_warnings", [])[-alert_slice:] if alert_slice else []
  211. ),
  212. "disambiguation_pending": (
  213. state.get("disambiguation_pending", [])[-alert_slice:] if alert_slice else []
  214. ),
  215. },
  216. }
  217. def _load_reader_signal(self, chapter: int) -> Dict[str, Any]:
  218. if not getattr(self.config, "context_reader_signal_enabled", True):
  219. return {}
  220. recent_limit = max(1, int(getattr(self.config, "context_reader_signal_recent_limit", 5)))
  221. pattern_window = max(1, int(getattr(self.config, "context_reader_signal_window_chapters", 20)))
  222. review_window = max(1, int(getattr(self.config, "context_reader_signal_review_window", 5)))
  223. include_debt = bool(getattr(self.config, "context_reader_signal_include_debt", False))
  224. recent_power = self.index_manager.get_recent_reading_power(limit=recent_limit)
  225. pattern_stats = self.index_manager.get_pattern_usage_stats(last_n_chapters=pattern_window)
  226. hook_stats = self.index_manager.get_hook_type_stats(last_n_chapters=pattern_window)
  227. review_trend = self.index_manager.get_review_trend_stats(last_n=review_window)
  228. low_score_ranges: List[Dict[str, Any]] = []
  229. for row in review_trend.get("recent_ranges", []):
  230. score = row.get("overall_score")
  231. if isinstance(score, (int, float)) and float(score) < 75:
  232. low_score_ranges.append(
  233. {
  234. "start_chapter": row.get("start_chapter"),
  235. "end_chapter": row.get("end_chapter"),
  236. "overall_score": score,
  237. }
  238. )
  239. signal: Dict[str, Any] = {
  240. "recent_reading_power": recent_power,
  241. "pattern_usage": pattern_stats,
  242. "hook_type_usage": hook_stats,
  243. "review_trend": review_trend,
  244. "low_score_ranges": low_score_ranges,
  245. "next_chapter": chapter,
  246. }
  247. if include_debt:
  248. signal["debt_summary"] = self.index_manager.get_debt_summary()
  249. return signal
  250. def _load_genre_profile(self, state: Dict[str, Any]) -> Dict[str, Any]:
  251. if not getattr(self.config, "context_genre_profile_enabled", True):
  252. return {}
  253. fallback = str(getattr(self.config, "context_genre_profile_fallback", "shuangwen") or "shuangwen")
  254. genre_raw = str((state.get("project") or {}).get("genre") or fallback)
  255. genres = self._parse_genre_tokens(genre_raw)
  256. if not genres:
  257. genres = [fallback]
  258. max_genres = max(1, int(getattr(self.config, "context_genre_profile_max_genres", 2)))
  259. genres = genres[:max_genres]
  260. primary_genre = genres[0]
  261. secondary_genres = genres[1:]
  262. composite = len(genres) > 1
  263. profile_path = self.config.project_root / ".claude" / "references" / "genre-profiles.md"
  264. taxonomy_path = self.config.project_root / ".claude" / "references" / "reading-power-taxonomy.md"
  265. profile_text = profile_path.read_text(encoding="utf-8") if profile_path.exists() else ""
  266. taxonomy_text = taxonomy_path.read_text(encoding="utf-8") if taxonomy_path.exists() else ""
  267. profile_excerpt = self._extract_genre_section(profile_text, primary_genre)
  268. taxonomy_excerpt = self._extract_genre_section(taxonomy_text, primary_genre)
  269. secondary_profiles: List[str] = []
  270. secondary_taxonomies: List[str] = []
  271. for extra in secondary_genres:
  272. secondary_profiles.append(self._extract_genre_section(profile_text, extra))
  273. secondary_taxonomies.append(self._extract_genre_section(taxonomy_text, extra))
  274. refs = self._extract_markdown_refs(
  275. "\n".join([profile_excerpt] + secondary_profiles),
  276. max_items=int(getattr(self.config, "context_genre_profile_max_refs", 8)),
  277. )
  278. composite_hints = self._build_composite_genre_hints(genres, refs)
  279. return {
  280. "genre": primary_genre,
  281. "genre_raw": genre_raw,
  282. "genres": genres,
  283. "composite": composite,
  284. "secondary_genres": secondary_genres,
  285. "profile_excerpt": profile_excerpt,
  286. "taxonomy_excerpt": taxonomy_excerpt,
  287. "secondary_profile_excerpts": secondary_profiles,
  288. "secondary_taxonomy_excerpts": secondary_taxonomies,
  289. "reference_hints": refs,
  290. "composite_hints": composite_hints,
  291. }
  292. def _build_writing_guidance(
  293. self,
  294. chapter: int,
  295. reader_signal: Dict[str, Any],
  296. genre_profile: Dict[str, Any],
  297. ) -> Dict[str, Any]:
  298. if not getattr(self.config, "context_writing_guidance_enabled", True):
  299. return {}
  300. guidance: List[str] = []
  301. limit = max(1, int(getattr(self.config, "context_writing_guidance_max_items", 6)))
  302. low_score_threshold = float(
  303. getattr(self.config, "context_writing_guidance_low_score_threshold", 75.0)
  304. )
  305. low_ranges = reader_signal.get("low_score_ranges") or []
  306. if low_ranges:
  307. worst = min(
  308. low_ranges,
  309. key=lambda row: float(row.get("overall_score", 9999)),
  310. )
  311. guidance.append(
  312. f"第{chapter}章优先修复近期低分段问题:参考{worst.get('start_chapter')}-{worst.get('end_chapter')}章,强化冲突推进与结尾钩子。"
  313. )
  314. hook_usage = reader_signal.get("hook_type_usage") or {}
  315. if hook_usage and getattr(self.config, "context_writing_guidance_hook_diversify", True):
  316. dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
  317. guidance.append(
  318. f"近期钩子类型“{dominant_hook}”使用偏多,本章建议做钩子差异化,避免连续同构。"
  319. )
  320. pattern_usage = reader_signal.get("pattern_usage") or {}
  321. if pattern_usage:
  322. top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
  323. guidance.append(
  324. f"爽点模式“{top_pattern}”近期高频,本章可保留主爽点但叠加一个新爽点副轴。"
  325. )
  326. review_trend = reader_signal.get("review_trend") or {}
  327. overall_avg = review_trend.get("overall_avg")
  328. if isinstance(overall_avg, (int, float)) and float(overall_avg) < low_score_threshold:
  329. guidance.append(
  330. f"最近审查均分{overall_avg:.1f}低于阈值{low_score_threshold:.1f},建议先保稳:减少跳场、每段补动作结果闭环。"
  331. )
  332. genre = str(genre_profile.get("genre") or "").strip()
  333. refs = genre_profile.get("reference_hints") or []
  334. if genre:
  335. guidance.append(f"题材锚定:按“{genre}”叙事主线推进,保持题材读者预期稳定兑现。")
  336. if refs:
  337. guidance.append(f"题材策略可执行提示:{refs[0]}")
  338. guidance.append("网文节奏基线:章首300字内给出目标与阻力,章末保留未闭合问题。")
  339. guidance.append("兑现密度基线:每600-900字给一次微兑现,并确保本章至少1处可量化变化。")
  340. genre_aliases = {
  341. "修仙": "xianxia",
  342. "修仙/玄幻": "xianxia",
  343. "玄幻": "xianxia",
  344. "爽文/系统流": "shuangwen",
  345. "高武": "xianxia",
  346. "西幻": "xianxia",
  347. "都市异能": "urban-power",
  348. "都市脑洞": "urban-power",
  349. "都市日常": "urban-power",
  350. "狗血言情": "romance",
  351. "古言": "romance",
  352. "青春甜宠": "romance",
  353. "替身文": "substitute",
  354. "规则怪谈": "rules-mystery",
  355. "悬疑脑洞": "mystery",
  356. "悬疑灵异": "mystery",
  357. "知乎短篇": "zhihu-short",
  358. "电竞": "esports",
  359. "直播文": "livestream",
  360. "克苏鲁": "cosmic-horror",
  361. }
  362. normalized_genre = genre_aliases.get(genre, genre.lower())
  363. genre_guidance = {
  364. "xianxia": "题材加权:强化升级/对抗结果的可见反馈,术语解释后置。",
  365. "shuangwen": "题材加权:维持高爽点密度,主爽点外叠加一个副轴反差。",
  366. "urban-power": "题材加权:优先写社会反馈链(他人反应→资源变化→地位变化)。",
  367. "romance": "题材加权:每章推进关系位移,避免情绪原地打转。",
  368. "mystery": "题材加权:线索必须可回收,优先以规则冲突制造悬念。",
  369. "rules-mystery": "题材加权:规则先于解释,代价先于胜利。",
  370. "zhihu-short": "题材加权:压缩铺垫,优先反转与高强度结尾钩。",
  371. "substitute": "题材加权:强化误解-拉扯-决断链路,避免重复虐点。",
  372. "esports": "题材加权:每场对抗至少写清一个战术决策点与其后果。",
  373. "livestream": "题材加权:强化“外部反馈→主角反制→数据变化”即时闭环。",
  374. "cosmic-horror": "题材加权:恐怖来源于规则与代价,不依赖空泛惊悚形容。",
  375. }
  376. genre_hint = genre_guidance.get(normalized_genre) or genre_guidance.get(genre)
  377. if genre_hint:
  378. guidance.append(genre_hint)
  379. composite_hints = genre_profile.get("composite_hints") or []
  380. if composite_hints:
  381. guidance.append(f"复合题材协同:{composite_hints[0]}")
  382. if not guidance:
  383. guidance.append("本章执行默认高可读策略:冲突前置、信息后置、段末留钩。")
  384. checklist = self._build_writing_checklist(
  385. chapter=chapter,
  386. guidance_items=guidance,
  387. reader_signal=reader_signal,
  388. genre_profile=genre_profile,
  389. )
  390. checklist_score = self._compute_writing_checklist_score(
  391. chapter=chapter,
  392. checklist=checklist,
  393. reader_signal=reader_signal,
  394. )
  395. if getattr(self.config, "context_writing_score_persist_enabled", True):
  396. self._persist_writing_checklist_score(checklist_score)
  397. return {
  398. "chapter": chapter,
  399. "guidance_items": guidance[:limit],
  400. "checklist": checklist,
  401. "checklist_score": checklist_score,
  402. "signals_used": {
  403. "has_low_score_ranges": bool(low_ranges),
  404. "hook_types": list(hook_usage.keys())[:3],
  405. "top_patterns": sorted(
  406. pattern_usage,
  407. key=pattern_usage.get,
  408. reverse=True,
  409. )[:3],
  410. "genre": genre,
  411. },
  412. }
  413. def _compute_writing_checklist_score(
  414. self,
  415. chapter: int,
  416. checklist: List[Dict[str, Any]],
  417. reader_signal: Dict[str, Any],
  418. ) -> Dict[str, Any]:
  419. total_items = len(checklist)
  420. required_items = 0
  421. completed_items = 0
  422. completed_required = 0
  423. total_weight = 0.0
  424. completed_weight = 0.0
  425. pending_labels: List[str] = []
  426. for item in checklist:
  427. if not isinstance(item, dict):
  428. continue
  429. required = bool(item.get("required"))
  430. weight = float(item.get("weight") or 1.0)
  431. total_weight += weight
  432. if required:
  433. required_items += 1
  434. completed = self._is_checklist_item_completed(item, reader_signal)
  435. if completed:
  436. completed_items += 1
  437. completed_weight += weight
  438. if required:
  439. completed_required += 1
  440. else:
  441. pending_labels.append(str(item.get("label") or item.get("id") or "未命名项"))
  442. completion_rate = (completed_items / total_items) if total_items > 0 else 1.0
  443. weighted_rate = (completed_weight / total_weight) if total_weight > 0 else completion_rate
  444. required_rate = (completed_required / required_items) if required_items > 0 else 1.0
  445. score = 100.0 * (0.5 * weighted_rate + 0.3 * required_rate + 0.2 * completion_rate)
  446. if getattr(self.config, "context_writing_score_include_reader_trend", True):
  447. trend_window = max(1, int(getattr(self.config, "context_writing_score_trend_window", 10)))
  448. trend = self.index_manager.get_writing_checklist_score_trend(last_n=trend_window)
  449. baseline = float(trend.get("score_avg") or 0.0)
  450. if baseline > 0:
  451. score += max(-10.0, min(10.0, (score - baseline) * 0.1))
  452. score = round(max(0.0, min(100.0, score)), 2)
  453. return {
  454. "chapter": chapter,
  455. "score": score,
  456. "completion_rate": round(completion_rate, 4),
  457. "weighted_completion_rate": round(weighted_rate, 4),
  458. "required_completion_rate": round(required_rate, 4),
  459. "total_items": total_items,
  460. "required_items": required_items,
  461. "completed_items": completed_items,
  462. "completed_required": completed_required,
  463. "total_weight": round(total_weight, 2),
  464. "completed_weight": round(completed_weight, 2),
  465. "pending_items": pending_labels,
  466. "trend_window": int(getattr(self.config, "context_writing_score_trend_window", 10)),
  467. }
  468. def _is_checklist_item_completed(self, item: Dict[str, Any], reader_signal: Dict[str, Any]) -> bool:
  469. item_id = str(item.get("id") or "")
  470. if item_id in {"fix_low_score_range", "readability_loop"}:
  471. review_trend = reader_signal.get("review_trend") or {}
  472. overall = review_trend.get("overall_avg")
  473. return isinstance(overall, (int, float)) and float(overall) >= 75.0
  474. if item_id == "hook_diversification":
  475. hook_usage = reader_signal.get("hook_type_usage") or {}
  476. return len(hook_usage) >= 2
  477. if item_id == "coolpoint_combo":
  478. pattern_usage = reader_signal.get("pattern_usage") or {}
  479. return len(pattern_usage) >= 2
  480. if item_id == "genre_anchor_consistency":
  481. return True
  482. source = str(item.get("source") or "")
  483. if source.startswith("fallback"):
  484. return True
  485. return False
  486. def _persist_writing_checklist_score(self, checklist_score: Dict[str, Any]) -> None:
  487. if not checklist_score:
  488. return
  489. try:
  490. self.index_manager.save_writing_checklist_score(
  491. WritingChecklistScoreMeta(
  492. chapter=int(checklist_score.get("chapter") or 0),
  493. template=str(getattr(self, "_active_template", self.DEFAULT_TEMPLATE) or self.DEFAULT_TEMPLATE),
  494. total_items=int(checklist_score.get("total_items") or 0),
  495. required_items=int(checklist_score.get("required_items") or 0),
  496. completed_items=int(checklist_score.get("completed_items") or 0),
  497. completed_required=int(checklist_score.get("completed_required") or 0),
  498. total_weight=float(checklist_score.get("total_weight") or 0.0),
  499. completed_weight=float(checklist_score.get("completed_weight") or 0.0),
  500. completion_rate=float(checklist_score.get("completion_rate") or 0.0),
  501. score=float(checklist_score.get("score") or 0.0),
  502. score_breakdown={
  503. "weighted_completion_rate": checklist_score.get("weighted_completion_rate"),
  504. "required_completion_rate": checklist_score.get("required_completion_rate"),
  505. "trend_window": checklist_score.get("trend_window"),
  506. },
  507. pending_items=list(checklist_score.get("pending_items") or []),
  508. source="context_manager",
  509. )
  510. )
  511. except Exception:
  512. pass
  513. def _resolve_context_stage(self, chapter: int) -> str:
  514. early = max(1, int(getattr(self.config, "context_dynamic_budget_early_chapter", 30)))
  515. late = max(early + 1, int(getattr(self.config, "context_dynamic_budget_late_chapter", 120)))
  516. if chapter <= early:
  517. return "early"
  518. if chapter >= late:
  519. return "late"
  520. return "mid"
  521. def _resolve_template_weights(self, template: str, chapter: int) -> Dict[str, float]:
  522. template_key = template if template in self.TEMPLATE_WEIGHTS else self.DEFAULT_TEMPLATE
  523. base = dict(self.TEMPLATE_WEIGHTS.get(template_key, self.TEMPLATE_WEIGHTS[self.DEFAULT_TEMPLATE]))
  524. if not getattr(self.config, "context_dynamic_budget_enabled", True):
  525. return base
  526. stage = self._resolve_context_stage(chapter)
  527. staged = self.TEMPLATE_WEIGHTS_DYNAMIC.get(stage, {}).get(template_key)
  528. if staged:
  529. return dict(staged)
  530. return base
  531. def _parse_genre_tokens(self, genre_raw: str) -> List[str]:
  532. text = str(genre_raw or "").strip()
  533. if not text:
  534. return []
  535. if not getattr(self.config, "context_genre_profile_support_composite", True):
  536. normalized_single = self._normalize_genre_token(text)
  537. return [normalized_single] if normalized_single else [text]
  538. separators = getattr(self.config, "context_genre_profile_separators", ("+", "/", "|", ",", ",", "、"))
  539. pattern = "|".join(re.escape(str(token)) for token in separators if str(token))
  540. if not pattern:
  541. return [text]
  542. tokens = [chunk.strip() for chunk in re.split(pattern, text) if chunk and chunk.strip()]
  543. deduped: List[str] = []
  544. seen = set()
  545. for token in tokens:
  546. normalized_token = self._normalize_genre_token(token)
  547. if not normalized_token:
  548. continue
  549. lower = normalized_token.lower()
  550. if lower in seen:
  551. continue
  552. seen.add(lower)
  553. deduped.append(normalized_token)
  554. if deduped:
  555. return deduped
  556. fallback_token = self._normalize_genre_token(text)
  557. return [fallback_token] if fallback_token else [text]
  558. def _normalize_genre_token(self, token: str) -> str:
  559. value = str(token or "").strip()
  560. if not value:
  561. return ""
  562. aliases = {
  563. "修仙/玄幻": "修仙",
  564. "玄幻修仙": "修仙",
  565. "玄幻": "修仙",
  566. "修真": "修仙",
  567. "都市修真": "都市异能",
  568. "都市高武": "高武",
  569. "都市奇闻": "都市脑洞",
  570. "古言脑洞": "古言",
  571. "游戏电竞": "电竞",
  572. "电竞文": "电竞",
  573. "直播": "直播文",
  574. "直播带货": "直播文",
  575. "主播": "直播文",
  576. "克系": "克苏鲁",
  577. "克系悬疑": "克苏鲁",
  578. }
  579. return aliases.get(value, value)
  580. def _build_composite_genre_hints(self, genres: List[str], refs: List[str]) -> List[str]:
  581. if len(genres) <= 1:
  582. return []
  583. primary = genres[0]
  584. secondaries = genres[1:]
  585. hints: List[str] = []
  586. hints.append(
  587. f"以“{primary}”作为主引擎推进主线,每章至少保留1处“{'/'.join(secondaries)}”特征表达。"
  588. )
  589. if refs:
  590. hints.append(f"复合题材执行参考:{refs[0]}")
  591. hints.append("主辅题材冲突时,优先保证主题材读者承诺,辅题材用于制造新鲜感。")
  592. return hints
  593. def _build_writing_checklist(
  594. self,
  595. chapter: int,
  596. guidance_items: List[str],
  597. reader_signal: Dict[str, Any],
  598. genre_profile: Dict[str, Any],
  599. ) -> List[Dict[str, Any]]:
  600. if not getattr(self.config, "context_writing_checklist_enabled", True):
  601. return []
  602. min_items = max(1, int(getattr(self.config, "context_writing_checklist_min_items", 3)))
  603. max_items = max(min_items, int(getattr(self.config, "context_writing_checklist_max_items", 6)))
  604. default_weight = float(getattr(self.config, "context_writing_checklist_default_weight", 1.0))
  605. if default_weight <= 0:
  606. default_weight = 1.0
  607. items: List[Dict[str, Any]] = []
  608. def _add_item(
  609. item_id: str,
  610. label: str,
  611. *,
  612. weight: Optional[float] = None,
  613. required: bool = False,
  614. source: str = "writing_guidance",
  615. verify_hint: str = "",
  616. ) -> None:
  617. if len(items) >= max_items:
  618. return
  619. if any(row.get("id") == item_id for row in items):
  620. return
  621. item_weight = float(weight if weight is not None else default_weight)
  622. if item_weight <= 0:
  623. item_weight = default_weight
  624. items.append(
  625. {
  626. "id": item_id,
  627. "label": label,
  628. "weight": round(item_weight, 2),
  629. "required": bool(required),
  630. "source": source,
  631. "verify_hint": verify_hint,
  632. }
  633. )
  634. low_ranges = reader_signal.get("low_score_ranges") or []
  635. if low_ranges:
  636. worst = min(low_ranges, key=lambda row: float(row.get("overall_score", 9999)))
  637. span = f"{worst.get('start_chapter')}-{worst.get('end_chapter')}"
  638. _add_item(
  639. "fix_low_score_range",
  640. f"修复低分区间问题(参考第{span}章)",
  641. weight=max(default_weight, 1.4),
  642. required=True,
  643. source="reader_signal.low_score_ranges",
  644. verify_hint="至少完成1处冲突升级,并在段末留下钩子。",
  645. )
  646. hook_usage = reader_signal.get("hook_type_usage") or {}
  647. if hook_usage:
  648. dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
  649. _add_item(
  650. "hook_diversification",
  651. f"钩子差异化(避免继续单一“{dominant_hook}”)",
  652. weight=max(default_weight, 1.2),
  653. required=True,
  654. source="reader_signal.hook_type_usage",
  655. verify_hint="结尾钩子类型与近20章主类型至少有一处差异。",
  656. )
  657. pattern_usage = reader_signal.get("pattern_usage") or {}
  658. if pattern_usage:
  659. top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
  660. _add_item(
  661. "coolpoint_combo",
  662. f"主爽点+副爽点组合(主爽点:{top_pattern})",
  663. weight=default_weight,
  664. required=False,
  665. source="reader_signal.pattern_usage",
  666. verify_hint="新增至少1个副爽点,并与主爽点形成因果链。",
  667. )
  668. review_trend = reader_signal.get("review_trend") or {}
  669. overall_avg = review_trend.get("overall_avg")
  670. if isinstance(overall_avg, (int, float)):
  671. _add_item(
  672. "readability_loop",
  673. "段落可读性闭环(动作→结果→情绪)",
  674. weight=max(default_weight, 1.1),
  675. required=True,
  676. source="reader_signal.review_trend",
  677. verify_hint="抽查3段,均包含动作结果闭环。",
  678. )
  679. genre = str(genre_profile.get("genre") or "").strip()
  680. if genre:
  681. _add_item(
  682. "genre_anchor_consistency",
  683. f"题材锚定一致性({genre})",
  684. weight=max(default_weight, 1.1),
  685. required=True,
  686. source="genre_profile.genre",
  687. verify_hint="主冲突与题材核心承诺保持一致。",
  688. )
  689. for idx, text in enumerate(guidance_items, start=1):
  690. if len(items) >= max_items:
  691. break
  692. label = str(text).strip()
  693. if not label:
  694. continue
  695. _add_item(
  696. f"guidance_item_{idx}",
  697. label,
  698. weight=default_weight,
  699. required=False,
  700. source="writing_guidance.guidance_items",
  701. verify_hint="完成后可在正文中定位对应段落。",
  702. )
  703. fallback_items = [
  704. (
  705. "opening_conflict",
  706. "开篇300字内给出冲突触发",
  707. "开头段出现明确目标与阻力。",
  708. ),
  709. (
  710. "scene_goal_block",
  711. "场景目标与阻力清晰",
  712. "每个场景至少有1个可验证目标。",
  713. ),
  714. (
  715. "ending_hook",
  716. "段末留钩并引出下一问",
  717. "结尾出现未解问题或下一步行动。",
  718. ),
  719. ]
  720. for item_id, label, verify_hint in fallback_items:
  721. if len(items) >= min_items or len(items) >= max_items:
  722. break
  723. _add_item(
  724. item_id,
  725. label,
  726. weight=default_weight,
  727. required=False,
  728. source="fallback",
  729. verify_hint=verify_hint,
  730. )
  731. return items[:max_items]
  732. def _compact_json_text(self, content: Any, budget: Optional[int]) -> str:
  733. raw = json.dumps(content, ensure_ascii=False)
  734. if budget is None or len(raw) <= budget:
  735. return raw
  736. if not getattr(self.config, "context_compact_text_enabled", True):
  737. return raw[:budget]
  738. min_budget = max(1, int(getattr(self.config, "context_compact_min_budget", 120)))
  739. if budget <= min_budget:
  740. return raw[:budget]
  741. head_ratio = float(getattr(self.config, "context_compact_head_ratio", 0.65))
  742. head_budget = int(budget * max(0.2, min(0.9, head_ratio)))
  743. tail_budget = max(0, budget - head_budget - 10)
  744. compact = f"{raw[:head_budget]}…[TRUNCATED]{raw[-tail_budget:] if tail_budget else ''}"
  745. return compact[:budget]
  746. def _extract_genre_section(self, text: str, genre: str) -> str:
  747. if not text:
  748. return ""
  749. lines = text.splitlines()
  750. capture: List[str] = []
  751. active = False
  752. target = genre.strip().lower()
  753. for line in lines:
  754. normalized = line.strip().lower()
  755. if normalized.startswith("## ") or normalized.startswith("### "):
  756. if active:
  757. break
  758. active = target in normalized
  759. if active:
  760. capture.append(line)
  761. continue
  762. if active:
  763. capture.append(line)
  764. if capture:
  765. return "\n".join(capture).strip()
  766. return "\n".join(lines[:80]).strip()
  767. def _extract_markdown_refs(self, text: str, max_items: int = 8) -> List[str]:
  768. if not text:
  769. return []
  770. refs: List[str] = []
  771. for line in text.splitlines():
  772. row = line.strip().lstrip("-*").strip()
  773. if not row or row.startswith("#"):
  774. continue
  775. refs.append(row)
  776. if len(refs) >= max(1, max_items):
  777. break
  778. return refs
  779. def _load_state(self) -> Dict[str, Any]:
  780. path = self.config.state_file
  781. if not path.exists():
  782. return {}
  783. return json.loads(path.read_text(encoding="utf-8"))
  784. def _load_outline(self, chapter: int) -> str:
  785. outline_dir = self.config.outline_dir
  786. patterns = [
  787. f"第{chapter}章*.md",
  788. f"第{chapter:02d}章*.md",
  789. f"第{chapter:03d}章*.md",
  790. f"第{chapter:04d}章*.md",
  791. ]
  792. for pattern in patterns:
  793. matches = list(outline_dir.glob(pattern))
  794. if matches:
  795. return matches[0].read_text(encoding="utf-8")
  796. return f"[大纲未找到: 第{chapter}章]"
  797. def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  798. summaries = []
  799. for ch in range(max(1, chapter - window), chapter):
  800. summary = self._load_summary_text(ch)
  801. if summary:
  802. summaries.append(summary)
  803. return summaries
  804. def _load_recent_meta(self, state: Dict[str, Any], chapter: int, window: int = 3) -> List[Dict[str, Any]]:
  805. meta = state.get("chapter_meta", {}) or {}
  806. results = []
  807. for ch in range(max(1, chapter - window), chapter):
  808. for key in (f"{ch:04d}", str(ch)):
  809. if key in meta:
  810. results.append({"chapter": ch, **meta.get(key, {})})
  811. break
  812. return results
  813. def _load_recent_appearances(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
  814. appearances = self.index_manager.get_recent_appearances(limit=limit)
  815. return appearances or []
  816. def _load_setting(self, keyword: str) -> str:
  817. settings_dir = self.config.settings_dir
  818. candidates = [
  819. settings_dir / f"{keyword}.md",
  820. ]
  821. for path in candidates:
  822. if path.exists():
  823. return path.read_text(encoding="utf-8")
  824. # fallback: any file containing keyword
  825. matches = list(settings_dir.glob(f"*{keyword}*.md"))
  826. if matches:
  827. return matches[0].read_text(encoding="utf-8")
  828. return f"[{keyword}设定未找到]"
  829. def _extract_summary_excerpt(self, text: str, max_chars: int) -> str:
  830. if not text:
  831. return ""
  832. match = self.SUMMARY_SECTION_RE.search(text)
  833. excerpt = match.group(1).strip() if match else text.strip()
  834. if max_chars > 0 and len(excerpt) > max_chars:
  835. return excerpt[:max_chars].rstrip()
  836. return excerpt
  837. def _load_summary_text(self, chapter: int, snippet_chars: Optional[int] = None) -> Optional[Dict[str, Any]]:
  838. summary_path = self.config.webnovel_dir / "summaries" / f"ch{chapter:04d}.md"
  839. if not summary_path.exists():
  840. return None
  841. text = summary_path.read_text(encoding="utf-8")
  842. if snippet_chars:
  843. summary_text = self._extract_summary_excerpt(text, snippet_chars)
  844. else:
  845. summary_text = text
  846. return {"chapter": chapter, "summary": summary_text}
  847. def _load_story_skeleton(self, chapter: int) -> List[Dict[str, Any]]:
  848. interval = max(1, int(self.config.context_story_skeleton_interval))
  849. max_samples = max(0, int(self.config.context_story_skeleton_max_samples))
  850. snippet_chars = int(self.config.context_story_skeleton_snippet_chars)
  851. if max_samples <= 0 or chapter <= interval:
  852. return []
  853. samples: List[Dict[str, Any]] = []
  854. cursor = chapter - interval
  855. while cursor >= 1 and len(samples) < max_samples:
  856. summary = self._load_summary_text(cursor, snippet_chars=snippet_chars)
  857. if summary and summary.get("summary"):
  858. samples.append(summary)
  859. cursor -= interval
  860. samples.reverse()
  861. return samples
  862. def _load_json_optional(self, path: Path) -> Dict[str, Any]:
  863. if not path.exists():
  864. return {}
  865. try:
  866. return json.loads(path.read_text(encoding="utf-8"))
  867. except json.JSONDecodeError:
  868. return {}
  869. def main():
  870. import argparse
  871. from .cli_output import print_success, print_error
  872. parser = argparse.ArgumentParser(description="Context Manager CLI")
  873. parser.add_argument("--project-root", type=str, help="项目根目录")
  874. parser.add_argument("--chapter", type=int, required=True)
  875. parser.add_argument("--template", type=str, default=ContextManager.DEFAULT_TEMPLATE)
  876. parser.add_argument("--no-snapshot", action="store_true")
  877. parser.add_argument("--max-chars", type=int, default=8000)
  878. args = parser.parse_args()
  879. config = None
  880. if args.project_root:
  881. from .config import DataModulesConfig
  882. config = DataModulesConfig.from_project_root(args.project_root)
  883. manager = ContextManager(config)
  884. try:
  885. payload = manager.build_context(
  886. chapter=args.chapter,
  887. template=args.template,
  888. use_snapshot=not args.no_snapshot,
  889. save_snapshot=True,
  890. max_chars=args.max_chars,
  891. )
  892. print_success(payload, message="context_built")
  893. try:
  894. manager.index_manager.log_tool_call("context_manager:build", True, chapter=args.chapter)
  895. except Exception:
  896. pass
  897. except Exception as exc:
  898. print_error("CONTEXT_BUILD_FAILED", str(exc), suggestion="请检查项目结构与依赖文件")
  899. try:
  900. manager.index_manager.log_tool_call(
  901. "context_manager:build", False, error_code="CONTEXT_BUILD_FAILED", error_message=str(exc), chapter=args.chapter
  902. )
  903. except Exception:
  904. pass
  905. if __name__ == "__main__":
  906. import sys
  907. if sys.platform == "win32":
  908. import io
  909. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
  910. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
  911. main()