diff --git a/CLAUDE.md b/CLAUDE.md index 438e455..e560551 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ - 上游吸收:input-placeholder 可配置化(品牌配置组,public.py 下发,widget/chat data-placeholder 管道);7 个测试文件(test_channel_hygiene / test_chat_rest_ip / test_lead_push_cc / test_cc_integration / test_ip_service_region / test_prompt_static / test_placeholder_static,commit d1aed8e)。 - 三不带:blog.html(static/ 无此文件)、Redis(app+scripts 零命中)、旧版 whatsapp-cc-completion(零命中,jc 版 cc_completion.py 取代)。 -**当前能力一览**:多租户引擎(TenantRuntime 隔离边界,engine 不感知 platform);站点模型(tenant_config.Site,一租户多站点覆盖);词表/提示词/品牌全配置化(seed_profiles 出厂包 + platform.db 热加载);Host 路由(host_middleware,未知 Host 404);WS 防串库(租户化 client key + 会话站点标);WS 每 IP 连接上限(conn_limiter,租户级 ws_conn_limit 配置)。 +**当前能力一览**:多租户引擎(TenantRuntime 隔离边界,engine 不感知 platform);站点模型(tenant_config.Site,一租户多站点覆盖);词表/提示词/品牌全配置化(seed_profiles 出厂包 + 文件级 registry_reload 热加载,Phase 2 升级为 platform.db);Host 路由(host_middleware,未知 Host 404);WS 防串库(租户化 client key + 会话站点标);WS 每 IP 连接上限(conn_limiter,租户级 ws_conn_limit 配置)。 ## Phase 2 清理单(前序审查挂账,集中列此) @@ -51,6 +51,8 @@ 8. **话术含引号/换行的转义**——编辑器批次(`_format_deflection_pool` docstring 有注)。 9. **产品决策项**:国名纠正 e164 变化时是否补发更正邮件(上游行为更优);REST/WS 服务端 IP 口径是否统一(出处:上游吸收审查)。 10. **avatar 从 seed_profiles 迁 uploads 卷**——上传功能落地时(出处:品牌下发审查)。 +11. **/health 分级健康**——Phase 4 部署批次(进程存活之外按依赖分级上报,健康检查可区分"进程活"与"依赖通";出处:终审审查 M-3)。 +12. **PromptProvider.from_dir 的 FileNotFoundError 带路径上下文**——提示词目录缺失时异常信息含实际路径,定位不用猜(出处:终审审查 M-4)。 ## Phase 2 计划编写注意 diff --git a/README.md b/README.md index ed13a75..cb83ad6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **现状能力**(Phase 1 收尾时点): - 多租户引擎:TenantRuntime 隔离边界,engine 包不感知平台层,可整体拎走独立部署。 -- 站点模型:一租户多站点(品牌/主题/站点级覆盖),词表、提示词、品牌全部配置化(seed_profiles 出厂包 + platform.db 热加载)。 +- 站点模型:一租户多站点(品牌/主题/站点级覆盖),词表、提示词、品牌全部配置化(seed_profiles 出厂包 + 文件级 registry_reload 热加载,Phase 2 升级为 platform.db)。 - Host 路由:按域名解析租户,未知 Host 404;WS 通道租户化客户端 key 防串库,另有限流与每 IP 连接上限。 - 引擎行为:cc 区号补全判定树、多语言墙(中/西/葡借道/俄/阿)、dual-contact 分步双要、CC 强制墙、lead 邮件推送。 diff --git a/app/engine/services/conversation.py b/app/engine/services/conversation.py index c9ea3bf..a469938 100644 --- a/app/engine/services/conversation.py +++ b/app/engine/services/conversation.py @@ -25,6 +25,7 @@ from app.engine.services.contact_pipeline import ( canon, digits_only, latest_stated_contacts, merge_contacts, normalize_contacts, resolve_by_recency, scan_user_messages, split_validate, ) +from app.engine.wall_schema import CompiledWall if TYPE_CHECKING: from app.platform.registry import TenantRuntime @@ -71,6 +72,8 @@ def context_seed_map(config_data: dict | None) -> dict: # - 原则: 误触发低害(多一条提醒),漏触发高危(红线失守),词表偏宽;上线后漏词案例回流补词 # Task 4 词表数据化: 三组触发正则的数据源 = DEFAULT_WALL(jiancai 出厂) # .triggers 编译产物(seed_profiles/jiancai/wall.json),上方精修说明随数据留存 +# C-1(终审): 本组模块常量降级为"默认墙视图"——供直调/旧测试按名锚定; +# 运行期触发词查找一律走租户墙(manager 的 self._wall.triggers) BOUNDARY_TRIGGER_RE = DEFAULT_WALL.triggers["boundary"] # 预算路径路由(评审#9): 命中 BOUNDARY 触发词且含预算词 → 走预算 playbook @@ -97,11 +100,14 @@ LANG_NOTE_ZH = DEFAULT_WALL.reminders["language_follow"]["zh"] LANG_NOTE_ES = DEFAULT_WALL.reminders["language_follow"]["es"] -def _lang_note_for(msg: str) -> str: +def _lang_note_for(msg: str, wall: CompiledWall | None = None) -> str: + # C-1(终审): wall=None = 模块级默认墙(LANG_NOTE_* 常量即该默认视图, + # 旧测试锚定);manager 一律传租户墙 + w = wall if wall is not None else DEFAULT_WALL if _CJK_RE.search(msg or ""): - return LANG_NOTE_ZH + return w.reminders["language_follow"]["zh"] if _ES_FEATURE_RE.search(msg or ""): - return LANG_NOTE_ES + return w.reminders["language_follow"]["es"] return "" @@ -157,45 +163,60 @@ def _cc_intl(wa_state: dict | None) -> str: return str((wa_state or {}).get("intl") or cc_completion.format_intl(e164)) -def _cc_script(wa_state: dict | None, lang: str) -> str: +def _cc_script(wa_state: dict | None, lang: str, + wall: CompiledWall | None = None) -> str: """CC 等待态本轮逐字话术(Task 4, spec §4;P3 扩 ES): 有 e164 → 回显 确认句(intl 由 Python 算好填入,GLM 不拼号);no_ip → 追问句 - (_CC_ASK_* 服务)。reminder 注入/墙替换/测试断言三方同源,统一走本函数。""" + (_CC_ASK_* 服务)。reminder 注入/墙替换/测试断言三方同源,统一走本函数。 + C-1(终审): wall=None = 模块级默认墙(_CC_ECHO_*/_CC_ASK_* 常量即该 + 默认视图);manager 一律传租户墙。""" + w = wall if wall is not None else DEFAULT_WALL if str((wa_state or {}).get("e164") or ""): intl = _cc_intl(wa_state) - echo = {"zh": _CC_ECHO_ZH, "es": _CC_ECHO_ES}.get(lang, _CC_ECHO_EN) - return echo.format(intl=intl) - return {"zh": _CC_ASK_ZH, "es": _CC_ASK_ES}.get(lang, _CC_ASK_EN) + echo = w.cc_templates["echo"] + return {"zh": echo["zh"], "es": echo["es"]}.get( + lang, echo["en"]).format(intl=intl) + ask = w.cc_templates["ask"] + return {"zh": ask["zh"], "es": ask["es"]}.get(lang, ask["en"]) -def _cc_wall_template(wa_state: dict | None, lang: str) -> str: +def _cc_wall_template(wa_state: dict | None, lang: str, + wall: CompiledWall | None = None) -> str: """CC 触发轮墙整段替换句: 回显轮 = ECHO 模板(自带 "Got it —" 衔接头); - no_ip 轮 = _CC_WALL_*(ASK 追问句 + 衔接头,沿用 Task 3 形态;P3 扩 ES)。""" + no_ip 轮 = _CC_WALL_*(ASK 追问句 + 衔接头,沿用 Task 3 形态;P3 扩 ES)。 + C-1(终审): wall=None = 模块级默认墙(_CC_WALL_* 常量即该默认视图); + manager 一律传租户墙。""" if str((wa_state or {}).get("e164") or ""): - return _cc_script(wa_state, lang) - return {"zh": _CC_WALL_ZH, "es": _CC_WALL_ES}.get(lang, _CC_WALL_EN) + return _cc_script(wa_state, lang, wall=wall) + w = wall if wall is not None else DEFAULT_WALL + tmpl = w.cc_templates["wall"] + return {"zh": tmpl["zh"], "es": tmpl["es"]}.get(lang, tmpl["en"]) -def _cc_confirm_note(wa_state: dict | None, lang: str) -> str: +def _cc_confirm_note(wa_state: dict | None, lang: str, + wall: CompiledWall | None = None) -> str: """cc 等待态确认句注入(Task 4, spec §4/§4b): cc_due 才注入,与邮箱轻补 cc_settled 串行互斥(两个"逐字使用这句"不同轮竞争)。外壳仿轻补指引: ⚠️ 系统指令声明 + 逐字话术 + 客户答复后致谢不追问。回显分支带零号码 唯一例外(4b-3 让位语义重建: 仅系统提供的补全号原文可写出)。 - Task 4 词表数据化: 指引话术数据源 = DEFAULT_WALL.reminders.cc_guide - ({intl}/{cc_script} 占位由本函数 .format() 回填,值逐字等价)。""" + Task 4 词表数据化: 指引话术数据源 = 墙 reminders.cc_guide + ({intl}/{cc_script} 占位由本函数 .format() 回填,值逐字等价)。 + C-1(终审): wall=None = 模块级默认墙;manager 一律传租户墙。""" if not wa_state or not cc_completion.cc_due(wa_state): return "" + w = wall if wall is not None else DEFAULT_WALL if str(wa_state.get("e164") or ""): intl = _cc_intl(wa_state) - return DEFAULT_WALL.reminders["cc_guide"]["echo"].format( - intl=intl, cc_script=_cc_script(wa_state, lang)) - return DEFAULT_WALL.reminders["cc_guide"]["ask"].format( - cc_script=_cc_script(wa_state, lang)) + return w.reminders["cc_guide"]["echo"].format( + intl=intl, cc_script=_cc_script(wa_state, lang, wall=wall)) + return w.reminders["cc_guide"]["ask"].format( + cc_script=_cc_script(wa_state, lang, wall=wall)) def _contact_completion_note( raw_info: dict | None, confirmed: dict | None, suspect: dict | None, lang: str = "en", email_ask: bool = True, + wall: CompiledWall | None = None, ) -> tuple[str, list[str]]: """邮箱轻补提醒(spec §4.2/§4.3b,2026-10-08 判定树设计收编)。 纯函数,返回(注入文案,计数键)。 @@ -208,6 +229,9 @@ def _contact_completion_note( suspect 仅为与调用处画像判定同签名,规则只看 confirmed(spec §4.2)。 email_ask=False(Task 9: contact.email_light_ask 开关)→ 恒空,不注入 也不产计数键。 + C-1(终审): wall=None = 模块级默认墙(_EMAIL_NOTE_* 常量即该默认视图); + manager 一律传租户墙(流式调用处与本函数在 reminder 内的调用同参, + 写回与注入永不漂移)。 """ if not email_ask: return "", [] @@ -222,7 +246,9 @@ def _contact_completion_note( return "", [] if not cc_completion.cc_settled((info.get("_cc") or {}).get("whatsapp")): return "", [] # whatsapp 确认流未落定: 邮箱轻补串行等待 - return (_EMAIL_NOTE_ZH if lang == "zh" else _EMAIL_NOTE_EN), ["_email_asked"] + w = wall if wall is not None else DEFAULT_WALL + return ((w.reminders["email_light"]["zh"] if lang == "zh" + else w.reminders["email_light"]["en"]), ["_email_asked"]) def _cc_pre_turn(raw_info: dict, confirmed: dict, ip_iso: str | None) -> bool: @@ -369,6 +395,11 @@ class ConversationManager: # 的 PromptProvider + compiled_wall 组装($DEFLECTION_POOL 单一事实源 # 注入)。Task 9: AI 凭据/config 全部经 runtime.config.data 接线。 self.runtime = runtime + # C-1(终审审查): 执行侧墙接线——过滤/替换/提醒全走租户编译墙, + # 与 AIService 的 $DEFLECTION_POOL 同源(runtime.compiled_wall); + # 模块级 DEFAULT_WALL 仅供无租户上下文的直调默认(boundary_filter + # 出厂视图 + 本模块旧测试锚定),会话流内一律本墙。 + self._wall = runtime.compiled_wall self.ai = ai_service or AIService( prompts=runtime.prompts, wall=runtime.compiled_wall, @@ -499,8 +530,8 @@ class ConversationManager: context_reminder = self._build_context_reminder( effective_raw, content, history, source=conv.source.value, - contact_flags=self._contact_flags) - if CONTACT_ASK_TRIGGER_RE.search(content or ""): + contact_flags=self._contact_flags, wall=self._wall) + if self._wall.triggers["contact_ask"].search(content or ""): logger.info("Contact-ask reminder fired (source=%s)", conv.source.value) # Task 3(spec §4.3/§6-1): 一次性计数写回 + _cc 状态同事务落库—— @@ -512,7 +543,7 @@ class ConversationManager: _, completion_marks = _contact_completion_note( effective_raw, _confirmed_now, _suspect_now, "zh" if _CJK_RE.search(content or "") else "en", - email_ask=self._contact_flags["email_light_ask"]) + email_ask=self._contact_flags["email_light_ask"], wall=self._wall) if completion_marks or _cc_changed: raw = dict(profile.raw_info or {}) for key in completion_marks: @@ -577,12 +608,13 @@ class ConversationManager: pending += token complete, pending = split_sentences(pending) for sentence in complete: - if violates_boundary(sentence): + if violates_boundary(sentence, wall=self._wall): violated = True dirty = sentence violation = "boundary" break - if no_contact_value(sentence, allow_number=cc_allow_number): + if no_contact_value(sentence, allow_number=cc_allow_number, + wall=self._wall): violated = True dirty = sentence violation = "no_contact" @@ -590,7 +622,8 @@ class ConversationManager: # Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断, # 其余句子照常流出——软于两墙的掐断+替换); # 渠道精确化: 只丢索要已在手渠道的句子 - if has_contact and asks_contact(sentence, _confirmed_channels): + if has_contact and asks_contact(sentence, _confirmed_channels, + wall=self._wall): logger.warning("Re-ask wall dropped sentence (conv=%s): %r", conv.id, sentence[:60]) dropped_ask = True @@ -604,15 +637,17 @@ class ConversationManager: if violated: break # 提前掐断生成,省 token if not violated and pending: - if violates_boundary(pending): + if violates_boundary(pending, wall=self._wall): violated = True dirty = pending violation = "boundary" - elif no_contact_value(pending, allow_number=cc_allow_number): + elif no_contact_value(pending, allow_number=cc_allow_number, + wall=self._wall): violated = True dirty = pending violation = "no_contact" - elif has_contact and asks_contact(pending, _confirmed_channels): + elif has_contact and asks_contact(pending, _confirmed_channels, + wall=self._wall): # Task 5b 再索要墙: 尾巴是索要句 → 静默丢弃(不流出不掐断) logger.warning("Re-ask wall dropped tail (conv=%s): %r", conv.id, pending[:60]) @@ -636,21 +671,26 @@ class ConversationManager: # NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,该路径概率高, # 客户该收到确认/追问而非"稍后联系"虚假承诺; # 价格墙路径(violation=="boundary")不变 - closing = _cc_wall_template(_wa_cc, _cc_lang_of(content)) + closing = _cc_wall_template(_wa_cc, _cc_lang_of(content), + wall=self._wall) else: - # Task 9 收口: 视图直取 DEFAULT_WALL.no_contact(单一 - # 事实源),不再经 boundary_filter 的模块级常量转引 - closing = (DEFAULT_WALL.no_contact["already"] if has_contact - else (DEFAULT_WALL.no_contact["app"] + # Task 9 收口: 视图直取墙 no_contact(单一事实源), + # 不经 boundary_filter 的模块级常量转引;C-1(终审): + # 取租户墙——替换文案各自来自自己的池 + _w = self._wall + closing = (_w.no_contact["already"] if has_contact + else (_w.no_contact["app"] if conv.source.value == "app" - else DEFAULT_WALL.no_contact["web"])) + else _w.no_contact["web"])) else: logger.warning( "Boundary filter intercepted sentence (conv=%s): %r | tail=%r", conv.id, dirty[:80], (pending or "")[:40]) - closing = (pivot_for(content, has_contact) if emitted + closing = (pivot_for(content, has_contact, wall=self._wall) + if emitted else deflection_for(content, _boundary_rotate(), - has_contact=has_contact)) + has_contact=has_contact, + wall=self._wall)) # Minor-5(审查修复): 已流出前缀与替换句之间补一个空格分隔 # ("...xxxx? Got it —"——英文衔接无空格会粘连);CC/触发轮缓冲 # 未流出,emitted 恒空,天然不受影响。空格随 closing 一起流出, @@ -690,7 +730,8 @@ class ConversationManager: logger.warning( "CC enforcement wall replaced reply (conv=%s): %r", conv.id, cc_full[:60]) - cc_template = _cc_wall_template(_wa_cc, cc_lang) + cc_template = _cc_wall_template(_wa_cc, cc_lang, + wall=self._wall) for i in range(0, len(cc_template), 40): yield {"type": "stream_chunk", "content": cc_template[i:i + 40]} @@ -702,7 +743,7 @@ class ConversationManager: if not violated and dropped_ask and not emitted.strip(): logger.warning("Re-ask wall dropped entire reply (conv=%s), " "falling back to closing", conv.id) - fallback = DEFAULT_WALL.no_contact["already"] + fallback = self._wall.no_contact["already"] for i in range(0, len(fallback), 40): yield {"type": "stream_chunk", "content": fallback[i:i + 40]} @@ -1141,24 +1182,29 @@ class ConversationManager: def _build_context_reminder(raw_info: dict, latest_msg: str, history: list[dict] | None = None, source: str = "", - contact_flags: dict | None = None) -> str: + contact_flags: dict | None = None, + wall: CompiledWall | None = None) -> str: """Build a context reminder from extracted profile info. Task 4 词表数据化: 六类轮中提醒话术(boundary/contact_ask/ language_follow/lead_given/cc_guide/email_light)的数据源 = - DEFAULT_WALL.reminders(seed_profiles/jiancai/wall.json); + 墙 reminders(seed_profiles//wall.json); {shown}/{no_contact_web}/{no_contact_already} 占位由本函数 .format() 回填,值与数据化前逐字等价。 contact_flags(Task 9): contact 行为开关——cc_completion=False 跳过 cc 确认指引,email_light_ask=False 跳过邮箱轻补;None = 全开 - (直调本函数的旧测试语义)。""" + (直调本函数的旧测试语义)。 + wall(C-1 终审): 租户编译墙(触发词/提醒话术/no_contact 全走它); + None = 模块级默认墙(直调/旧测试语义——BOUNDARY_TRIGGER_RE 等 + 模块常量即该默认视图)。""" + w = wall if wall is not None else DEFAULT_WALL # 职能边界提醒(评审#7: 前置到早退之前,首条消息画像为空也要生效) boundary_note = "" - if BOUNDARY_TRIGGER_RE.search(latest_msg or ""): - if BUDGET_TRIGGER_RE.search(latest_msg or ""): + if w.triggers["boundary"].search(latest_msg or ""): + if w.triggers["budget"].search(latest_msg or ""): # 评审#9: 预算求推荐 → 预算路径(推荐+反问+引导),不一刀切要号 - boundary_note = DEFAULT_WALL.reminders["boundary"]["budget"] + boundary_note = w.reminders["boundary"]["budget"] else: - boundary_note = DEFAULT_WALL.reminders["boundary"]["normal"] + boundary_note = w.reminders["boundary"]["normal"] # 联系方式状态前移(供 boundary/contact 提醒共用判定) contacts_all = normalize_contacts(raw_info.get("联系方式")) @@ -1169,23 +1215,23 @@ class ConversationManager: # 冒烟实证: 抽象指令("用App话术")GLM 会无视并背 prompt 里更显眼的 web 脚本—— # 提醒必须携带该分支的逐字话术,不让模型自己挑。 contact_note = "" - if CONTACT_ASK_TRIGGER_RE.search(latest_msg or ""): + if w.triggers["contact_ask"].search(latest_msg or ""): if source == "app": if confirmed or suspect: # 已留资: 索要我方联系方式 → 逐字"经理主动联系"版, # 与"禁止再索要"提醒不再打架 # Task 3 收尾: 第三处禁令同步改写(禁令集完整)——与前两处 # 同语义,只禁已提供过的渠道,缺失渠道放行轻补一次 - contact_note = DEFAULT_WALL.reminders["contact_ask"]["app_already"] + contact_note = w.reminders["contact_ask"]["app_already"] else: - contact_note = DEFAULT_WALL.reminders["contact_ask"]["app"] + contact_note = w.reminders["contact_ask"]["app"] else: - contact_note = DEFAULT_WALL.reminders["contact_ask"]["web"].format( - no_contact_web=DEFAULT_WALL.no_contact["web"]) + 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) + lang_note = _lang_note_for(latest_msg, wall=wall) if not raw_info: # 空画像:仅返回边界/联系/语言提醒(可能为空串) @@ -1213,7 +1259,7 @@ class ConversationManager: completion_note, _marks = _contact_completion_note( raw_info, confirmed, suspect, lang="zh" if _CJK_RE.search(latest_msg or "") else "en", - email_ask=flags["email_light_ask"]) + email_ask=flags["email_light_ask"], wall=wall) # Task 4(spec §4 回显确认轮): cc 等待态注入确认句指引——有 e164 用 # 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled @@ -1221,30 +1267,31 @@ class ConversationManager: # attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。 # P3: 语言路由扩 ES(_cc_lang_of),西语轮注入西语模板。 cc_note = (_cc_confirm_note( - (raw_info.get("_cc") or {}).get("whatsapp"), _cc_lang_of(latest_msg)) + (raw_info.get("_cc") or {}).get("whatsapp"), _cc_lang_of(latest_msg), + wall=wall) if flags["cc_completion"] else "") # 联系方式专项(D6) 与 职能边界(D6联动) if confirmed: shown = ", ".join( f"{k}: {' '.join(str(v).split())[:60]}" for k, v in confirmed.items()) - # 逐字话术直接引用 DEFAULT_WALL.no_contact["already"](Task 9 收口: + # 逐字话术直接引用墙 no_contact["already"](Task 9 收口: # 视图直取单一事实源)——与零号码墙替换句同源,三处"经理主动联系" # 话术不漂移 # Task 3 防自锁改写(spec 4.1-6): 原"绝对禁止再索要任何联系方式!" # 会抑制缺失渠道轻补——只禁已提供过的渠道,缺失渠道放行一次 - parts.append(DEFAULT_WALL.reminders["lead_given"]["forbidden"].format( + parts.append(w.reminders["lead_given"]["forbidden"].format( shown=shown)) - parts.append(DEFAULT_WALL.reminders["lead_given"]["respond_script"].format( - no_contact_already=DEFAULT_WALL.no_contact["already"])) + parts.append(w.reminders["lead_given"]["respond_script"].format( + no_contact_already=w.no_contact["already"])) if boundary_note: parts.append(boundary_note) # Task 3 防自锁改写: 尾部补缺失渠道豁免(与上方改写同因) - parts.append(DEFAULT_WALL.reminders["lead_given"]["boundary_tail"]) + parts.append(w.reminders["lead_given"]["boundary_tail"]) elif suspect: shown = ", ".join( f"{k}: {' '.join(str(v).split())[:60]}" for k, v in suspect.items()) - parts.append(DEFAULT_WALL.reminders["lead_given"]["suspect"].format( + parts.append(w.reminders["lead_given"]["suspect"].format( shown=shown)) if boundary_note: parts.append(boundary_note) diff --git a/requirements.txt b/requirements.txt index d3f0e9d..bb1b53f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ fastapi>=0.115.0 uvicorn[standard]>=0.34.0 sqlalchemy[asyncio]>=2.0.0 aiosqlite>=0.20.0 -alembic>=1.14.0 zhipuai>=2.1.5 aiosmtplib>=3.0.0 httpx>=0.27.0 diff --git a/seed_profiles/auto/tenant.json b/seed_profiles/auto/tenant.json index 64fb840..b16906d 100644 --- a/seed_profiles/auto/tenant.json +++ b/seed_profiles/auto/tenant.json @@ -17,6 +17,10 @@ "welcome": "Hey! What kind of car are you looking for?", "topics": ["Get Quotation", "Distributor Cooperation"] }, + "email": { + "sender_name": "XYH", + "notify_emails": ["18339827479@163.com"] + }, "push": { "prefix": "ai-kefu" }, diff --git a/seed_profiles/jiancai/tenant.json b/seed_profiles/jiancai/tenant.json index 5dce3e7..47542b1 100644 --- a/seed_profiles/jiancai/tenant.json +++ b/seed_profiles/jiancai/tenant.json @@ -26,6 +26,10 @@ "welcome": "What kind of piping project are you working on?", "topics": ["Get Quotation", "Pipe Specifications", "Delivery & Shipping"] }, + "email": { + "sender_name": "HESUPIPE", + "notify_emails": ["hesu@hesuu.com"] + }, "push": { "prefix": "ai-kefu-jc" }, diff --git a/tests/conftest.py b/tests/conftest.py index 5a5e8c8..c957763 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,6 +107,22 @@ def build_jiancai_runtime(): return _JIANCAI_RUNTIME +_AUTO_RUNTIME = None + + +def build_auto_runtime(): + """(非夹具)auto TenantRuntime——C-1 终审回归锁用(两租户墙差异断言), + 纪律同 build_jiancai_runtime: 只走加载期,无 engine 无句柄, + 模块级缓存单例跨测试复用。""" + global _AUTO_RUNTIME + if _AUTO_RUNTIME is None: + reg = TenantRegistry.load_from_dir( + SEED_DIR, data_dir=REPO_ROOT / "tenants") + _AUTO_RUNTIME = next( + rt for rt in reg.runtimes() if rt.config.slug == "auto") + return _AUTO_RUNTIME + + # Task 7: app_client 默认域名——既有测试不带 host 头,默认落 jiancai # (与 5b 之前 default_runtime 兜底的语义一致,既有断言零改动) JIANCAI_HOST = "chat2.agent.hnxinyouhe.com" diff --git a/tests/test_tenant_config_wiring.py b/tests/test_tenant_config_wiring.py index 8bec4be..9e7f4d7 100644 --- a/tests/test_tenant_config_wiring.py +++ b/tests/test_tenant_config_wiring.py @@ -103,6 +103,24 @@ def test_send_lead_email_uses_tenant_sender_and_recipients(monkeypatch): assert str(captured["msg"]["To"]) == "x@t.com, y@t.com" +def test_seed_tenants_ship_email_group(monkeypatch): + """I-1(终审): 两租户 seed 自带 email 组——resolve_email_settings 各自 + 返回正确收件人/发件名(auto: XYH→163 信箱; jiancai: HESUPIPE→hesuu.com), + 不再依赖 env NOTIFY_EMAIL 平台兜底(patch 成异值证明 seed 槽胜出)。 + 收件人取值依据 brand_values.json: auto=notify_email 环境实际值, + jiancai=品牌邮箱(notify_email 记录的是 notify@example.com 占位)。 + From 头渲染时外层再包【】(send_* 的 msg["From"] 拼法),seed 存裸名。""" + monkeypatch.setattr(settings, "NOTIFY_EMAIL", "env-notify@x.com") + from tests.conftest import build_auto_runtime + au = email_service.resolve_email_settings(build_auto_runtime().config.data) + jc_r = email_service.resolve_email_settings( + build_jiancai_runtime().config.data) + assert au["sender_name"] == "XYH" + assert au["notify_emails"] == ["18339827479@163.com"] + assert jc_r["sender_name"] == "HESUPIPE" + assert jc_r["notify_emails"] == ["hesu@hesuu.com"] + + # ---- push ---------------------------------------------------------------- def _patch_push_env(monkeypatch): diff --git a/tests/test_wall_execution_tenant.py b/tests/test_wall_execution_tenant.py new file mode 100644 index 0000000..5cd28b9 --- /dev/null +++ b/tests/test_wall_execution_tenant.py @@ -0,0 +1,121 @@ +"""C-1(终审审查)回归锁: 墙的执行侧必须走租户管道。 + +修复前缺陷: conversation.py 执行侧(过滤/替换/提醒)全走模块级 +DEFAULT_WALL(jiancai 出厂),runtime.compiled_wall 全库只有 AIService 的 +$DEFLECTION_POOL 一处消费——auto 租户会话实际跑的是 jc 墙(多拦/替换 +文案串租户)。本文件用两租户真 runtime + 脚本化假 AI 端到端断言: + +- 价格墙: jc 独有 pattern([€£]\\s?\\d,auto 词表无此组)命中的句子在 + jiancai 被拦+替换(收尾来自 jc pivots 池),在 auto 放行逐字流出; +- 零号码墙: 同一邮箱违规句两租户都拦,替换句精确等于各自墙的 + no_contact["web"](jc "within 12 hours" vs auto "replies fast!"); +- 提醒侧: contact_ask 触发消息的注入 context 携带各自墙的 + no_contact["web"](web 模板占位 {no_contact_web} 回填)。 +""" +import asyncio + +from sqlalchemy import select + +from app.engine.models import Message, MessageRole +from app.engine.services.conversation import ConversationManager +from tests.conftest import build_auto_runtime, build_jiancai_runtime + +# 判别句选取依据(两墙 diff 实证,勿随手改文案——判别力依赖词表差异): +# - PRICE_REPLY 撞 jc 独有的 [€£]\s?\d 组(auto 词表无),零号码两墙都不拦; +# - EMAIL_REPLY 撞零号码墙 any_email(两墙同拦,替换文案不同); +# - 末句无尾随空白 → 以 pending 尾巴形态裁决,走同一条最终检查路径。 +PRICE_REPLY = "Thanks for asking. The total is €500 for one set." +EMAIL_REPLY = "Sure, you can also email me at sales@example.com anytime." +USER_MSG = "do you ship to Nigeria?" + + +class _ScriptedAI: + """固定回复脚本桩: 小块 token 流出 reply(走真实 split_sentences/ + 逐句门控路径),捕获注入的 context(提醒侧断言用)。""" + + def __init__(self, reply): + self._reply = reply + self.captured_context = None + + async def stream_chat(self, messages, context=""): + self.captured_context = context + for i in range(0, len(self._reply), 7): + yield self._reply[i:i + 7] + + async def extract_info(self, messages, existing_info): + return {} + + async def evaluate(self, profile, recent_messages): + return {"intent_level": "pending", "reasoning": "", + "contact_info": {}, "info_sufficient": False} + + +def _send(async_db, runtime, reply, user_msg=USER_MSG): + """一轮会话 → (落库的助手回复, 注入 context)。落库文本与流出统一 + (stream_process_message 契约),取 Message 表即可代表客户所见。""" + + async def run(): + async with async_db() as db: + ai = _ScriptedAI(reply) + mgr = ConversationManager(db, runtime=runtime, ai_service=ai) + conv = await mgr.create_conversation(source=None) # standalone + async for _ in mgr.stream_process_message(conv.session_id, + user_msg): + pass + msgs = (await db.execute( + select(Message).where(Message.conversation_id == conv.id) + .order_by(Message.created_at))).scalars().all() + replies = [m.content for m in msgs + if m.role == MessageRole.assistant] + return (replies[-1] if replies else ""), ai.captured_context + return asyncio.run(run()) + + +def test_price_wall_pattern_is_tenant_scoped(async_db): + """jc 独有 [€£] 价格 pattern: jiancai 拦截替换(€ 数字不流出,收尾 = + jc pivots 池首句);auto 放行逐字流出——同一引擎代码,墙随租户。""" + jc = build_jiancai_runtime() + au = build_auto_runtime() + # 判别前提(词表趋同护栏): 该句必须只撞 jc 墙,否则本测试失去判别力 + from app.engine.services.boundary_filter import violates_boundary + assert violates_boundary(PRICE_REPLY, wall=jc.compiled_wall) is True + assert violates_boundary(PRICE_REPLY, wall=au.compiled_wall) is False + + jc_reply, _ = _send(async_db, jc, PRICE_REPLY) + assert "€500" not in jc_reply + # 已流出干净前缀("Thanks for asking.")→ 违规后接 pivot 过渡句收尾 + assert jc_reply.endswith(jc.compiled_wall.pivots["en"][0]) + + au_reply, _ = _send(async_db, au, PRICE_REPLY) + assert au_reply == PRICE_REPLY # auto 墙无此 pattern → 整句放行 + + +def test_no_contact_replacement_copy_from_tenant_pool(async_db): + """零号码墙(两租户同拦同一邮箱句): 替换句精确等于各自墙的 + no_contact["web"](standalone 源+未留资 → web 变体)。修复前 auto + 会话收的是 jc 文案(12 hours),本测试即失败。""" + jc = build_jiancai_runtime() + au = build_auto_runtime() + assert (jc.compiled_wall.no_contact["web"] + != au.compiled_wall.no_contact["web"]) # 判别前提 + + jc_reply, _ = _send(async_db, jc, EMAIL_REPLY) + au_reply, _ = _send(async_db, au, EMAIL_REPLY) + assert jc_reply == jc.compiled_wall.no_contact["web"] + assert au_reply == au.compiled_wall.no_contact["web"] + + +def test_contact_ask_reminder_uses_tenant_wall_text(async_db): + """提醒侧同锁: contact_ask 触发消息的注入 context 携带各自墙的 + no_contact["web"] 文案(web 模板 {no_contact_web} 回填),互不渗漏。""" + jc = build_jiancai_runtime() + au = build_auto_runtime() + jc_web = jc.compiled_wall.no_contact["web"] + au_web = au.compiled_wall.no_contact["web"] + + _, jc_ctx = _send(async_db, jc, "ok.", user_msg="how can I contact you?") + _, au_ctx = _send(async_db, au, "ok.", user_msg="how can I contact you?") + assert jc_web in jc_ctx + assert au_web in au_ctx + assert au_web not in jc_ctx + assert jc_web not in au_ctx