feat: initial commit
This commit is contained in:
@@ -0,0 +1,809 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ContextManager - assemble context packs with weighted priorities.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from runtime_compat import enable_windows_utf8_stdio
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from chapter_outline_loader import load_chapter_outline
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.chapter_outline_loader import load_chapter_outline
|
||||
|
||||
from .config import get_config
|
||||
from .index_manager import IndexManager, WritingChecklistScoreMeta
|
||||
from .context_ranker import ContextRanker
|
||||
from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch
|
||||
from .context_weights import (
|
||||
DEFAULT_TEMPLATE as CONTEXT_DEFAULT_TEMPLATE,
|
||||
TEMPLATE_WEIGHTS as CONTEXT_TEMPLATE_WEIGHTS,
|
||||
TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT as CONTEXT_TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT,
|
||||
)
|
||||
from .genre_aliases import normalize_genre_token, to_profile_key
|
||||
from .genre_profile_builder import (
|
||||
build_composite_genre_hints,
|
||||
extract_genre_section,
|
||||
extract_markdown_refs,
|
||||
parse_genre_tokens,
|
||||
)
|
||||
from .writing_guidance_builder import (
|
||||
build_methodology_guidance_items,
|
||||
build_methodology_strategy_card,
|
||||
build_guidance_items,
|
||||
build_writing_checklist,
|
||||
is_checklist_item_completed,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContextManager:
|
||||
DEFAULT_TEMPLATE = CONTEXT_DEFAULT_TEMPLATE
|
||||
TEMPLATE_WEIGHTS = CONTEXT_TEMPLATE_WEIGHTS
|
||||
TEMPLATE_WEIGHTS_DYNAMIC = CONTEXT_TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT
|
||||
EXTRA_SECTIONS = {
|
||||
"story_skeleton",
|
||||
"memory",
|
||||
"preferences",
|
||||
"alerts",
|
||||
"reader_signal",
|
||||
"genre_profile",
|
||||
"writing_guidance",
|
||||
"wiki",
|
||||
}
|
||||
SECTION_ORDER = [
|
||||
"core",
|
||||
"scene",
|
||||
"global",
|
||||
"reader_signal",
|
||||
"genre_profile",
|
||||
"writing_guidance",
|
||||
"story_skeleton",
|
||||
"memory",
|
||||
"wiki",
|
||||
"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.noma_dir / "preferences.json")
|
||||
memory = self._load_json_optional(self.config.noma_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)
|
||||
wiki_data = self._load_wiki_context()
|
||||
|
||||
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,
|
||||
"wiki": wiki_data,
|
||||
"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_wiki_context(self) -> Dict[str, Any]:
|
||||
"""Load relevant wiki entries for context assembly."""
|
||||
from .wiki_manager import WikiManager
|
||||
|
||||
wiki_dir = self.config.wiki_dir
|
||||
if not wiki_dir.exists():
|
||||
return {}
|
||||
|
||||
wiki = WikiManager(self.config)
|
||||
|
||||
protagonist_wiki = None
|
||||
protagonist = self.index_manager.get_protagonist()
|
||||
if protagonist:
|
||||
eid = protagonist.get("id", "")
|
||||
if eid:
|
||||
entry = wiki.get_entity_wiki(eid)
|
||||
if entry:
|
||||
protagonist_wiki = entry.get("frontmatter", {})
|
||||
|
||||
plot_threads = wiki.get_plot_threads()
|
||||
patterns = wiki.get_writing_patterns()
|
||||
|
||||
return {
|
||||
"protagonist_profile": protagonist_wiki,
|
||||
"plot_threads": plot_threads.get("body", "")[:500] if plot_threads else None,
|
||||
"writing_patterns": patterns[-5:] if patterns 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")
|
||||
project = state.get("project") or {}
|
||||
project_info = state.get("project_info") or {}
|
||||
genre_raw = str(project.get("genre") or project_info.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 {}
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
guidance_bundle = build_guidance_items(
|
||||
chapter=chapter,
|
||||
reader_signal=reader_signal,
|
||||
genre_profile=genre_profile,
|
||||
low_score_threshold=low_score_threshold,
|
||||
hook_diversify_enabled=bool(
|
||||
getattr(self.config, "context_writing_guidance_hook_diversify", True)
|
||||
),
|
||||
)
|
||||
|
||||
guidance = list(guidance_bundle.get("guidance") or [])
|
||||
methodology_strategy: Dict[str, Any] = {}
|
||||
|
||||
if self._is_methodology_enabled_for_genre(genre_profile):
|
||||
methodology_strategy = build_methodology_strategy_card(
|
||||
chapter=chapter,
|
||||
reader_signal=reader_signal,
|
||||
genre_profile=genre_profile,
|
||||
label=str(getattr(self.config, "context_methodology_label", "digital-serial-v1")),
|
||||
)
|
||||
guidance.extend(build_methodology_guidance_items(methodology_strategy))
|
||||
|
||||
checklist = self._build_writing_checklist(
|
||||
chapter=chapter,
|
||||
guidance_items=guidance,
|
||||
reader_signal=reader_signal,
|
||||
genre_profile=genre_profile,
|
||||
strategy_card=methodology_strategy,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
low_ranges = guidance_bundle.get("low_ranges") or []
|
||||
hook_usage = guidance_bundle.get("hook_usage") or {}
|
||||
pattern_usage = guidance_bundle.get("pattern_usage") or {}
|
||||
genre = str(guidance_bundle.get("genre") or genre_profile.get("genre") or "").strip()
|
||||
|
||||
hook_types = list(hook_usage.keys())[:3] if isinstance(hook_usage, dict) else []
|
||||
top_patterns = (
|
||||
sorted(pattern_usage, key=pattern_usage.get, reverse=True)[:3]
|
||||
if isinstance(pattern_usage, dict)
|
||||
else []
|
||||
)
|
||||
|
||||
return {
|
||||
"chapter": chapter,
|
||||
"guidance_items": guidance[:limit],
|
||||
"checklist": checklist,
|
||||
"checklist_score": checklist_score,
|
||||
"methodology": methodology_strategy,
|
||||
"signals_used": {
|
||||
"has_low_score_ranges": bool(low_ranges),
|
||||
"hook_types": hook_types,
|
||||
"top_patterns": top_patterns,
|
||||
"genre": genre,
|
||||
"methodology_enabled": bool(methodology_strategy.get("enabled")),
|
||||
},
|
||||
}
|
||||
|
||||
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:
|
||||
return is_checklist_item_completed(item, reader_signal)
|
||||
|
||||
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 as exc:
|
||||
logger.warning("failed to persist writing checklist score: %s", exc)
|
||||
|
||||
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)
|
||||
dynamic_weights = getattr(self.config, "context_template_weights_dynamic", None)
|
||||
if not isinstance(dynamic_weights, dict):
|
||||
dynamic_weights = self.TEMPLATE_WEIGHTS_DYNAMIC
|
||||
|
||||
stage_weights = dynamic_weights.get(stage, {}) if isinstance(dynamic_weights.get(stage, {}), dict) else {}
|
||||
staged = stage_weights.get(template_key)
|
||||
if isinstance(staged, dict):
|
||||
return dict(staged)
|
||||
|
||||
return base
|
||||
|
||||
def _parse_genre_tokens(self, genre_raw: str) -> List[str]:
|
||||
support_composite = bool(getattr(self.config, "context_genre_profile_support_composite", True))
|
||||
separators_raw = getattr(self.config, "context_genre_profile_separators", ("+", "/", "|", ","))
|
||||
separators = tuple(str(token) for token in separators_raw if str(token))
|
||||
return parse_genre_tokens(
|
||||
genre_raw,
|
||||
support_composite=support_composite,
|
||||
separators=separators,
|
||||
)
|
||||
|
||||
def _normalize_genre_token(self, token: str) -> str:
|
||||
return normalize_genre_token(token)
|
||||
|
||||
def _build_composite_genre_hints(self, genres: List[str], refs: List[str]) -> List[str]:
|
||||
return build_composite_genre_hints(genres, refs)
|
||||
|
||||
def _build_writing_checklist(
|
||||
self,
|
||||
chapter: int,
|
||||
guidance_items: List[str],
|
||||
reader_signal: Dict[str, Any],
|
||||
genre_profile: Dict[str, Any],
|
||||
strategy_card: Dict[str, Any] | None = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
_ = chapter
|
||||
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
|
||||
|
||||
return build_writing_checklist(
|
||||
guidance_items=guidance_items,
|
||||
reader_signal=reader_signal,
|
||||
genre_profile=genre_profile,
|
||||
strategy_card=strategy_card,
|
||||
min_items=min_items,
|
||||
max_items=max_items,
|
||||
default_weight=default_weight,
|
||||
)
|
||||
|
||||
def _is_methodology_enabled_for_genre(self, genre_profile: Dict[str, Any]) -> bool:
|
||||
if not bool(getattr(self.config, "context_methodology_enabled", False)):
|
||||
return False
|
||||
|
||||
whitelist_raw = getattr(self.config, "context_methodology_genre_whitelist", ("*",))
|
||||
if isinstance(whitelist_raw, str):
|
||||
whitelist_iter = [whitelist_raw]
|
||||
else:
|
||||
whitelist_iter = list(whitelist_raw or [])
|
||||
|
||||
whitelist = {str(token).strip().lower() for token in whitelist_iter if str(token).strip()}
|
||||
if not whitelist:
|
||||
return True
|
||||
if "*" in whitelist or "all" in whitelist:
|
||||
return True
|
||||
|
||||
genre = str((genre_profile or {}).get("genre") or "").strip()
|
||||
if not genre:
|
||||
return False
|
||||
|
||||
profile_key = to_profile_key(genre)
|
||||
return profile_key in whitelist
|
||||
|
||||
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:
|
||||
return extract_genre_section(text, genre)
|
||||
|
||||
def _extract_markdown_refs(self, text: str, max_items: int = 8) -> List[str]:
|
||||
return extract_markdown_refs(text, max_items=max_items)
|
||||
|
||||
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:
|
||||
return load_chapter_outline(self.config.project_root, chapter, max_chars=1500)
|
||||
|
||||
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.noma_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:
|
||||
# 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .noma/state.json)
|
||||
from project_locator import resolve_project_root
|
||||
from .config import DataModulesConfig
|
||||
|
||||
resolved_root = resolve_project_root(args.project_root)
|
||||
config = DataModulesConfig.from_project_root(resolved_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 as exc:
|
||||
logger.warning("failed to log successful tool call: %s", exc)
|
||||
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 as log_exc:
|
||||
logger.warning("failed to log failed tool call: %s", log_exc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if sys.platform == "win32":
|
||||
enable_windows_utf8_stdio()
|
||||
main()
|
||||
Reference in New Issue
Block a user