Selaa lähdekoodia

fix: reject invalid story-system genres

lingfengQAQ 1 kuukausi sitten
vanhempi
sitoutus
46e4575a9b

+ 40 - 28
webnovel-writer/scripts/data_modules/story_system_engine.py

@@ -25,6 +25,7 @@ ANTI_PATTERN_SOURCE_FIELDS = {
 
 _TEXT_TOKEN_RE = re.compile(r"[\s|,,、/;;::()()【】\[\]<>《》\"'!?!?。…]+")
 _PLACEHOLDER_QUERY_RE = re.compile(r"^\s*(\{[^{}]*章纲目标[^{}]*\}|第\s*\d+\s*章\s*章纲目标)\s*$")
+_ASCII_LETTER_RE = re.compile(r"[A-Za-z]")
 
 
 def is_placeholder_query(query: str) -> bool:
@@ -34,6 +35,23 @@ def is_placeholder_query(query: str) -> bool:
     return bool(_PLACEHOLDER_QUERY_RE.match(text))
 
 
+class StorySystemRoutingError(ValueError):
+    """Raised when story-system cannot select a route row."""
+
+
+def _validate_explicit_genre_source(genre: Optional[str]) -> Optional[str]:
+    normalized = str(genre or "").strip()
+    if not normalized:
+        return None
+    if _ASCII_LETTER_RE.search(normalized):
+        raise StorySystemRoutingError(
+            "story-system 题材参数必须使用中文名称,不能使用英文 profile key "
+            f"'{normalized}'。不会生成 .story-system contracts。"
+            "例如:规则怪谈、悬疑、玄幻。"
+        )
+    return normalized
+
+
 class StorySystemEngine:
     def __init__(self, csv_dir: str | Path):
         self.csv_dir = Path(csv_dir)
@@ -46,6 +64,7 @@ class StorySystemEngine:
         chapter_directive: Optional[Dict[str, Any]] = None,
     ) -> Dict[str, Any]:
         chapter_directive = chapter_directive or {}
