Pārlūkot izejas kodu

feat: add phase-c compact context and writing guidance

lingfengQAQ 4 mēneši atpakaļ
vecāks
revīzija
bf0b0e24a1

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

@@ -35,6 +35,17 @@
   - 引用 `.claude/references/genre-profiles.md` 与 `reading-power-taxonomy.md`
   - 输出 `reference_hints` 供 Writer 快速执行
 
+## Phase C 扩展段
+- `writing_guidance`
+  - 基于 `reader_signal` + `genre_profile` 生成章节级执行建议
+  - 优先提示低分区间修复、钩子差异化、爽点模式优化、题材锚定
+  - 输出 `guidance_items` 与 `signals_used`
+
+## 紧凑文本策略
+- 当 section 超出预算时,文本采用紧凑截断(头部 + 截断标记 + 尾部)
+- 截断标记固定为 `…[TRUNCATED]`
+- 保留 `content` 原始结构,`text` 用于快速注入模型上下文
+
 ## 兼容性约束
 - 不改变既有 key 名和字段语义。
 - 仅重排列表顺序;内容不删改(除已有过滤逻辑)。
@@ -63,3 +74,12 @@ Phase B:
 - `context_genre_profile_enabled`
 - `context_genre_profile_max_refs`
 - `context_genre_profile_fallback`
+
+Phase C:
+- `context_compact_text_enabled`
+- `context_compact_min_budget`
+- `context_compact_head_ratio`
+- `context_writing_guidance_enabled`
+- `context_writing_guidance_max_items`
+- `context_writing_guidance_low_score_threshold`
+- `context_writing_guidance_hook_diversify`

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

@@ -156,6 +156,13 @@ class DataModulesConfig:
     context_genre_profile_enabled: bool = True
     context_genre_profile_max_refs: int = 8
     context_genre_profile_fallback: str = "shuangwen"
+    context_compact_text_enabled: bool = True
+    context_compact_min_budget: int = 120
+    context_compact_head_ratio: float = 0.65
+    context_writing_guidance_enabled: bool = True
+    context_writing_guidance_max_items: int = 6
+    context_writing_guidance_low_score_threshold: float = 75.0
+    context_writing_guidance_hook_diversify: bool = True
 
     export_recent_changes_slice: int = 20
     export_disambiguation_slice: int = 20

+ 93 - 3
.claude/scripts/data_modules/context_manager.py

@@ -31,6 +31,7 @@ class ContextManager:
         "alerts",
         "reader_signal",
         "genre_profile",
+        "writing_guidance",
     }
     SECTION_ORDER = [
         "core",
@@ -38,6 +39,7 @@ class ContextManager:
         "global",
         "reader_signal",
         "genre_profile",
+        "writing_guidance",
         "story_skeleton",
         "memory",
         "preferences",
@@ -123,9 +125,7 @@ class ContextManager:
                 budget = extra_budget
             else:
                 budget = None
-            text = json.dumps(content, ensure_ascii=False)
-            if budget is not None and len(text) > budget:
-                text = text[:budget]
+            text = self._compact_json_text(content, budget)
             assembled["sections"][name] = {"content": content, "text": text, "budget": budget}
 
         assembled["template"] = template
@@ -192,6 +192,7 @@ class ContextManager:
         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},
@@ -200,6 +201,7 @@ class ContextManager:
             "global": global_ctx,
             "reader_signal": reader_signal,
             "genre_profile": genre_profile,
+            "writing_guidance": writing_guidance,
             "story_skeleton": story_skeleton,
             "preferences": preferences,
             "memory": memory,
@@ -279,6 +281,94 @@ class ContextManager:
             "reference_hints": refs,
         }
 
+    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]}")
+
+        if not guidance:
+            guidance.append("本章执行默认高可读策略:冲突前置、信息后置、段末留钩。")
+
+        return {
+            "chapter": chapter,
+            "guidance_items": guidance[:limit],
+            "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 _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 ""

+ 57 - 0
.claude/scripts/data_modules/tests/test_context_manager.py

@@ -237,3 +237,60 @@ def test_context_manager_reader_signal_with_debt_and_disable_switch(temp_project
 
     manager.config.context_genre_profile_enabled = False
     assert manager._load_genre_profile({"project": {"genre": "xuanhuan"}}) == {}
+
+
+def test_context_manager_includes_writing_guidance(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=70,
+            dimension_scores={"plot": 70},
+            severity_counts={"high": 1},
+            critical_issues=["节奏拖沓"],
+        )
+    )
+
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(4, use_snapshot=False, save_snapshot=False)
+
+    guidance = payload["sections"]["writing_guidance"]["content"]
+    assert guidance.get("chapter") == 4
+    items = guidance.get("guidance_items") or []
+    assert isinstance(items, list)
+    assert items
+    assert guidance.get("signals_used", {}).get("genre") == "xuanhuan"
+
+
+def test_context_manager_compact_text_truncation(temp_project):
+    manager = ContextManager(temp_project)
+    manager.config.context_compact_text_enabled = True
+    manager.config.context_compact_min_budget = 80
+    manager.config.context_compact_head_ratio = 0.6
+
+    content = {"a": "x" * 200, "b": "y" * 200}
+    compact = manager._compact_json_text(content, budget=120)
+    assert len(compact) <= 120
+    assert "[TRUNCATED]" in compact
+
+    manager.config.context_compact_text_enabled = False
+    raw_cut = manager._compact_json_text(content, budget=100)
+    assert len(raw_cut) <= 100

+ 6 - 0
README.md

@@ -773,6 +773,12 @@ git checkout ch0045
 - 新增 `reader_signal` 段:自动聚合追读力与审查趋势信号
 - 新增 `genre_profile` 段:自动按题材加载策略参考(支持回退)
 - 目标:让写作阶段更接近网文平台读者偏好(钩子强度、爽点分布、低分补救)
+
+### Context Contract v2(阶段 C)
+
+- 新增 `writing_guidance`:按章生成可执行写作建议(低分修复/钩子差异化/题材锚定)
+- 新增紧凑文本策略:超预算 section 使用 `…[TRUNCATED]` 保留头尾关键信息
+- 目标:在有限上下文预算下提升“可写性”和“网文感”
 - **invalid_facts 表**:追踪无效事实,支持 pending/confirmed 状态
 - **父子向量索引**:parent_chunk_id 支持摘要-场景层级检索
 - **Token 预算管理**:ContextManager 实现 40%/35%/25% 优先级分配