Prechádzať zdrojové kódy

fix: harden story-system chapter directives and review feedback

lingfengQAQ 1 mesiac pred
rodič
commit
4296ad1f75

+ 1 - 1
pytest.ini

@@ -1,4 +1,4 @@
 [pytest]
 testpaths = webnovel-writer/scripts/data_modules/tests webnovel-writer/scripts/tests
 pythonpath = webnovel-writer/scripts
-addopts = -q --cov --cov-report=term-missing --cov-fail-under=90 -p no:cacheprovider
+addopts = -p no:anyio -p no:asyncio -p no:debugging -p pytest_cov -q --cov --cov-report=term-missing --cov-fail-under=90 -p no:cacheprovider

+ 17 - 0
sitecustomize.py

@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+
+def _looks_like_pytest_process() -> bool:
+    argv0 = Path(str(sys.argv[0] or "")).name.lower()
+    if "pytest" in argv0:
+        return True
+    return any("pytest" in str(arg).lower() for arg in sys.argv[1:3])
+
+
+if _looks_like_pytest_process():
+    # Prevent broken global pytest plugin autoload on this Windows machine.
+    os.environ.setdefault("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1")

+ 11 - 5
webnovel-writer/scripts/__init__.py

@@ -7,13 +7,19 @@ This package contains all Python scripts for the webnovel-writer plugin.
 __version__ = "5.5.5"
 __author__ = "lcy"
 
-# Expose main modules
-from . import security_utils
-from . import project_locator
-from . import chapter_paths
-
 __all__ = [
     "security_utils",
     "project_locator",
     "chapter_paths",
 ]
+
+
+def __getattr__(name):
+    if name not in __all__:
+        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+    import importlib
+
+    module = importlib.import_module(f"{__name__}.{name}")
+    globals()[name] = module
+    return module

+ 171 - 0
webnovel-writer/scripts/chapter_outline_loader.py

@@ -110,6 +110,42 @@ def _extract_outline_section(content: str, chapter_num: int) -> str | None:
     return None
 
 
+def _parse_chinese_chapter_num(value: str) -> int | None:
+    text = str(value or "").strip()
+    if not text:
+        return None
+    if text.isdigit():
+        return int(text)
+    if text in _CHINESE_NUMERAL_DIGITS:
+        return _CHINESE_NUMERAL_DIGITS[text]
+    if text == "十":
+        return 10
+    if "十" in text:
+        left, _, right = text.partition("十")
+        tens = _CHINESE_NUMERAL_DIGITS.get(left, 1 if not left else 0)
+        ones = _CHINESE_NUMERAL_DIGITS.get(right, 0) if right else 0
+        parsed = tens * 10 + ones
+        return parsed or None
+    parsed = 0
+    for char in text:
+        digit = _CHINESE_NUMERAL_DIGITS.get(char)
+        if digit is None:
+            return None
+        parsed = parsed * 10 + digit
+    return parsed or None
+
+
+def _extract_directive_section(content: str, chapter_num: int) -> str | None:
+    matches = list(_CHAPTER_HEADING_RE.finditer(content))
+    for index, match in enumerate(matches):
+        parsed = _parse_chinese_chapter_num(match.group(2))
+        if parsed != chapter_num:
+            continue
+        end = matches[index + 1].start() if index + 1 < len(matches) else len(content)
+        return content[match.start():end].strip()
+    return _extract_outline_section(content, chapter_num)
+
+
 def load_chapter_outline(project_root: Path, chapter_num: int, max_chars: int | None = 1500) -> str:
     outline_dir = project_root / "大纲"
 
@@ -137,6 +173,56 @@ _PLOT_SECTION_FIELD_MAP = {
     "本章禁区": "prohibitions",
 }
 
+_CHAPTER_HEADING_RE = re.compile(
+    r"^(#{1,6})\s*第\s*([0-9零〇一二两三四五六七八九十]+)\s*章\b.*$",
+    re.MULTILINE,
+)
+
+_CHINESE_NUMERAL_DIGITS = {
+    "零": 0,
+    "〇": 0,
+    "一": 1,
+    "二": 2,
+    "两": 2,
+    "三": 3,
+    "四": 4,
+    "五": 5,
+    "六": 6,
+    "七": 7,
+    "八": 8,
+    "九": 9,
+}
+
+_DIRECTIVE_FIELD_MAP = {
+    "目标": "goal",
+    "本章目标": "goal",
+    "章目标": "goal",
+    "阻力": "obstacles",
+    "障碍": "obstacles",
+    "代价": "cost",
+    "时间锚点": "time_anchor",
+    "时间": "time_anchor",
+    "章内跨度": "chapter_span",
+    "章节跨度": "chapter_span",
+    "倒计时状态": "countdown",
+    "倒计时": "countdown",
+    "cbn": "cbn",
+    "cpns": "cpns",
+    "cen": "cen",
+    "必须覆盖节点": "must_cover_nodes",
+    "本章禁区": "forbidden_zones",
+    "章末未闭合问题": "chapter_end_open_question",
+    "章末问题": "chapter_end_open_question",
+    "钩子类型": "hook_type",
+    "钩子强度": "hook_strength",
+    "关键实体": "key_entities",
+    "涉及实体": "key_entities",
+    "strand": "strand",
+    "反派层级": "antagonist_tier",
+}
+
+_DIRECTIVE_LIST_FIELDS = {"cpns", "must_cover_nodes", "forbidden_zones", "key_entities"}
+
 
 def _clean_plot_line(line: str) -> str:
     text = str(line or "").strip()
@@ -166,6 +252,27 @@ def _append_plot_value(target: Dict[str, Any], field: str, value: str) -> None:
         target[field] = value
 
 
+def _split_directive_values(value: str) -> list[str]:
+    text = _clean_plot_line(value)
+    if not text:
+        return []
+    return [part.strip() for part in re.split(r"[、,,;;|]+", text) if part.strip()]
+
+
+def _append_directive_value(target: Dict[str, Any], field: str, value: str) -> None:
+    value = _clean_plot_line(value)
+    if not value:
+        return
+    if field in _DIRECTIVE_LIST_FIELDS:
+        target.setdefault(field, [])
+        for item in _split_directive_values(value) or [value]:
+            if item not in target[field]:
+                target[field].append(item)
+        return
+    if field not in target:
+        target[field] = value
+
+
 def parse_chapter_plot_structure(outline_text: str) -> Dict[str, Any]:
     text = str(outline_text or "")
     if not text or text.startswith("⚠️"):