+        genre = _validate_explicit_genre_source(genre)
         route = self._route(query=query, genre=genre)
         search_query = self._expand_query(
             query,
@@ -142,7 +161,7 @@ class StorySystemEngine:
         inferred_canonical = "" if genre else self._infer_genre_from_text(query)
 
         matched = None
-        route_source = "empty_csv_fallback"
+        route_source = ""
         for row in route_rows:
             aliases = (
                 self._split_multi_value(row.get("关键词"))
@@ -161,11 +180,8 @@ class StorySystemEngine:
             matched = self._fallback_row_for_genre(route_rows, inferred_canonical)
             if matched is not None:
                 route_source = "inferred_genre_fallback"
-        if matched is None and route_rows:
-            matched = route_rows[0]
-            route_source = "default_seed_fallback"
         if matched is None:
-            return self._empty_route(query=query, genre=genre)
+            raise self._routing_error(query=query, genre=genre, route_rows=route_rows)
 
         primary_genre = str(matched.get("题材/流派") or genre or "").strip()
         explicit_canonical = resolve_genre(genre)
@@ -531,26 +547,22 @@ class StorySystemEngine:
         cfg = CSV_CONFIG.get("裁决规则") or {}
         return str(cfg.get("contract_inject") or "")
 
-    def _empty_route(self, query: str, genre: Optional[str]) -> Dict[str, Any]:
-        fallback_genre = str(genre or "未命中题材").strip()
-        resolved_explicit = resolve_genre(genre)
-        canonical_genre = resolved_explicit if resolved_explicit not in (None, "全部") else ""
-        route_source = "explicit_genre_fallback" if genre else "empty_csv_fallback"
-        return {
-            "meta": {
-                "primary_genre": fallback_genre,
-                "canonical_genre": canonical_genre,
-                "route_source": route_source,
-                "genre_filter": canonical_genre,
-                "recommended_base_tables": ["命名规则", "人设与关系"],
-                "recommended_dynamic_tables": ["桥段套路", "爽点与节奏", "场景写法"],
-            },
-            "core_tone": "",
-            "pacing_strategy": "",
-            "route_anti_patterns": [],
-            "recommended_base_tables": ["命名规则", "人设与关系"],
-            "recommended_dynamic_tables": ["桥段套路", "爽点与节奏", "场景写法"],
-            "genre_filter": canonical_genre,
-            "default_query": "",
-            "source_trace": [{"table": "题材与调性推理", "id": "", "reason": f"{route_source}:{query}"}],
-        }
+    def _routing_error(
+        self,
+        *,
+        query: str,
+        genre: Optional[str],
+        route_rows: List[Dict[str, Any]],
+    ) -> StorySystemRoutingError:
+        query_text = str(query or "").strip()
+        genre_text = str(genre or "").strip()
+        if not route_rows:
+            detail = "题材与调性推理.csv 没有可用路由行"
+        else:
+            detail = f"query={query_text!r}, genre={genre_text!r} 未命中任何路由行"
+        return StorySystemRoutingError(
+            f"无法匹配 story-system 题材路由:{detail}。"
+            "不会生成 .story-system contracts。"
+            "请使用中文题材/流派(例如:规则怪谈、玄幻、仙侠),"
+            "或先在 题材与调性推理.csv 添加路由行。"
+        )

+ 23 - 0
webnovel-writer/scripts/data_modules/tests/test_init_project_pruning.py

@@ -1,6 +1,8 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 
+import pytest
+
 
 def test_init_skips_dead_templates_and_empty_libraries_for_single_protagonist(tmp_path, monkeypatch):
     import init_project as init_project_module
@@ -68,3 +70,24 @@ def test_init_generates_conditional_protagonist_group_and_heroine(tmp_path, monk
 
     assert (project_root / "设定集" / "主角组.md").is_file()
     assert (project_root / "设定集" / "女主卡.md").is_file()
+
+
+def test_init_rejects_english_profile_key_before_writing_state(tmp_path, monkeypatch):
+    import init_project as init_project_module
+
+    monkeypatch.setattr(init_project_module, "is_git_available", lambda: False)
+    project_root = tmp_path / "book"
+
+    with pytest.raises(SystemExit) as exc:
+        init_project_module.init_project(
+            str(project_root),
+            title="测试书",
+            genre="rules-mystery",
+            protagonist_name="陆鸣",
+            target_chapters=50,
+        )
+
+    message = str(exc.value)
+    assert "rules-mystery" in message
+    assert "规则怪谈" in message
+    assert not (project_root / ".webnovel" / "state.json").exists()

+ 20 - 0
webnovel-writer/scripts/data_modules/tests/test_reasoning_engine.py

@@ -45,6 +45,26 @@ def _setup_csvs(csv_dir):
                 "推荐动态检索表": "桥段套路|爽点与节奏",
                 "默认查询词": "退婚|打脸",
             },
+            {
+                "编号": "GR-002",
+                "适用技能": "story-system",
+                "分类": "题材路由",
+                "层级": "知识补充",
+                "关键词": "末日|末日求生",
+                "意图与同义词": "末世求生",
+                "适用题材": "科幻",
+                "大模型指令": "",
+                "核心摘要": "末日求生需要资源压力和秩序崩塌。",
+                "详细展开": "",
+                "题材/流派": "末日求生",
+                "题材别名": "末世",
+                "核心调性": "资源压力下的秩序重建",
+                "节奏策略": "先生存后扩张",
+                "毒点": "",
+                "推荐基础检索表": "命名规则|人设与关系",
+                "推荐动态检索表": "桥段套路|爽点与节奏",
+                "默认查询词": "末日|生存",
+            },
         ],
     )
 

+ 75 - 1
webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py

@@ -5,6 +5,8 @@ import csv
 import json
 import sys
 
+import pytest
+
 
 def _write_csv(path, headers, rows):
     with open(path, "w", encoding="utf-8-sig", newline="") as f:
@@ -155,7 +157,79 @@ def test_story_system_warns_on_placeholder_query(tmp_path, monkeypatch, capsys):
             "json",
         ],
     )
-    main()
+    with pytest.raises(SystemExit) as exc:
+        main()
+
+    assert exc.value.code == 2
 
     captured = capsys.readouterr()
     assert "placeholder" in captured.err
