"""wall.json → CompiledWall 编译器契约(Task 4 词表数据化)。 seed_profiles//wall.json 是租户墙词表/话术的唯一数据源; compile_wall 把 JSON 编译为运行时对象,坏配置必须显性失败(信息含字段名)。 """ import json import re from pathlib import Path import pytest from app.engine.wall_schema import CompiledWall, WallConfigError, compile_wall REPO_ROOT = Path(__file__).resolve().parents[1] def _load(slug: str) -> dict: return json.loads( (REPO_ROOT / "seed_profiles" / slug / "wall.json").read_text(encoding="utf-8")) def test_compile_produces_regex_objects(): wall = compile_wall(_load("jiancai")) assert isinstance(wall, CompiledWall) assert wall.price_patterns assert all(isinstance(p, re.Pattern) for p in wall.price_patterns) assert wall.deflections["es"] assert all(isinstance(p, re.Pattern) for p in wall.reask_patterns) assert all(isinstance(p, re.Pattern) for p in wall.triggers.values()) assert isinstance(wall.zero_number["long_number"], re.Pattern) def test_compile_rejects_bad_regex(): cfg = _load("jiancai") cfg["price_patterns"] = ["([unclosed"] with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "price_patterns" in str(ei.value) assert "regex" in str(ei.value) def test_compile_rejects_bad_regex_in_nested_field(): cfg = _load("jiancai") cfg["triggers"]["boundary"] = {"pattern": "(?Pbudget", "flags": "i"} with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "triggers.boundary" in str(ei.value) def test_compile_rejects_unknown_flag(): cfg = _load("jiancai") cfg["reask_patterns"] = [{"pattern": "ok", "flags": "x"}] with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "reask_patterns" in str(ei.value) def test_missing_and_unknown_top_level_fields(): cfg = _load("jiancai") del cfg["no_contact"] with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "no_contact" in str(ei.value) cfg = _load("jiancai") cfg["deflectionz"] = [] # 拼写错误必须在编译期显性失败 with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "deflectionz" in str(ei.value) def test_meta_key_is_ignored(): cfg = _load("jiancai") cfg["_meta"] = {"doc": "任何 _ 前缀键为数据来源标注,不参与编译"} assert compile_wall(cfg).no_contact["web"] def test_flags_entry_restores_ignorecase(): wall = compile_wall({"price_patterns": [{"pattern": "BUDGET|预算", "flags": "i"}], **{k: v for k, v in _load("jiancai").items() if k != "price_patterns"}}) assert any(p.search("my BUDGET plan") for p in wall.price_patterns) def test_both_seed_walls_compile(): for slug in ("jiancai", "auto"): wall = compile_wall(_load(slug)) assert wall.triggers["boundary"].search("how much is it?") assert wall.no_contact["web"] assert wall.reminders["boundary"]["normal"].startswith("⚠️ 职能边界提醒") # 租户差异钉死: jiancai 多语言墙/auto 中英+$ 且无 es 池 if slug == "jiancai": assert "es" in wall.deflections assert len(wall.price_patterns) > 10 else: assert "es" not in wall.deflections assert "_meta" in _load(slug) # 引擎特性字段沿用 jiancai(显性标注) def test_default_wall_matches_jiancai_seed(): """boundary_filter 的出厂默认墙 = jiancai seed 逐字段等价(等价回归的锚)。""" from app.engine.services import boundary_filter as bf wall = compile_wall(_load("jiancai")) assert [p.pattern for p in bf.DEFAULT_WALL.price_patterns] == \ [p.pattern for p in wall.price_patterns] assert [p.flags for p in bf.DEFAULT_WALL.price_patterns] == \ [p.flags for p in wall.price_patterns] assert bf.DEFLECTIONS_ZH == list(wall.deflections["zh"]) assert bf.NO_CONTACT_WEB == wall.no_contact["web"] assert bf.LONG_NUMBER_RE.pattern == wall.zero_number["long_number"].pattern assert [p.pattern for p in bf.REASK_PATTERNS] == \ [p.pattern for p in wall.reask_patterns] def test_conversation_triggers_match_seed(): """conversation 三组触发正则 = wall.triggers 编译产物(pattern+flags 双等价)。""" from app.engine.services import conversation as conv wall = compile_wall(_load("jiancai")) for name, module_re in (("boundary", conv.BOUNDARY_TRIGGER_RE), ("budget", conv.BUDGET_TRIGGER_RE), ("contact_ask", conv.CONTACT_ASK_TRIGGER_RE)): assert module_re.pattern == wall.triggers[name].pattern, name assert module_re.flags == wall.triggers[name].flags, name # ---- 审查修复契约(I-1/I-2/I-3 + M-4 防呆分支) ---- def test_missing_es_pool_falls_back_to_en(): """I-1: auto 墙无 es 池,西语消息路由 es 后回落 EN 池,绝不 KeyError。""" from app.engine.services.boundary_filter import deflection_for, pivot_for auto = compile_wall(_load("auto")) assert "es" not in auto.deflections # 前提: auto 确实无 es 池 msg = "¿Cuánto cuesta?" # 重音符 → _lang_pool 路由 es → 无池 → 降级 EN assert deflection_for(msg, wall=auto) in auto.deflections["en"] assert (deflection_for(msg, has_contact=True, wall=auto) in auto.deflections_contact["en"]) assert pivot_for(msg, wall=auto) == auto.pivots["en"][0] assert pivot_for(msg, has_contact=True, wall=auto) == auto.pivots_contact["en"][0] # 有 es 池的墙(jiancai)不受降级逻辑影响: 仍命中 ES 池 jc = compile_wall(_load("jiancai")) assert deflection_for(msg, wall=jc) in jc.deflections["es"] assert pivot_for(msg, wall=jc) == jc.pivots["es"][0] def test_template_placeholder_whitelist_exported(): """I-2: 白名单 = conversation.py 运行期 .format() 回填键全集(grep 实证)。""" from app.engine.wall_schema import TEMPLATE_PLACEHOLDERS assert TEMPLATE_PLACEHOLDERS == { "shown", "intl", "cc_script", "no_contact_web", "no_contact_app", "no_contact_already", } def test_template_with_literal_braces_rejected(): """I-2: 话术里的字面花括号(把中文"格式"写成 {格式})编译期拒绝。""" cfg = _load("jiancai") cfg["reminders"]["boundary"]["normal"] = "请用 {格式} 重新描述预算" with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "reminders.boundary.normal" in str(ei.value) # ---- 2026-11-03 英文漂中文修复: language_follow.other 可选变体 ---- def test_language_follow_other_optional_variant(): """other 为可选变体: 在场则校验,缺省合法(auto 墙/生成器产出/生产 DB 快照无此键;运行期 _lang_note_for 回退 DEFAULT_WALL 文案)。设为必填 会让新代码编译旧生产快照即炸全部租户运行时——绝不改为必填。""" import copy base = _load("jiancai") assert "other" in base["reminders"]["language_follow"] # 出厂墙已有 compile_wall(base) # 在场: 合法 without = copy.deepcopy(base) del without["reminders"]["language_follow"]["other"] compile_wall(without) # 缺省: 合法 bad = copy.deepcopy(base) bad["reminders"]["language_follow"]["other"] = "" with pytest.raises(WallConfigError): compile_wall(bad) # 空串: 显性拒绝 def test_language_follow_other_text_pinned(): """other 文案钉死关键语义: 纯反漂中文(不提任何语言分类——无重音符的 西/葡语客户会落进本桶,分类断言会把他们误导去英语)+模板句跟随+号码 照抄(与 zh/es 两条同款条款)。""" note = _load("jiancai")["reminders"]["language_follow"]["other"] assert "不要出现汉字" in note assert "无法确定时用英语" in note assert "跟随客户语言自然表达" in note and "逐字照抄" in note assert "西语" not in note # 措辞禁区: 不做语言分类断言 def test_language_note_other_wall_fallback(): """租户墙无 other 键(auto/存量生产快照)→ _lang_note_for 回退 DEFAULT_WALL 文案——引擎部署即全租户生效,无需推配置。""" from app.engine.services import conversation as conv auto = compile_wall(_load("auto")) assert "other" not in auto.reminders["language_follow"] # 前提钉死 note = conv._lang_note_for("how much?", history=[], wall=auto) assert "语言提醒" in note and "不要出现汉字" in note def test_template_with_unknown_placeholder_rejected(): """I-2: 白名单外占位符({name})编译期拒绝——原属运行期 KeyError。""" cfg = _load("jiancai") cfg["cc_templates"]["ask"]["en"] = "Hi {name}, could you share your WhatsApp?" with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "cc_templates.ask.en" in str(ei.value) def test_pattern_dict_unknown_key_rejected(): """I-3: 键拼错({"flagss"})曾静默忽略→墙悄悄放宽,必须显性拒绝。""" cfg = _load("jiancai") cfg["price_patterns"] = [{"pattern": r"\$?\s*\d+", "flagss": "i"}] with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "flagss" in str(ei.value) assert "pattern" in str(ei.value) # 合法键清单一并给出 def test_reminder_unknown_variant_key_rejected(): """M-4: reminders 类别下未知变体键(拼错)显性拒绝。""" cfg = _load("jiancai") cfg["reminders"]["boundary"]["bogus"] = "话术" with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "reminders.boundary.bogus" in str(ei.value) def test_pool_missing_required_language_rejected(): """M-4: 池缺必填语言(eng/zh 之外的核心双语)编译期拒绝。""" cfg = _load("jiancai") del cfg["deflections"]["zh"] with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "deflections" in str(ei.value) assert "zh" in str(ei.value) def test_pool_non_list_items_rejected(): """M-4: 池语言值非列表(标量串)编译期拒绝。""" cfg = _load("jiancai") cfg["deflections"]["zh"] = "not-a-list" with pytest.raises(WallConfigError) as ei: compile_wall(cfg) assert "deflections.zh" in str(ei.value) def test_patterns_non_list_and_empty_rejected(): """M-4: patterns 字段非列表/空列表均编译期拒绝。""" for bad in ("nope", []): cfg = _load("jiancai") cfg["price_patterns"] = bad with pytest.raises(WallConfigError): compile_wall(cfg)