feat: 出站补全渲染——首封e164+标注/主题行/推送派生/canon双保险/面板尾注

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-08 19:51:46 +08:00
parent 9d16235454
commit 7df2e4ae1e
8 changed files with 524 additions and 37 deletions
+140
View File
@@ -64,3 +64,143 @@ def test_returning_and_correction_emails(monkeypatch):
email_service.send_correction_email({"email": "a@b.com"})
) is True
assert "[询盘·更正1]" in str(captured["msg"]["Subject"])
# ---- Task 6(spec §5 首封补全号矩阵): (confirmed, source, 有无 e164) × 显示值+标注 ----
# 等待/未确认态: 裸号 + SA IP → 高置信建议(attempts=1 = 追问轮轮末已耗一轮)
_CC_IP_PROPOSED = {"e164": "+966501234567", "intl": "+966 50 123 4567",
"confirmed": False, "attempts": 1, "source": "ip_proposed",
"canon": "0501234567"}
# 裸写 IP 国码前缀,静默规范化(confirmed=true,码是客户自己写的)
_CC_PREFIX_NORM = {"e164": "+966501234567", "intl": "+966 50 123 4567",
"confirmed": True, "attempts": 0, "source": "prefix_normalized",
"canon": "966501234567"}
# 客户报国名重算后确认(埃及 = +20)
_CC_CUSTOMER_STATED = {"e164": "+20501234567", "intl": "+20 50 123 4567",
"confirmed": True, "attempts": 1, "source": "customer_stated",
"canon": "0501234567"}
# attempts 耗尽落定(标注保留"未经客户确认",spec §4)
_CC_FALLBACK = {"e164": "+966501234567", "intl": "+966 50 123 4567",
"confirmed": False, "attempts": 2, "source": "fallback",
"canon": "0501234567"}
# 无建议国(IP 无效): 无 e164
_CC_NO_IP = {"confirmed": False, "attempts": 1, "source": "no_ip",
"canon": "0501234567"}
# Task 3 调和防御: fallback-无-e164(迁移分支可产出,有机状态机不产生)
_CC_FALLBACK_NO_E164 = {"confirmed": False, "attempts": 0, "source": "fallback",
"canon": "0501234567"}
def test_lead_email_cc_unconfirmed_e164_with_note(monkeypatch):
"""首封渲染(未确认态): 正文 e164 +(区号按 IP 推测,未经客户确认);
主题行 e164 无标注(主题拥挤);裸号原文被补全号替换。"""
captured = _capture_send(monkeypatch)
assert asyncio.run(email_service.send_lead_email(
customer_profile={"目标国家": "沙特阿拉伯"},
contact_info={"whatsapp": "0501234567"},
reasoning="", conversation_messages=[],
intent_level="interested",
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)},
)) is True
subject = str(captured["msg"]["Subject"])
body = _html(captured["msg"])
assert "+966501234567(区号按 IP 推测,未经客户确认)" in body
assert "+966501234567" in subject # 主题行号码用 e164
assert "区号按 IP 推测" not in subject # 主题不加标注
assert "(缺国家区号)" not in body
assert "0501234567" not in body # 裸号原文不再出现
def test_lead_email_cc_confirmed_sources_no_note(monkeypatch):
"""confirmed=true(prefix_normalized/customer_stated): e164 无标注——
码是客户自己写的/客户确认过的,零披露。"""
for state, raw in ((dict(_CC_PREFIX_NORM), "966501234567"),
(dict(_CC_CUSTOMER_STATED), "0501234567")):
captured = _capture_send(monkeypatch)
asyncio.run(email_service.send_lead_email(
customer_profile={}, contact_info={"whatsapp": raw},
reasoning="", conversation_messages=[],
cc_info={"whatsapp": state},
))
body = _html(captured["msg"])
assert state["e164"] in body
assert "未经客户确认" not in body
assert "(缺国家区号)" not in body
def test_lead_email_cc_fallback_keeps_note(monkeypatch):
"""fallback 落定(confirmed=false 有 e164): e164 + 未经确认标注。"""
captured = _capture_send(monkeypatch)
asyncio.run(email_service.send_lead_email(
customer_profile={}, contact_info={"whatsapp": "0501234567"},
reasoning="", conversation_messages=[],
cc_info={"whatsapp": dict(_CC_FALLBACK)},
))
body = _html(captured["msg"])
assert "+966501234567(区号按 IP 推测,未经客户确认)" in body
def test_lead_email_cc_no_e164_states_raw_with_missing_note(monkeypatch):
"""无 e164 态: no_ip 与 fallback-无-e164(Task 3 调和防御)→ 裸号原文 +
(缺国家区号),不崩;主题行同样裸号。"""
for state in (dict(_CC_NO_IP), dict(_CC_FALLBACK_NO_E164)):
captured = _capture_send(monkeypatch)
asyncio.run(email_service.send_lead_email(
customer_profile={}, contact_info={"whatsapp": "0501234567"},
reasoning="", conversation_messages=[],
cc_info={"whatsapp": state},
))
body = _html(captured["msg"])
assert "0501234567(缺国家区号)" in body
assert "未经客户确认" not in body
assert "+966" not in body
assert "0501234567" in str(captured["msg"]["Subject"])
def test_lead_email_merged_phone_shares_whatsapp_state(monkeypatch):
"""canon 组归并渲染: phone 与 whatsapp 同号(phone 无独立态)共享 whatsapp
组状态——两行都出补全号,不出现一行 e164 一行裸号的自相矛盾。"""
captured = _capture_send(monkeypatch)
asyncio.run(email_service.send_lead_email(
customer_profile={}, contact_info={"whatsapp": "0501234567",
"phone": "0501234567"},
reasoning="", conversation_messages=[],
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)},
))
body = _html(captured["msg"])
assert body.count("+966501234567(区号按 IP 推测,未经客户确认)") == 2
def test_lead_email_stale_cc_state_ignored(monkeypatch):
"""canon 不匹配的残留态(换号后旧 _cc)不渲染: 裸号原文、无任何标注——
旧补全永不跟随新号。"""
captured = _capture_send(monkeypatch)
asyncio.run(email_service.send_lead_email(
customer_profile={}, contact_info={"whatsapp": "0512345678"},
reasoning="", conversation_messages=[],
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)}, # canon=0501234567 ≠ 新号
))
body = _html(captured["msg"])
assert "0512345678" in body
assert "+966501234567" not in body
assert "(缺国家区号)" not in body
def test_correction_and_returning_render_cc(monkeypatch):
"""更正邮件正文/回访邮件主题行同样 _cc 感知: 更正正文带标注 e164;
回访主题纯 e164 无标注。"""
captured = _capture_send(monkeypatch)
assert asyncio.run(email_service.send_correction_email(
{"whatsapp": "0501234567"}, count=2,
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)},
)) is True
assert "[询盘·更正2]" in str(captured["msg"]["Subject"])
assert "+966501234567(区号按 IP 推测,未经客户确认)" in _html(captured["msg"])
assert asyncio.run(email_service.send_returning_lead_email(
{"whatsapp": "0501234567"},
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)},
)) is True
subject = str(captured["msg"]["Subject"])
assert "+966501234567" in subject
assert "区号按 IP 推测" not in subject
+64 -5
View File
@@ -57,15 +57,15 @@ def _setup(async_db, monkeypatch, source=ConversationSource.widget, ai=None):
sent = []
async def fake_lead(customer_profile, contact_info, reasoning,
conversation_messages, intent_level=""):
conversation_messages, intent_level="", cc_info=None):
sent.append(("lead", dict(contact_info), intent_level))
return True
async def fake_nudge(contact_info):
async def fake_nudge(contact_info, cc_info=None):
sent.append(("nudge", dict(contact_info), ""))
return True
async def fake_fix(contact_info, count=1):
async def fake_fix(contact_info, count=1, cc_info=None):
sent.append(("fix", dict(contact_info), ""))
return True
@@ -276,7 +276,9 @@ def test_oscillation_does_not_burn_correction_quota(async_db, monkeypatch):
select(Evaluation).where(Evaluation.conversation_id == conv.id))).scalar_one()
ev = asyncio.run(check())
assert ev.correction_count == 2
assert ev.contact_info == {"phone": "18325698745", "whatsapp": "18325698745"}
# Task 6: 末轮裸号轮末补建 _cc(no_ip,确认流未走完) → 快照带等待态尾注
assert ev.contact_info == {"phone": "18325698745(待确认补全号)",
"whatsapp": "18325698745(待确认补全号)"}
asyncio.run(manager.db.close())
@@ -304,7 +306,9 @@ def test_standalone_number_overrides_uncooperative_extractor(async_db, monkeypat
assert kinds == ["lead", "fix", "fix"], f"实际: {kinds}"
assert sent[2][1]["phone"] == "15236541128" # 更正2 邮件内容就是裸号
assert ev.correction_count == 2
assert ev.contact_info == {"phone": "15236541128", "whatsapp": "15236541128"}
# Task 6: 末轮裸号轮末补建 _cc(确认流未走完) → 快照带等待态尾注
assert ev.contact_info == {"phone": "15236541128(待确认补全号)",
"whatsapp": "15236541128(待确认补全号)"}
asyncio.run(manager.db.close())
@@ -1714,3 +1718,58 @@ def test_violated_closing_separated_by_space(async_db, monkeypatch):
tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1]
assert tail == streamed # 落库=流出
asyncio.run(manager.db.close())
# ---- Task 6: 出站渲染集成(面板尾注/确认不补发/换号走更正/跨会话 e164 去重) ----
def test_cc_first_email_panel_tail_and_no_resend_on_confirm(async_db, monkeypatch):
"""spec §5 邮件时序集成: 首封快照 ev.contact_info 存裸号原文+等待态尾注
(待确认补全号)(面板同步,e164 走派生不落本体——防每轮误判换号);
客户确认(号码未变)不补发更正;换号(canon 变化)才走现有更正通道。"""
_sa(monkeypatch)
ai = _RecAI(extract=[{}, {"cc_reply": {"affirm": True, "country": None}},
{}, {}])
(manager, conv), sent = _setup(async_db, monkeypatch, ai=ai,
source=ConversationSource.standalone)
async def get_ev():
return (await manager.db.execute(
select(Evaluation).where(Evaluation.conversation_id == conv.id))
).scalar_one()
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
assert [s[0] for s in sent] == ["lead"]
ev = asyncio.run(get_ev())
# 面板尾注: 首封时确认流未落定(attempts=1 < 2),快照值带等待态标注;
# 尾注全非数字,不影响后续 canon 比对
assert ev.contact_info["whatsapp"] == "0501234567(待确认补全号)"
asyncio.run(_send(manager, conv.session_id, "yes that's right"))
asyncio.run(_send(manager, conv.session_id, "and the fittings?"))
assert [s[0] for s in sent] == ["lead"] # 确认落定(号码未变)不补发
asyncio.run(_send(manager, conv.session_id, "my new whatsapp is 0512345678"))
assert [s[0] for s in sent] == ["lead", "fix"] # 换号 → 现有更正通道
ev = asyncio.run(get_ev())
assert ev.contact_info["whatsapp"] == "0512345678(待确认补全号)"
asyncio.run(manager.db.close())
def test_cross_session_dedup_merges_cc_e164_canon(async_db, monkeypatch):
"""Task 6 canon 双保险(跨会话 24h 去重): A 会话留裸号(首封带补全号发信),
B 会话同号带 + 重访 → 去重集并入 A 的 _cc.e164 canon → 回访轻提醒,
不退化为全量询盘。"""
_sa(monkeypatch)
ai1 = _RecAI(extract=[{}])
(m1, c1), sent1 = _setup(async_db, monkeypatch, ai=ai1,
source=ConversationSource.standalone)
asyncio.run(_send(m1, c1.session_id, "my whatsapp is 0501234567"))
assert [s[0] for s in sent1] == ["lead"]
asyncio.run(m1.db.close())
ai2 = _RecAI(extract=[{}])
(m2, c2), sent2 = _setup(async_db, monkeypatch, ai=ai2,
source=ConversationSource.standalone)
asyncio.run(_send(m2, c2.session_id, "my whatsapp is +966501234567"))
assert [s[0] for s in sent2] == ["nudge"] # e164 形态同号 → 仍判回访
asyncio.run(m2.db.close())
+101 -2
View File
@@ -68,14 +68,14 @@ def test_map_intent():
assert map_intent(IntentLevel.pending, True) == "medium" # DB 读回枚举成员形态
def _seed_push_ctx(async_db, client_info=None):
def _seed_push_ctx(async_db, client_info=None, sid="s-push"):
"""建 conv/profile/ev 三件套,返回 (conv, profile, ev)。"""
from app.models.database import ConversationSource
async def run():
async with async_db() as db:
conv = Conversation(
session_id="s-push", source=ConversationSource.widget,
session_id=sid, source=ConversationSource.widget,
client_info=client_info or {"ip": "8.8.8.8", "lang": "en"},
)
db.add(conv)
@@ -533,3 +533,102 @@ def test_sweep_stale_pending(async_db, monkeypatch):
statuses = sorted(l.status for l in logs)
assert statuses == ["failed", "pending", "success"]
asyncio.run(run())
# ---- Task 6(spec §5 首封即补全号): 推送 contact 纯 e164 + profile 派生标注 ----
_PUSH_CC_WA = {"e164": "+966501234567", "intl": "+966 50 123 4567",
"confirmed": False, "attempts": 1, "source": "ip_proposed",
"canon": "0501234567"}
_PUSH_CC_PHONE = {"e164": "+96613800138000", "intl": "+966 138 001 3800",
"confirmed": False, "attempts": 0, "source": "naive",
"canon": "13800138000"}
def _dispatch_with_cc(async_db, monkeypatch, raw_info, confirmed, sid="s-cc"):
"""三件套 + 指定 raw_info(_cc) → dispatch,返回 pending log。"""
from app.services import lead_push_service as svc
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
_seed_push_ctx(async_db, sid=sid)
async def run():
async with async_db() as db:
from app.models.database import Conversation, CustomerProfile, Evaluation
conv = (await db.execute(
select(Conversation).where(Conversation.session_id == sid))
).scalar_one()
profile = (await db.execute(
select(CustomerProfile).where(
CustomerProfile.conversation_id == conv.id))).scalar_one()
ev = (await db.execute(
select(Evaluation).where(
Evaluation.conversation_id == conv.id))).scalar_one()
profile.raw_info = raw_info
await db.commit()
log = await svc.dispatch_lead_push(
db, ev, conv, profile, confirmed, "lead",
{"intent_level": "interested"}, True)
await db.commit()
return log
return asyncio.run(run())
def test_push_contact_cc_e164_and_derived_note(async_db, monkeypatch):
"""contact.phone/whatsapp = 纯 e164(confirmed 与否都给——首封即补全号,
契约要求"带国际区号且有效");标注进 profile"号码补全说明"派生键
(出站时由 _cc 派生,不落正常键);_cc 本体不泄漏(先消费后过滤)。"""
raw_info = {"目标国家": "肯尼亚",
"_cc": {"whatsapp": dict(_PUSH_CC_WA), "phone": dict(_PUSH_CC_PHONE)}}
log = _dispatch_with_cc(
async_db, monkeypatch, raw_info,
{"whatsapp": "0501234567", "phone": "13800138000"})
assert log is not None and log.status == "pending"
p = log.request_payload
assert p["contact"]["whatsapp"] == "+966501234567" # 补全号直发
assert p["contact"]["phone"] == "+96613800138000"
note = p["profile"]["号码补全说明"]
assert "0501234567→+966501234567(区号按 IP 推测,未经客户确认)" in note
assert "13800138000→+96613800138000(区号按 IP 推测,未经客户确认)" in note
assert "_cc" not in p["profile"] # 本体不泄漏
assert not [k for k in p["profile"] if str(k).startswith("_")]
assert p["profile"]["目标国家"] == "肯尼亚" # 正常键不受影响
def test_push_confirmed_state_e164_without_note(async_db, monkeypatch):
"""confirmed 态(prefix_normalized): contact 给 e164,但无需披露——
"号码补全说明"键不出现。"""
state = {"e164": "+966501234567", "intl": "+966 50 123 4567",
"confirmed": True, "attempts": 0, "source": "prefix_normalized",
"canon": "966501234567"}
log = _dispatch_with_cc(
async_db, monkeypatch, {"_cc": {"whatsapp": state}},
{"whatsapp": "966501234567"})
p = log.request_payload
assert p["contact"]["whatsapp"] == "+966501234567"
assert "号码补全说明" not in p["profile"]
def test_push_no_e164_state_raw_contact_and_missing_note(async_db, monkeypatch):
"""无 e164 态(no_ip/fallback-无-e164): contact 给裸号原文(拿不出补全号
不硬造),"号码补全说明"披露(缺国家区号)。"""
for source in ("no_ip", "fallback"):
state = {"confirmed": False, "attempts": 1, "source": source,
"canon": "0501234567"}
log = _dispatch_with_cc(
async_db, monkeypatch, {"_cc": {"whatsapp": dict(state)}},
{"whatsapp": "0501234567"}, sid=f"s-cc-{source}")
p = log.request_payload
assert p["contact"]["whatsapp"] == "0501234567" # 裸号原文
assert p["profile"]["号码补全说明"] == "0501234567(缺国家区号)"
def test_push_stale_cc_canon_ignored(async_db, monkeypatch):
"""canon 不匹配的残留态(换号后旧 _cc): contact 用原文、零派生——
旧补全永不跟随新号。"""
log = _dispatch_with_cc(
async_db, monkeypatch, {"_cc": {"whatsapp": dict(_PUSH_CC_WA)}},
{"whatsapp": "0512345678"})
p = log.request_payload
assert p["contact"]["whatsapp"] == "0512345678"
assert "号码补全说明" not in p["profile"]
assert "_cc" not in p["profile"]
+6 -6
View File
@@ -32,15 +32,15 @@ def _setup(async_db, monkeypatch, ai, lead_succeeds=True):
sent = []
async def fake_lead(customer_profile, contact_info, reasoning,
conversation_messages, intent_level=""):
conversation_messages, intent_level="", cc_info=None):
sent.append(("lead", dict(contact_info)))
return lead_succeeds
async def fake_nudge(contact_info):
async def fake_nudge(contact_info, cc_info=None):
sent.append(("returning", dict(contact_info)))
return lead_succeeds
async def fake_fix(contact_info, count=1):
async def fake_fix(contact_info, count=1, cc_info=None):
sent.append(("correction", dict(contact_info)))
return lead_succeeds
@@ -158,7 +158,7 @@ def test_push_disabled_writes_nothing(async_db, monkeypatch):
sent = []
async def fake_lead(customer_profile, contact_info, reasoning,
conversation_messages, intent_level=""):
conversation_messages, intent_level="", cc_info=None):
sent.append("lead")
return True
@@ -192,7 +192,7 @@ def test_fire_end_to_end_single_loop(async_db, monkeypatch):
sent = []
async def fake_lead(customer_profile, contact_info, reasoning,
conversation_messages, intent_level=""):
conversation_messages, intent_level="", cc_info=None):
sent.append("lead")
return True
@@ -230,7 +230,7 @@ def test_fire_happens_only_after_commit(async_db, monkeypatch):
fired = []
async def fake_lead(customer_profile, contact_info, reasoning,
conversation_messages, intent_level=""):
conversation_messages, intent_level="", cc_info=None):
sent.append("lead")
return True