+    assert "无法匹配 story-system 题材路由" in captured.err
+
+
+def test_story_system_persist_unroutable_exits_without_contracts(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",
+        [
+            "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+            "大模型指令", "核心摘要", "详细展开", "题材/流派", "canonical_genre", "题材别名", "核心调性",
+            "节奏策略", "毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
+        ],
+        [
+            {
+                "编号": "GR-001",
+                "适用技能": "story-system",
+                "分类": "题材路由",
+                "层级": "知识补充",
+                "关键词": "玄幻退婚流",
+                "意图与同义词": "退婚流",
+                "适用题材": "玄幻",
+                "大模型指令": "",
+                "核心摘要": "",
+                "详细展开": "",
+                "题材/流派": "玄幻退婚流",
+                "canonical_genre": "玄幻",
+                "题材别名": "退婚流",
+                "核心调性": "",
+                "节奏策略": "",
+                "毒点": "",
+                "推荐基础检索表": "命名规则",
+                "推荐动态检索表": "桥段套路",
+                "默认查询词": "",
+            }
+        ],
+    )
+
+    from story_system import main
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "story_system",
+            "rules-mystery",
+            "--genre",
+            "rules-mystery",
+            "--project-root",
+            str(project_root),
+            "--persist",
+            "--csv-dir",
+            str(csv_dir),
+            "--format",
+            "json",
+        ],
+    )
+    with pytest.raises(SystemExit) as exc:
+        main()
+
+    assert exc.value.code == 2
+    captured = capsys.readouterr()
+    assert "rules-mystery" in captured.err
+    assert "规则怪谈" in captured.err
+    assert not (project_root / ".story-system").exists()

+ 106 - 3
webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py

@@ -5,7 +5,9 @@ import csv
 import uuid
 from pathlib import Path
 
-from data_modules.story_system_engine import StorySystemEngine
+import pytest
+
+from data_modules.story_system_engine import StorySystemEngine, StorySystemRoutingError
 
 
 def _write_csv(path, headers, rows):
@@ -149,10 +151,32 @@ def test_story_system_falls_back_to_explicit_genre():
         csv_dir / "题材与调性推理.csv",
         [
             "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
-            "大模型指令", "核心摘要", "详细展开", "题材/流派", "题材别名", "核心调性",
+            "大模型指令", "核心摘要", "详细展开", "题材/流派", "canonical_genre", "题材别名", "核心调性",
             "节奏策略", "毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
         ],
-        [],
+        [
+            {
+                "编号": "GR-010",
+                "适用技能": "story-system",
+                "分类": "题材路由",
+                "层级": "知识补充",
+                "关键词": "甜宠日常",
+                "意图与同义词": "",
+                "适用题材": "现言",
+                "大模型指令": "",
+                "核心摘要": "",
+                "详细展开": "",
+                "题材/流派": "现言",
+                "canonical_genre": "现言",
+                "题材别名": "",
+                "核心调性": "",
+                "节奏策略": "",
+                "毒点": "",
+                "推荐基础检索表": "命名规则|人设与关系",
+                "推荐动态检索表": "桥段套路|爽点与节奏|场景写法",
+                "默认查询词": "",
+            }
+        ],
     )
 
     engine = StorySystemEngine(csv_dir=csv_dir)
@@ -163,6 +187,85 @@ def test_story_system_falls_back_to_explicit_genre():
     assert contract["master_setting"]["route"]["recommended_dynamic_tables"] == ["桥段套路", "爽点与节奏", "场景写法"]
 
 
