diff --git a/webnovel-writer/scripts/data_modules/story_system_engine.py b/webnovel-writer/scripts/data_modules/story_system_engine.py index 39c59985..4f8d3c4c 100644 --- a/webnovel-writer/scripts/data_modules/story_system_engine.py +++ b/webnovel-writer/scripts/data_modules/story_system_engine.py @@ -189,7 +189,21 @@ def _route(self, query: str, genre: Optional[str]) -> Dict[str, Any]: if matched is None: raise self._routing_error(query=query, genre=genre, route_rows=route_rows) - primary_genre = str(matched.get("题材/流派") or genre or "").strip() + # When the caller states the genre explicitly but no row matches by + # keyword, the fallback row is borrowed only for its recommended tables. + # Its own 「题材/流派」/「核心调性」/「节奏策略」/「默认查询词」/「毒点」 describe + # that row's sub-genre, which may belong to a different genre entirely + # (e.g. GR-001 玄幻退婚流 also lists 仙侠 in 适用题材). Keep what the caller + # asked for, mirroring how canonical_genre prefers the explicit genre + # below; genre-level tone/pacing still reach chapter contracts via the + # reasoning layer (裁决规则). + # (inferred_genre_fallback intentionally keeps the row's sub-genre: there + # the genre was only guessed from free text, so the row is more specific.) + borrow_row_details = route_source != "explicit_genre_fallback" + if borrow_row_details: + primary_genre = str(matched.get("题材/流派") or genre or "").strip() + else: + primary_genre = str(genre or "").strip() explicit_canonical = self._primary_resolved_genre(genre) canonical_genre = str(matched.get("canonical_genre") or "").strip() row_canonicals = [ @@ -220,13 +234,13 @@ def _route(self, query: str, genre: Optional[str]) -> Dict[str, Any]: "recommended_base_tables": self._split_multi_value(matched.get("推荐基础检索表")), "recommended_dynamic_tables": self._split_multi_value(matched.get("推荐动态检索表")), }, - "core_tone": str(matched.get("核心调性") or "").strip(), - "pacing_strategy": str(matched.get("节奏策略") or "").strip(), - "route_anti_patterns": self._extract_route_anti_patterns(matched), + "core_tone": str(matched.get("核心调性") or "").strip() if borrow_row_details else "", + "pacing_strategy": str(matched.get("节奏策略") or "").strip() if borrow_row_details else "", + "route_anti_patterns": self._extract_route_anti_patterns(matched) if borrow_row_details else [], "recommended_base_tables": self._split_multi_value(matched.get("推荐基础检索表")), "recommended_dynamic_tables": self._split_multi_value(matched.get("推荐动态检索表")), "genre_filter": genre_filter, - "default_query": str(matched.get("默认查询词") or "").strip(), + "default_query": str(matched.get("默认查询词") or "").strip() if borrow_row_details else "", "source_trace": [{"table": "题材与调性推理", "id": matched.get("编号", ""), "reason": route_source}], } diff --git a/webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py b/webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py index 9e4da838..0a78ff9a 100644 --- a/webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py +++ b/webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py @@ -188,6 +188,123 @@ def test_story_system_falls_back_to_explicit_genre(): assert contract["master_setting"]["route"]["recommended_dynamic_tables"] == ["桥段套路", "爽点与节奏", "场景写法"] +def test_explicit_genre_fallback_keeps_requested_genre_not_row_subgenre(): + """A fallback row is borrowed for its recommended tables only. + + Its own 「题材/流派」/「核心调性」/「节奏策略」/「默认查询词」/「毒点」 describe the + row's sub-genre and must not leak into a project whose caller explicitly + asked for a different genre. Mirrors the real 题材与调性推理 table, where + GR-001 is the first row whose 「适用题材」 contains 仙侠 while its + 「题材/流派」 is 玄幻退婚流. + """ + 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", + ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "桥段名称", "毒点"], + [ + { + "编号": "TR-001", + "适用技能": "write", + "分类": "桥段", + "层级": "知识补充", + "关键词": "退婚|打脸", + "适用题材": "玄幻|仙侠", + "核心摘要": "退婚现场要给足羞辱和反击空间", + "桥段名称": "退婚三年之约", + "毒点": "主角还没反打就被配角替他出手", + } + ], + ) + + _write_csv( + csv_dir / "裁决规则.csv", + [ + "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材", + "大模型指令", "核心摘要", "详细展开", "题材", "风格优先级", "爽点优先级", + "节奏默认策略", "毒点权重", "冲突裁决", "contract注入层", "反模式", + ], + [ + { + "编号": "RS-002", + "适用技能": "story-system", + "分类": "裁决", + "层级": "推理层", + "关键词": "仙侠", + "意图与同义词": "修仙怎么写", + "适用题材": "仙侠", + "大模型指令": "按冲突裁决排序命中条目", + "核心摘要": "仙侠裁决规则", + "详细展开": "", + "题材": "仙侠", + "风格优先级": "冷硬算计 > 超然物外", + "爽点优先级": "境界碾压 > 底牌揭晓", + "节奏默认策略": "慢蓄快爆", + "毒点权重": "修炼水字数 > 逻辑断裂", + "冲突裁决": "爽点与节奏 > 桥段套路 > 场景写法", + "contract注入层": "CHAPTER_BRIEF.writing_guidance", + "反模式": "修炼变流水账", + } + ], + ) + + engine = StorySystemEngine(csv_dir=csv_dir) + contract = engine.build(query="仙侠", genre="仙侠", chapter=None) + master = contract["master_setting"] + route = master["route"] + + assert route["route_source"] == "explicit_genre_fallback" + # Regression: primary_genre used to leak the fallback row's own sub-genre. + assert route["primary_genre"] == "仙侠" + # canonical_genre already preferred the explicit genre; the two must agree. + assert route["canonical_genre"] == "仙侠" + # The row is still borrowed for its recommended tables. + assert route["recommended_dynamic_tables"] == ["桥段套路", "爽点与节奏", "场景写法"] + + # Regression: the row's 默认查询词 (退婚|打脸) used to pull TR-001 into + # base_context, injecting 退婚流 tropes into every chapter's writing_guidance. + assert "TR-001" not in {row.get("编号") for row in master["base_context"]} + # Regression: the row's own tone/pacing used to become locked master + # constraints for an unrelated genre. + assert master["master_constraints"]["core_tone"] == "" + assert master["master_constraints"]["pacing_strategy"] == "" + # The row's own 毒点 must not leak either. + assert "打脸不能软收尾" not in {item["text"] for item in contract["anti_patterns"]} + + def test_story_system_unmatched_genre_raises_routing_error(): csv_dir = _make_local_tmp_path() / "csv" csv_dir.mkdir()