Bladeren bron

feat: add phase-b reader signals and genre profile context

lingfengQAQ 4 maanden geleden
bovenliggende
commit
07e91d945d

+ 19 - 0
.claude/references/context-contract-v2.md

@@ -25,6 +25,16 @@
 - `alerts`
   - 优先 `critical/high` 或包含关键风险词的项
 
+## Phase B 扩展段
+- `reader_signal`
+  - 聚合最近章节追读力元数据(钩子/爽点/微兑现)
+  - 聚合最近窗口的模式使用统计(`pattern_usage` / `hook_type_usage`)
+  - 聚合审查趋势与低分区间(`review_trend` / `low_score_ranges`)
+- `genre_profile`
+  - 基于 `state.json -> project.genre` 自动选取题材策略片段
+  - 引用 `.claude/references/genre-profiles.md` 与 `reading-power-taxonomy.md`
+  - 输出 `reference_hints` 供 Writer 快速执行
+
 ## 兼容性约束
 - 不改变既有 key 名和字段语义。
 - 仅重排列表顺序;内容不删改(除已有过滤逻辑)。
@@ -44,3 +54,12 @@
 - `context_ranker_alert_critical_keywords`
 - `context_ranker_debug`
 
+Phase B:
+- `context_reader_signal_enabled`
+- `context_reader_signal_recent_limit`
+- `context_reader_signal_window_chapters`
+- `context_reader_signal_review_window`
+- `context_reader_signal_include_debt`
+- `context_genre_profile_enabled`
+- `context_genre_profile_max_refs`
+- `context_genre_profile_fallback`

+ 8 - 0
.claude/scripts/data_modules/config.py

@@ -148,6 +148,14 @@ class DataModulesConfig:
         "断裂",
     )
     context_ranker_debug: bool = False
+    context_reader_signal_enabled: bool = True
+    context_reader_signal_recent_limit: int = 5
+    context_reader_signal_window_chapters: int = 20
+    context_reader_signal_review_window: int = 5
+    context_reader_signal_include_debt: bool = False
+    context_genre_profile_enabled: bool = True
+    context_genre_profile_max_refs: int = 8
+    context_genre_profile_fallback: str = "shuangwen"
 
     export_recent_changes_slice: int = 20
     export_disambiguation_slice: int = 20

+ 127 - 2
.claude/scripts/data_modules/context_manager.py

@@ -24,8 +24,25 @@ class ContextManager:
         "emotion": {"core": 0.45, "scene": 0.35, "global": 0.20},
         "transition": {"core": 0.50, "scene": 0.25, "global": 0.25},
     }
-    EXTRA_SECTIONS = {"story_skeleton", "memory", "preferences", "alerts"}
-    SECTION_ORDER = ["core", "scene", "global", "story_skeleton", "memory", "preferences", "alerts"]
+    EXTRA_SECTIONS = {
+        "story_skeleton",
+        "memory",
+        "preferences",
+        "alerts",
+        "reader_signal",
+        "genre_profile",
+    }
+    SECTION_ORDER = [
+        "core",
+        "scene",
+        "global",
+        "reader_signal",
+        "genre_profile",
+        "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):
@@ -173,12 +190,16 @@ class ContextManager:
         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)
 
         return {
             "meta": {"chapter": chapter},
             "core": core,
             "scene": scene,
             "global": global_ctx,
+            "reader_signal": reader_signal,
+            "genre_profile": genre_profile,
             "story_skeleton": story_skeleton,
             "preferences": preferences,
             "memory": memory,
@@ -192,6 +213,110 @@ class ContextManager:
             },
         }
 
+    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 = str((state.get("project") or {}).get("genre") or fallback)
+        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, genre)
+        taxonomy_excerpt = self._extract_genre_section(taxonomy_text, genre)
+        refs = self._extract_markdown_refs(
+            profile_excerpt,
+            max_items=int(getattr(self.config, "context_genre_profile_max_refs", 8)),
+        )
+
+        return {
+            "genre": genre,
+            "profile_excerpt": profile_excerpt,
+            "taxonomy_excerpt": taxonomy_excerpt,
+            "reference_hints": refs,
+        }
+
+    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("## "):
+                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():

+ 104 - 1
.claude/scripts/data_modules/tests/test_context_manager.py

@@ -9,7 +9,12 @@ import json
 import pytest
 
 from data_modules.config import DataModulesConfig
-from data_modules.index_manager import IndexManager, EntityMeta
+from data_modules.index_manager import (
+    IndexManager,
+    EntityMeta,
+    ChapterReadingPowerMeta,
+    ReviewMetrics,
+)
 from data_modules.context_manager import ContextManager
 from data_modules.snapshot_manager import SnapshotManager, SnapshotVersionMismatch
 from data_modules.query_router import QueryRouter
