# 联系方式驱动的询盘邮件(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 "