feat: 出站补全渲染——首封e164+标注/主题行/推送派生/canon双保险/面板尾注
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user