Quellcode durchsuchen

Merge pull request #132 from lingfengQAQ/fix/issue-131-style-memory-consumption

fix: load-context 消费 project_memory.json 文风记忆与风格契约
聆风 vor 1 Monat
Ursprung
Commit
024fb030d7

+ 2 - 2
webnovel-writer/agents/context-agent.md

@@ -33,7 +33,7 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" memo
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-reader-signals --limit 5 --last-n 20
 ```
 
-load-context 已含(不要重复查):`story_contracts`(MASTER/volume/chapter/review)、`recent_summaries`、`urgent_loops`、`active_rules`、`protagonist`、`memory_pack`(追读力)、`genre_profile_excerpt`。只有返回空 contracts 时才直接 Read `.story-system/*.json`。
+load-context 已含(不要重复查):`story_contracts`(MASTER/volume/chapter/review)、`recent_summaries`、`urgent_loops`、`active_rules`、`protagonist`、`memory_pack`(追读力)、`genre_profile_excerpt`、`author_style_patterns`(/webnovel-learn 累积的作者文风修正)、`style_contract`(设定集/风格契约)。只有返回空 contracts 时才直接 Read `.story-system/*.json`。
 
 裁决层(chapter 合同的 `reasoning` 对象):`style_priority`、`pacing_strategy`、`genre`,必须在第 4 段消费。`chapter_focus` / `dynamic_context` 等 CSV 派生项仅作写法参考,不得覆盖章纲与 `chapter_directive.goal` 约束。
 
@@ -43,7 +43,7 @@ load-context 已含(不要重复查):`story_contracts`(MASTER/volume/cha
 2. 确定卷号:优先 runtime contracts / latest commit;必要时兼容读取 `state.json` 投影。
 3. 按需深查:配角 → `query-entity`;规则 → `query-rules`;时间跨度 → `get-timeline` 或读时间线文件。时间规则:跨夜须过渡、倒计时不跳跃、不回跳。
 4. 伏笔:`urgent_loops` 已在基础包;`remaining ≤ 5` 或超期的必须处理,可选伏笔最多 5 条。
-5. 组装:动机 = 目标+处境+钩子压力;情绪底色 = 上章结尾+走向;可用能力 = 境界+设定禁用。合并 `reasoning` + `anti_patterns` + 用户明确提供的项目级文风规则(只消费、不暴露文件名)。
+5. 组装:动机 = 目标+处境+钩子压力;情绪底色 = 上章结尾+走向;可用能力 = 境界+设定禁用。合并 `reasoning` + `anti_patterns` + `author_style_patterns` + `style_contract`(作者累积的项目级文风规则,只消费、不暴露文件名)。
 6. 红线校验(第 6 段),任一 fail 回第 5 步重组。
 
 ## 4. 写作铁律

+ 76 - 0
webnovel-writer/scripts/data_modules/memory_contract_adapter.py

@@ -7,6 +7,7 @@ MemoryContractAdapter——薄适配器,包装现有模块满足 MemoryContrac
 """
 from __future__ import annotations
 
+import json
 import logging
 from pathlib import Path
 from typing import Any, Dict, List, Optional
@@ -251,12 +252,87 @@ class MemoryContractAdapter:
         except Exception as e:
             logger.warning("load_context: genre_profile_excerpt failed: %s", e)
 
+        # 8. 作者文风记忆(/webnovel-learn 写入的 project_memory.json)
+        try:
+            patterns = self._load_author_style_patterns()
+            if patterns:
+                sections["author_style_patterns"] = patterns
+        except Exception as e:
+            logger.warning("load_context: author_style_patterns failed: %s", e)
+
+        # 9. 风格契约(设定集/风格契约.md,作者手写的文风约定)
+        try:
+            contract = self._load_style_contract()
+            if contract:
+                sections["style_contract"] = contract
+        except Exception as e:
+            logger.warning("load_context: style_contract failed: %s", e)
+
         return ContextPack(
             chapter=chapter,
             sections=sections,
             budget_used_tokens=0,
         )
 
+    _STYLE_PATTERNS_LIMIT = 10
+    _STYLE_PATTERN_DESC_MAX_CHARS = 200
+    _STYLE_CONTRACT_MAX_CHARS = 2000
+
+    @staticmethod
+    def _importance_weight(value: Any) -> float:
+        """importance 归一为数值权重(越大越重要);兼容命名档位与数字字符串。"""
+        named = {
+            "critical": 5.0,
+            "highest": 5.0,
+            "high": 4.0,
+            "medium": 3.0,
+            "normal": 3.0,
+            "low": 2.0,
+            "lowest": 1.0,
+        }
+        raw = str(value if value is not None else "").strip().lower()
+        if raw in named:
+            return named[raw]
+        try:
+            return float(raw)
+        except ValueError:
+            return named["medium"]
+
+    def _load_author_style_patterns(self) -> List[Dict[str, Any]]:
+        memory_path = self.config.webnovel_dir / "project_memory.json"
+        if not memory_path.exists():
+            return []
+        try:
+            data = json.loads(memory_path.read_text(encoding="utf-8"))
+        except json.JSONDecodeError as e:
+            logger.warning("project_memory.json 解析失败,跳过: %s", e)
+            return []
+        patterns = data.get("patterns") if isinstance(data, dict) else None
+        if not isinstance(patterns, list):
+            return []
+        valid = [p for p in patterns if isinstance(p, dict) and str(p.get("description", "")).strip()]
+        valid.sort(key=lambda p: self._importance_weight(p.get("importance")), reverse=True)
+        result = []
+        for p in valid[: self._STYLE_PATTERNS_LIMIT]:
+            item: Dict[str, Any] = {
+                "pattern_type": str(p.get("pattern_type", "other")),
+                "description": str(p["description"])[: self._STYLE_PATTERN_DESC_MAX_CHARS],
+            }
+            if p.get("source_chapter") is not None:
+                item["source_chapter"] = p["source_chapter"]
+            result.append(item)
+        return result
+
+    def _load_style_contract(self) -> str:
+        path = self.config.settings_dir / "风格契约.md"
+        if not path.exists():
+            matches = sorted(self.config.settings_dir.glob("*风格契约*.md")) if self.config.settings_dir.exists() else []
+            if not matches:
+                return ""
+            path = matches[0]
+        text = path.read_text(encoding="utf-8").strip()
+        return text[: self._STYLE_CONTRACT_MAX_CHARS]
+
     def query_entity(self, entity_id: str) -> Optional[EntitySnapshot]:
         try:
             sm = self._state_manager()

+ 69 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_contract_adapter.py

@@ -428,3 +428,72 @@ class TestCommitChapter:
         assert (tmp_path / ".story-system" / "commits" / "chapter_003.commit.json").is_file()
         assert result.chapter == 3
         assert "commit_status=accepted" in result.warnings
+
+
+class TestLoadContextAuthorStyle:
+    """issue #131:load_context 必须消费 project_memory.json 与 风格契约.md。"""
+
+    def _write_memory(self, tmp_path: Path, patterns) -> None:
+        (tmp_path / ".webnovel" / "project_memory.json").write_text(
+            json.dumps({"patterns": patterns}, ensure_ascii=False), encoding="utf-8"
+        )
+
+    def test_author_style_patterns_present(self, tmp_path):
+        cfg = _make_project(tmp_path)
+        self._write_memory(
+            tmp_path,
+            [
+                {
+                    "pattern_type": "写作风格",
+                    "description": "禁止“有什么东西”等模糊指代。",
+                    "source_chapter": 1,
+                    "importance": "5",
+                },
+                {
+                    "pattern_type": "节奏",
+                    "description": "开篇三段内必须进入冲突。",
+                    "importance": "low",
+                },
+            ],
+        )
+        pack = MemoryContractAdapter(cfg).load_context(chapter=3)
+        section = pack.sections.get("author_style_patterns")
+        assert section, "load_context 未返回 author_style_patterns(issue #131 主诉)"
+        assert any("模糊指代" in str(p.get("description", "")) for p in section)
+
+    def test_patterns_sorted_by_importance_and_capped(self, tmp_path):
+        cfg = _make_project(tmp_path)
+        patterns = [
+            {"pattern_type": "低", "description": f"低优先级规则{i}", "importance": "low"}
+            for i in range(12)
+        ]
+        patterns.append(
+            {"pattern_type": "高", "description": "最重要的规则", "importance": "5"}
+        )
+        self._write_memory(tmp_path, patterns)
+        pack = MemoryContractAdapter(cfg).load_context(chapter=2)
+        section = pack.sections["author_style_patterns"]
+        assert len(section) <= 10, "patterns 未按 token 预算截断"
+        assert section[0]["description"] == "最重要的规则", "高重要度未排在前面"
+
+    def test_style_contract_present(self, tmp_path):
+        cfg = _make_project(tmp_path)
+        settings = tmp_path / "设定集"
+        settings.mkdir(exist_ok=True)
+        (settings / "风格契约.md").write_text("短句为主,少用成语。", encoding="utf-8")
+        pack = MemoryContractAdapter(cfg).load_context(chapter=2)
+        assert "短句为主" in str(pack.sections.get("style_contract", ""))
+
+    def test_absent_files_sections_omitted(self, tmp_path):
+        cfg = _make_project(tmp_path)
+        pack = MemoryContractAdapter(cfg).load_context(chapter=2)
+        assert "author_style_patterns" not in pack.sections
+        assert "style_contract" not in pack.sections
+
+    def test_malformed_memory_does_not_crash(self, tmp_path):
+        cfg = _make_project(tmp_path)
+        (tmp_path / ".webnovel" / "project_memory.json").write_text(
+            "{broken json", encoding="utf-8"
+        )
+        pack = MemoryContractAdapter(cfg).load_context(chapter=2)
+        assert "author_style_patterns" not in pack.sections