@@ -220,3 +327,67 @@ def parse_chapter_plot_structure(outline_text: str) -> Dict[str, Any]:
 def load_chapter_plot_structure(project_root: Path, chapter_num: int) -> Dict[str, Any]:
     outline = load_chapter_outline(project_root, chapter_num, max_chars=None)
     return parse_chapter_plot_structure(outline)
+
+
+def parse_chapter_execution_directive(outline_text: str) -> Dict[str, Any]:
+    text = str(outline_text or "")
+    if not text or text.startswith("⚠️"):
+        return {}
+
+    directive: Dict[str, Any] = {}
+    current_field = ""
+    for raw_line in text.splitlines():
+        stripped = raw_line.strip()
+        if not stripped:
+            current_field = ""
+            continue
+        if _CHAPTER_HEADING_RE.match(stripped):
+            current_field = ""
+            continue
+
+        cleaned = _clean_plot_line(stripped)
+        matched_field = ""
+        matched_value = ""
+        for label, field in _DIRECTIVE_FIELD_MAP.items():
+            match = re.match(rf"^{re.escape(label)}\s*[::]\s*(.*)$", cleaned, re.IGNORECASE)
+            if match:
+                matched_field = field
+                matched_value = match.group(1).strip()
+                break
+
+        if matched_field:
+            current_field = matched_field
+            _append_directive_value(directive, matched_field, matched_value)
+            continue
+        if current_field:
+            _append_directive_value(directive, current_field, cleaned)
+
+    plot_structure = parse_chapter_plot_structure(text)
+    for source_key, target_key in (
+        ("cbn", "cbn"),
+        ("cpns", "cpns"),
+        ("cen", "cen"),
+        ("mandatory_nodes", "must_cover_nodes"),
+        ("prohibitions", "forbidden_zones"),
+    ):
+        if plot_structure.get(source_key) and not directive.get(target_key):
+            directive[target_key] = plot_structure[source_key]
+
+    if directive:
+        directive["source"] = "chapter_outline"
+    return directive
+
+
+def load_chapter_execution_directive(project_root: Path, chapter_num: int) -> Dict[str, Any]:
+    outline_dir = project_root / "大纲"
+    split_outline = _find_split_outline_file(outline_dir, chapter_num)
+    if split_outline is not None:
+        return parse_chapter_execution_directive(split_outline.read_text(encoding="utf-8"))
+
+    volume_outline = _find_volume_outline_file(project_root, chapter_num)
+    if volume_outline is None:
+        return {}
+    section = _extract_directive_section(volume_outline.read_text(encoding="utf-8"), chapter_num)
+    if section is None:
+        return {}
+    return parse_chapter_execution_directive(section)

+ 74 - 0
webnovel-writer/scripts/data_modules/placeholder_scanner.py

@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import json
+import re
+from pathlib import Path
+from typing import Any, Dict, List
+
+
+PLACEHOLDER_PATTERNS = [
+    re.compile(r"\[待[^\]]*\]"),
+    re.compile(r"(暂名)|\(暂名\)|(待补充)|\(待补充\)"),
+    re.compile(r"\{占位\}|<占位>"),
+]
+
+
+def _scan_file(path: Path, project_root: Path) -> List[Dict[str, Any]]:
+    results: List[Dict[str, Any]] = []
+    try:
+        lines = path.read_text(encoding="utf-8").splitlines()
+    except UnicodeDecodeError:
+        return results
+
+    for line_no, line in enumerate(lines, start=1):
+        for pattern in PLACEHOLDER_PATTERNS:
+            for match in pattern.finditer(line):
+                rel = path.relative_to(project_root).as_posix()
+                results.append(
+                    {
+                        "file": rel,
+                        "line": line_no,
+                        "pattern": match.group(0),
+                        "context": line.strip(),
+                        "suggested_fill_phase": "plan" if rel.startswith("大纲/") else "setting_update",
+                    }
+                )
+    return results
+
+
+def scan_placeholders(project_root: str | Path) -> List[Dict[str, Any]]:
+    root = Path(project_root).expanduser().resolve()
+    targets: List[Path] = []
+    for dirname in ("大纲", "设定集"):
+        base = root / dirname
+        if base.is_dir():
+            targets.extend(sorted(base.rglob("*.md")))
+
+    results: List[Dict[str, Any]] = []
+    for path in targets:
+        results.extend(_scan_file(path, root))
+    return results
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Scan outline/settings files for unresolved placeholders")
+    parser.add_argument("--project-root", required=True)
+    parser.add_argument("--format", choices=["json", "text"], default="json")
+    args = parser.parse_args()
+
+    results = scan_placeholders(args.project_root)
+    if args.format == "json":
+        print(json.dumps({"ok": not results, "placeholders": results}, ensure_ascii=False, indent=2))
+        return
+    if not results:
+        print("OK no placeholders found")
+        return
+    for item in results:
+        print(f"{item['file']}:{item['line']} {item['pattern']} {item['context']}")
+
+
+if __name__ == "__main__":
+    main()

+ 26 - 1
webnovel-writer/scripts/data_modules/prewrite_validator.py

@@ -6,6 +6,8 @@ import json
 from pathlib import Path
 from typing import Any, Dict
 
+from .placeholder_scanner import scan_placeholders
+
 
 class PrewriteValidator:
     def __init__(self, project_root: Path):
@@ -39,11 +41,15 @@ class PrewriteValidator:
             blocking_reasons.append(
                 "缺少 Story System 合同: " + ", ".join(missing_contracts)
             )
+        related_placeholders = self._related_placeholders(story_contract)
+        if related_placeholders:
+            blocking_reasons.append("当前章节相关设定存在未补齐占位")
         return {
             "chapter": chapter,
-            "blocking": bool(pending) or bool(missing_contracts),
+            "blocking": bool(pending) or bool(missing_contracts) or bool(related_placeholders),
             "blocking_reasons": blocking_reasons,
             "missing_contracts": missing_contracts,
+            "related_placeholders": related_placeholders,
             "forbidden_zones": list(review_contract.get("blocking_rules") or []),
             "disambiguation_domain": {
                 "pending_count": len(pending),
@@ -59,3 +65,22 @@ class PrewriteValidator:
                 "prohibitions": list(plot_structure.get("prohibitions") or []),
             },
         }