+def test_story_system_unmatched_genre_raises_routing_error():
+    csv_dir = _make_local_tmp_path() / "csv"
+    csv_dir.mkdir()
+
+    _write_csv(
+        csv_dir / "题材与调性推理.csv",
+        [
+            "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+            "大模型指令", "核心摘要", "详细展开", "题材/流派", "canonical_genre", "题材别名", "核心调性",
+            "节奏策略", "毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
+        ],
+        [
+            {
+                "编号": "GR-001",
+                "适用技能": "story-system",
+                "分类": "题材路由",
+                "层级": "知识补充",
+                "关键词": "玄幻退婚流|退婚打脸",
+                "意图与同义词": "退婚流|废材逆袭",
+                "适用题材": "玄幻",
+                "大模型指令": "",
+                "核心摘要": "",
+                "详细展开": "",
+                "题材/流派": "玄幻退婚流",
+                "canonical_genre": "玄幻",
+                "题材别名": "退婚流",
+                "核心调性": "",
+                "节奏策略": "",
+                "毒点": "",
+                "推荐基础检索表": "命名规则",
+                "推荐动态检索表": "桥段套路",
+                "默认查询词": "",
+            }
+        ],
+    )
+
+    engine = StorySystemEngine(csv_dir=csv_dir)
+
+    with pytest.raises(StorySystemRoutingError) as exc:
+        engine.build(query="赛博厨神", genre="赛博厨神", chapter=None)
+
+    message = str(exc.value)
+    assert "赛博厨神" in message
+    assert "未命中任何路由行" in message
+    assert "玄幻退婚流" not in message
+
+
+def test_story_system_routes_chinese_rules_mystery_to_canonical_suspense():
+    csv_dir = Path(__file__).resolve().parents[3] / "references" / "csv"
+
+    contract = StorySystemEngine(csv_dir=csv_dir).build(
+        query="规则怪谈",
+        genre="规则怪谈",
+        chapter=None,
+    )
+
+    route = contract["master_setting"]["route"]
+    assert route["canonical_genre"] == "悬疑"
+    assert route["genre_filter"] == "悬疑"
+    assert route["route_source"] != "default_seed_fallback"
+
+
+def test_story_system_rejects_english_explicit_genre_even_when_query_routes():
+    csv_dir = Path(__file__).resolve().parents[3] / "references" / "csv"
+
+    for query in ("rules-mystery", "规则怪谈"):
+        with pytest.raises(StorySystemRoutingError) as exc:
+            StorySystemEngine(csv_dir=csv_dir).build(
+                query=query,
+                genre="rules-mystery",
+                chapter=None,
+            )
+
+        message = str(exc.value)
+        assert "rules-mystery" in message
+        assert "规则怪谈" in message
+        assert "不会生成 .story-system contracts" in message
+
+
 def test_route_output_includes_canonical_genre():
     csv_dir = _make_local_tmp_path() / "csv"
     csv_dir.mkdir()

+ 14 - 0
webnovel-writer/scripts/init_project.py

@@ -36,6 +36,19 @@ if sys.platform == "win32":
     enable_windows_utf8_stdio()
 
 
+_ASCII_LETTER_RE = re.compile(r"[A-Za-z]")
+
+
+def _validate_initial_genre_source(genre: str) -> str:
+    normalized = str(genre or "").strip()
+    if _ASCII_LETTER_RE.search(normalized):
+        raise SystemExit(
+            "题材必须使用中文名称,不能使用英文 profile key "
+            f"'{normalized}'。例如:规则怪谈、悬疑、玄幻。"
+        )
+    return normalized
+
+
 def _read_text_if_exists(path: Path) -> str:
     if not path.exists():
         return ""
@@ -273,6 +286,7 @@ def init_project(
     project_path = Path(project_dir).expanduser().resolve()
     if ".claude" in project_path.parts:
         raise SystemExit("Refusing to initialize a project inside .claude. Choose a different directory.")
+    genre = _validate_initial_genre_source(genre)
     project_path.mkdir(parents=True, exist_ok=True)
 
     # 目录结构(同时兼容“卷目录”与后续扩展)

+ 10 - 7
webnovel-writer/scripts/story_system.py

@@ -11,7 +11,7 @@ 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, is_placeholder_query
+from data_modules.story_system_engine import StorySystemEngine, StorySystemRoutingError, is_placeholder_query
 from chapter_outline_loader import load_chapter_execution_directive
 
 
@@ -77,12 +77,15 @@ def main() -> None:
         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,
-    )
+    try:
+        contract = engine.build(
+            query=args.query,
+            genre=args.genre or None,
+            chapter=args.chapter or None,
+            chapter_directive=chapter_directive,
+        )
+    except StorySystemRoutingError as exc:
+        parser.exit(2, f"error: {exc}\n")
 
     if args.persist:
         persist_story_seed(