Co-Authored-By: Claude Code <noreply@anthropic.com>
70 KiB
联系方式驱动的询盘邮件(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. 背景与问题(为什么做)
线上痛点:大量无联系方式的询盘邮件推送至销售邮箱(无效询盘 + 有意向但无法联系的客户)。根因链(已代码验证):
- widget/embedded/standalone 来源的
should_end没有联系方式守卫(_has_real_contact仅 app 生效,conversation.py:230); - evaluator.md 要求"无联系方式 info_sufficient 必须 false",但 GLM 不遵守(
conversation.py:253注释自证); - 判定 interested 即结束会话锁死客户(
conversation.py:350),连补联系方式的机会都没有; - 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: 写脚本(只读,不写库)
"""批次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
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: 写失败测试
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"}
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(归一化/合并/校验部分)
"""统一联系方式管道:联系方式检测的唯一事实源。
三个动作:
- 归一化:中英文 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
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: 写失败测试(含误报反例——二手车语境全是数字)
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)
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
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行后)
correction_sent: Mapped[bool] = mapped_column(Boolean, default=False)
- Step 3.2: main.py 的
_migrate追加迁移(page_token 块之后)
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
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 过滤):
"""邮件渲染单测: 标签主题/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="<b>强意向</b>",
conversation_messages=[
{"role": "user", "content": "<script>alert(1)</script>"},
{"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 "<script>" not in body # D12: 转义生效
assert "<script>" in body
assert "_internal" not in body # 内部 key 不渲染
def test_lead_email_unevaluated_tag(monkeypatch):
captured = _capture_send(monkeypatch)
asyncio.run(email_service.send_lead_email(
customer_profile={}, contact_info={"email": "a@b.com"},
reasoning="", conversation_messages=[], intent_level="",
))
assert "[新询盘·未评估]" in str(captured["msg"]["Subject"])
def test_returning_and_correction_emails(monkeypatch):
captured = _capture_send(monkeypatch)
assert asyncio.run(
email_service.send_returning_lead_email({"whatsapp": "+254700000000"})
) is True
assert "[线索回访]" in str(captured["msg"]["Subject"])
assert asyncio.run(
email_service.send_correction_email({"email": "a@b.com"})
) is True
assert "[询盘·更正]" in str(captured["msg"]["Subject"])
- Step 4.1: 重写 email_service.py
import html
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
from app.config import settings
logger = logging.getLogger(__name__)
TAG_LABELS = {
"interested": "意向强", "pending": "待确认",
"not_interested": "无意向", "": "未评估",
}
CONTACT_LABELS = {"phone": "手机", "whatsapp": "WhatsApp", "email": "邮箱", "wechat": "微信"}
def _esc(v) -> str:
return html.escape(str(v)) if v else ""
def _contact_rows(contacts: dict) -> str:
return "".join(
f"<tr><td style='padding:6px 12px;font-weight:bold;'>{html.escape(CONTACT_LABELS.get(k, k))}</td>"
f"<td style='padding:6px 12px;'>{_esc(v)}</td></tr>"
for k, v in (contacts or {}).items() if v
)
async def _send(msg: MIMEMultipart) -> bool:
if not all([settings.SMTP_HOST, settings.SMTP_USER, settings.NOTIFY_EMAIL]):
logger.warning("SMTP not configured, skipping email")
return False
try:
await aiosmtplib.send(
msg, hostname=settings.SMTP_HOST, port=settings.SMTP_PORT,
username=settings.SMTP_USER, password=settings.SMTP_PASS,
use_tls=True,
)
logger.info("Lead email sent to %s", settings.NOTIFY_EMAIL)
return True
except Exception:
logger.exception("Failed to send lead email")
return False
async def send_lead_email(
customer_profile: dict,
contact_info: dict,
reasoning: str,
conversation_messages: list[dict],
intent_level: str = "",
) -> bool:
tag = TAG_LABELS.get(intent_level, "未评估")
contacts = dict(contact_info or {})
country = (customer_profile or {}).get("目标国家", "")
if not country:
country = (customer_profile or {}).get("target_country", "未知")
msg = MIMEMultipart("alternative")
contact_parts = [
f"{CONTACT_LABELS.get(k, k)}: {v}" for k, v in contacts.items() if v
]
subject = f"[新询盘·{tag}] {country}"
if contact_parts:
subject += " | " + ", ".join(contact_parts)
msg["Subject"] = subject
msg["From"] = f"【XYH】 <{settings.SMTP_USER}>"
msg["To"] = settings.NOTIFY_EMAIL
info_rows = "".join(
f"<tr><td style='padding:6px 12px;font-weight:bold;'>{_esc(k)}</td>"
f"<td style='padding:6px 12px;'>{_esc(v)}</td></tr>"
for k, v in (customer_profile or {}).items() if not str(k).startswith("_")
)
chat_lines = "".join(
f"<div style='margin:4px 0;'><b>{'客户' if m.get('role') == 'user' else 'AI'}:</b> {_esc(m.get('content', ''))}</div>"
for m in conversation_messages or []
)
html_body = f"""
<html><body style="font-family:sans-serif;color:#333;">
<h2>🎉 新询盘({html.escape(tag)})</h2>
<h3>联系方式</h3>
<table style="border-collapse:collapse;border:1px solid #ddd;">{_contact_rows(contacts)}</table>
<h3>客户画像</h3>
<table style="border-collapse:collapse;border:1px solid #ddd;">{info_rows}</table>
<h3>评估理由</h3><p>{_esc(reasoning)}</p>
<h3>完整对话记录</h3>
<div style="background:#f5f5f5;padding:12px;border-radius:6px;">{chat_lines}</div>
<p style="color:#999;font-size:12px;">注:邮件为触发时快照,对话可能仍在进行,最新记录请以后台为准。</p>
</body></html>
"""
msg.attach(MIMEText(html_body, "html", "utf-8"))
return await _send(msg)
async def send_returning_lead_email(contact_info: dict) -> bool:
"""D5: 24h 内同联系方式再访,只发一行轻提醒。"""
contact_parts = [
f"{CONTACT_LABELS.get(k, k)}: {v}" for k, v in (contact_info or {}).items() if v
]
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[线索回访] {' | '.join(contact_parts) or '已知客户'}再次来访"
msg["From"] = f"【XYH】 <{settings.SMTP_USER}>"
msg["To"] = settings.NOTIFY_EMAIL
msg.attach(MIMEText(
f"<html><body><p>该联系方式 24h 内已发过完整询盘,客户又开启了新对话,"
f"详情请查后台。</p></body></html>", "html", "utf-8"))
return await _send(msg)
async def send_correction_email(contact_info: dict) -> bool:
"""D4: 联系方式变化(改号/新增)后的轻量更正邮件。"""
msg = MIMEMultipart("alternative")
msg["Subject"] = "[询盘·更正] 客户联系方式已更新"
msg["From"] = f"【XYH】 <{settings.SMTP_USER}>"
msg["To"] = settings.NOTIFY_EMAIL
msg.attach(MIMEText(
f"<html><body><p>客户更新了联系方式,请以本封为准:</p>"
f"<table>{_contact_rows(contact_info)}</table>"
f"<p style='color:#999;font-size:12px;'>每个会话最多发送一次更正。</p>"
f"</body></html>", "html", "utf-8"))
return await _send(msg)
- Step 4.2: 确认测试转绿 + 导入无损
Run: python -m pytest tests/test_email_rendering.py -v → 3 PASS
Run: python -m pytest -q → 39 passed
Run: python -c "from app.services import email_service" → 无输出(成功)
- Step 4.3: Commit
git add app/services/email_service.py
git commit -m "feat(email): 标签/转义/中文标签列/回访轻提醒/更正邮件"
Task 5: conversation.py — 管道接线 + 深合并修复 + 评估前置(T1/T3/D7)
Files:
-
Modify:
app/services/conversation.py(仅stream_process_message,154-246 行区域) -
Test:
tests/test_lead_email_trigger.py -
Step 5.1: 写失败测试(FakeAI + async_db 模式)
"""批次1 集成测试:管道接线/邮件触发/结束守卫/卡单标记。"""
import asyncio
from app.models.database import ConversationSource, ConversationStatus
from app.services.conversation import ConversationManager
from app.services import email_service
class FakeAI:
"""替代 GLM 的确定性桩:构造参数即每轮返回值。"""
def __init__(self, extract=None, evaluate=None):
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
self._evaluate = evaluate or {}
self.eval_calls = 0
async def chat(self, messages, context=""):
return "ok reply"
async def stream_chat(self, messages, context=""):
for token in ("ok ", "reply"):
yield token
async def extract_info(self, messages, existing_info):
# 第 N 轮客户消息 → seq[N-1](messages 为 [u,a,u,a...] 全量历史)
idx = min(max(len(messages) // 2 - 1, 0), len(self._extract_seq) - 1)
return self._extract_seq[idx]
async def evaluate(self, profile, recent_messages):
self.eval_calls += 1
return dict(self._evaluate)
INTERESTED = {
"intent_level": "interested", "reasoning": "预算国家车型齐全",
"contact_info": {}, "info_sufficient": True,
}
async def _send(manager, sid, text):
async for _ in manager.stream_process_message(sid, text):
pass
def _setup(async_db, monkeypatch, source=ConversationSource.widget, ai=None):
sent = []
async def fake_lead(customer_profile, contact_info, reasoning,
conversation_messages, intent_level=""):
sent.append(("lead", dict(contact_info), intent_level))
return True
async def fake_nudge(contact_info):
sent.append(("nudge", dict(contact_info), ""))
return True
async def fake_fix(contact_info):
sent.append(("fix", dict(contact_info), ""))
return True
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
monkeypatch.setattr(email_service, "send_returning_lead_email", fake_nudge)
monkeypatch.setattr(email_service, "send_correction_email", fake_fix)
async def run():
async with async_db() as db:
manager = ConversationManager(db, ai_service=ai or FakeAI())
conv = await manager.create_conversation(source=source)
return manager, conv
return asyncio.run(run()), sent
def test_extractor_empty_round_preserves_contact(async_db, monkeypatch):
# T1: extractor 第2轮返回空联系方式,不得抹掉第1轮已提取的
(manager, conv), _ = _setup(async_db, monkeypatch, ai=FakeAI(
extract=[{"联系方式": {"手机": "+8613800138000"}}, {"联系方式": {}}],
))
asyncio.run(_send(manager, conv.session_id, "my phone is +8613800138000"))
asyncio.run(_send(manager, conv.session_id, "how much"))
profile = asyncio.run(manager._get_profile(conv.id))
assert (profile.raw_info or {}).get("联系方式", {}).get("phone") == "+8613800138000"
def test_evaluation_skipped_below_3_user_messages(async_db, monkeypatch):
# D7: 前2条客户消息不调 evaluate
ai = FakeAI()
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai)
asyncio.run(_send(manager, conv.session_id, "hi"))
asyncio.run(_send(manager, conv.session_id, "price?"))
assert ai.eval_calls == 0
asyncio.run(_send(manager, conv.session_id, "kenya, 20k"))
assert ai.eval_calls == 1
(注:T3"正则兜住 extractor 漏提取 → 仍触发邮件"的用例依赖 Task 6 的触发器,移至 Task 6 Step 6.1 一并转绿,本 Task 不含。)
- Step 5.2: 运行确认失败
Run: pytest tests/test_lead_email_trigger.py -v
Expected: 2 用例 FAIL(T1 抹数据 / D7 评估未跳过)
- Step 5.3: 修改 stream_process_message
在 stream_process_message 中,将现有 Round 2(extract)与 Round 3(evaluate)区域(conversation.py:206-224)替换为:
# Round 2: Extract info (background)
full_history = history + [{"role": "assistant", "content": ai_reply}]
profile = await self._get_profile(conv.id)
new_info = await self.ai.extract_info(
full_history, existing_info=profile.raw_info or {}
)
logger.info("Extractor result: %s", new_info)
if new_info:
raw = dict(profile.raw_info or {})
for k, v in new_info.items():
# 联系方式深合并(同key新值覆盖),其余顶层覆盖 —— 堵浅合并抹数据
if k == "联系方式":
raw[k] = merge_contacts(raw.get(k), v)
else:
raw[k] = v
profile.raw_info = raw
self.db.add(profile)
# Round 2.5: 联系方式正则辅源(S4: 扫全量客户消息历史)
# 🔴 合并方向: 扫描器只填空,extractor 已有值优先——否则扫描器的
# 首个历史值会压掉 extractor 后续捕获的改号, D4 更正邮件失效
scanned = scan_user_messages(full_history)
if scanned:
raw = dict(profile.raw_info or {})
raw["联系方式"] = merge_contacts(scanned, raw.get("联系方式"))
profile.raw_info = raw
self.db.add(profile)
# Round 3: Evaluate (仅 ≥3 条客户消息才有意义, 前两轮必然 pending —— D7)
user_msg_count = sum(1 for m in full_history if m["role"] == "user")
evaluation_ran = user_msg_count >= 3
if evaluation_ran:
evaluation = await self.ai.evaluate(
profile=profile.raw_info or {},
recent_messages=full_history,
)
else:
# 客户端 evaluated 事件语义保持 pending;邮件标签由 evaluation_ran 决定(D2)
evaluation = {"intent_level": "pending", "reasoning": "",
"contact_info": {}, "info_sufficient": False}
logger.info("Evaluation (stream): profile=%s | eval=%s",
profile.raw_info, evaluation)
文件顶部 import 区追加(Task 6 会再补 normalize_contacts, split_validate, canon——本任务只导入用到的,避免未用导入):
from app.services.contact_pipeline import merge_contacts, scan_user_messages
注意:旧代码中 user_msg_count 原在 evaluate 之后计算(conversation.py:225),现在提前——旧的 should_end 区域在 Task 7 重写,本 Task 不动结束逻辑。evaluation_ran 在 Task 6 的触发器中使用(D2:跳过评估时邮件标签必须是[未评估]而不是[待确认])。
- Step 5.4: 运行确认通过
Run: pytest tests/test_lead_email_trigger.py -v
Expected: 2 PASS(T3 用例在 Task 6 引入)
- Step 5.5: Commit
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "feat(pipeline): 接入统一管道/深合并修复/正则辅源/评估前置"
Task 6: 邮件触发器 + 卡单标记 + finalize UPDATE(D1/D4/D5/D8/D10)
Files:
-
Modify:
app/services/conversation.py -
Test:
tests/test_lead_email_trigger.py -
Step 6.1: 写失败测试(追加)
测试基建两条卫生规则(Task 5 质量评审采纳项,随本任务落地):
- 本文件每个用例结尾若持有 manager,追加
asyncio.run(manager.db.close())(含既有 T1/D7 两例)——消除悬挂读事务的 GC 警告; FakeAI.evaluate按_normalize_evaluation的生产行为做默认补全/非法值矫正(4 个 key 全兜底),防止后续用例传部分 dict 时与生产行为分叉。
from app.models.database import Evaluation, IntentLevel
from sqlalchemy import select
def _mk_ai(extract, evaluate=None):
return FakeAI(extract=extract, evaluate=evaluate)
def test_regex_catches_extractor_miss(async_db, monkeypatch):
# T3(自 Task 5 移入): extractor 全程漏提取,正则从消息里捞到 email → 仍然触发邮件
(manager, conv), sent = _setup(async_db, monkeypatch, ai=FakeAI(extract={}))
asyncio.run(_send(manager, conv.session_id, "my email is a@b.com"))
assert len(sent) == 1 and sent[0][0] == "lead"
assert sent[0][1]["email"] == "a@b.com"
def test_first_message_contact_sends_email_with_unevaluated_tag(async_db, monkeypatch):
# D1: 第1条消息含联系方式即发,标签[未评估](评估未跑 → intent_level 传空,D2)
(manager, conv), sent = _setup(async_db, monkeypatch,
ai=_mk_ai({"联系方式": {"手机": "+8613800138000"}}))
asyncio.run(_send(manager, conv.session_id, "wa me +8613800138000"))
assert len(sent) == 1 and sent[0][0] == "lead"
assert sent[0][2] == "" # 评估跳过 → 邮件 intent_level 参数为空串 → 标签[未评估]
def test_no_contact_never_emails_and_marks_stuck(async_db, monkeypatch):
# D8: interested+无联系方式 → 不发邮件,upsert Evaluation 卡单记录
(manager, conv), sent = _setup(async_db, monkeypatch,
ai=_mk_ai({}, evaluate=INTERESTED))
for text in ("hi", "kenya", "20k budget", "byd tang"):
asyncio.run(_send(manager, conv.session_id, text))
assert sent == []
async def check():
ev = (await manager.db.execute(
select(Evaluation).where(Evaluation.conversation_id == conv.id)
)).scalar_one()
return ev
ev = asyncio.run(check())
assert ev.intent_level == IntentLevel.interested
assert (ev.contact_info or {}) == {}
assert ev.email_sent is False
def test_email_sent_once_per_conversation(async_db, monkeypatch):
# D4: 联系方式持续存在,后续消息不得重发
(manager, conv), sent = _setup(async_db, monkeypatch,
ai=_mk_ai({"联系方式": {"email": "a@b.com"}}))
asyncio.run(_send(manager, conv.session_id, "mail me a@b.com"))
asyncio.run(_send(manager, conv.session_id, "another question"))
asyncio.run(_send(manager, conv.session_id, "more"))
assert len(sent) == 1
def test_correction_email_on_change_once(async_db, monkeypatch):
# D4: 改号 → 1封更正;再改 → 不再发(cap 必须被真正跑到第二次改号)
ai = FakeAI(extract=[{"联系方式": {"手机": "+8613800138000"}},
{"联系方式": {"手机": "+8613800138000"}},
{"联系方式": {"手机": "+8613800139999"}},
{"联系方式": {"手机": "+8613800139999"}},
{"联系方式": {"手机": "+8613800122222"}}])
(manager, conv), sent = _setup(async_db, monkeypatch, ai=ai)
asyncio.run(_send(manager, conv.session_id, "my number +8613800138000"))
asyncio.run(_send(manager, conv.session_id, "wrong, use +8613800139999"))
asyncio.run(_send(manager, conv.session_id, "again +8613800122222"))
asyncio.run(_send(manager, conv.session_id, "fourth message"))
asyncio.run(_send(manager, conv.session_id, "fifth message"))
kinds = [s[0] for s in sent]
assert kinds == ["lead", "fix"] # 第5轮 contact 再变(C),correction_sent 已锁 → 不发
def test_suspect_contact_never_triggers_email(async_db, monkeypatch):
# D1 边界: 校验不过的疑似联系方式(5位 whatsapp)绝不触发邮件
(manager, conv), sent = _setup(async_db, monkeypatch,
ai=_mk_ai({"联系方式": {"whatsapp": "12345"}}))
for text in ("hi", "second", "third"):
asyncio.run(_send(manager, conv.session_id, text))
assert sent == []
def test_24h_dedup_sends_nudge(async_db, monkeypatch):
# D5: 另一会话24h内已对同值发过全量 → 本会话只发轻提醒
from datetime import datetime, timezone
ai1 = _mk_ai({"联系方式": {"email": "same@x.com"}})
(manager, conv1), sent1 = _setup(async_db, monkeypatch, ai=ai1)
asyncio.run(_send(manager, conv1.session_id, "mail same@x.com"))
assert [s[0] for s in sent1] == ["lead"]
ai2 = _mk_ai({"联系方式": {"email": "same@x.com"}})
(manager, conv2), sent2 = _setup(async_db, monkeypatch, ai=ai2)
asyncio.run(_send(manager, conv2.session_id, "im back same@x.com"))
assert [s[0] for s in sent2] == ["nudge"]
- Step 6.2: 运行确认失败
Run: pytest tests/test_lead_email_trigger.py -v
Expected: 新增 6 用例 FAIL(含自 Task 5 移入的 T3 用例)
- Step 6.3: 实现(conversation.py)
(a0) 先在 app/services/contact_pipeline.py 追加 canon(含单测,先红后绿):
def canon(value: str) -> str:
"""去重比对用的规范形:邮箱小写,其余取纯数字(S3/R3 best-effort)。"""
value = str(value or "").strip()
if "@" in value:
return value.lower()
return digits_only(value)
def test_canon_normalizes_for_dedup():
from app.services.contact_pipeline import canon
assert canon("A@B.COM") == "a@b.com"
assert canon("+254 712-345 678") == "254712345678"
(a) 类内新增三个方法:
async def _get_or_init_evaluation(self, conv_id: str) -> Evaluation:
result = await self.db.execute(
select(Evaluation).where(Evaluation.conversation_id == conv_id)
)
ev = result.scalar_one_or_none()
if not ev:
ev = Evaluation(
conversation_id=conv_id,
intent_level=IntentLevel.pending,
contact_info={},
)
self.db.add(ev)
await self.db.flush()
return ev
async def _recently_emailed_canons(self) -> set[str]:
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
# 只取需要的列:该查询在每次首触/重试时执行,避免整行 ORM 实例化。
# 🔴 禁止加 LIMIT:"任一值见过"的判定只有覆盖完整 24h 窗口才正确。
result = await self.db.execute(
select(Evaluation.contact_info).where(
Evaluation.email_sent.is_(True),
Evaluation.evaluated_at >= cutoff,
)
)
seen: set[str] = set()
for (contact_info,) in result:
for v in (contact_info or {}).values():
if v:
seen.add(canon(str(v)))
return seen
async def _maybe_send_lead_email(
self, conv: Conversation, profile: CustomerProfile, evaluation: dict,
evaluated: bool = True,
) -> None:
"""D1: 联系方式是发邮件的唯一条件。confirmed 才触发;suspect 只进提醒。
evaluated=False 表示本轮跳过了 GLM 评估(<3条客户消息)——此时邮件
intent_level 必须传空串(→[未评估]),不得把客户端事件用的 "pending"
透传成 [待确认](D2 两者语义不同)。
"""
contacts_all = normalize_contacts((profile.raw_info or {}).get("联系方式"))
confirmed, _suspect = split_validate(contacts_all)
if not confirmed:
return
ev = await self._get_or_init_evaluation(conv.id)
if not ev.email_sent:
# S3: 任一值已见于24h内其他会话 → 轻提醒;否则全量
seen = await self._recently_emailed_canons()
if any(canon(str(v)) in seen for v in confirmed.values()):
sent = await email_service.send_returning_lead_email(confirmed)
else:
messages = await self._load_history(conv.id)
sent = await email_service.send_lead_email(
customer_profile=profile.raw_info or {},
contact_info=confirmed,
reasoning=evaluation.get("reasoning", ""),
conversation_messages=messages,
intent_level=evaluation.get("intent_level", "") if evaluated else "",
)
if sent:
ev.email_sent = True
ev.contact_info = confirmed
self.db.add(ev)
elif not ev.correction_sent:
old_canons = {canon(str(v)) for v in (ev.contact_info or {}).values()}
if any(canon(str(v)) not in old_canons for v in confirmed.values()):
if await email_service.send_correction_email(confirmed):
ev.correction_sent = True
ev.contact_info = confirmed
self.db.add(ev)
顶部 import 追加:from datetime import datetime, timedelta, timezone、from app.services import email_service、contact_pipeline 导入补 canon。
(b) stream_process_message 在 evaluate 之后、should_end 之前插入:
# 邮件触发(D1): 与会话结束彻底解耦,联系方式到手即发
await self._maybe_send_lead_email(
conv, profile, evaluation, evaluated=evaluation_ran
)
# 卡单标记(D8): 有意向但没联系方式 → 落库供 admin 人工兜底
confirmed_now, _ = split_validate(
normalize_contacts((profile.raw_info or {}).get("联系方式")))
if (user_msg_count >= 3
and evaluation.get("intent_level") == "interested"
and not confirmed_now):
ev = await self._get_or_init_evaluation(conv.id)
ev.intent_level = IntentLevel.interested
ev.reasoning = evaluation.get("reasoning", "")
ev.contact_info = {}
self.db.add(ev)
(c) _finalize 改 UPDATE 语义(D10),替换 conversation.py:339-344 的 INSERT:
evaluation_record = await self._get_or_init_evaluation(conv.id)
evaluation_record.intent_level = intent_enum
evaluation_record.reasoning = evaluation.get("reasoning", "")
# 结束时的管道快照;保留 email_sent / correction_sent
confirmed, _ = split_validate(
normalize_contacts((profile.raw_info or {}).get("联系方式")))
evaluation_record.contact_info = confirmed or evaluation_record.contact_info or {}
self.db.add(evaluation_record)
- Step 6.4: 运行确认通过
Run: pytest tests/test_lead_email_trigger.py -v
Expected: 全部 PASS
- Step 6.5: Commit
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "feat(trigger): 联系方式驱动邮件触发器/卡单标记/finalize改UPDATE"
Task 7: 全来源结束守卫 + 拆除旧触发路径(D3/D9/D13)
Files:
-
Modify:
app/services/conversation.py:226-236(should_end 区域)与_has_real_contact(248-262,删除) -
Modify:
app/api/routes/chat.py:10,94-97,108-144 -
Modify:
app/api/routes/chat_rest.py:138-142(及 docstring 第 12-13 行) -
Modify:
tests/test_chat_rest_message.py(删 3 个钉死旧路径的测试) -
Test:
tests/test_lead_email_trigger.py -
Step 7.1: 写失败测试(追加)
def test_widget_never_ends_without_contact_all_sources(async_db, monkeypatch):
# D3/P1: widget 来源 interested+sufficient 但无联系方式 → 永不结束
(manager, conv), _ = _setup(async_db, monkeypatch,
ai=_mk_ai({}, evaluate=INTERESTED))
for text in ("hi", "kenya", "20k", "tang", "ship how"):
asyncio.run(_send(manager, conv.session_id, text))
conv_now = asyncio.run(manager.get_by_session(conv.session_id))
assert conv_now.status == ConversationStatus.active
def test_ends_with_contact_and_interested(async_db, monkeypatch):
# D3: confirmed联系方式 + interested + ≥3轮 → 正常结束
ai = _mk_ai({"联系方式": {"email": "a@b.com"}}, evaluate=INTERESTED)
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai)
for text in ("hi", "kenya", "20k", "tang"):
asyncio.run(_send(manager, conv.session_id, text))
conv_now = asyncio.run(manager.get_by_session(conv.session_id))
assert conv_now.status == ConversationStatus.completed
- Step 7.2: 运行确认失败
Run: pytest tests/test_lead_email_trigger.py -v
Expected: widget 用例 FAIL(现状 widget 无守卫会结束)
- Step 7.3: 修改 should_end(conversation.py:226-236 区域)
# 结束守卫(D3): 全来源必须有 confirmed 联系方式 —— 无联系方式永不结束,
# AI 保持争取机会。(推翻"不改widget"红线,2026-08-17 产品决策)
should_end = (
bool(confirmed_now)
and evaluation.get("info_sufficient", False)
and user_msg_count >= 3
and evaluation.get("intent_level") == "interested"
)
(confirmed_now 已在 Task 6 卡单标记处计算,紧邻复用。)随后:
-
删除
_has_real_contact静态方法(248-262)及其上方的 app-only 注释(226-229); -
删除整个
process_message方法(70-152):grep 确认零调用方的死代码,且其对_has_real_contact的调用(137 行)在方法删除后会变成悬空引用——与其留一个必然 AttributeError 的死方法,不如随本提交一并清除(其 131-134 的红线注释同时消失); -
顺手删除旧区域遗留的
user_msg_count = sum(...)重复赋值行(conversation.py:225,Step 5.3 已提前计算)。 -
Step 7.4: 拆除路由层旧触发(D9,同一提交)
chat.py:
- 删除
if event["type"] == "evaluated" and event.get("ended")触发块(94-97 行区域,含_try_send_email调用); - 保留
ended = False标志 +ended = True+ 外层if ended: break(HEAD 的连接生命周期语义:ended 后关闭连接。只拆邮件触发,不改连接行为——若去掉外层 break,连接会悬挂到代理超时 1h); - 删除
_try_send_email整个函数(108-144)与from app.services import email_service(第 10 行);随之from sqlalchemy.ext.asyncio import AsyncSession(第 5 行)不再被使用,一并删除。
chat_rest.py:
- 删除 138-142 的 lazy import 与调用块;
- docstring 第 12-13 行的 "fires
_try_send_emailonended && intent_level == "interested"" 改为 "lead email is triggered insideConversationManagerwhen contact info is detected (see contact-driven-lead-email plan)"。
tests/test_chat_rest_message.py:
-
删除
test_email_fires_on_interested_end、test_email_does_not_fire_when_not_interested及模块 docstring 中的 🔴 段(其保护的行为已被tests/test_lead_email_trigger.py的 D1/D4 测试取代); -
保留 reply/404/429 用例。
-
Step 7.4b: 确认死代码删除无残留引用(须在 7.4 删除 docstring 中的旧引用之后执行)
Run: grep -rn "process_message\b" app/ tests/ | grep -v stream_process_message
Expected: 无输出。(注意顺序:若在 7.4 之前跑,tests/test_chat_rest_message.py docstring 里的裸 process_message 字样会产生一条命中——那是即将被 7.4 删除的旧文案。)
- Step 7.5: 全量测试
Run: pytest -v
Expected: 全部 PASS(含未改动的 context_seeding/enum/rate_limit/smoke)
- Step 7.6: Commit
git add app/services/conversation.py app/api/routes/chat.py app/api/routes/chat_rest.py tests/
git commit -m "feat(guard): 全来源结束守卫+拆除旧ended触发路径(邮件触发内聚到Manager)"
Task 8: 定向提醒(D6)— 不重复索要
Files:
-
Modify:
app/services/conversation.py(_build_context_reminder312-328、_enrich_short_message279-310) -
Test:
tests/test_lead_email_trigger.py -
Step 8.1: 写失败测试(追加)
def test_reminder_says_no_reasking_when_contact_present():
raw = {"目标国家": "肯尼亚", "联系方式": {"whatsapp": "+254712345678"}}
text = ConversationManager._build_context_reminder(raw, "yes", [])
assert "禁止" in text and "+254712345678" in text
def test_reminder_suspect_variant_asks_to_confirm():
raw = {"联系方式": {"whatsapp": "12345"}} # 校验不过 → suspect
text = ConversationManager._build_context_reminder(raw, "yes", [])
assert "确认" in text and "禁止" in text
def test_reminder_hides_internal_keys():
raw = {"目标国家": "肯尼亚", "_internal": "x"}
text = ConversationManager._build_context_reminder(raw, "hi", [])
assert "_internal" not in text
- Step 8.2: 运行确认失败
Run: pytest tests/test_lead_email_trigger.py -v
Expected: 3 用例 FAIL
- Step 8.3: 修改
_build_context_reminder
@staticmethod
def _build_context_reminder(raw_info: dict, latest_msg: str, history: list[dict] | None = None) -> str:
"""Build a context reminder from extracted profile info."""
if not raw_info:
return ""
parts = ["[上下文提醒 - 这是一条系统指令,绝对不要向客户提及这段内容]"]
parts.append("客户已明确告知以下信息,你绝对不能再问这些:")
for key, val in raw_info.items():
if str(key).startswith("_") or key == "联系方式":
continue # 内部状态与联系方式单独渲染
if val:
parts.append(f"{key}: {val}")
# 联系方式专项(D6): confirmed=禁止索要; suspect=礼貌确认。绝不重复索要已给的。
contacts_all = normalize_contacts(raw_info.get("联系方式"))
confirmed, suspect = split_validate(contacts_all)
if confirmed:
shown = ", ".join(
f"{k}: {' '.join(str(v).split())[:60]}" for k, v in confirmed.items())
parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要任何联系方式!"
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
elif suspect:
shown = ", ".join(
f"{k}: {' '.join(str(v).split())[:60]}" for k, v in suspect.items())
parts.append(f"⚠️ 客户疑似已提供联系方式({shown})。禁止重新索要;"
f"可礼貌地请客户确认一次。")
if len(latest_msg.strip()) < 25:
parts.append("⚠️ 客户发的是短消息!必须结合上面已知信息来理解,绝对不能当作独立问题!")
return "\n".join(parts)
_enrich_short_message 的 facts 构造行加同样过滤,并跳过 联系方式(避免 dict-repr 进提示词,与 reminder 的专项渲染保持一致):
facts = [f"{k}: {v}" for k, v in raw_info.items()
if v and not str(k).startswith("_") and k != "联系方式"]
- Step 8.4: 运行确认通过 + Commit
Run: pytest tests/test_lead_email_trigger.py -v
Expected: 全部 PASS
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "feat(reminder): 联系方式定向提醒(confirmed禁索要/suspect礼貌确认)"
Task 9: system.md 话术(D11)
Files:
-
Modify:
app/prompts/system.md:60-82(对话流程 + 联系方式引导策略两节) -
Step 9.1: 修改话术
"## 对话流程"第 3、4 条替换为:
3. 第3-4轮用"转接销售"或"发报价单"的方式引导联系方式(见下方策略)
4. 拿到联系方式后:确认一句("Got it!"),然后继续正常解答客户问题——不必急着结束对话,也别再提联系方式
"## 联系方式引导策略"节追加(放在"### 注意"之前):
### 价值交换优先(比单纯索要多给一个理由)
- "I'll send you our full price list — what's your WhatsApp?"
- "Want me to send a proper quote? Drop your WhatsApp or email."
- 话术固定为"完整报价单/报价"级别,禁止承诺具体价格、折扣或时限(仅指索要联系方式时的话术,正常聊车报价照旧)
### 引流兜底(先聊后引,顺序不能反)
- 聊天内按上述方式要联系方式,最多两次
- 两次都没要到,才指引用户点面板上的 WhatsApp 按钮直接找我们:
"You can also just message us on WhatsApp (button at the top) — our guy replies fast."
- 禁止一上来就推 WhatsApp 按钮跳过聊天索要
### 系统提醒配合
- 系统会在上下文提醒里告诉你客户是否已留联系方式
- 提醒说"已提供"时:绝对禁止再要,确认收到即可
- 提醒说"疑似提供"时:礼貌请客户确认一次,不要当成没给过重新索要
"### 注意"节的"拿到后说一句…别再重复要"保持不变。
- Step 9.2: 验证 prompt 加载
Run: python -c "from app.services.ai_service import SYSTEM_PROMPT; assert '价值交换优先' in SYSTEM_PROMPT; print('ok')"
Expected: ok
- Step 9.3: Commit
git add app/prompts/system.md
git commit -m "feat(prompt): 价值交换/先聊后引/系统提醒配合话术"
Task 10: 全量回归 + 人工验收
- Step 10.1: 全量测试
Run: pytest -v
Expected: 全部 PASS
- Step 10.2: 本地起服务人工冒烟
Run: uvicorn app.main:app --port 8010(.env 指向测试 SMTP/_NOTIFY_EMAIL)
操作:打开 http://127.0.0.1:8010/,第 1 条消息直接输入 "my whatsapp is +254712345678" → 收到 [新询盘·未评估] 邮件(含该号码、完整转义对话);继续聊 3+ 轮不再收邮件;再说 "wrong number, use +254711111111" → 收到 [询盘·更正];无联系方式的对话聊 5 轮 → 零邮件且输入框不锁死。
Expected: 全部符合
- Step 10.3: 上线后一周观测(对照 2026-08-17-baseline.txt)
指标:①新邮件 100% 带联系方式 ②全量邮件量 vs 基线上界 ③[更正]/[回访]占比 ④admin 中卡单(interested+无联系方式)数量
- Step 10.4: 最终 Commit(如有遗漏小修)
git add -A && git commit -m "chore: 批次1收尾"
Task 11: 移除自动结束(决策 A,2026-08-18 追加)
背景:邮件已与结束解耦,自动结束只剩收尾 UX/停止计费两个弱价值,却会踢出还没问完的客户(锁输入+刷新丢上下文+销售响应空窗)。用户拍板方案 A。
Files:
-
Modify:
app/services/conversation.py(stream 结束区域 +_finalize删除) -
Test:
tests/test_lead_email_trigger.py -
Step 11.1: 写失败测试(先红)
def test_conversation_never_auto_ends_even_with_contact_and_interested(async_db, monkeypatch):
# 决策A(2026-08-18): 自动结束移除——即使 contact+interested+≥3轮也保持 active
ai = _mk_ai({"联系方式": {"email": "a@b.com"}}, evaluate=INTERESTED)
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai)
for text in ("hi", "kenya", "20k", "tang", "more questions"):
asyncio.run(_send(manager, conv.session_id, text))
conv_now = asyncio.run(manager.get_by_session(conv.session_id))
assert conv_now.status == ConversationStatus.active
asyncio.run(manager.db.close())
def test_evaluation_upsert_keeps_flags_and_intent(async_db, monkeypatch):
# 评估落库泛化: intent 刷新为 interested,email_sent 不被触碰
ai = _mk_ai({"联系方式": {"email": "a@b.com"}}, evaluate=INTERESTED)
(manager, conv), sent = _setup(async_db, monkeypatch, ai=ai)
for text in ("hi", "kenya", "20k"):
asyncio.run(_send(manager, conv.session_id, text))
async def check():
ev = (await manager.db.execute(
select(Evaluation).where(Evaluation.conversation_id == conv.id)
)).scalar_one()
return ev
ev = asyncio.run(check())
assert ev.intent_level == IntentLevel.interested
assert ev.email_sent is True and ev.correction_sent is False
asyncio.run(manager.db.close())
同时把 test_ends_with_contact_and_interested 删除(其断言的行为已被决策 A 废除,由上面的 never-auto-ends 用例取代)。
- Step 11.2: 运行确认失败
Run: python -m pytest tests/test_lead_email_trigger.py -v -k "never_auto_ends or upsert_keeps"
Expected: never_auto_ends FAIL(现状会结束);upsert_keeps 视现状可能通过(先记录)
- Step 11.3: 实现
(a) 删除结束区域(should_end 判定 + if should_end: await self._finalize(...)),evaluated 事件的 ended 恒为 False(事件本身保留,客户端协议不变)。
(b) 将 D8 卡单块泛化为每轮评估落库:
# 评估落库(D8 泛化): 每轮评估后刷新意向;email_sent/correction_sent
# 永不触碰。有意向无联系方式 = 卡单(admin 可筛);已发过邮件的
# contact_info 由触发器更正分支管理,这里不覆盖。
if user_msg_count >= 3:
ev = await self._get_or_init_evaluation(conv.id)
ev.intent_level = IntentLevel(evaluation.get("intent_level", "pending"))
ev.reasoning = evaluation.get("reasoning", "")
if not confirmed_now:
ev.contact_info = {}
elif not ev.email_sent:
ev.contact_info = confirmed_now
self.db.add(ev)
(c) 删除 _finalize 方法整体(超时关闭功能未来另行实现,届时基于 _get_or_init_evaluation 重建)。删除前 grep 确认零调用方。
- Step 11.4: 全量测试
Run: python -m pytest -q
Expected: 57 passed(56 − 删1 + 增2)
- Step 11.5: Commit — 🔴 SKIP(沿用不提交约定)
Task 12: 联系方式更正覆盖 + 更正上限 3 次(2026-08-18 用户实测反馈)
背景:用户实测 "wrong number, use X" 未触发更正——extractor 受"保留已有信息"倾向回传旧值,扫描器"首中+填空"结构性无法翻新(风险台账中该残余首次真实命中)。用户决策:①确定性"最新显式自述优先"覆盖;②extractor.md 教会覆盖语义;③更正上限放宽至 3 次;④锁邮件不锁快照。
Files: contact_pipeline.py(新增 latest_stated_contacts + CORRECTION_CTX_RE + PHONE_CTX 扩展 new/other/another)、conversation.py(Round 2.5 覆盖 + 触发器计数分支)、database.py + main.py(correction_sent→correction_count,迁移替换)、email_service.py(更正邮件带次数)、app/prompts/extractor.md(覆盖语义一行)、测试三文件。
核心规则:latest_stated_contacts(messages) 反向取每 key 全历史最后一次显式自述——上下文门控捕获(含 my new/other/another number is)与"更正语境 ∧ 裸国际号"有覆盖权;纯裸数字无覆盖权。Round 2.5 合并后:canon 与现值不同的 key 强制覆盖(唯一允许文本压过 extractor 的位置)。触发器更正分支:correction_count < 3 → 发邮件(主题 [询盘·更正N])并计数;已达上限 → 仅刷新 ev.contact_info。
关键测试:test_correction_overrides_extractor_echo(实测场景复刻:extractor 恒回旧值 + wrong number 消息 → 管道翻新 + lead/fix 各一封)、test_bare_second_number_overrides(无更正语境的 my new number is)、test_correction_cap_three_then_snapshot_only(A→B→C→D→E:lead+3 fix,第5值仅进快照)、原 test_correction_email_on_change_once 期望更新为 ["lead","fix","fix"]、渲染测试断言 [询盘·更正1]、latest_stated_* 单测四例。
Task 12b: 同号同步(2026-08-18 用户实测反馈 II)
背景:用户实测更正邮件里联系方式新旧混排(phone=新号, whatsapp=旧号)。根因:extractor 习惯把同一号码镜像进 phone/whatsapp 两个 key,更正只翻新命中的 key,兄弟 key 滞留旧值进邮件/快照。
修复:contact_pipeline.sync_same_number_keys(before, after) 纯函数——after 中某 key 翻新时,与其旧值同号的兄弟 key 一并翻新(email 身份独立不同步;两 key 本就不同号时不动)。接线在 Round 2 深合并前捕获 before、Task 12 覆盖后执行同步(覆盖 extractor 与 override 两条更新路径)。
测试:3 个纯函数单测(镜像同步/真不同号不动/email 独立)+ 1 个集成复刻(extractor 镜像旧号 → wrong number → 断言邮件与快照两 key 均为最新号)。
Task 12c: 最近提及裁决(2026-08-18 用户实测反馈 III)
背景:用户实测更正配额被虚假更正烧光(仅 1 次真实变化却 correction_count=3,最后一封更正邮件显示旧号)。根因:extractor 在历史消息里的多个号码间振荡(GLM 每轮从全历史提取,回传值不稳定),深合并如实应用翻转,12b 同号同步进一步放大到镜像 key,每次翻转触发一封虚假更正。
修复:resolve_by_recency(before, after, messages) 取代 12b 的同号同步——镜像身份组(before 同 canon 的非 email key)内部分歧时,取"出现在最晚客户消息里"的值:旧号在更早消息里天然落败(杀振荡)、幻觉值 last_idx=-1 天然落败(压制幻觉)、客户最晚消息里的新号天然胜出(extractor 抓到的裸号正确翻新)。
验收测试:test_oscillation_does_not_burn_correction_quota——完整复刻用户序列(A→B→三轮振荡→裸号 C),断言仅 2 次更正且第 2 封内容就是 C;12b 的镜像同步场景由 recency 涵盖(原测试继续通过)。
4. 验收清单(对照已定决策)
| 决策 | 验收 |
|---|---|
| D1 邮件唯一条件 | test_first_message_contact_sends_email_with_unevaluated_tag |
| D3 永不自动结束(决策A) | test_widget_never_ends_without_contact_all_sources / test_conversation_never_auto_ends_even_with_contact_and_interested / test_evaluation_upsert_keeps_flags_and_intent |
| D4 防重发+更正 | test_email_sent_once_per_conversation / test_correction_email_on_change_once |
| D5 24h 去重 | test_24h_dedup_sends_nudge |
| D6 不重复索要 | test_reminder_* 三例 |
| D7 评估前置 | test_evaluation_skipped_below_3_user_messages |
| D8 卡单标记 | test_no_contact_never_emails_and_marks_stuck |
| D9/D10 旧路径拆除+UPDATE | Task 7 Step 7.4/7.5 全量回归绿 |
| 不丢失(T1/T2/T3) | test_extractor_empty_round_preserves_contact / 归一化用例 / test_regex_catches_extractor_miss |
| D12 转义 | email_service 全字段 _esc + 人工冒烟 |
5. 残余风险台账(明示接受)
| 风险 | 缓解 | 残余 |
|---|---|---|
| 正则误报 → 错发一封 + 提醒压制再索要 | 高置信模式/校验分级/上线监控无效率 | 低概率,方向安全 |
| 号码变体击穿 24h 去重 | canon 纯数字比对 | best-effort |
| extractor 连续漏提取"原值+更正值"时,扫描器首个历史值滞留 | 邻接绑定+extractor优先合并压低概率 | 双重漏提取,极低 |
| 给号即沉默 + SMTP 首发失败 | 下条消息自然重试(email_sent 未置位) | 双重失败丢线索(极低) |
| 发送成功后落库失败(毫秒级窗口)→ 重试时重复发送 | at-least-once 为有意选择:漏一条线索比重复一封邮件贵 | 接受 |
App 端 ended 恒为 false(决策A) |
App 若有"对话结束"闭环 UI 需知晓并调整;widget/独立页零影响 | 已知会用户 |
| 第 1 轮 extractor 幻觉出从未提及的号码 → 首封全量邮件 phone/whatsapp 混排 | 需"幻觉+首轮无历史组"双条件叠加,概率极低;whatsapp 侧仍真实可联 | 观察期,若实测出现再做 12e(无消息依据的 canon 降级) |
| 客户撤回已给联系方式 | — | 接受 |
| 邮件量上升(含无意向留号者) | 标签分流 + 基线上界预估 | 预期内 |
6. 后续批次(另行成 plan,本计划不含)
- 批次 2:widget 会话持久化(localStorage 独立前缀 + 48h 过期)+ WS 限流/消息长度(消息级拒绝防重连风暴;session ≥60/h、IP 放宽;REST 长度复用 429)。
- 批次 3:admin"缺联系方式线索"筛选(基于 D8 卡单记录)+ UI 留资卡片(提交即写管道;端点限流+校验+只弹一次)。
- 健康轨:删 Redis、SQLite 备份+WAL、GLM 超时重试、admin 分页 total 修复、国家映射、ADMIN_PASSWORD 修改(强烈建议)、删除已无调用方的
AIService.chat(Task 7 后仅剩 provider 内部引用)。(process_message死代码已并入本计划 Task 7 删除。)