| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- ContextManager - assemble context packs with weighted priorities.
- """
- from __future__ import annotations
- import json
- import re
- from pathlib import Path
- from typing import Any, Dict, List, Optional
- from .config import get_config
- from .index_manager import IndexManager, WritingChecklistScoreMeta
- from .context_ranker import ContextRanker
- from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch
- class ContextManager:
- DEFAULT_TEMPLATE = "plot"
- TEMPLATE_WEIGHTS = {
- "plot": {"core": 0.40, "scene": 0.35, "global": 0.25},
- "battle": {"core": 0.35, "scene": 0.45, "global": 0.20},
- "emotion": {"core": 0.45, "scene": 0.35, "global": 0.20},
- "transition": {"core": 0.50, "scene": 0.25, "global": 0.25},
- }
- TEMPLATE_WEIGHTS_DYNAMIC = {
- "early": {
- "plot": {"core": 0.48, "scene": 0.39, "global": 0.13},
- "battle": {"core": 0.42, "scene": 0.50, "global": 0.08},
- "emotion": {"core": 0.52, "scene": 0.38, "global": 0.10},
- "transition": {"core": 0.56, "scene": 0.28, "global": 0.16},
- },
- "mid": {
- "plot": {"core": 0.40, "scene": 0.35, "global": 0.25},
- "battle": {"core": 0.35, "scene": 0.45, "global": 0.20},
- "emotion": {"core": 0.45, "scene": 0.35, "global": 0.20},
- "transition": {"core": 0.50, "scene": 0.25, "global": 0.25},
- },
- "late": {
- "plot": {"core": 0.36, "scene": 0.29, "global": 0.35},
- "battle": {"core": 0.31, "scene": 0.39, "global": 0.30},
- "emotion": {"core": 0.41, "scene": 0.29, "global": 0.30},
- "transition": {"core": 0.46, "scene": 0.21, "global": 0.33},
- },
- }
- EXTRA_SECTIONS = {
- "story_skeleton",
- "memory",
- "preferences",
- "alerts",
- "reader_signal",
- "genre_profile",
- "writing_guidance",
- }
- SECTION_ORDER = [
- "core",
- "scene",
- "global",
- "reader_signal",
- "genre_profile",
- "writing_guidance",
- "story_skeleton",
- "memory",
- "preferences",
- "alerts",
- ]
- SUMMARY_SECTION_RE = re.compile(r"##\s*剧情摘要\s*\r?\n(.*?)(?=\r?\n##|\Z)", re.DOTALL)
- def __init__(self, config=None, snapshot_manager: Optional[SnapshotManager] = None):
- self.config = config or get_config()
- self.snapshot_manager = snapshot_manager or SnapshotManager(self.config)
- self.index_manager = IndexManager(self.config)
- self.context_ranker = ContextRanker(self.config)
- def _is_snapshot_compatible(self, cached: Dict[str, Any], template: str) -> bool:
- """判断快照是否可用于当前模板。"""
- if not isinstance(cached, dict):
- return False
- meta = cached.get("meta")
- if not isinstance(meta, dict):
- # 兼容旧快照:未记录 template 时仅允许默认模板复用
- return template == self.DEFAULT_TEMPLATE
- cached_template = meta.get("template")
- if not isinstance(cached_template, str):
- return template == self.DEFAULT_TEMPLATE
- return cached_template == template
- def build_context(
- self,
- chapter: int,
- template: str | None = None,
- use_snapshot: bool = True,
- save_snapshot: bool = True,
- max_chars: Optional[int] = None,
- ) -> Dict[str, Any]:
- template = template or self.DEFAULT_TEMPLATE
- self._active_template = template
- if template not in self.TEMPLATE_WEIGHTS:
- template = self.DEFAULT_TEMPLATE
- self._active_template = template
- if use_snapshot:
- try:
- cached = self.snapshot_manager.load_snapshot(chapter)
- if cached and self._is_snapshot_compatible(cached, template):
- return cached.get("payload", cached)
- except SnapshotVersionMismatch:
- # Snapshot incompatible; rebuild below.
- pass
- pack = self._build_pack(chapter)
- if getattr(self.config, "context_ranker_enabled", True):
- pack = self.context_ranker.rank_pack(pack, chapter)
- assembled = self.assemble_context(pack, template=template, max_chars=max_chars)
- if save_snapshot:
- meta = {"template": template}
- self.snapshot_manager.save_snapshot(chapter, assembled, meta=meta)
- return assembled
- def assemble_context(
- self,
- pack: Dict[str, Any],
- template: str = DEFAULT_TEMPLATE,
- max_chars: Optional[int] = None,
- ) -> Dict[str, Any]:
- chapter = int((pack.get("meta") or {}).get("chapter") or 0)
- weights = self._resolve_template_weights(template=template, chapter=chapter)
- max_chars = max_chars or 8000
- extra_budget = int(self.config.context_extra_section_budget or 0)
- sections = {}
- for section_name in self.SECTION_ORDER:
- if section_name in pack:
- sections[section_name] = pack[section_name]
- assembled: Dict[str, Any] = {"meta": pack.get("meta", {}), "sections": {}}
- for name, content in sections.items():
- weight = weights.get(name, 0.0)
- if weight > 0:
- budget = int(max_chars * weight)
- elif name in self.EXTRA_SECTIONS and extra_budget > 0:
- budget = extra_budget
- else:
- budget = None
- text = self._compact_json_text(content, budget)
- assembled["sections"][name] = {"content": content, "text": text, "budget": budget}
- assembled["template"] = template
- assembled["weights"] = weights
- if chapter > 0:
- assembled.setdefault("meta", {})["context_weight_stage"] = self._resolve_context_stage(chapter)
- return assembled
- def filter_invalid_items(self, items: List[Dict[str, Any]], source_type: str, id_key: str) -> List[Dict[str, Any]]:
- confirmed = self.index_manager.get_invalid_ids(source_type, status="confirmed")
- pending = self.index_manager.get_invalid_ids(source_type, status="pending")
- result = []
- for item in items:
- item_id = str(item.get(id_key, ""))
- if item_id in confirmed:
- continue
- if item_id in pending:
- item = dict(item)
- item["warning"] = "pending_invalid"
- result.append(item)
- return result
- def apply_confidence_filter(self, items: List[Dict[str, Any]], min_confidence: float) -> List[Dict[str, Any]]:
- filtered: List[Dict[str, Any]] = []
- for item in items:
- conf = item.get("confidence")
- if conf is None or conf >= min_confidence:
- filtered.append(item)
- return filtered
- def _build_pack(self, chapter: int) -> Dict[str, Any]:
- state = self._load_state()
- core = {
- "chapter_outline": self._load_outline(chapter),
- "protagonist_snapshot": state.get("protagonist_state", {}),
- "recent_summaries": self._load_recent_summaries(
- chapter,
- window=self.config.context_recent_summaries_window,
- ),
- "recent_meta": self._load_recent_meta(
- state,
- chapter,
- window=self.config.context_recent_meta_window,
- ),
- }
- scene = {
- "location_context": state.get("protagonist_state", {}).get("location", {}),
- "appearing_characters": self._load_recent_appearances(
- limit=self.config.context_max_appearing_characters,
- ),
- }
- scene["appearing_characters"] = self.filter_invalid_items(
- scene["appearing_characters"], source_type="entity", id_key="entity_id"
- )
- global_ctx = {
- "worldview_skeleton": self._load_setting("世界观"),
- "power_system_skeleton": self._load_setting("力量体系"),
- "style_contract_ref": self._load_setting("风格契约"),
- }
- preferences = self._load_json_optional(self.config.webnovel_dir / "preferences.json")
- memory = self._load_json_optional(self.config.webnovel_dir / "project_memory.json")
- story_skeleton = self._load_story_skeleton(chapter)
- alert_slice = max(0, int(self.config.context_alerts_slice))
- reader_signal = self._load_reader_signal(chapter)
- genre_profile = self._load_genre_profile(state)
- writing_guidance = self._build_writing_guidance(chapter, reader_signal, genre_profile)
- return {
- "meta": {"chapter": chapter},
- "core": core,
- "scene": scene,
- "global": global_ctx,
- "reader_signal": reader_signal,
- "genre_profile": genre_profile,
- "writing_guidance": writing_guidance,
- "story_skeleton": story_skeleton,
- "preferences": preferences,
- "memory": memory,
- "alerts": {
- "disambiguation_warnings": (
- state.get("disambiguation_warnings", [])[-alert_slice:] if alert_slice else []
- ),
- "disambiguation_pending": (
- state.get("disambiguation_pending", [])[-alert_slice:] if alert_slice else []
- ),
- },
- }
- def _load_reader_signal(self, chapter: int) -> Dict[str, Any]:
- if not getattr(self.config, "context_reader_signal_enabled", True):
- return {}
- recent_limit = max(1, int(getattr(self.config, "context_reader_signal_recent_limit", 5)))
- pattern_window = max(1, int(getattr(self.config, "context_reader_signal_window_chapters", 20)))
- review_window = max(1, int(getattr(self.config, "context_reader_signal_review_window", 5)))
- include_debt = bool(getattr(self.config, "context_reader_signal_include_debt", False))
- recent_power = self.index_manager.get_recent_reading_power(limit=recent_limit)
- pattern_stats = self.index_manager.get_pattern_usage_stats(last_n_chapters=pattern_window)
- hook_stats = self.index_manager.get_hook_type_stats(last_n_chapters=pattern_window)
- review_trend = self.index_manager.get_review_trend_stats(last_n=review_window)
- low_score_ranges: List[Dict[str, Any]] = []
- for row in review_trend.get("recent_ranges", []):
- score = row.get("overall_score")
- if isinstance(score, (int, float)) and float(score) < 75:
- low_score_ranges.append(
- {
- "start_chapter": row.get("start_chapter"),
- "end_chapter": row.get("end_chapter"),
- "overall_score": score,
- }
- )
- signal: Dict[str, Any] = {
- "recent_reading_power": recent_power,
- "pattern_usage": pattern_stats,
- "hook_type_usage": hook_stats,
- "review_trend": review_trend,
- "low_score_ranges": low_score_ranges,
- "next_chapter": chapter,
- }
- if include_debt:
- signal["debt_summary"] = self.index_manager.get_debt_summary()
- return signal
- def _load_genre_profile(self, state: Dict[str, Any]) -> Dict[str, Any]:
- if not getattr(self.config, "context_genre_profile_enabled", True):
- return {}
- fallback = str(getattr(self.config, "context_genre_profile_fallback", "shuangwen") or "shuangwen")
- genre_raw = str((state.get("project") or {}).get("genre") or fallback)
- genres = self._parse_genre_tokens(genre_raw)
- if not genres:
- genres = [fallback]
- max_genres = max(1, int(getattr(self.config, "context_genre_profile_max_genres", 2)))
- genres = genres[:max_genres]
- primary_genre = genres[0]
- secondary_genres = genres[1:]
- composite = len(genres) > 1
- profile_path = self.config.project_root / ".claude" / "references" / "genre-profiles.md"
- taxonomy_path = self.config.project_root / ".claude" / "references" / "reading-power-taxonomy.md"
- profile_text = profile_path.read_text(encoding="utf-8") if profile_path.exists() else ""
- taxonomy_text = taxonomy_path.read_text(encoding="utf-8") if taxonomy_path.exists() else ""
- profile_excerpt = self._extract_genre_section(profile_text, primary_genre)
- taxonomy_excerpt = self._extract_genre_section(taxonomy_text, primary_genre)
- secondary_profiles: List[str] = []
- secondary_taxonomies: List[str] = []
- for extra in secondary_genres:
- secondary_profiles.append(self._extract_genre_section(profile_text, extra))
- secondary_taxonomies.append(self._extract_genre_section(taxonomy_text, extra))
- refs = self._extract_markdown_refs(
- "\n".join([profile_excerpt] + secondary_profiles),
- max_items=int(getattr(self.config, "context_genre_profile_max_refs", 8)),
- )
- composite_hints = self._build_composite_genre_hints(genres, refs)
- return {
- "genre": primary_genre,
- "genre_raw": genre_raw,
- "genres": genres,
- "composite": composite,
- "secondary_genres": secondary_genres,
- "profile_excerpt": profile_excerpt,
- "taxonomy_excerpt": taxonomy_excerpt,
- "secondary_profile_excerpts": secondary_profiles,
- "secondary_taxonomy_excerpts": secondary_taxonomies,
- "reference_hints": refs,
- "composite_hints": composite_hints,
- }
- def _build_writing_guidance(
- self,
- chapter: int,
- reader_signal: Dict[str, Any],
- genre_profile: Dict[str, Any],
- ) -> Dict[str, Any]:
- if not getattr(self.config, "context_writing_guidance_enabled", True):
- return {}
- guidance: List[str] = []
- limit = max(1, int(getattr(self.config, "context_writing_guidance_max_items", 6)))
- low_score_threshold = float(
- getattr(self.config, "context_writing_guidance_low_score_threshold", 75.0)
- )
- low_ranges = reader_signal.get("low_score_ranges") or []
- if low_ranges:
- worst = min(
- low_ranges,
- key=lambda row: float(row.get("overall_score", 9999)),
- )
- guidance.append(
- f"第{chapter}章优先修复近期低分段问题:参考{worst.get('start_chapter')}-{worst.get('end_chapter')}章,强化冲突推进与结尾钩子。"
- )
- hook_usage = reader_signal.get("hook_type_usage") or {}
- if hook_usage and getattr(self.config, "context_writing_guidance_hook_diversify", True):
- dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
- guidance.append(
- f"近期钩子类型“{dominant_hook}”使用偏多,本章建议做钩子差异化,避免连续同构。"
- )
- pattern_usage = reader_signal.get("pattern_usage") or {}
- if pattern_usage:
- top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
- guidance.append(
- f"爽点模式“{top_pattern}”近期高频,本章可保留主爽点但叠加一个新爽点副轴。"
- )
- review_trend = reader_signal.get("review_trend") or {}
- overall_avg = review_trend.get("overall_avg")
- if isinstance(overall_avg, (int, float)) and float(overall_avg) < low_score_threshold:
- guidance.append(
- f"最近审查均分{overall_avg:.1f}低于阈值{low_score_threshold:.1f},建议先保稳:减少跳场、每段补动作结果闭环。"
- )
- genre = str(genre_profile.get("genre") or "").strip()
- refs = genre_profile.get("reference_hints") or []
- if genre:
- guidance.append(f"题材锚定:按“{genre}”叙事主线推进,保持题材读者预期稳定兑现。")
- if refs:
- guidance.append(f"题材策略可执行提示:{refs[0]}")
- guidance.append("网文节奏基线:章首300字内给出目标与阻力,章末保留未闭合问题。")
- guidance.append("兑现密度基线:每600-900字给一次微兑现,并确保本章至少1处可量化变化。")
- genre_aliases = {
- "修仙": "xianxia",
- "修仙/玄幻": "xianxia",
- "玄幻": "xianxia",
- "爽文/系统流": "shuangwen",
- "高武": "xianxia",
- "西幻": "xianxia",
- "都市异能": "urban-power",
- "都市脑洞": "urban-power",
- "都市日常": "urban-power",
- "狗血言情": "romance",
- "古言": "romance",
- "青春甜宠": "romance",
- "替身文": "substitute",
- "规则怪谈": "rules-mystery",
- "悬疑脑洞": "mystery",
- "悬疑灵异": "mystery",
- "知乎短篇": "zhihu-short",
- "电竞": "esports",
- "直播文": "livestream",
- "克苏鲁": "cosmic-horror",
- }
- normalized_genre = genre_aliases.get(genre, genre.lower())
- genre_guidance = {
- "xianxia": "题材加权:强化升级/对抗结果的可见反馈,术语解释后置。",
- "shuangwen": "题材加权:维持高爽点密度,主爽点外叠加一个副轴反差。",
- "urban-power": "题材加权:优先写社会反馈链(他人反应→资源变化→地位变化)。",
- "romance": "题材加权:每章推进关系位移,避免情绪原地打转。",
- "mystery": "题材加权:线索必须可回收,优先以规则冲突制造悬念。",
- "rules-mystery": "题材加权:规则先于解释,代价先于胜利。",
- "zhihu-short": "题材加权:压缩铺垫,优先反转与高强度结尾钩。",
- "substitute": "题材加权:强化误解-拉扯-决断链路,避免重复虐点。",
- "esports": "题材加权:每场对抗至少写清一个战术决策点与其后果。",
- "livestream": "题材加权:强化“外部反馈→主角反制→数据变化”即时闭环。",
- "cosmic-horror": "题材加权:恐怖来源于规则与代价,不依赖空泛惊悚形容。",
- }
- genre_hint = genre_guidance.get(normalized_genre) or genre_guidance.get(genre)
- if genre_hint:
- guidance.append(genre_hint)
- composite_hints = genre_profile.get("composite_hints") or []
- if composite_hints:
- guidance.append(f"复合题材协同:{composite_hints[0]}")
- if not guidance:
- guidance.append("本章执行默认高可读策略:冲突前置、信息后置、段末留钩。")
- checklist = self._build_writing_checklist(
- chapter=chapter,
- guidance_items=guidance,
- reader_signal=reader_signal,
- genre_profile=genre_profile,
- )
- checklist_score = self._compute_writing_checklist_score(
- chapter=chapter,
- checklist=checklist,
- reader_signal=reader_signal,
- )
- if getattr(self.config, "context_writing_score_persist_enabled", True):
- self._persist_writing_checklist_score(checklist_score)
- return {
- "chapter": chapter,
- "guidance_items": guidance[:limit],
- "checklist": checklist,
- "checklist_score": checklist_score,
- "signals_used": {
- "has_low_score_ranges": bool(low_ranges),
- "hook_types": list(hook_usage.keys())[:3],
- "top_patterns": sorted(
- pattern_usage,
- key=pattern_usage.get,
- reverse=True,
- )[:3],
- "genre": genre,
- },
- }
- def _compute_writing_checklist_score(
- self,
- chapter: int,
- checklist: List[Dict[str, Any]],
- reader_signal: Dict[str, Any],
- ) -> Dict[str, Any]:
- total_items = len(checklist)
- required_items = 0
- completed_items = 0
- completed_required = 0
- total_weight = 0.0
- completed_weight = 0.0
- pending_labels: List[str] = []
- for item in checklist:
- if not isinstance(item, dict):
- continue
- required = bool(item.get("required"))
- weight = float(item.get("weight") or 1.0)
- total_weight += weight
- if required:
- required_items += 1
- completed = self._is_checklist_item_completed(item, reader_signal)
- if completed:
- completed_items += 1
- completed_weight += weight
- if required:
- completed_required += 1
- else:
- pending_labels.append(str(item.get("label") or item.get("id") or "未命名项"))
- completion_rate = (completed_items / total_items) if total_items > 0 else 1.0
- weighted_rate = (completed_weight / total_weight) if total_weight > 0 else completion_rate
- required_rate = (completed_required / required_items) if required_items > 0 else 1.0
- score = 100.0 * (0.5 * weighted_rate + 0.3 * required_rate + 0.2 * completion_rate)
- if getattr(self.config, "context_writing_score_include_reader_trend", True):
- trend_window = max(1, int(getattr(self.config, "context_writing_score_trend_window", 10)))
- trend = self.index_manager.get_writing_checklist_score_trend(last_n=trend_window)
- baseline = float(trend.get("score_avg") or 0.0)
- if baseline > 0:
- score += max(-10.0, min(10.0, (score - baseline) * 0.1))
- score = round(max(0.0, min(100.0, score)), 2)
- return {
- "chapter": chapter,
- "score": score,
- "completion_rate": round(completion_rate, 4),
- "weighted_completion_rate": round(weighted_rate, 4),
- "required_completion_rate": round(required_rate, 4),
- "total_items": total_items,
- "required_items": required_items,
- "completed_items": completed_items,
- "completed_required": completed_required,
- "total_weight": round(total_weight, 2),
- "completed_weight": round(completed_weight, 2),
- "pending_items": pending_labels,
- "trend_window": int(getattr(self.config, "context_writing_score_trend_window", 10)),
- }
- def _is_checklist_item_completed(self, item: Dict[str, Any], reader_signal: Dict[str, Any]) -> bool:
- item_id = str(item.get("id") or "")
- if item_id in {"fix_low_score_range", "readability_loop"}:
- review_trend = reader_signal.get("review_trend") or {}
- overall = review_trend.get("overall_avg")
- return isinstance(overall, (int, float)) and float(overall) >= 75.0
- if item_id == "hook_diversification":
- hook_usage = reader_signal.get("hook_type_usage") or {}
- return len(hook_usage) >= 2
- if item_id == "coolpoint_combo":
- pattern_usage = reader_signal.get("pattern_usage") or {}
- return len(pattern_usage) >= 2
- if item_id == "genre_anchor_consistency":
- return True
- source = str(item.get("source") or "")
- if source.startswith("fallback"):
- return True
- return False
- def _persist_writing_checklist_score(self, checklist_score: Dict[str, Any]) -> None:
- if not checklist_score:
- return
- try:
- self.index_manager.save_writing_checklist_score(
- WritingChecklistScoreMeta(
- chapter=int(checklist_score.get("chapter") or 0),
- template=str(getattr(self, "_active_template", self.DEFAULT_TEMPLATE) or self.DEFAULT_TEMPLATE),
- total_items=int(checklist_score.get("total_items") or 0),
- required_items=int(checklist_score.get("required_items") or 0),
- completed_items=int(checklist_score.get("completed_items") or 0),
- completed_required=int(checklist_score.get("completed_required") or 0),
- total_weight=float(checklist_score.get("total_weight") or 0.0),
- completed_weight=float(checklist_score.get("completed_weight") or 0.0),
- completion_rate=float(checklist_score.get("completion_rate") or 0.0),
- score=float(checklist_score.get("score") or 0.0),
- score_breakdown={
- "weighted_completion_rate": checklist_score.get("weighted_completion_rate"),
- "required_completion_rate": checklist_score.get("required_completion_rate"),
- "trend_window": checklist_score.get("trend_window"),
- },
- pending_items=list(checklist_score.get("pending_items") or []),
- source="context_manager",
- )
- )
- except Exception:
- pass
- def _resolve_context_stage(self, chapter: int) -> str:
- early = max(1, int(getattr(self.config, "context_dynamic_budget_early_chapter", 30)))
- late = max(early + 1, int(getattr(self.config, "context_dynamic_budget_late_chapter", 120)))
- if chapter <= early:
- return "early"
- if chapter >= late:
- return "late"
- return "mid"
- def _resolve_template_weights(self, template: str, chapter: int) -> Dict[str, float]:
- template_key = template if template in self.TEMPLATE_WEIGHTS else self.DEFAULT_TEMPLATE
- base = dict(self.TEMPLATE_WEIGHTS.get(template_key, self.TEMPLATE_WEIGHTS[self.DEFAULT_TEMPLATE]))
- if not getattr(self.config, "context_dynamic_budget_enabled", True):
- return base
- stage = self._resolve_context_stage(chapter)
- staged = self.TEMPLATE_WEIGHTS_DYNAMIC.get(stage, {}).get(template_key)
- if staged:
- return dict(staged)
- return base
- def _parse_genre_tokens(self, genre_raw: str) -> List[str]:
- text = str(genre_raw or "").strip()
- if not text:
- return []
- if not getattr(self.config, "context_genre_profile_support_composite", True):
- normalized_single = self._normalize_genre_token(text)
- return [normalized_single] if normalized_single else [text]
- separators = getattr(self.config, "context_genre_profile_separators", ("+", "/", "|", ",", ",", "、"))
- pattern = "|".join(re.escape(str(token)) for token in separators if str(token))
- if not pattern:
- return [text]
- tokens = [chunk.strip() for chunk in re.split(pattern, text) if chunk and chunk.strip()]
- deduped: List[str] = []
- seen = set()
- for token in tokens:
- normalized_token = self._normalize_genre_token(token)
- if not normalized_token:
- continue
- lower = normalized_token.lower()
- if lower in seen:
- continue
- seen.add(lower)
- deduped.append(normalized_token)
- if deduped:
- return deduped
- fallback_token = self._normalize_genre_token(text)
- return [fallback_token] if fallback_token else [text]
- def _normalize_genre_token(self, token: str) -> str:
- value = str(token or "").strip()
- if not value:
- return ""
- aliases = {
- "游戏电竞": "电竞",
- "电竞文": "电竞",
- "直播": "直播文",
- "直播带货": "直播文",
- "主播": "直播文",
- "克系": "克苏鲁",
- "克系悬疑": "克苏鲁",
- }
- return aliases.get(value, value)
- def _build_composite_genre_hints(self, genres: List[str], refs: List[str]) -> List[str]:
- if len(genres) <= 1:
- return []
- primary = genres[0]
- secondaries = genres[1:]
- hints: List[str] = []
- hints.append(
- f"以“{primary}”作为主引擎推进主线,每章至少保留1处“{'/'.join(secondaries)}”特征表达。"
- )
- if refs:
- hints.append(f"复合题材执行参考:{refs[0]}")
- hints.append("主辅题材冲突时,优先保证主题材读者承诺,辅题材用于制造新鲜感。")
- return hints
- def _build_writing_checklist(
- self,
- chapter: int,
- guidance_items: List[str],
- reader_signal: Dict[str, Any],
- genre_profile: Dict[str, Any],
- ) -> List[Dict[str, Any]]:
- if not getattr(self.config, "context_writing_checklist_enabled", True):
- return []
- min_items = max(1, int(getattr(self.config, "context_writing_checklist_min_items", 3)))
- max_items = max(min_items, int(getattr(self.config, "context_writing_checklist_max_items", 6)))
- default_weight = float(getattr(self.config, "context_writing_checklist_default_weight", 1.0))
- if default_weight <= 0:
- default_weight = 1.0
- items: List[Dict[str, Any]] = []
- def _add_item(
- item_id: str,
- label: str,
- *,
- weight: Optional[float] = None,
- required: bool = False,
- source: str = "writing_guidance",
- verify_hint: str = "",
- ) -> None:
- if len(items) >= max_items:
- return
- if any(row.get("id") == item_id for row in items):
- return
- item_weight = float(weight if weight is not None else default_weight)
- if item_weight <= 0:
- item_weight = default_weight
- items.append(
- {
- "id": item_id,
- "label": label,
- "weight": round(item_weight, 2),
- "required": bool(required),
- "source": source,
- "verify_hint": verify_hint,
- }
- )
- low_ranges = reader_signal.get("low_score_ranges") or []
- if low_ranges:
- worst = min(low_ranges, key=lambda row: float(row.get("overall_score", 9999)))
- span = f"{worst.get('start_chapter')}-{worst.get('end_chapter')}"
- _add_item(
- "fix_low_score_range",
- f"修复低分区间问题(参考第{span}章)",
- weight=max(default_weight, 1.4),
- required=True,
- source="reader_signal.low_score_ranges",
- verify_hint="至少完成1处冲突升级,并在段末留下钩子。",
- )
- hook_usage = reader_signal.get("hook_type_usage") or {}
- if hook_usage:
- dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
- _add_item(
- "hook_diversification",
- f"钩子差异化(避免继续单一“{dominant_hook}”)",
- weight=max(default_weight, 1.2),
- required=True,
- source="reader_signal.hook_type_usage",
- verify_hint="结尾钩子类型与近20章主类型至少有一处差异。",
- )
- pattern_usage = reader_signal.get("pattern_usage") or {}
- if pattern_usage:
- top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
- _add_item(
- "coolpoint_combo",
- f"主爽点+副爽点组合(主爽点:{top_pattern})",
- weight=default_weight,
- required=False,
- source="reader_signal.pattern_usage",
- verify_hint="新增至少1个副爽点,并与主爽点形成因果链。",
- )
- review_trend = reader_signal.get("review_trend") or {}
- overall_avg = review_trend.get("overall_avg")
- if isinstance(overall_avg, (int, float)):
- _add_item(
- "readability_loop",
- "段落可读性闭环(动作→结果→情绪)",
- weight=max(default_weight, 1.1),
- required=True,
- source="reader_signal.review_trend",
- verify_hint="抽查3段,均包含动作结果闭环。",
- )
- genre = str(genre_profile.get("genre") or "").strip()
- if genre:
- _add_item(
- "genre_anchor_consistency",
- f"题材锚定一致性({genre})",
- weight=max(default_weight, 1.1),
- required=True,
- source="genre_profile.genre",
- verify_hint="主冲突与题材核心承诺保持一致。",
- )
- for idx, text in enumerate(guidance_items, start=1):
- if len(items) >= max_items:
- break
- label = str(text).strip()
- if not label:
- continue
- _add_item(
- f"guidance_item_{idx}",
- label,
- weight=default_weight,
- required=False,
- source="writing_guidance.guidance_items",
- verify_hint="完成后可在正文中定位对应段落。",
- )
- fallback_items = [
- (
- "opening_conflict",
- "开篇300字内给出冲突触发",
- "开头段出现明确目标与阻力。",
- ),
- (
- "scene_goal_block",
- "场景目标与阻力清晰",
- "每个场景至少有1个可验证目标。",
- ),
- (
- "ending_hook",
- "段末留钩并引出下一问",
- "结尾出现未解问题或下一步行动。",
- ),
- ]
- for item_id, label, verify_hint in fallback_items:
- if len(items) >= min_items or len(items) >= max_items:
- break
- _add_item(
- item_id,
- label,
- weight=default_weight,
- required=False,
- source="fallback",
- verify_hint=verify_hint,
- )
- return items[:max_items]
- def _compact_json_text(self, content: Any, budget: Optional[int]) -> str:
- raw = json.dumps(content, ensure_ascii=False)
- if budget is None or len(raw) <= budget:
- return raw
- if not getattr(self.config, "context_compact_text_enabled", True):
- return raw[:budget]
- min_budget = max(1, int(getattr(self.config, "context_compact_min_budget", 120)))
- if budget <= min_budget:
- return raw[:budget]
- head_ratio = float(getattr(self.config, "context_compact_head_ratio", 0.65))
- head_budget = int(budget * max(0.2, min(0.9, head_ratio)))
- tail_budget = max(0, budget - head_budget - 10)
- compact = f"{raw[:head_budget]}…[TRUNCATED]{raw[-tail_budget:] if tail_budget else ''}"
- return compact[:budget]
- def _extract_genre_section(self, text: str, genre: str) -> str:
- if not text:
- return ""
- lines = text.splitlines()
- capture: List[str] = []
- active = False
- target = genre.strip().lower()
- for line in lines:
- normalized = line.strip().lower()
- if normalized.startswith("## ") or normalized.startswith("### "):
- if active:
- break
- active = target in normalized
- if active:
- capture.append(line)
- continue
- if active:
- capture.append(line)
- if capture:
- return "\n".join(capture).strip()
- return "\n".join(lines[:80]).strip()
- def _extract_markdown_refs(self, text: str, max_items: int = 8) -> List[str]:
- if not text:
- return []
- refs: List[str] = []
- for line in text.splitlines():
- row = line.strip().lstrip("-*").strip()
- if not row or row.startswith("#"):
- continue
- refs.append(row)
- if len(refs) >= max(1, max_items):
- break
- return refs
- def _load_state(self) -> Dict[str, Any]:
- path = self.config.state_file
- if not path.exists():
- return {}
- return json.loads(path.read_text(encoding="utf-8"))
- def _load_outline(self, chapter: int) -> str:
- outline_dir = self.config.outline_dir
- patterns = [
- f"第{chapter}章*.md",
- f"第{chapter:02d}章*.md",
- f"第{chapter:03d}章*.md",
- f"第{chapter:04d}章*.md",
- ]
- for pattern in patterns:
- matches = list(outline_dir.glob(pattern))
- if matches:
- return matches[0].read_text(encoding="utf-8")
- return f"[大纲未找到: 第{chapter}章]"
- def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
- summaries = []
- for ch in range(max(1, chapter - window), chapter):
- summary = self._load_summary_text(ch)
- if summary:
- summaries.append(summary)
- return summaries
- def _load_recent_meta(self, state: Dict[str, Any], chapter: int, window: int = 3) -> List[Dict[str, Any]]:
- meta = state.get("chapter_meta", {}) or {}
- results = []
- for ch in range(max(1, chapter - window), chapter):
- for key in (f"{ch:04d}", str(ch)):
- if key in meta:
- results.append({"chapter": ch, **meta.get(key, {})})
- break
- return results
- def _load_recent_appearances(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
- appearances = self.index_manager.get_recent_appearances(limit=limit)
- return appearances or []
- def _load_setting(self, keyword: str) -> str:
- settings_dir = self.config.settings_dir
- candidates = [
- settings_dir / f"{keyword}.md",
- ]
- for path in candidates:
- if path.exists():
- return path.read_text(encoding="utf-8")
- # fallback: any file containing keyword
- matches = list(settings_dir.glob(f"*{keyword}*.md"))
- if matches:
- return matches[0].read_text(encoding="utf-8")
- return f"[{keyword}设定未找到]"
- def _extract_summary_excerpt(self, text: str, max_chars: int) -> str:
- if not text:
- return ""
- match = self.SUMMARY_SECTION_RE.search(text)
- excerpt = match.group(1).strip() if match else text.strip()
- if max_chars > 0 and len(excerpt) > max_chars:
- return excerpt[:max_chars].rstrip()
- return excerpt
- def _load_summary_text(self, chapter: int, snippet_chars: Optional[int] = None) -> Optional[Dict[str, Any]]:
- summary_path = self.config.webnovel_dir / "summaries" / f"ch{chapter:04d}.md"
- if not summary_path.exists():
- return None
- text = summary_path.read_text(encoding="utf-8")
- if snippet_chars:
- summary_text = self._extract_summary_excerpt(text, snippet_chars)
- else:
- summary_text = text
- return {"chapter": chapter, "summary": summary_text}
- def _load_story_skeleton(self, chapter: int) -> List[Dict[str, Any]]:
- interval = max(1, int(self.config.context_story_skeleton_interval))
- max_samples = max(0, int(self.config.context_story_skeleton_max_samples))
- snippet_chars = int(self.config.context_story_skeleton_snippet_chars)
- if max_samples <= 0 or chapter <= interval:
- return []
- samples: List[Dict[str, Any]] = []
- cursor = chapter - interval
- while cursor >= 1 and len(samples) < max_samples:
- summary = self._load_summary_text(cursor, snippet_chars=snippet_chars)
- if summary and summary.get("summary"):
- samples.append(summary)
- cursor -= interval
- samples.reverse()
- return samples
- def _load_json_optional(self, path: Path) -> Dict[str, Any]:
- if not path.exists():
- return {}
- try:
- return json.loads(path.read_text(encoding="utf-8"))
- except json.JSONDecodeError:
- return {}
- def main():
- import argparse
- from .cli_output import print_success, print_error
- parser = argparse.ArgumentParser(description="Context Manager CLI")
- parser.add_argument("--project-root", type=str, help="项目根目录")
- parser.add_argument("--chapter", type=int, required=True)
- parser.add_argument("--template", type=str, default=ContextManager.DEFAULT_TEMPLATE)
- parser.add_argument("--no-snapshot", action="store_true")
- parser.add_argument("--max-chars", type=int, default=8000)
- args = parser.parse_args()
- config = None
- if args.project_root:
- from .config import DataModulesConfig
- config = DataModulesConfig.from_project_root(args.project_root)
- manager = ContextManager(config)
- try:
- payload = manager.build_context(
- chapter=args.chapter,
- template=args.template,
- use_snapshot=not args.no_snapshot,
- save_snapshot=True,
- max_chars=args.max_chars,
- )
- print_success(payload, message="context_built")
- try:
- manager.index_manager.log_tool_call("context_manager:build", True, chapter=args.chapter)
- except Exception:
- pass
- except Exception as exc:
- print_error("CONTEXT_BUILD_FAILED", str(exc), suggestion="请检查项目结构与依赖文件")
- try:
- manager.index_manager.log_tool_call(
- "context_manager:build", False, error_code="CONTEXT_BUILD_FAILED", error_message=str(exc), chapter=args.chapter
- )
- except Exception:
- pass
- if __name__ == "__main__":
- import sys
- if sys.platform == "win32":
- import io
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
- main()
|