@@ -134,3 +139,101 @@ def test_context_manager_applies_ranker_and_contract_meta(temp_project):
     warnings = payload["sections"]["alerts"]["content"]["disambiguation_warnings"]
     if warnings and isinstance(warnings[0], dict):
         assert "critical" in str(warnings[0].get("message", "")) or warnings[0].get("severity") == "high"
+
+
+def test_context_manager_includes_reader_signal_and_genre_profile(temp_project):
+    state = {
+        "project": {"genre": "xuanhuan"},
+        "protagonist_state": {"name": "萧炎"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    idx = IndexManager(temp_project)
+    idx.save_chapter_reading_power(
+        ChapterReadingPowerMeta(
+            chapter=3,
+            hook_type="悬念钩",
+            hook_strength="strong",
+            coolpoint_patterns=["身份掉马"],
+        )
+    )
+    idx.save_review_metrics(
+        ReviewMetrics(
+            start_chapter=1,
+            end_chapter=3,
+            overall_score=72,
+            dimension_scores={"plot": 72},
+            severity_counts={"high": 1},
+            critical_issues=["节奏拖沓"],
+        )
+    )
+
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(4, use_snapshot=False, save_snapshot=False)
+
+    reader_signal = payload["sections"]["reader_signal"]["content"]
+    assert "recent_reading_power" in reader_signal
+    assert "pattern_usage" in reader_signal
+    assert "hook_type_usage" in reader_signal
+    assert "review_trend" in reader_signal
+    assert isinstance(reader_signal.get("low_score_ranges"), list)
+
+    genre_profile = payload["sections"]["genre_profile"]["content"]
+    assert genre_profile.get("genre") == "xuanhuan"
+    assert "profile_excerpt" in genre_profile
+    assert "taxonomy_excerpt" in genre_profile
+
+
+def test_context_manager_genre_section_and_refs_extraction(temp_project):
+    refs_dir = temp_project.project_root / ".claude" / "references"
+    refs_dir.mkdir(parents=True, exist_ok=True)
+
+    (refs_dir / "genre-profiles.md").write_text(
+        """
+## shuangwen
+- 节奏快
+- 打脸密集
+
+## xuanhuan
+- 升级线清晰
+- 资源争夺
+""".strip(),
+        encoding="utf-8",
+    )
+    (refs_dir / "reading-power-taxonomy.md").write_text(
+        """
+## xuanhuan
+- 钩子强度优先 strong
+- 爽点使用战力跨级
+""".strip(),
+        encoding="utf-8",
+    )
+
+    manager = ContextManager(temp_project)
+
+    profile = manager._load_genre_profile({"project": {"genre": "xuanhuan"}})
+    assert profile["genre"] == "xuanhuan"
+    assert "升级线清晰" in profile["profile_excerpt"]
+    assert "钩子强度" in profile["taxonomy_excerpt"]
+    assert isinstance(profile["reference_hints"], list)
+    assert profile["reference_hints"]
+
+    fallback_excerpt = manager._extract_genre_section("## a\n1\n## b\n2", "unknown")
+    assert fallback_excerpt.startswith("## a")
+
+
+def test_context_manager_reader_signal_with_debt_and_disable_switch(temp_project):
+    manager = ContextManager(temp_project)
+    manager.config.context_reader_signal_include_debt = True
+
+    signal = manager._load_reader_signal(chapter=5)
+    assert "debt_summary" in signal
+
+    manager.config.context_reader_signal_enabled = False
+    assert manager._load_reader_signal(chapter=5) == {}
+
+    manager.config.context_genre_profile_enabled = False
+    assert manager._load_genre_profile({"project": {"genre": "xuanhuan"}}) == {}

+ 6 - 0
README.md

@@ -767,6 +767,12 @@ git checkout ch0045
 - 工作流可观测性:`workflow_manager.py` 会写入 `.webnovel/observability/call_trace.jsonl`
 
 参考文档:`.claude/references/context-contract-v2.md`
+
+### Context Contract v2(阶段 B)
+
+- 新增 `reader_signal` 段:自动聚合追读力与审查趋势信号
+- 新增 `genre_profile` 段:自动按题材加载策略参考(支持回退)
+- 目标:让写作阶段更接近网文平台读者偏好(钩子强度、爽点分布、低分补救)
 - **invalid_facts 表**:追踪无效事实,支持 pending/confirmed 状态
 - **父子向量索引**:parent_chunk_id 支持摘要-场景层级检索
 - **Token 预算管理**:ContextManager 实现 40%/35%/25% 优先级分配