# 联系方式驱动的询盘邮件(Contact-Driven Lead Email)实施计划 v1.1 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 联系方式成为发询盘邮件的唯一条件——统一管道检测到联系方式立刻发邮件(不看消息数、不看意向评级);无联系方式永不发邮件、永不结束会话;客户已给的联系方式绝不丢失、绝不重复索要。 **Architecture:** 新建确定性"统一联系方式管道"(`app/services/contact_pipeline.py`):extractor 提取(多语言 LLM 源)+ 正则高置信扫描(确定性辅源)→ key 归一化 → 深合并(同 key 新值覆盖)→ 格式校验(confirmed/suspect 分级)。管道结果作为唯一事实源,同时供邮件触发器(去重 + 更正 + 24h 跨会话去重)、定向提醒(禁止重复索要)、会话结束守卫(全来源)三处消费。邮件触发从路由层(ended && interested)整体迁移到 `ConversationManager` 内部,与结束逻辑彻底解耦。 **Tech Stack:** Python 3.13 / FastAPI / SQLAlchemy 2.0 async + SQLite(aiosqlite) / pytest(asyncio_mode=auto) --- ## 1. 背景与问题(为什么做) 线上痛点:大量**无联系方式**的询盘邮件推送至销售邮箱(无效询盘 + 有意向但无法联系的客户)。根因链(已代码验证): 1. widget/embedded/standalone 来源的 `should_end` 没有联系方式守卫(`_has_real_contact` 仅 app 生效,`conversation.py:230`); 2. evaluator.md 要求"无联系方式 info_sufficient 必须 false",但 GLM 不遵守(`conversation.py:253` 注释自证); 3. 判定 interested 即结束会话锁死客户(`conversation.py:350`),连补联系方式的机会都没有; 4. extractor 某轮返回空 `联系方式` 时浅合并整层覆盖,已提取的联系方式被静默抹掉(`conversation.py:119`)。 ## 2. 已定决策(本计划的 spec,评审以此为准) | # | 决策 | 内容 | |---|---|---| | D1 | **邮件唯一条件** | 管道 confirmed 联系方式 → 立即发邮件。≥3轮、interested、info_sufficient 一律不参与邮件门槛。第 1 条消息含联系方式也发 | | D2 | **意向降级为标签** | 邮件主题打标签:interested→[意向强]、pending→[待确认]、not_interested→[无意向]、未评估(<3轮未跑评估)→[未评估]。**评估被跳过时邮件 intent_level 参数传空串**(→[未评估]),但发给客户端的 evaluated 事件仍报 `pending`(客户端语义不变)——两者不得混用 | | D3 | **会话永不自动结束(2026-08-18 决策 A,取代原"全来源守卫")** | 邮件与结束解耦后,自动结束只剩收尾UX/停止计费两个弱价值,却会把还没问完的客户锁死(刷新丢上下文+销售响应空窗)。**任何情况都不自动结束**:会话保持 active 直到客户自然沉默,关闭交给超时清理(健康轨)。evaluated 事件 `ended` 恒 False(App 端需知晓);`_finalize` 删除,D10 的 upsert 语义由"每轮评估落库"承担 | | D4 | **防重发** | 同会话 email_sent 标记,全量只发一次;联系方式变化(改号/新增key/无提示换值)→轻量[更正]邮件,**每会话≤3封(2026-08-18 用户测试反馈放宽)**;达到上限后"锁邮件不锁快照"——Evaluation.contact_info 仍刷新为最新(admin 永远看到客户最新号码) | | D5 | **24h 跨会话去重** | 同一联系方式值(规范化比对)24h 内已发过全量 → 新会话只发一行"该客户又来聊了"轻提醒 | | D6 | **不重复索要** | 管道 confirmed → 系统提醒注入"禁止再索要,确认收到后继续解答";suspect(校验不过) → "礼貌确认,禁止重新索要"。suspect 不触发邮件、不参与结束 | | D7 | **评估前置** | 用户消息 <3 条跳过 GLM evaluate 调用(结果必然 pending),纯省成本;邮件标签用[未评估] | | D8 | **卡单标记** | interested + 无联系方式 → upsert Evaluation(admin 后续可筛,人工兜底的数据基础) | | D9 | **旧触发路径拆除** | `chat.py:94-97` 与 `chat_rest.py:140-142` 的 ended&&interested→`_try_send_email` 整体删除,与 D1 触发器**同一个 PR**(两套并存=双发邮件)。**批次 1 全部提交在同一分支、合并后一次部署——严禁部署分支中间提交**:Task 6(新触发器生效)与 Task 7(旧路径拆除)之间存在新旧双触发窗口,该窗口只允许存在于开发分支上 | | D10 | **Evaluation upsert 语义** | `_get_or_init_evaluation` 是唯一建行入口(绝不 INSERT 已存在记录,唯一约束会 IntegrityError);每轮评估落库只更新 intent/reasoning/快照,**永不触碰 email_sent/correction_sent**(原 `_finalize` 已随决策 A 删除) | | D11 | **话术**(system.md) | ①价值交换固定模板:只承诺"完整报价单",禁止自由发挥价格/时限;②先聊后引:聊天内要两次未果才引导点 WhatsApp 按钮;③拿到联系方式后确认一句、继续解答,不急着收尾 | | D12 | **邮件转义(S2 提前)** | email_service 全量 `html.escape`(邮件触发频率上升,注入面同步放大,不等健康轨) | | D13 | **行为变化已批准** | 本计划推翻"不改 widget 行为"红线(2026-08-17 产品决策)。上线时同步清理 `conversation.py:131-134、226-229` 的 app-only 注释与 `chat_rest.py` 模块 docstring 第 12-13 行 | **多值/晋升细则(S1/S3/S4 钉死):** - S1 suspect 只用于"防重复索要"提醒,永不自动晋升;客户后续重发或改发新值时由 extractor/正则重新捕获、重新校验(新值覆盖)。 - S3 24h 去重多值规则:confirmed 中**任一**值在 24h 内已发过 → 轻提醒(新值会显示在提醒主题里,信息不丢;全部值都未见 → 全量)。 - S4 正则每轮扫描**全部客户消息历史**(非仅当轮),兜住 GLM 失败轮次漏掉的联系方式。 **明确不做(防扩散):** 范式全量重构、超时清理、UI 留资卡片、WS 限流、会话持久化、admin 筛选视图 → 批次 2/3 或健康轨(见 §6)。`process_message`(`conversation.py:70`,grep 确认零调用方的死代码)在 Task 7 随守卫改造**一并删除**(若保留,其对 `_has_real_contact` 的调用会成为悬空引用);Task 7 之前不碰它。 ## 3. 文件结构 | 文件 | 动作 | 职责 | |---|---|---| | `app/services/contact_pipeline.py` | 新建 | 纯函数管道:归一化/深合并/校验分级/正则扫描/规范化比对 | | `app/services/conversation.py` | 修改 | 接线管道、邮件触发器、全来源结束守卫、卡单标记、评估前置、提醒注入、finalize UPDATE、深合并修复 | | `app/services/email_service.py` | 修改 | 转义、标签、中文标签列、轻提醒/更正邮件、"对话进行中"提示 | | `app/models/database.py` | 修改 | Evaluation 加 `correction_sent` 列 | | `app/main.py` | 修改 | `_migrate` 加 correction_sent 的 PRAGMA 迁移 | | `app/api/routes/chat.py` | 修改 | 删除 `_try_send_email` 与触发块 | | `app/api/routes/chat_rest.py` | 修改 | 删除触发块与 lazy import,更新 docstring | | `app/prompts/system.md` | 修改 | D11 话术 | | `scripts/baseline_stats.py` | 新建 | 批次 0.5 基线统计(只读) | | `tests/test_contact_pipeline.py` | 新建 | 管道单元测试 | | `tests/test_lead_email_trigger.py` | 新建 | 触发器/结束守卫/卡单集成测试(FakeAI + async_db) | | `tests/test_chat_rest_message.py` | 修改 | 删除钉死旧触发路径的 3 个测试(被新测试取代) | > ⚠️ **行号锚点使用规则**:文中行号是计划撰写时(当前 HEAD)的定位辅助。Task 5/6 会向 `conversation.py` 插入代码,**后续任务(Task 7/8)引用的行号会漂移约 20 行**——实施时一律以任务中引用的**代码内容**定位(如 `should_end`、`_has_real_contact` 方法名),禁止盲改行号区域。 --- ## 批次 0.5:基线统计 ### Task 0: 基线脚本 **Files:** - Create: `scripts/baseline_stats.py` - [ ] **Step 0.1: 写脚本(只读,不写库)** ```python """批次0.5 基线统计:上线前存档,上线后同口径对比。只读。""" import re import sqlite3 import sys DB = sys.argv[1] if len(sys.argv) > 1 else "./data/cc_ai_kefu.db" EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") INTL_RE = re.compile(r"\+\d{6,15}") def main() -> None: conn = sqlite3.connect(DB) cur = conn.cursor() # 1) 历史已发邮件中无联系方式占比(按 source) rows = cur.execute( """SELECT c.source, COUNT(*) AS sent, SUM(CASE WHEN COALESCE(e.contact_info, '{}') IN ('{}', 'null', '') OR e.contact_info IS NULL THEN 1 ELSE 0 END) AS no_contact FROM evaluations e JOIN conversations c ON c.id = e.conversation_id WHERE e.email_sent = 1 GROUP BY c.source""" ).fetchall() print("== 已发邮件联系方式缺失(按来源)==") for source, sent, no_contact in rows: pct = (no_contact / sent * 100) if sent else 0 print(f" {source}: 发送 {sent}, 缺失 {no_contact} ({pct:.0f}%)") # 2) 用户消息中含联系方式样式的会话占比(新策略邮件量上界预估) convs = cur.execute("SELECT id FROM conversations").fetchall() with_contact = early_contact = total = 0 for (cid,) in convs: msgs = cur.execute( "SELECT role, content FROM messages WHERE conversation_id=? ORDER BY created_at", (cid,), ).fetchall() user_msgs = [m for m in msgs if m[0] == "user"] if not user_msgs: continue total += 1 hit_idx = next( (i for i, m in enumerate(user_msgs) if EMAIL_RE.search(m[1]) or INTL_RE.search(m[1])), None, ) if hit_idx is not None: with_contact += 1 if hit_idx < 2: early_contact += 1 print("== 新策略邮件量预估 ==") print(f" 有用户消息的会话: {total}") print(f" 消息中含联系方式样式: {with_contact}" f" ({with_contact / total * 100 if total else 0:.0f}%) ← 全量邮件上界") print(f" 联系方式出现在前2条消息内: {early_contact}" f" ({early_contact / total * 100 if total else 0:.0f}%) ← 即时触发增量") conn.close() if __name__ == "__main__": main() ``` - [ ] **Step 0.2: 运行并存档** Run: `python scripts/baseline_stats.py | tee docs/superpowers/plans/2026-08-17-baseline.txt` Expected: 打印三组数字(存档文件用于上线后对比) - [ ] **Step 0.3: Commit** ```bash git add scripts/baseline_stats.py docs/superpowers/plans/2026-08-17-baseline.txt git commit -m "chore(baseline): 询盘邮件改造前基线统计" ``` --- ## 批次 1:核心实现 ### Task 1: contact_pipeline — 归一化 / 深合并 / 校验分级 **Files:** - Create: `app/services/contact_pipeline.py` - Test: `tests/test_contact_pipeline.py` - [ ] **Step 1.1: 写失败测试** ```python from app.services.contact_pipeline import ( merge_contacts, normalize_contacts, split_validate, ) def test_normalize_chinese_and_english_keys(): raw = {"手机": "+8613800138000", "邮箱": "a@b.com", "微信": "wxid_1", "WhatsApp号码": "+254712345678", "空": ""} out = normalize_contacts(raw) assert out == { "phone": "+8613800138000", "email": "a@b.com", "wechat": "wxid_1", "whatsapp": "+254712345678", } def test_normalize_later_alias_wins_on_same_key(): # 同 key 别名冲突时后写者覆盖(上游 extractor 单轮内一般不会同时给 手机 与 tel, # 但语义必须被钉死并被测试) assert normalize_contacts({"手机": "1111111", "tel": "2222222"}) == {"phone": "2222222"} ``` ```python def test_merge_new_value_wins_same_key(): old = {"phone": "+254700000000"} new = {"手机": "+254711111111"} # 客户改号 assert merge_contacts(old, new) == {"phone": "+254711111111"} def test_merge_empty_new_keeps_old(): # T1 场景: extractor 某轮返回空联系方式,不得抹掉已有值 old = {"phone": "+254700000000"} assert merge_contacts(old, {}) == {"phone": "+254700000000"} assert merge_contacts(old, None) == {"phone": "+254700000000"} def test_split_validate_confirmed_and_suspect(): confirmed, suspect = split_validate({ "email": "a@b.com", # 合法邮箱 "phone": "+254 712 345 678", # 数字 12 位 → confirmed "whatsapp": "12345", # 数字 5 位 → suspect }) assert confirmed == {"email": "a@b.com", "phone": "+254 712 345 678"} assert suspect == {"whatsapp": "12345"} def test_split_validate_bad_email_is_suspect(): confirmed, suspect = split_validate({"email": "not-an-email"}) assert confirmed == {} and suspect == {"email": "not-an-email"} def test_split_validate_wechat_id_is_confirmed(): # 微信号无数字格式(wxid_x 等),非空≥4字符即 confirmed——否则只留微信的 # 客户永远无法触发邮件 confirmed, suspect = split_validate({"wechat": "wxid_1"}) assert confirmed == {"wechat": "wxid_1"} and suspect == {} def test_split_validate_wechat_short_is_suspect(): confirmed, suspect = split_validate({"wechat": "abc"}) assert confirmed == {} and suspect == {"wechat": "abc"} def test_normalize_coerces_int_values(): # extractor 偶尔会把号码输出成 int,不得崩溃且要能归一化 assert normalize_contacts({"手机": 254712345678}) == {"phone": "254712345678"} def test_merge_none_old_keeps_new(): assert merge_contacts(None, {"email": "a@b.com"}) == {"email": "a@b.com"} ``` - [ ] **Step 1.2: 运行确认失败** Run: `pytest tests/test_contact_pipeline.py -v` Expected: FAIL(ModuleNotFoundError) - [ ] **Step 1.3: 实现 contact_pipeline.py(归一化/合并/校验部分)** ```python """统一联系方式管道:联系方式检测的唯一事实源。 三个动作: - 归一化:中英文 key 别名统一为 phone/whatsapp/email/wechat - 深合并:同 key 新值覆盖旧值(客户改号场景),空值不覆盖 - 校验分级:confirmed(格式通过,可触发邮件/结束)/ suspect(疑似, 仅用于提醒 AI 礼貌确认,防止重复索要——D6/S1) 上游约定:raw_info 中 "_" 前缀 key 为内部状态,禁止进入提示词渲染。 """ import re KEY_ALIASES = { "phone": "phone", "手机": "phone", "电话": "phone", "电话号码": "phone", "tel": "phone", "telephone": "phone", "phone_number": "phone", "phone number": "phone", "mobile": "phone", "mobile_number": "phone", "mobile number": "phone", "whatsapp": "whatsapp", "wa": "whatsapp", "whats app": "whatsapp", "whatsapp号码": "whatsapp", "whatsapp number": "whatsapp", "email": "email", "邮箱": "email", "e-mail": "email", "mail": "email", "电子邮件": "email", "wechat": "wechat", "微信": "wechat", "wx": "wechat", "weixin": "wechat", } def normalize_contacts(raw: dict | None) -> dict: """key 归一化 + 去空值。同 key 别名冲突时后写者覆盖。""" out: dict[str, str] = {} for k, v in (raw or {}).items(): key = KEY_ALIASES.get(str(k).strip().lower()) val = str(v or "").strip() if key and val: out[key] = val return out def merge_contacts(old: dict | None, new: dict | None) -> dict: """同 key 新值覆盖旧值;空/缺省不覆盖(堵浅合并抹数据 bug)。""" return {**normalize_contacts(old), **normalize_contacts(new)} def digits_only(s: str) -> str: """提取纯数字(split_validate 位数判断与后续 canon 复用)。""" return re.sub(r"\D", "", s) def split_validate(contacts: dict) -> tuple[dict, dict]: """返回 (confirmed, suspect)。电话/WhatsApp 取数字 7-15 位为合格; 微信号无数字格式,取非空 ≥4 字符为合格(否则 wxid_x 类真实微信号 会永远滞留 suspect,只留微信的客户无法触发邮件——违背 D1)。""" confirmed: dict[str, str] = {} suspect: dict[str, str] = {} for k, v in normalize_contacts(contacts).items(): if k == "email": ok = re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", v) is not None elif k == "wechat": ok = len(v.strip()) >= 4 else: ok = 7 <= len(digits_only(v)) <= 15 (confirmed if ok else suspect)[k] = v return confirmed, suspect ``` (`canon()` 的定义已移至 Task 6——它只被去重逻辑消费,与本任务的纯函数无关。) - [ ] **Step 1.4: 运行确认通过** Run: `pytest tests/test_contact_pipeline.py -v` Expected: 7 PASS(6 项基础用例 + wechat 修正用例) - [ ] **Step 1.5: Commit** ```bash git add app/services/contact_pipeline.py tests/test_contact_pipeline.py git commit -m "feat(pipeline): 联系方式归一化/深合并/校验分级" ``` ### Task 2: contact_pipeline — 正则扫描(高置信,S4) **Files:** - Modify: `app/services/contact_pipeline.py` - Test: `tests/test_contact_pipeline.py` - [ ] **Step 2.1: 写失败测试(含误报反例——二手车语境全是数字)** ```python from app.services.contact_pipeline import scan_user_messages def m(*texts): return [{"role": "user", "content": t} for t in texts] + [ {"role": "assistant", "content": "reply"}] def test_scan_email(): found = scan_user_messages(m("my email is john.doe@x.co send quotes")) assert found == {"email": "john.doe@x.co"} def test_scan_intl_phone(): found = scan_user_messages(m("you can reach me +254712345678")) assert found == {"phone": "+254712345678"} def test_scan_whatsapp_context_word(): found = scan_user_messages(m("whatsapp: 0712 345 678")) assert found.get("whatsapp") == "0712 345 678" def test_scan_wa_me_link(): found = scan_user_messages(m("chat https://wa.me/254712345678")) assert found == {"whatsapp": "254712345678"} def test_scan_ignores_prices_years_and_ai_messages(): texts = m( "budget $25,000 for 2024 BYD Tang", # 价格+年份 "50,000 km mileage", # 里程 "I want 5 units", # 数量 ) # 上一条含 assistant 消息,确保只扫 user found = scan_user_messages(texts) assert found == {} def test_scan_full_history_not_only_last(): # S4: 扫全量历史,早轮的联系方式也要能捞到 msgs = [ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "hey"}, {"role": "user", "content": "mail me a@b.com"}, {"role": "assistant", "content": "got it"}, {"role": "user", "content": "how much is tang"}, ] assert scan_user_messages(msgs) == {"email": "a@b.com"} def test_scan_context_word_does_not_grab_unrelated_numbers(): # S1: 上下文词必须与数字邻接(同一匹配内),消息任意位置的7位数字不得被抓 assert scan_user_messages(m("call me on delivery, order 1234567")) == {} assert scan_user_messages(m("车架号码是 LFV3A23C8K3123456 吗")) == {} assert scan_user_messages(m("engine number 2ZRFE1234567?")) == {} assert scan_user_messages(m("我手机坏了 budget 1500000 ksh")) == {} def test_scan_adjacent_context_phone(): assert scan_user_messages(m("my phone number is 0712 345 678")) == { "phone": "0712 345 678"} assert scan_user_messages(m("call me at +86 138 0013 8000")) == { "phone": "+86 138 0013 8000"} ``` - [ ] **Step 2.2: 运行确认失败** Run: `pytest tests/test_contact_pipeline.py -v` Expected: 新增用例 FAIL(scan_user_messages 未定义) - [ ] **Step 2.3: 实现扫描器(追加到 contact_pipeline.py)** ```python EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") INTL_PHONE_RE = re.compile(r"\+\d{6,15}") # 邻接绑定:上下文词与数字必须在同一匹配内(仅空白衔接)——禁止"消息任意位置 # 有上下文词 + 任意位置有 7 位数字"的消息级误绑(S1:车架号/订单号/先令价格) WHATSAPP_CTX_RE = re.compile( r"(?:whats\s*app|wa\.?me)\D{0,12}(\+?\d[\d\s\-().]{5,17}\d)", re.IGNORECASE, ) PHONE_CTX_RE = re.compile( r"(?:my\s+phone\s+(?:number\s+)?is|my\s+number\s+is|" r"call\s+me\s+(?:at|on)|reach\s+me\s+(?:at|on)|" r"phone\s*[::]|number\s*[::]|号码\s*[::]|手机号?\s*[::]|电话\s*[::])\s*" r"(\+?\d[\d\s\-().]{5,17}\d)", re.IGNORECASE, ) def scan_user_messages(messages: list[dict]) -> dict: """S4: 扫描全部客户消息,返回高置信捕获(email / +国际号 / whatsapp·wa.me / 显式电话上下文邻接数字)。裸数字(价格/年份/里程/车架号)一律不收。""" found: dict[str, str] = {} for msg in messages: if msg.get("role") != "user": continue text = msg.get("content", "") if "email" not in found: hit = EMAIL_RE.search(text) if hit: found["email"] = hit.group(0) if "phone" not in found: hit = INTL_PHONE_RE.search(text) if hit: found["phone"] = hit.group(0) if "whatsapp" not in found: hit = WHATSAPP_CTX_RE.search(text) if hit: found["whatsapp"] = hit.group(1) if "phone" not in found: hit = PHONE_CTX_RE.search(text) if hit: found["phone"] = hit.group(1) return found ``` - [ ] **Step 2.4: 运行确认通过** Run: `pytest tests/test_contact_pipeline.py -v` Expected: 19 PASS(11 存量 + 8 本任务,含 S1 邻接反例与邻接正向用例) - [ ] **Step 2.5: Commit** ```bash git add app/services/contact_pipeline.py tests/test_contact_pipeline.py git commit -m "feat(pipeline): 高置信正则扫描(仅客户消息/全量历史/价格年份不误捕)" ``` ### Task 3: Evaluation 加 correction_sent 列 + 迁移(D4) **Files:** - Modify: `app/models/database.py:109-126` - Modify: `app/main.py:16-22` - [ ] **Step 3.1: database.py 的 Evaluation 类加列**(`email_sent` 行后) ```python correction_sent: Mapped[bool] = mapped_column(Boolean, default=False) ``` - [ ] **Step 3.2: main.py 的 `_migrate` 追加迁移**(page_token 块之后) ```python result = await conn.execute(text("PRAGMA table_info(evaluations)")) columns = {row[1] for row in result} if "correction_sent" not in columns: await conn.execute(text( "ALTER TABLE evaluations ADD COLUMN correction_sent BOOLEAN DEFAULT 0" )) ``` - [ ] **Step 3.3: 验证未破坏** Run: `pytest tests/test_smoke.py -v` Expected: PASS(create_all 在全新库上含新列;PRAGMA ALTER 分支与既有 page_token 迁移一样无测试覆盖,属既有模式,接受——不要把本步当作 ALTER 分支的验证) - [ ] **Step 3.4: Commit** ```bash git add app/models/database.py app/main.py git commit -m "feat(db): evaluations.correction_sent 列 + PRAGMA 迁移" ``` ### Task 4: email_service — 转义 / 标签 / 轻提醒 / 更正邮件(D2/D4/D12) **Files:** - Modify: `app/services/email_service.py` - Test: `tests/test_lead_email_trigger.py`(本 Task 只改实现,集成验证在 Task 7) - [ ] **Step 4.0: 写失败测试(先红)** Create `tests/test_email_rendering.py`(monkeypatch 模块内 `_send`,不触网;断言主题标签、HTML 转义、内部 key 过滤): ```python """邮件渲染单测: 标签主题/HTML转义/内部key过滤(D2/D12)。不触网。""" import asyncio from app.services import email_service def _capture_send(monkeypatch): captured = {} async def fake_send(msg): captured["msg"] = msg return True monkeypatch.setattr(email_service, "_send", fake_send) return captured def _html(msg) -> str: return msg.get_payload()[0].get_payload(decode=True).decode("utf-8") def test_lead_email_subject_tag_and_escaped_body(monkeypatch): captured = _capture_send(monkeypatch) ok = asyncio.run(email_service.send_lead_email( customer_profile={"目标国家": "肯尼亚", "_internal": "x"}, contact_info={"phone": "+254712345678"}, reasoning="强意向", conversation_messages=[ {"role": "user", "content": ""}, {"role": "assistant", "content": "ok"}, ], intent_level="interested", )) assert ok is True subject = str(captured["msg"]["Subject"]) assert "[新询盘·意向强]" in subject and "肯尼亚" in subject body = _html(captured["msg"]) assert "