+
+    def _related_placeholders(self, story_contract: Dict[str, Any]) -> list[Dict[str, Any]]:
+        chapter_brief = story_contract.get("chapter_brief") or {}
+        directive = chapter_brief.get("chapter_directive") or {}
+        entity_terms = [
+            str(item or "").strip()
+            for item in directive.get("key_entities") or []
+            if str(item or "").strip()
+        ]
+        if not entity_terms:
+            return []
+
+        related: list[Dict[str, Any]] = []
+        for item in scan_placeholders(self.project_root):
+            context = str(item.get("context") or "")
+            file_name = Path(str(item.get("file") or "")).stem
+            if any(term in context or term in file_name for term in entity_terms):
+                related.append(item)
+        return related

+ 52 - 0
webnovel-writer/scripts/data_modules/review_schema.py

@@ -7,10 +7,17 @@
 """
 from __future__ import annotations
 
+import json
 from dataclasses import asdict, dataclass, field
 from datetime import datetime
+from pathlib import Path
 from typing import Any, Dict, List, Optional
 
+try:
+    from security_utils import atomic_write_json
+except ImportError:  # pragma: no cover
+    from scripts.security_utils import atomic_write_json
+
 VALID_SEVERITIES = {"critical", "high", "medium", "low"}
 VALID_CATEGORIES = {
     "continuity", "setting", "character", "timeline",
@@ -174,3 +181,48 @@ def parse_review_output(chapter: int, raw: Dict[str, Any]) -> ReviewResult:
         issues=issues,
         summary=str(raw.get("summary", "")),
     )
+
+
+def _read_json_if_exists(path: Path) -> Any | None:
+    if not path.is_file():
+        return None
+    try:
+        return json.loads(path.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as exc:
+        raise ValueError(f"Bad JSON in {path}") from exc
+
+
+def _write_json(path: Path, payload: Any) -> None:
+    atomic_write_json(path, payload, backup=True)
+
+
+def append_ai_flavor_anti_patterns(project_root: str | Path, result: ReviewResult) -> int:
+    root = Path(project_root).expanduser().resolve()
+    path = root / ".story-system" / "anti_patterns.json"
+    existing = _read_json_if_exists(path) or []
+    if not isinstance(existing, list):
+        existing = []
+
+    seen_texts = {str(item.get("text") or "").strip() for item in existing if isinstance(item, dict)}
+    additions: List[Dict[str, Any]] = []
+    for index, issue in enumerate(result.issues, start=1):
+        if issue.category != "ai_flavor" or issue.severity not in {"medium", "high", "critical"}:
+            continue
+        text = (issue.evidence or issue.description or "").strip()[:200]
+        if not text or text in seen_texts:
+            continue
+        seen_texts.add(text)
+        additions.append(
+            {
+                "text": text,
+                "source_table": "review_extracted",
+                "source_id": f"ch{int(result.chapter):04d}_issue_{index}",
+                "category": issue.category,
+                "added_at": datetime.now().isoformat(timespec="seconds"),
+            }
+        )
+
+    if additions:
+        path.parent.mkdir(parents=True, exist_ok=True)
+        _write_json(path, [*existing, *additions])
+    return len(additions)

+ 1 - 0
webnovel-writer/scripts/data_modules/story_contract_schema.py

@@ -32,6 +32,7 @@ class MasterSetting(BaseModel):
 class ChapterBrief(BaseModel):
     meta: ContractMeta
     override_allowed: Dict[str, Any] = Field(default_factory=dict)
+    chapter_directive: Dict[str, Any] = Field(default_factory=dict)
     dynamic_context: List[Dict[str, Any]] = Field(default_factory=list)
     source_trace: List[Dict[str, Any]] = Field(default_factory=list)
 

+ 130 - 14
webnovel-writer/scripts/data_modules/story_system_engine.py

@@ -24,15 +24,34 @@ ANTI_PATTERN_SOURCE_FIELDS = {
 }
 
 _TEXT_TOKEN_RE = re.compile(r"[\s|,,、/;;::()()【】\[\]<>《》\"'!?!?。…]+")
+_PLACEHOLDER_QUERY_RE = re.compile(r"^\s*(\{[^{}]*章纲目标[^{}]*\}|第\s*\d+\s*章\s*章纲目标)\s*$")
+
+
+def is_placeholder_query(query: str) -> bool:
+    text = str(query or "").strip()
+    if not text:
+        return True
+    return bool(_PLACEHOLDER_QUERY_RE.match(text))
 
 
 class StorySystemEngine:
     def __init__(self, csv_dir: str | Path):
         self.csv_dir = Path(csv_dir)
 
-    def build(self, query: str, genre: Optional[str], chapter: Optional[int]) -> Dict[str, Any]:
+    def build(
+        self,
+        query: str,
+        genre: Optional[str],
+        chapter: Optional[int],
+        chapter_directive: Optional[Dict[str, Any]] = None,
+    ) -> Dict[str, Any]:
+        chapter_directive = chapter_directive or {}
         route = self._route(query=query, genre=genre)
-        search_query = self._expand_query(query, route.get("default_query", ""))
+        search_query = self._expand_query(
+            query,
+            route.get("default_query", ""),
+            self._directive_query_text(chapter_directive),
+        )
         base_context = self._collect_tables(
             search_query,
             route["recommended_base_tables"],
@@ -53,7 +72,7 @@ class StorySystemEngine:
             fallback_genre = resolve_genre(genre) or genre
             if fallback_genre != canonical_genre:
                 reasoning = self._load_reasoning(fallback_genre)
-        ranked = self._apply_reasoning(reasoning, base_context, dynamic_context)
+        ranked = self._apply_reasoning(reasoning, base_context, dynamic_context, chapter_directive)
 
         source_trace = route["source_trace"] + self._build_source_trace_with_reasoning(ranked, reasoning)
 
@@ -95,8 +114,9 @@ class StorySystemEngine:
                         "chapter": chapter,
                     },
                     "override_allowed": {
-                        "chapter_focus": self._suggest_chapter_focus(query, dynamic_context),
+                        "chapter_focus": self._suggest_chapter_focus(query, chapter_directive),
                     },
+                    "chapter_directive": chapter_directive,
                     "dynamic_context": ranked,
                     "source_trace": source_trace,
                     "reasoning": (
@@ -229,12 +249,15 @@ class StorySystemEngine:
                     )
         return extracted
 
-    def _suggest_chapter_focus(self, query: str, dynamic_rows: List[Dict[str, Any]]) -> str:
-        for row in dynamic_rows:
-            summary = str(row.get("核心摘要") or "").strip()
-            if summary:
-                return summary
-        return query
+    def _suggest_chapter_focus(self, query: str, chapter_directive: Optional[Dict[str, Any]] = None) -> str:
+        directive = chapter_directive or {}
+        goal = str(directive.get("goal") or "").strip()
+        if goal:
+            return goal
+        query_text = str(query or "").strip()
+        if query_text and not is_placeholder_query(query_text):
+            return query_text
+        return ""
 
     def _build_source_trace(self, *groups: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
         trace: List[Dict[str, Any]] = []
@@ -262,14 +285,27 @@ class StorySystemEngine:
     def _split_multi_value(self, raw: Any) -> List[str]:
         return [item.strip() for item in re.split(r"[|;;]+", str(raw or "")) if item.strip()]
 
-    def _expand_query(self, query: str, default_query: str) -> str:
+    def _expand_query(self, query: str, default_query: str, chapter_query: str = "") -> str:
         items: List[str] = []
-        for candidate in [query, *self._split_multi_value(default_query)]:
+        for candidate in [query, chapter_query, *self._split_multi_value(default_query)]:
             text = str(candidate or "").strip()
             if text and text not in items:
                 items.append(text)
         return " ".join(items)
 
+    def _directive_query_text(self, chapter_directive: Dict[str, Any]) -> str:
+        parts: List[str] = []
+        for key in ("goal", "strand", "antagonist_tier"):
+            value = str(chapter_directive.get(key) or "").strip()
+            if value:
+                parts.append(value)
+        for key in ("key_entities", "must_cover_nodes"):
+            for value in chapter_directive.get(key) or []:
+                text = str(value or "").strip()
+                if text:
+                    parts.append(text)
+        return " ".join(parts)
+
     def _fallback_row_for_genre(self, rows: List[Dict[str, Any]], genre: str) -> Dict[str, Any] | None:
         genre_text = self._normalize_text(resolve_genre(genre) or genre)
         for row in rows:
@@ -329,10 +365,12 @@ class StorySystemEngine:
         reasoning: Dict[str, Any],
         base_context: List[Dict[str, Any]],
         dynamic_context: List[Dict[str, Any]],
+        chapter_directive: Optional[Dict[str, Any]] = None,
     ) -> List[Dict[str, Any]]:
         """Rank *base_context* + *dynamic_context* rows using 冲突裁决 priority."""
         combined = [dict(r) for r in base_context] + [dict(r) for r in dynamic_context]
-        if not reasoning:
+        chapter_terms = self._chapter_keyword_terms(chapter_directive or {})
+        if not reasoning and not chapter_terms:
             return combined
 
         priority_order = [
@@ -343,14 +381,90 @@ class StorySystemEngine:
         priority_map = {name: idx for idx, name in enumerate(priority_order)}
 
         genre_label = reasoning.get("题材", "")
+        ranked_priorities = sorted(priority_map.values())
+        max_priority = max(ranked_priorities) if ranked_priorities else 0
+        max_chapter_score = 0
         for row in combined:
             table = str(row.get("_table") or "")
             row["_priority_rank"] = priority_map.get(table, 999)
             row["_reasoning_rule"] = genre_label
+            row["_chapter_keyword_score"] = self._chapter_keyword_score(row, chapter_terms)
+            max_chapter_score = max(max_chapter_score, int(row.get("_chapter_keyword_score") or 0))
 
-        combined.sort(key=lambda r: r["_priority_rank"])
+        for row in combined:
+            row["_combined_rank_score"] = self._combined_rank_score(
+                int(row.get("_priority_rank") or 999),
+                int(row.get("_chapter_keyword_score") or 0),
+                max_priority=max_priority,
+                max_chapter_score=max_chapter_score,
+                has_reasoning=bool(priority_map),
+                has_chapter_terms=bool(chapter_terms),
+            )
+
+        combined.sort(
+            key=lambda r: (
+                -float(r.get("_combined_rank_score") or 0.0),
+                r["_priority_rank"],
+                -int(r.get("_chapter_keyword_score") or 0),
+            )
+        )
         return combined
 
+    def _combined_rank_score(
+        self,
+        priority_rank: int,
+        chapter_score: int,
+        *,
+        max_priority: int,
+        max_chapter_score: int,
+        has_reasoning: bool,
+        has_chapter_terms: bool,
+    ) -> float:
+        priority_component = 0.0
+        if has_reasoning:
+            if priority_rank >= 999:
+                priority_component = 0.0
+            elif max_priority <= 0:
+                priority_component = 1.0
+            else:
+                priority_component = 1.0 - (priority_rank / float(max_priority + 1))
+
+        chapter_component = 0.0
+        if has_chapter_terms and max_chapter_score > 0:
+            chapter_component = chapter_score / float(max_chapter_score)
+
+        if has_reasoning and has_chapter_terms:
+            return round((priority_component * 0.4) + (chapter_component * 0.6), 6)
+        if has_chapter_terms:
+            return round(chapter_component, 6)
+        return round(priority_component, 6)
+
+    def _chapter_keyword_terms(self, chapter_directive: Dict[str, Any]) -> List[str]:
+        raw_items: List[str] = []
+        for key in ("goal", "strand", "antagonist_tier"):
+            value = str(chapter_directive.get(key) or "").strip()
+            if value:
+                raw_items.append(value)
+        for key in ("key_entities", "must_cover_nodes"):
+            raw_items.extend(str(item or "") for item in chapter_directive.get(key) or [])
+
+        terms: List[str] = []
+        for item in raw_items:
+            for token in _TEXT_TOKEN_RE.split(item):
+                token = token.strip().lower()
+                if len(token) >= 2 and token not in terms:
+                    terms.append(token)
+        return terms
+
+    def _chapter_keyword_score(self, row: Dict[str, Any], terms: List[str]) -> int:
+        if not terms:
+            return 0
+        haystack = " ".join(
+            str(row.get(field) or "")
+            for field in ("关键词", "意图与同义词", "适用场景", "核心摘要", "详细展开")
+        ).lower()
+        return sum(1 for term in terms if term and term in haystack)
+
     def _rank_anti_patterns(
         self,
         reasoning: Dict[str, Any],
@@ -402,6 +516,8 @@ class StorySystemEngine:
                     "summary": row.get("核心摘要", ""),
                     "reasoning_rule": row.get("_reasoning_rule", ""),
                     "priority_rank": row.get("_priority_rank", 999),
+                    "chapter_keyword_score": row.get("_chapter_keyword_score", 0),
+                    "combined_rank_score": row.get("_combined_rank_score", 0),
                     "inject_target": inject_target,
                 }
             )

+ 55 - 0
webnovel-writer/scripts/data_modules/tests/test_chapter_outline_directive.py

@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+
+from chapter_outline_loader import load_chapter_execution_directive
+
+
+def test_load_chapter_execution_directive_from_volume_outline(tmp_path):
+    outline_dir = tmp_path / "大纲"
+    outline_dir.mkdir()
+    (tmp_path / ".webnovel").mkdir()
+    (tmp_path / ".webnovel" / "state.json").write_text(
+        json.dumps({"progress": {"volumes_planned": [{"volume": 1, "chapters_range": "1-50"}]}}),
+        encoding="utf-8",
+    )
+    (outline_dir / "第1卷-详细大纲.md").write_text(
+        "\n".join(
+            [
+                "### 第一章:债从天降",
+                "- 目标:搞清楚借据条款的荒谬",
+                "- 阻力:杂役不能随意离开宗门",
+                "- 代价:暴露自己懂账",
+                "- 时间锚点:D-Day 清晨",
+                "- 章内跨度:一炷香",
+                "- 倒计时状态:三日内还债",
+                "- Strand:债务调查",
+                "- 反派层级:小反派",
+                "- 关键实体:陆鸣、借据、利息",
+                "- CBN:醒来发现债务",
+                "- CPNs:检查借据;发现复利陷阱",
+                "- CEN:决定去井边打听",
+                "- 必须覆盖节点:借据金额;复利算法",
+                "- 本章禁区:不得离开宗门;不得提前摊牌",
+                "- 章末未闭合问题:谁改了借据?",
+                "- 钩子类型:信息钩",
+                "- 钩子强度:中",
+                "",
+                "### 第二章:井边口风",
+                "- 目标:打听债主来历",
+            ]
+        ),
+        encoding="utf-8",
+    )
+
+    directive = load_chapter_execution_directive(tmp_path, 1)
+
+    assert directive["goal"] == "搞清楚借据条款的荒谬"
+    assert directive["time_anchor"] == "D-Day 清晨"
+    assert directive["chapter_span"] == "一炷香"
+    assert directive["countdown"] == "三日内还债"
+    assert directive["cpns"] == ["检查借据", "发现复利陷阱"]
+    assert "不得离开宗门" in directive["forbidden_zones"]
+    assert "借据" in directive["key_entities"]
+    assert directive["chapter_end_open_question"] == "谁改了借据?"

+ 48 - 0
webnovel-writer/scripts/data_modules/tests/test_placeholder_scanner.py

@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+import sys
+
+import pytest
+
+from data_modules.placeholder_scanner import scan_placeholders
+
+
+def test_placeholder_scanner_finds_pending_marks(tmp_path):
+    outline_dir = tmp_path / "大纲"
+    settings_dir = tmp_path / "设定集"
+    outline_dir.mkdir()
+    settings_dir.mkdir()
+    (outline_dir / "第1卷-卷纲.md").write_text(
+        "第一位女主(暂名)| [待章纲拆分时具体设计]\n",
+        encoding="utf-8",
+    )
+    (settings_dir / "主角卡.md").write_text("- 兄弟:{占位}\n", encoding="utf-8")
+
+    results = scan_placeholders(tmp_path)
+
+    assert len(results) == 3
+    assert any(item["pattern"] == "(暂名)" for item in results)
+    assert any(item["pattern"].startswith("[待章纲") for item in results)
+    assert any(item["pattern"] == "{占位}" for item in results)
+
+
+def test_webnovel_placeholder_scan_cli_forwards_project_root(monkeypatch, tmp_path, capsys):
+    import data_modules.webnovel as webnovel_module
+
+    project_root = tmp_path / "book"
+    (project_root / ".webnovel").mkdir(parents=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    (project_root / "大纲").mkdir()
+    (project_root / "大纲" / "总纲.md").write_text("[待补充]\n", encoding="utf-8")
+
+    monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "placeholder-scan"])
+
+    with pytest.raises(SystemExit) as exc:
+        webnovel_module.main()
+
+    assert int(exc.value.code or 0) == 0
+    payload = json.loads(capsys.readouterr().out)
+    assert payload["ok"] is False
+    assert payload["placeholders"][0]["file"] == "大纲/总纲.md"

+ 36 - 0
webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py

@@ -64,3 +64,39 @@ def test_prewrite_validator_blocks_when_required_contracts_missing(tmp_path):
     assert payload["blocking"] is True
     assert "missing_contracts" in payload
     assert set(payload["missing_contracts"]) >= {"master_setting", "review_contract"}
+
+
+def test_prewrite_validator_blocks_related_entity_placeholders(tmp_path):
+    project_root = tmp_path
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text(
+        json.dumps(
+            {
+                "disambiguation_pending": [],
+                "disambiguation_warnings": [],
+                "chapter_meta": {},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    settings_dir = project_root / "设定集"
+    settings_dir.mkdir(parents=True, exist_ok=True)
+    (settings_dir / "苏云.md").write_text("苏云:第一位女主(暂名)\n", encoding="utf-8")
+    (settings_dir / "远期角色.md").write_text("后续兄弟:[待补充]\n", encoding="utf-8")
+
+    payload = PrewriteValidator(project_root).build(
+        chapter=8,
+        review_contract={},
+        plot_structure={},
+        story_contract={
+            "master_setting": {"ok": True},
+            "chapter_brief": {"chapter_directive": {"key_entities": ["苏云"]}},
+            "volume_brief": {"ok": True},
+            "review_contract": {"ok": True},
+        },
+    )
+
+    assert payload["blocking"] is True
+    assert any("占位" in reason for reason in payload["blocking_reasons"])
+    assert [item["file"] for item in payload["related_placeholders"]] == ["设定集/苏云.md"]

+ 55 - 1
webnovel-writer/scripts/data_modules/tests/test_review_schema.py

@@ -1,8 +1,15 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 """审查 schema 测试"""
+import json
+
 import pytest
-from data_modules.review_schema import ReviewIssue, ReviewResult, parse_review_output
+from data_modules.review_schema import (
+    ReviewIssue,
+    ReviewResult,
+    append_ai_flavor_anti_patterns,
+    parse_review_output,
+)
 
 
 def test_review_issue_blocking_defaults():
@@ -115,3 +122,50 @@ def test_review_result_to_metrics_dict():
     assert metrics["overall_score"] < 100
     assert metrics["dimension_scores"]["continuity"] < 100
     assert metrics["dimension_scores"]["ai_flavor"] < 100
+
+
+def test_ai_flavor_review_issue_added_to_anti_patterns(tmp_path):
+    result = ReviewResult(
+        chapter=2,
+        issues=[
+            ReviewIssue(
+                severity="medium",
+                category="ai_flavor",
+                evidence="唯一一个知道复利公式的人。唯一一个知道账本秘密的人。",
+            ),
+            ReviewIssue(severity="low", category="ai_flavor", evidence="低风险句式"),
+            ReviewIssue(severity="high", category="logic", evidence="逻辑问题"),
+        ],
+    )
+
+    added = append_ai_flavor_anti_patterns(tmp_path, result)
+
+    patterns = json.loads((tmp_path / ".story-system" / "anti_patterns.json").read_text(encoding="utf-8"))
+    assert added == 1
+    assert any("唯一一个知道" in item["text"] for item in patterns)
+    assert patterns[0]["source_id"].startswith("ch0002_issue_")
+
+
+def test_ai_flavor_review_feedback_dedupes_evidence(tmp_path):
+    existing = tmp_path / ".story-system" / "anti_patterns.json"
+    existing.parent.mkdir(parents=True)
+    existing.write_text(
+        json.dumps([{"text": "第一片 / 第二片 / 第三片", "source_table": "review_extracted"}], ensure_ascii=False),
+        encoding="utf-8",
+    )
+    result = ReviewResult(
+        chapter=3,
+        issues=[
+            ReviewIssue(
+                severity="high",
+                category="ai_flavor",
+                evidence="第一片 / 第二片 / 第三片",
+            )
+        ],
+    )
+
+    added = append_ai_flavor_anti_patterns(tmp_path, result)
+
+    patterns = json.loads(existing.read_text(encoding="utf-8"))
+    assert added == 0
+    assert len(patterns) == 1

+ 43 - 0
webnovel-writer/scripts/data_modules/tests/test_runtime_contract_builder.py

@@ -53,3 +53,46 @@ def test_runtime_contract_builder_creates_volume_and_review_contracts(tmp_path):
     assert review_contract["meta"]["contract_type"] == "REVIEW_CONTRACT"
     assert "发现陷阱" in review_contract["must_check"]
     assert "不可提前摊牌" in review_contract["blocking_rules"]
+
+
+def test_runtime_contract_builder_surfaces_review_extracted_anti_patterns(tmp_path):
+    project_root = tmp_path
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text(
+        json.dumps({"progress": {"volumes_planned": []}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    story_root = project_root / ".story-system"
+    story_root.mkdir(parents=True, exist_ok=True)
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING"},
+                "route": {"primary_genre": "仙侠"},
+                "master_constraints": {"core_tone": "克制具体"},
+                "base_context": [],
+                "source_trace": [],
+                "override_policy": {"locked": [], "append_only": ["anti_patterns"], "override_allowed": []},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "anti_patterns.json").write_text(
+        json.dumps(
+            [
+                {
+                    "text": "唯一一个知道复利公式的人",
+                    "source_table": "review_extracted",
+                    "source_id": "ch0002_issue_1",
+                }
+            ],
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    volume_brief, review_contract = RuntimeContractBuilder(project_root).build_for_chapter(3)
+
+    assert "唯一一个知道复利公式的人" in volume_brief["anti_patterns"]
+    assert "唯一一个知道复利公式的人" in review_contract["anti_patterns"]

+ 32 - 0
webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py

@@ -127,3 +127,35 @@ def test_story_system_default_csv_dir_routes_real_genre_seed(tmp_path, monkeypat
     payload = json.loads(capsys.readouterr().out)
     assert payload["master_setting"]["route"]["primary_genre"] == "玄幻退婚流"
     assert payload["master_setting"]["route"]["route_source"] != "empty_csv_fallback"
+
+
+def test_story_system_warns_on_placeholder_query(tmp_path, monkeypatch, capsys):
+    project_root = tmp_path / "book"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    csv_dir = tmp_path / "csv"
+    csv_dir.mkdir()
+    _write_csv(csv_dir / "题材与调性推理.csv", ["编号", "关键词"], [])
+
+    from story_system import main
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "story_system",
+            "{章纲目标}",
+            "--project-root",
+            str(project_root),
+            "--chapter",
+            "1",
+            "--csv-dir",
+            str(csv_dir),
+            "--format",
+            "json",
+        ],
+    )
+    main()
+
+    captured = capsys.readouterr()
+    assert "placeholder" in captured.err

+ 172 - 0
webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py

@@ -398,3 +398,175 @@ def test_build_uses_canonical_genre_for_reasoning_lookup():
 
     assert contract["master_setting"]["route"]["canonical_genre"] == "玄幻"
     assert contract["chapter_brief"]["reasoning"]["genre"] == "玄幻"
+
+
+def test_chapter_focus_uses_directive_goal_not_dynamic_summary():
+    csv_dir = _make_local_tmp_path() / "csv"
+    csv_dir.mkdir()
+    _write_csv(
+        csv_dir / "题材与调性推理.csv",
+        [
+            "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+            "大模型指令", "核心摘要", "详细展开", "题材/流派", "canonical_genre", "题材别名", "核心调性",
+            "节奏策略", "毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
+        ],
+        [
+            {
+                "编号": "GR-001", "适用技能": "story-system", "分类": "题材路由", "层级": "知识补充",
+                "关键词": "仙侠", "意图与同义词": "", "适用题材": "仙侠", "大模型指令": "",
+                "核心摘要": "", "详细展开": "", "题材/流派": "仙侠", "canonical_genre": "仙侠",
+                "题材别名": "", "核心调性": "", "节奏策略": "", "毒点": "",
+                "推荐基础检索表": "", "推荐动态检索表": "场景写法", "默认查询词": "",
+            }
+        ],
+    )
+    _write_csv(
+        csv_dir / "场景写法.csv",
+        ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "毒点"],
+        [
+            {
+                "编号": "SP-087", "适用技能": "write", "分类": "场景", "层级": "知识补充",
+                "关键词": "论道", "适用题材": "仙侠", "核心摘要": "文斗场面的张力来自观点击中修行根基。",
+                "毒点": "",
+            }
+        ],
+    )
+
+    contract = StorySystemEngine(csv_dir).build(
+        query="仙侠",
+        genre="仙侠",
+        chapter=2,
+        chapter_directive={"goal": "井边对话收集借贷情报"},
+    )
+
+    brief = contract["chapter_brief"]
+    assert brief["chapter_directive"]["goal"] == "井边对话收集借贷情报"
+    assert brief["override_allowed"]["chapter_focus"] == "井边对话收集借贷情报"
+
+
+def test_chapter_focus_never_taken_from_dynamic_context_summary_for_placeholder_query():
+    engine = StorySystemEngine(_make_local_tmp_path())
+    focus = engine._suggest_chapter_focus("{章纲目标}", {})
+
+    assert focus == ""
+
+
+def test_story_system_reference_matching_prefers_chapter_keywords_with_same_priority():
+    csv_dir = _make_local_tmp_path() / "csv"
+    csv_dir.mkdir()
+    _write_csv(
+        csv_dir / "题材与调性推理.csv",
+        [
+            "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+            "大模型指令", "核心摘要", "详细展开", "题材/流派", "canonical_genre", "题材别名", "核心调性",
+            "节奏策略", "毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
+        ],
+        [
+            {
+                "编号": "GR-001", "适用技能": "story-system", "分类": "题材路由", "层级": "知识补充",
+                "关键词": "仙侠", "意图与同义词": "", "适用题材": "仙侠", "大模型指令": "",
+                "核心摘要": "", "详细展开": "", "题材/流派": "仙侠", "canonical_genre": "仙侠",
+                "题材别名": "", "核心调性": "", "节奏策略": "", "毒点": "",
+                "推荐基础检索表": "", "推荐动态检索表": "场景写法", "默认查询词": "宗门",
+            }
+        ],
+    )
+    _write_csv(
+        csv_dir / "场景写法.csv",
+        ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "适用场景", "核心摘要", "毒点"],
+        [
+            {
+                "编号": "SP-001", "适用技能": "write", "分类": "场景", "层级": "知识补充",
+                "关键词": "宗门|论道", "适用题材": "仙侠", "适用场景": "论道",
+                "核心摘要": "宗门论道要写观点交锋。", "毒点": "",
+            },
+            {
+                "编号": "FIN-001", "适用技能": "write", "分类": "场景", "层级": "知识补充",
+                "关键词": "借据|利息|复利|债", "适用题材": "仙侠", "适用场景": "借贷调查",
+                "核心摘要": "借贷场景要写清条款陷阱。", "毒点": "",
+            },
+        ],
+    )
+
+    contract = StorySystemEngine(csv_dir).build(
+        query="看穿借据条款的荒谬",
+        genre="仙侠",
+        chapter=1,
+        chapter_directive={"goal": "看穿借据条款的荒谬", "key_entities": ["借据", "利息", "复利"]},
+    )
+
+    selected = contract["chapter_brief"]["dynamic_context"]
+    assert selected[0]["编号"] == "FIN-001"
+
+
+def test_story_system_reference_matching_combines_priority_and_chapter_keywords():
+    csv_dir = _make_local_tmp_path() / "csv"
+    csv_dir.mkdir()
+    _write_csv(
+        csv_dir / "题材与调性推理.csv",
+        [
+            "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+            "大模型指令", "核心摘要", "详细展开", "题材/流派", "canonical_genre", "题材别名", "核心调性",
+            "节奏策略", "毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
+        ],
+        [
+            {
+                "编号": "GR-001", "适用技能": "story-system", "分类": "题材路由", "层级": "知识补充",
+                "关键词": "仙侠", "意图与同义词": "", "适用题材": "仙侠", "大模型指令": "",
+                "核心摘要": "", "详细展开": "", "题材/流派": "仙侠", "canonical_genre": "仙侠",
+                "题材别名": "", "核心调性": "", "节奏策略": "", "毒点": "",
+                "推荐基础检索表": "", "推荐动态检索表": "桥段套路|场景写法", "默认查询词": "宗门",
+            }
+        ],
+    )
+    _write_csv(
+        csv_dir / "裁决规则.csv",
+        [
+            "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+            "大模型指令", "核心摘要", "详细展开", "题材", "风格优先级", "爽点优先级",
+            "节奏默认策略", "毒点权重", "冲突裁决", "contract注入层", "反模式",
+        ],
+        [
+            {
+                "编号": "RS-001", "适用技能": "story-system", "分类": "裁决", "层级": "推理层",
+                "关键词": "仙侠", "意图与同义词": "", "适用题材": "仙侠", "大模型指令": "",
+                "核心摘要": "", "详细展开": "", "题材": "仙侠", "风格优先级": "",
+                "爽点优先级": "", "节奏默认策略": "", "毒点权重": "",
+                "冲突裁决": "桥段套路 > 场景写法", "contract注入层": "CHAPTER_BRIEF", "反模式": "",
+            }
+        ],
+    )
+    _write_csv(
+        csv_dir / "桥段套路.csv",
+        ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "桥段名称", "毒点"],
+        [
+            {
+                "编号": "TR-001", "适用技能": "write", "分类": "桥段", "层级": "知识补充",
+                "关键词": "宗门|论道", "适用题材": "仙侠", "核心摘要": "宗门论道冲突。",
+                "桥段名称": "论道", "毒点": "",
+            }
+        ],
+    )
+    _write_csv(
+        csv_dir / "场景写法.csv",
+        ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "适用场景", "核心摘要", "毒点"],
+        [
+            {
+                "编号": "FIN-001", "适用技能": "write", "分类": "场景", "层级": "知识补充",
+                "关键词": "借据|利息|复利|债", "适用题材": "仙侠", "适用场景": "借贷调查",
+                "核心摘要": "借贷场景要写清条款陷阱。", "毒点": "",
+            }
+        ],
+    )
+
+    contract = StorySystemEngine(csv_dir).build(
+        query="看穿借据条款的荒谬",
+        genre="仙侠",
+        chapter=1,
+        chapter_directive={"goal": "看穿借据条款的荒谬", "key_entities": ["借据", "利息", "复利"]},
+    )
+
+    selected = contract["chapter_brief"]["dynamic_context"]
+    assert [row["编号"] for row in selected[:2]] == ["FIN-001", "TR-001"]
+    trace_by_id = {row["id"]: row for row in contract["chapter_brief"]["source_trace"]}
+    assert trace_by_id["FIN-001"]["combined_rank_score"] > trace_by_id["TR-001"]["combined_rank_score"]

+ 15 - 0
webnovel-writer/scripts/data_modules/webnovel.py

@@ -360,6 +360,14 @@ def main() -> None:
     p_review_pipeline.add_argument("--report-file", default="", help="审查报告路径")
     p_review_pipeline.add_argument("--save-metrics", action="store_true", help="直接写入 index.db")
 
+    p_placeholder_scan = sub.add_parser("placeholder-scan", help="扫描大纲/设定集未补齐占位")
+    p_placeholder_scan.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
+
+    p_master_outline_sync = sub.add_parser("master-outline-sync", help="当前卷规划完成后写回 V+1 最小总纲锚点")
+    p_master_outline_sync.add_argument("--volume", type=int, required=True, help="当前已完成规划的卷号")
+    p_master_outline_sync.add_argument("--writeback-file", default="", help="显式结构化写回 JSON")
+    p_master_outline_sync.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
+
     knowledge_parser = sub.add_parser("knowledge", help="时序知识查询")
     knowledge_sub = knowledge_parser.add_subparsers(dest="knowledge_action")
 
@@ -467,6 +475,13 @@ def main() -> None:
         if args.save_metrics:
             return_args.append("--save-metrics")
         raise SystemExit(_run_script("review_pipeline.py", return_args))
+    if tool == "placeholder-scan":
+        raise SystemExit(_run_data_module("placeholder_scanner", [*forward_args, "--format", str(args.format)]))
+    if tool == "master-outline-sync":
+        return_args = [*forward_args, "--volume", str(args.volume), "--format", str(args.format)]
+        if args.writeback_file:
+            return_args.extend(["--writeback-file", str(args.writeback_file)])
+        raise SystemExit(_run_script("update_master_outline.py", return_args))
 
     if tool == "knowledge":
         from .knowledge_query import KnowledgeQuery

+ 3 - 1
webnovel-writer/scripts/review_pipeline.py

@@ -25,7 +25,7 @@ def _ensure_scripts_path() -> None:
 
 _ensure_scripts_path()
 
-from data_modules.review_schema import parse_review_output
+from data_modules.review_schema import append_ai_flavor_anti_patterns, parse_review_output
 
 
 def _resolve_report_path(project_root: Path, report_file: str) -> Path:
@@ -149,12 +149,14 @@ def build_review_artifacts(
 ) -> Dict[str, Any]:
     raw = json.loads(review_results_path.read_text(encoding="utf-8"))
     result = parse_review_output(chapter=chapter, raw=raw)
+    anti_patterns_added = append_ai_flavor_anti_patterns(project_root, result)
     metrics = result.to_metrics_dict(report_file=report_file)
 
     return {
         "chapter": chapter,
         "review_result": result.to_dict(),
         "metrics": metrics,
+        "anti_patterns_added": anti_patterns_added,
     }
 
 

+ 2 - 1
webnovel-writer/scripts/security_utils.py

@@ -22,7 +22,8 @@ from typing import Any, Dict, Optional, Union
 try:
     from filelock import FileLock
     HAS_FILELOCK = True
-except ImportError:
+except (ImportError, OSError):
+    FileLock = None  # type: ignore[assignment]
     HAS_FILELOCK = False
 
 

+ 13 - 1
webnovel-writer/scripts/story_system.py

@@ -11,7 +11,8 @@ from runtime_compat import enable_windows_utf8_stdio
 
 from data_modules.runtime_contract_builder import RuntimeContractBuilder
 from data_modules.story_contracts import persist_runtime_contracts, persist_story_seed
-from data_modules.story_system_engine import StorySystemEngine
+from data_modules.story_system_engine import StorySystemEngine, is_placeholder_query
+from chapter_outline_loader import load_chapter_execution_directive
 
 
 def _default_csv_dir() -> Path:
@@ -65,11 +66,22 @@ def main() -> None:
     args = parser.parse_args()
     project_root = _resolve_project_root(args.project_root)
     csv_dir = Path(args.csv_dir).expanduser().resolve() if args.csv_dir else _default_csv_dir()
+    if is_placeholder_query(args.query):
+        print(
+            "warning: story-system query appears to be a placeholder; parse the real chapter goal from the outline.",
+            file=sys.stderr,
+        )
+    chapter_directive = (
+        load_chapter_execution_directive(project_root, args.chapter)
+        if args.chapter
+        else {}
+    )
     engine = StorySystemEngine(csv_dir=csv_dir)
     contract = engine.build(
         query=args.query,
         genre=args.genre or None,
         chapter=args.chapter or None,
+        chapter_directive=chapter_directive,
     )
 
     if args.persist: