From baae51281e76d4f241144d8c6979820e50dbf181 Mon Sep 17 00:00:00 2001 From: PTAbabybearR <49743756+PTAbabybearR@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:50:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20explicit=5Fgenre=5Ffallback=20?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=B0=83=E7=94=A8=E6=96=B9=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E7=9A=84=E9=A2=98=E6=9D=90=EF=BC=88#135=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 走 fallback 时,匹配到的行只是被借用其推荐检索表与调性,其「题材/流派」是 该行自身的子流派,可能属于完全不同的题材。真实表中 GR-001(题材/流派=玄幻退婚流, 适用题材=玄幻|仙侠)是第一行,导致任何 genre=仙侠 且未命中关键词的项目 都会把 primary_genre 写成「玄幻退婚流」,并把 TR-001 退婚桥段注入 CHAPTER_BRIEF.writing_guidance。 显式指定 genre 时保留调用方的输入,与 canonical_genre 既有的「显式优先」 逻辑对齐(同函数 :201-203)。 inferred_genre_fallback 保持原行为不变:该路径下 genre 仅由自由文本推断, fallback 行的子流派反而更具体,此行为由 test_route_infers_canonical_genre_from_spaced_query 断言。 新增回归测试,复刻真实表中 GR-001 的形状——现有 test_story_system_falls_back_to_explicit_genre 的 fixture 里行的「题材/流派」 与传入 genre 相同(都是「现言」),两种实现都能通过,无法暴露该问题。 --- .../data_modules/story_system_engine.py | 9 ++++ .../tests/test_story_system_engine.py | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/webnovel-writer/scripts/data_modules/story_system_engine.py b/webnovel-writer/scripts/data_modules/story_system_engine.py index 39c59985..4624e303 100644 --- a/webnovel-writer/scripts/data_modules/story_system_engine.py +++ b/webnovel-writer/scripts/data_modules/story_system_engine.py @@ -190,6 +190,15 @@ def _route(self, query: str, genre: Optional[str]) -> Dict[str, Any]: 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 + # and tone -- its own 「题材/流派」 is that row's sub-genre and may belong to + # a different genre entirely. Keep what the caller asked for, mirroring + # how canonical_genre prefers the explicit genre below. + # (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.) + if route_source == "explicit_genre_fallback" and genre: + primary_genre = str(genre).strip() explicit_canonical = self._primary_resolved_genre(genre) canonical_genre = str(matched.get("canonical_genre") or "").strip() row_canonicals = [ 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..0af22585 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,60 @@ 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 tables/tone only. + + Its own 「题材/流派」 must not overwrite the genre the caller asked for. + 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": "玄幻", + "题材别名": "", + "核心调性": "先压后爆", + "节奏策略": "三章内必须有首次有效反打", + "毒点": "", + "推荐基础检索表": "命名规则|人设与关系", + "推荐动态检索表": "桥段套路|爽点与节奏|场景写法", + "默认查询词": "", + } + ], + ) + + engine = StorySystemEngine(csv_dir=csv_dir) + contract = engine.build(query="仙侠", genre="仙侠", chapter=None) + route = contract["master_setting"]["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"] == "仙侠" + assert route["primary_genre"] != "玄幻退婚流" + # canonical_genre already preferred the explicit genre; the two must agree. + assert route["canonical_genre"] == "仙侠" + + def test_story_system_unmatched_genre_raises_routing_error(): csv_dir = _make_local_tmp_path() / "csv" csv_dir.mkdir() From 43996d56b9ef71a0c7266903cc1e5133b638441f Mon Sep 17 00:00:00 2001 From: lingfengQAQ <75821188+lingfengQAQ@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:01:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20explicit=5Fgenre=5Ffallback=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=80=9F=E7=94=A8=20fallback=20=E8=A1=8C?= =?UTF-8?q?=E7=9A=84=E6=9F=A5=E8=AF=A2=E8=AF=8D=E3=80=81=E8=B0=83=E6=80=A7?= =?UTF-8?q?=E4=B8=8E=E6=AF=92=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fallback 行仅用于借出推荐检索表:其「默认查询词」(退婚|打脸|废材逆袭) 此前仍会把 TR-001 等退婚流桥段注入 base_context,「核心调性」「节奏策略」 「毒点」也会成为无关题材的锁定约束。题材级调性/节奏仍经裁决规则 (reasoning 层)注入章合同。 回归测试扩充为端到端断言:base_context 不含 TR-001、master_constraints 为空、行级毒点不进 anti_patterns;已验证在旧实现上失败。 Refs #135 --- .../data_modules/story_system_engine.py | 27 ++++--- .../tests/test_story_system_engine.py | 79 +++++++++++++++++-- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/webnovel-writer/scripts/data_modules/story_system_engine.py b/webnovel-writer/scripts/data_modules/story_system_engine.py index 4624e303..4f8d3c4c 100644 --- a/webnovel-writer/scripts/data_modules/story_system_engine.py +++ b/webnovel-writer/scripts/data_modules/story_system_engine.py @@ -189,16 +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 - # and tone -- its own 「题材/流派」 is that row's sub-genre and may belong to - # a different genre entirely. Keep what the caller asked for, mirroring - # how canonical_genre prefers the explicit genre below. + # 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.) - if route_source == "explicit_genre_fallback" and genre: - primary_genre = str(genre).strip() + 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 = [ @@ -229,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 0af22585..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 @@ -189,11 +189,13 @@ def test_story_system_falls_back_to_explicit_genre(): def test_explicit_genre_fallback_keeps_requested_genre_not_row_subgenre(): - """A fallback row is borrowed for its tables/tone only. + """A fallback row is borrowed for its recommended tables only. - Its own 「题材/流派」 must not overwrite the genre the caller asked for. - Mirrors the real 题材与调性推理 table, where GR-001 is the first row whose - 「适用题材」 contains 仙侠 while its 「题材/流派」 is 玄幻退婚流. + 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() @@ -222,24 +224,85 @@ def test_explicit_genre_fallback_keeps_requested_genre_not_row_subgenre(): "题材别名": "", "核心调性": "先压后爆", "节奏策略": "三章内必须有首次有效反打", - "毒点": "", + "毒点": "打脸不能软收尾", "推荐基础检索表": "命名规则|人设与关系", "推荐动态检索表": "桥段套路|爽点与节奏|场景写法", - "默认查询词": "", + "默认查询词": "退婚|打脸|废材逆袭", + } + ], + ) + + _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) - route = contract["master_setting"]["route"] + 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"] == "仙侠" - 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():