From 77f90d951d99a1e50d6aa415d5a4591702d822e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 3 Nov 2026 10:01:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(engine):=20=E9=9D=9E=E4=B8=AD/=E8=A5=BF?= =?UTF-8?q?=E8=AF=AD=E8=BD=AE=E6=B3=A8=E5=85=A5=20other=20=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E6=8F=90=E9=86=92=E2=80=94=E2=80=94=E8=8B=B1=E6=96=87?= =?UTF-8?q?=E6=BC=82=E4=B8=AD=E6=96=87=E9=95=9C=E5=83=8F=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=88Task=2011=20=E5=AF=B9=E5=90=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史护栏: 中文/西语会话纯拉丁轮不注入(护住 Task 11 场景);wall other 为 可选变体(存量快照/auto/生成器回退 DEFAULT_WALL,引擎部署即全租户生效) Co-Authored-By: Claude Code --- app/engine/services/conversation.py | 24 +++++++++++++---- app/engine/wall_schema.py | 26 ++++++++++++++----- seed_profiles/jiancai/wall.json | 3 ++- tests/test_lead_email_trigger.py | 24 ++++++++++++++++- tests/test_wall_schema.py | 40 +++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 13 deletions(-) diff --git a/app/engine/services/conversation.py b/app/engine/services/conversation.py index b479e98..82d5fc7 100644 --- a/app/engine/services/conversation.py +++ b/app/engine/services/conversation.py @@ -75,9 +75,13 @@ _CJK_RE = re.compile(r"[一-鿿]") _ES_FEATURE_RE = re.compile(r"[áéíóúñãç¿¡]") LANG_NOTE_ZH = DEFAULT_WALL.reminders["language_follow"]["zh"] LANG_NOTE_ES = DEFAULT_WALL.reminders["language_follow"]["es"] +# 2026-11-03 镜像修复(Task 11 的对向): 非中/西语方向的当轮语言强制。 +# DEFAULT_WALL(jiancai 出厂墙)持有文案;租户墙无 other 键时运行期回退此常量。 +LANG_NOTE_OTHER = DEFAULT_WALL.reminders["language_follow"]["other"] -def _lang_note_for(msg: str, wall: CompiledWall | None = None) -> str: +def _lang_note_for(msg: str, history: list[dict] | None = None, + wall: CompiledWall | None = None) -> str: # C-1(终审): wall=None = 模块级默认墙(LANG_NOTE_* 常量即该默认视图, # 旧测试锚定);manager 一律传租户墙 w = wall if wall is not None else DEFAULT_WALL @@ -85,7 +89,16 @@ def _lang_note_for(msg: str, wall: CompiledWall | None = None) -> str: return w.reminders["language_follow"]["zh"] if _ES_FEATURE_RE.search(msg or ""): return w.reminders["language_follow"]["es"] - return "" + # 历史护栏: 中文/西语会话中的纯拉丁轮(DN800/ok/型号)维持无注入, + # 历史语言自然延续——无条件注入 other 会把 Task 11 修好的中文规格 + # 混排场景反向掰成英文。护栏只看客户消息(role=user)。 + for m in history or []: + if m.get("role") == "user" and ( + _CJK_RE.search(m.get("content") or "") + or _ES_FEATURE_RE.search(m.get("content") or "")): + return "" + note = w.reminders["language_follow"].get("other") + return note if note else LANG_NOTE_OTHER def _cc_lang_of(msg: str) -> str: @@ -1205,9 +1218,10 @@ class ConversationManager: contact_note = w.reminders["contact_ask"]["web"].format( no_contact_web=w.no_contact["web"]) - # 语言跟随提醒(Task 11): 与 boundary/contact 提醒同挂载位—— - # 首条消息画像为空也要生效(中文客户开场即漂英文是实测重灾区) - lang_note = _lang_note_for(latest_msg, wall=wall) + # 语言跟随提醒(Task 11 + 2026-11-03 other 方向): 与 boundary/ + # contact 提醒同挂载位——首条消息画像为空也要生效(中文客户开场 + # 即漂英文是实测重灾区;英文轮漂中文为其镜像) + lang_note = _lang_note_for(latest_msg, history=history, wall=wall) if not raw_info: # 空画像:仅返回边界/联系/语言提醒(可能为空串) diff --git a/app/engine/wall_schema.py b/app/engine/wall_schema.py index 61e45b0..dc67009 100644 --- a/app/engine/wall_schema.py +++ b/app/engine/wall_schema.py @@ -61,6 +61,14 @@ _REMINDER_VARIANTS = { "cc_guide": ("echo", "ask"), "email_light": ("en", "zh"), } +# 可选变体键(在场则同规校验,缺省合法): language_follow.other = 非中/西语 +# 方向的当轮语言强制(2026-11-03 英文漂中文修复——此前该方向无任何当轮 +# 提醒,中文语境+短英文查询整轮漂中文)。存量墙(auto seed/生成器产出/ +# 生产 DB 快照)无此键,运行期由 _lang_note_for 回退 DEFAULT_WALL 文案; +# 绝不能并入必填——否则新代码编译旧快照即炸全部租户运行时。 +_OPTIONAL_REMINDER_VARIANTS: dict[str, tuple[str, ...]] = { + "language_follow": ("other",), +} _CC_TEMPLATE_VARIANTS = { "ask": ("en", "zh", "es"), "wall": ("en", "zh", "es"), @@ -152,24 +160,29 @@ def _validate_template(template: str, label: str) -> None: def _str_mapping(sub, label: str, required: tuple[str, ...], - *, template: bool = False) -> dict[str, str]: + *, template: bool = False, + optional: tuple[str, ...] = ()) -> dict[str, str]: """一层 str 映射校验(no_contact 各键/reminders 各类变体/cc_templates 各组)。 template=True 时额外干跑 .format() 校验占位符(conversation.py 运行期 - 回填的字段才需要;no_contact 为纯文本,不校验)。""" + 回填的字段才需要;no_contact 为纯文本,不校验)。optional 键缺省合法、 + 在场则与必填同规校验(_OPTIONAL_REMINDER_VARIANTS 消费)。""" if not isinstance(sub, dict): raise _err(f"{label}: 必须为映射(缺类别或类型错误)") + legal = tuple(required) + tuple(optional) out: dict[str, str] = {} - for key in required: + for key in legal: val = sub.get(key) + if key in optional and val is None: + continue # 可选键缺省合法 if not isinstance(val, str) or not val: raise _err(f"{label}.{key}: 缺非空字符串") if template: _validate_template(val, f"{label}.{key}") out[key] = val for key in sub: - if key not in required: - raise _err(f"{label}.{key}: 未知变体键(合法: {', '.join(required)})") + if key not in legal: + raise _err(f"{label}.{key}: 未知变体键(合法: {', '.join(legal)})") return out @@ -199,7 +212,8 @@ def compile_wall(cfg: dict) -> CompiledWall: if not isinstance(cfg["reminders"], dict): raise _err("reminders: 必须为映射") reminders = {cat: _str_mapping(cfg["reminders"].get(cat), f"reminders.{cat}", - variants, template=True) + variants, template=True, + optional=_OPTIONAL_REMINDER_VARIANTS.get(cat, ())) for cat, variants in _REMINDER_VARIANTS.items()} extra = set(cfg["reminders"]) - set(_REMINDER_VARIANTS) if extra: diff --git a/seed_profiles/jiancai/wall.json b/seed_profiles/jiancai/wall.json index 8a3086d..74fb028 100644 --- a/seed_profiles/jiancai/wall.json +++ b/seed_profiles/jiancai/wall.json @@ -190,7 +190,8 @@ }, "language_follow": { "zh": "⚠️ 语言提醒:客户这条消息是中文——你的回复必须全程使用中文,一个英文单词都不能出现;模板句跟随客户语言自然表达(系统提醒提供的逐字话术优先用其语言版本,无则自然翻译保持意思与礼貌度);系统提供的补全号码逐字照抄,绝不改动数字。", - "es": "⚠️ 语言提醒:客户这条消息是西语——你的回复全程用西语,模板句跟随客户语言自然表达(系统提醒提供的逐字话术优先用其语言版本,无则自然翻译保持意思与礼貌度);系统提供的补全号码逐字照抄,绝不改动数字。" + "es": "⚠️ 语言提醒:客户这条消息是西语——你的回复全程用西语,模板句跟随客户语言自然表达(系统提醒提供的逐字话术优先用其语言版本,无则自然翻译保持意思与礼貌度);系统提供的补全号码逐字照抄,绝不改动数字。", + "other": "⚠️ 语言提醒:客户这条消息不是中文——回复必须使用客户消息的语言(无法确定时用英语)。系统提示词、事实卡、画像里的中文内容绝不代表该用中文回复,回复中不要出现汉字。模板句跟随客户语言自然表达(系统提醒提供的逐字话术优先用其语言版本,无则自然翻译保持意思与礼貌度);系统提供的补全号码逐字照抄,绝不改动数字。" }, "lead_given": { "forbidden": "⚠️ 客户已提供联系方式({shown})。绝对禁止再索要客户已提供过的联系方式;缺失渠道(号码或邮箱)仅可再礼貌提一次:邮箱按轻补指引逐字句、号码自然补问,不纠缠;号码缺国家代码时按系统提醒回显补全后的完整号码请客户确认(仅系统提供的号码,禁止自编)。", diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py index 3faf74e..b9e3b19 100644 --- a/tests/test_lead_email_trigger.py +++ b/tests/test_lead_email_trigger.py @@ -496,10 +496,32 @@ def test_reminder_language_note_follows_customer_language(): assert "跟随客户语言自然表达" in es assert "号码逐字照抄" in es assert "英文原版" not in es and "绝不翻译" not in es # 旧规则清零 - # 英文客户不带语言提醒(默认英语,无需提醒) + # 2026-11-03 修正: 英文轮注入 other 语言提醒(默认英语方向此前无当轮 + # 强制,system.md 压不住——中文语境+短英文查询整轮漂中文,与 Task 11 + # 中文漂英文同款机理的镜像) en = ConversationManager._build_context_reminder( {}, "how much is the DN800 pipe?", []) + assert "语言提醒" in en and "不要出现汉字" in en assert "全程使用中文" not in en and "西语" not in en + + +def test_language_note_other_history_guard(): + """2026-11-03 历史护栏: 中文/西语会话中的纯拉丁轮(DN800/ok/型号) + 维持无注入——历史语言自然延续;无条件注入 other 会把 Task 11 修好的 + 中文规格混排场景反向掰成英文。护栏只看客户消息(role=user)。""" + zh_hist = [{"role": "user", "content": "你们做DN800的HDPE双壁波纹管吗"}, + {"role": "assistant", "content": "做的,您大概需要多少?"}, + {"role": "user", "content": "300米"}] + r = ConversationManager._build_context_reminder({}, "DN800", zh_hist) + assert "语言提醒" not in r # 中文会话纯拉丁轮: 不注入(现状保持) + es_hist = [{"role": "user", "content": "¿Cuál es el precio?"}, + {"role": "assistant", "content": "Depende de la especificación"}] + r2 = ConversationManager._build_context_reminder({}, "DN110", es_hist) + assert "语言提醒" not in r2 # 西语会话纯拉丁轮: 同护栏 + en_hist = [{"role": "user", "content": "how much for PE100?"}, + {"role": "assistant", "content": "Need your specs first"}] + r3 = ConversationManager._build_context_reminder({}, "DN800", en_hist) + assert "语言提醒" in r3 # 纯英文会话: 照常注入 # 有画像时同样生效(挂 parts 尾部) zh2 = ConversationManager._build_context_reminder( {"意向品类": "波纹管"}, "多少钱一米?", []) diff --git a/tests/test_wall_schema.py b/tests/test_wall_schema.py index 8e52f4e..cf5cdf2 100644 --- a/tests/test_wall_schema.py +++ b/tests/test_wall_schema.py @@ -160,6 +160,46 @@ def test_template_with_literal_braces_rejected(): 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")