Przeglądaj źródła

feat(context): add scored writing checklist to guidance

lingfengQAQ 4 miesięcy temu
rodzic
commit
6991fd31f9

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

@@ -163,6 +163,10 @@ class DataModulesConfig:
     context_writing_guidance_max_items: int = 6
     context_writing_guidance_low_score_threshold: float = 75.0
     context_writing_guidance_hook_diversify: bool = True
+    context_writing_checklist_enabled: bool = True
+    context_writing_checklist_min_items: int = 3
+    context_writing_checklist_max_items: int = 6
+    context_writing_checklist_default_weight: float = 1.0
 
     export_recent_changes_slice: int = 20
     export_disambiguation_slice: int = 20

+ 161 - 0
.claude/scripts/data_modules/context_manager.py

@@ -337,9 +337,17 @@ class ContextManager:
         if not guidance:
             guidance.append("本章执行默认高可读策略:冲突前置、信息后置、段末留钩。")
 
+        checklist = self._build_writing_checklist(
+            chapter=chapter,
+            guidance_items=guidance,
+            reader_signal=reader_signal,
+            genre_profile=genre_profile,
+        )
+
         return {
             "chapter": chapter,
             "guidance_items": guidance[:limit],
+            "checklist": checklist,
             "signals_used": {
                 "has_low_score_ranges": bool(low_ranges),
                 "hook_types": list(hook_usage.keys())[:3],
@@ -352,6 +360,159 @@ class ContextManager:
             },
         }
 
+    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:

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

@@ -278,6 +278,12 @@ def test_context_manager_includes_writing_guidance(temp_project):
     assert isinstance(items, list)
     assert items
     assert guidance.get("signals_used", {}).get("genre") == "xuanhuan"
+    checklist = guidance.get("checklist") or []
+    assert isinstance(checklist, list)
+    assert checklist
+    first_item = checklist[0]
+    assert isinstance(first_item, dict)
+    assert {"id", "label", "weight", "required", "source", "verify_hint"}.issubset(first_item.keys())
 
 
 def test_context_manager_compact_text_truncation(temp_project):

+ 17 - 1
.claude/scripts/data_modules/tests/test_extract_chapter_context.py

@@ -89,6 +89,7 @@ def test_build_chapter_context_payload_includes_contract_sections(tmp_path):
     assert payload["context_contract_version"] == "v2"
     assert "writing_guidance" in payload
     assert isinstance(payload["writing_guidance"].get("guidance_items"), list)
+    assert isinstance(payload["writing_guidance"].get("checklist"), list)
     assert payload["genre_profile"].get("genre") == "xuanhuan"
 
 
@@ -107,10 +108,25 @@ def test_render_text_contains_writing_guidance_section(tmp_path):
         "context_contract_version": "v2",
         "reader_signal": {"review_trend": {"overall_avg": 72}, "low_score_ranges": [{"start_chapter": 8, "end_chapter": 9}]},
         "genre_profile": {"genre": "xuanhuan", "reference_hints": ["升级线清晰"]},
-        "writing_guidance": {"guidance_items": ["先修低分", "钩子差异化"]},
+        "writing_guidance": {
+            "guidance_items": ["先修低分", "钩子差异化"],
+            "checklist": [
+                {
+                    "id": "fix_low_score_range",
+                    "label": "修复低分区间问题",
+                    "weight": 1.4,
+                    "required": True,
+                    "source": "reader_signal.low_score_ranges",
+                    "verify_hint": "至少完成1处冲突升级",
+                }
+            ],
+        },
     }
 
     text = _render_text(payload)
     assert "## 写作执行建议" in text
     assert "先修低分" in text
     assert "## Contract (v2)" in text
+    assert "### 执行检查清单(可评分)" in text
+    assert "- 总权重: 1.40" in text
+    assert "[必做][w=1.4] 修复低分区间问题" in text

+ 35 - 2
.claude/scripts/extract_chapter_context.py

@@ -250,11 +250,45 @@ def _render_text(payload: Dict[str, Any]) -> str:
 
     writing_guidance = payload.get("writing_guidance") or {}
     guidance_items = writing_guidance.get("guidance_items") or []
-    if guidance_items:
+    checklist = writing_guidance.get("checklist") or []
+    if guidance_items or checklist:
         lines.append("## 写作执行建议")
         lines.append("")
         for idx, item in enumerate(guidance_items, start=1):
             lines.append(f"{idx}. {item}")
+
+        if checklist:
+            total_weight = 0.0
+            required_count = 0
+            for row in checklist:
+                if isinstance(row, dict):
+                    try:
+                        total_weight += float(row.get("weight") or 0)
+                    except (TypeError, ValueError):
+                        pass
+                    if row.get("required"):
+                        required_count += 1
+
+            lines.append("")
+            lines.append("### 执行检查清单(可评分)")
+            lines.append("")
+            lines.append(f"- 项目数: {len(checklist)}")
+            lines.append(f"- 总权重: {total_weight:.2f}")
+            lines.append(f"- 必做项: {required_count}")
+            lines.append("")
+
+            for idx, row in enumerate(checklist, start=1):
+                if not isinstance(row, dict):
+                    lines.append(f"{idx}. {row}")
+                    continue
+                label = str(row.get("label") or "").strip() or "未命名项"
+                weight = row.get("weight")
+                required_tag = "必做" if row.get("required") else "可选"
+                verify_hint = str(row.get("verify_hint") or "").strip()
+                lines.append(f"{idx}. [{required_tag}][w={weight}] {label}")
+                if verify_hint:
+                    lines.append(f"   - 验收: {verify_hint}")
+
         lines.append("")
 
     reader_signal = payload.get("reader_signal") or {}
@@ -306,4 +340,3 @@ def main():
 
 if __name__ == "__main__":
     main()
-