diff --git a/app/services/cc_completion.py b/app/services/cc_completion.py
index ca3d3a7..f145721 100644
--- a/app/services/cc_completion.py
+++ b/app/services/cc_completion.py
@@ -301,3 +301,71 @@ def name_to_region(name: str | None) -> str | None:
if key in NAME_TO_REGION:
return NAME_TO_REGION[key]
return _NAME_LOOKUP.get(key.casefold())
+
+
+# ---- Task 6 出站渲染(spec §5):首封补全号的展示派生(纯函数,邮件/推送共用) ----
+
+# 标注文案(spec §5 钉死):有 e164 未确认 → 未经确认;无 e164 → 缺区号。
+NOTE_UNCONFIRMED = "(区号按 IP 推测,未经客户确认)"
+NOTE_MISSING_CC = "(缺国家区号)"
+
+
+def cc_state_for(cc_map: dict | None, channel: str, value: str) -> dict | None:
+ """渠道 → _cc 状态解析(canon 组归并感知,出站渲染入口)。
+
+ 优先本渠道状态;本渠道无态但 canon 与 whatsapp 组一致时共享组状态
+ (同号 phone/whatsapp 确认一次,渲染也同标——否则同封邮件一行 e164
+ 一行裸号自相矛盾)。canon 不匹配(换号后残留/防御性垃圾)一律 None,
+ 调用方回退裸号原文——旧补全永不跟随新号。
+ """
+ digits = _digits(value)
+ if not digits:
+ return None
+ for state in ((cc_map or {}).get(channel), (cc_map or {}).get("whatsapp")):
+ if isinstance(state, dict) and str(state.get("canon") or "") == digits:
+ return state
+ return None
+
+
+def outbound_number(value: str, state: dict | None) -> str:
+ """邮件正文联系方式行的展示值(spec §5 首封矩阵):
+
+ - 有 e164 且 confirmed → 纯 e164(prefix_normalized/ip_confirm/
+ customer_stated,码是客户自己写的/确认过的,零标注);
+ - 有 e164 未 confirmed → e164 +(区号按 IP 推测,未经客户确认)
+ (ip_proposed/naive/fallback 统一口径,spec §4 标注语义);
+ - 无 e164(no_ip / 迁移分支可产出的 fallback-无-e164)→ 裸号原文 +
+ (缺国家区号)——拿不出补全号不硬造;
+ - 无状态(显式 +/00 号码等)→ 原样。
+ """
+ raw = str(value or "")
+ e164 = str((state or {}).get("e164") or "")
+ if not e164:
+ return raw + NOTE_MISSING_CC if state is not None else raw
+ if state.get("confirmed"):
+ return e164
+ return e164 + NOTE_UNCONFIRMED
+
+
+def outbound_e164(value: str, state: dict | None) -> str:
+ """主题行/推送 contact 用:有 e164 给纯 e164(无标注——主题拥挤、
+ 对方契约要"带国际区号且有效"的干净号),其余原样。"""
+ e164 = str((state or {}).get("e164") or "")
+ return e164 or str(value or "")
+
+
+def completion_note(value: str, state: dict | None) -> str | None:
+ """推送画像派生键「号码补全说明」的单渠道条目;无需披露返回 None。
+
+ confirmed 的补全零披露;未确认 → "原文→补全号+标注";缺区号 →
+ "原文+(缺国家区号)"。出站时由 _cc 计算,不落正常键(零 LLM 泄漏面)。
+ """
+ raw = str(value or "")
+ if state is None:
+ return None
+ e164 = str(state.get("e164") or "")
+ if not e164:
+ return raw + NOTE_MISSING_CC
+ if state.get("confirmed"):
+ return None
+ return f"{raw}→{e164}{NOTE_UNCONFIRMED}"
diff --git a/app/services/conversation.py b/app/services/conversation.py
index 874d6ee..1d62795 100644
--- a/app/services/conversation.py
+++ b/app/services/conversation.py
@@ -282,6 +282,49 @@ def _cc_pre_turn(raw_info: dict, confirmed: dict, ip_iso: str | None) -> bool:
return raw_info.get("_cc") != before
+# Task 6(spec §5 面板同步): 等待确认期间的联系方式值尾注——防"邮件带补全、
+# 后台显裸号"的分裂。尾注全为非数字字符,canon() 纯数字提取不受影响
+# (24h 去重/更正判定照常)。
+_CC_PENDING_TAIL = "(待确认补全号)"
+
+
+def _panel_contact_info(confirmed: dict, cc_map: dict | None) -> dict:
+ """ev.contact_info 存储视图(Task 6 评审钉死): 裸号原文 + 等待态尾注,
+ e164 走派生不落本体——e164 替换本体会让"存储值 vs 每轮裸号确认值"
+ 永远不同 canon,退化成每轮误判换号→更正连发/回访退化。
+ 只标 whatsapp 确认流覆盖的号码(canon 与 _cc.whatsapp 一致,含同号
+ phone 镜像): phone 独立静默态不进确认流、attempts 永不推进,cc_due
+ 恒真,若照标会永久滞留且语义失真。"""
+ wa = (cc_map or {}).get("whatsapp")
+ wa_canon = str((wa or {}).get("canon") or "")
+ pending = bool(wa_canon) and cc_completion.cc_due(wa)
+ out: dict = {}
+ for k, v in (confirmed or {}).items():
+ if (pending and k in ("phone", "whatsapp")
+ and digits_only(str(v)) == wa_canon):
+ out[k] = f"{v}{_CC_PENDING_TAIL}"
+ else:
+ out[k] = v
+ return out
+
+
+def _channel_canons(confirmed: dict, cc_map: dict | None) -> dict[str, set[str]]:
+ """canon 口径双保险(Task 6 评审钉死): 每渠道候选集 = 裸号 canon ∪
+ _cc.e164 的 canon——同号换形态(裸号↔+前缀完整形态)不误判换号
+ (更正连发)、回访不退化成全量询盘。只扩"新值侧": 若把当前 _cc 的
+ e164 canon 并入旧值/历史集,换号后新号恰为前缀完整形态时会自我满足,
+ 真更正被吞。"""
+ out: dict[str, set[str]] = {}
+ for k, v in (confirmed or {}).items():
+ cands = {canon(str(v))}
+ state = cc_completion.cc_state_for(cc_map, k, str(v))
+ e164 = str((state or {}).get("e164") or "")
+ if e164:
+ cands.add(canon(e164))
+ out[k] = cands
+ return out
+
+
# 方案B(13.6): 迂回话术轮换序号——连续破防时不至于复读同一句
_boundary_seq = 0
@@ -720,6 +763,7 @@ class ConversationManager:
# 评估落库(D8 泛化): 每轮评估后刷新意向;email_sent/correction_count
# 永不触碰。有意向无联系方式 = 卡单(admin 可筛);已发过邮件的
# contact_info 由触发器更正分支管理,这里不覆盖。
+ # Task 6: 存储视图走面板尾注(等待态标注,e164 派生不落本体)
if user_msg_count >= 3:
ev = await self._get_or_init_evaluation(conv.id)
ev.intent_level = IntentLevel(evaluation.get("intent_level", "pending"))
@@ -727,7 +771,8 @@ class ConversationManager:
if not confirmed_now:
ev.contact_info = {}
elif not ev.email_sent:
- ev.contact_info = confirmed_now
+ ev.contact_info = _panel_contact_info(
+ confirmed_now, (profile.raw_info or {}).get("_cc"))
self.db.add(ev)
# 决策A(2026-08-18): 自动结束移除——会话永不因评估结果结束;
@@ -761,18 +806,29 @@ class ConversationManager:
async def _recently_emailed_canons(self) -> set[str]:
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
- # 只取需要的列;🔴 禁止加 LIMIT——完整 24h 窗口才正确
+ # 只取需要的列;🔴 禁止加 LIMIT——完整 24h 窗口才正确。
+ # Task 6 双保险: join 画像取历史会话的 _cc——各渠道 e164 的 canon
+ # 一并入集(快照存裸号原文+尾注,客户以 + 前缀完整形态回访同号时
+ # 仍要命中,否则回访退化成全量询盘)。
result = await self.db.execute(
- select(Evaluation.contact_info).where(
+ select(Evaluation.contact_info, CustomerProfile.raw_info)
+ .outerjoin(
+ CustomerProfile,
+ CustomerProfile.conversation_id == Evaluation.conversation_id)
+ .where(
Evaluation.email_sent.is_(True),
Evaluation.evaluated_at >= cutoff,
)
)
seen: set[str] = set()
- for (contact_info,) in result:
+ for contact_info, raw_info in result:
for v in (contact_info or {}).values():
if v:
seen.add(canon(str(v)))
+ for state in ((raw_info or {}).get("_cc") or {}).values():
+ e164 = str((state or {}).get("e164") or "")
+ if e164:
+ seen.add(canon(e164))
return seen
async def _cc_round_end(
@@ -840,16 +896,22 @@ class ConversationManager:
confirmed, _suspect = split_validate(contacts_all)
if not confirmed:
return
+ # Task 6(spec §5): 出站渲染取 _cc(首封补全号/标注/主题行/推送派生);
+ # 轮末钩子已保证 _cc 与最终联系方式同 canon 同步
+ cc_map = (profile.raw_info or {}).get("_cc") or {}
ev = await self._get_or_init_evaluation(conv.id)
if not ev.email_sent:
- # S3: 任一值已见于24h内其他会话 → 轻提醒;否则全量
+ # S3: 任一值已见于24h内其他会话 → 轻提醒;否则全量。
+ # Task 6 双保险: 比对候选集含 _cc.e164 的 canon(见 _channel_canons)
seen = await self._recently_emailed_canons()
- if any(canon(str(v)) in seen for v in confirmed.values()):
+ if any(cands & seen
+ for cands in _channel_canons(confirmed, cc_map).values()):
await self._collect_push(
pending_pushes, ev, conv, profile, confirmed,
"returning", evaluation, evaluated)
- sent = await email_service.send_returning_lead_email(confirmed)
+ sent = await email_service.send_returning_lead_email(
+ confirmed, cc_info=cc_map)
else:
await self._collect_push(
pending_pushes, ev, conv, profile, confirmed,
@@ -861,27 +923,32 @@ class ConversationManager:
reasoning=evaluation.get("reasoning", ""),
conversation_messages=messages,
intent_level=evaluation.get("intent_level", "") if evaluated else "",
+ cc_info=cc_map,
)
if sent:
ev.email_sent = True
- ev.contact_info = confirmed
+ ev.contact_info = _panel_contact_info(confirmed, cc_map)
self.db.add(ev)
else:
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()):
+ # Task 6 双保险: 新值候选集含 _cc.e164 的 canon——确认落定
+ # (号码未变)不补发更正;真换号(两组候选都不在旧集)才走更正
+ if any(not (cands & old_canons)
+ for cands in _channel_canons(confirmed, cc_map).values()):
if ev.correction_count < 3:
count = ev.correction_count + 1
await self._collect_push(
pending_pushes, ev, conv, profile, confirmed,
"correction", evaluation, evaluated)
- if await email_service.send_correction_email(confirmed, count=count):
+ if await email_service.send_correction_email(
+ confirmed, count=count, cc_info=cc_map):
ev.correction_count = count
- ev.contact_info = confirmed
+ ev.contact_info = _panel_contact_info(confirmed, cc_map)
self.db.add(ev)
else:
# D4'(2026-08-18): 锁邮件不锁快照 —— 上限已到,
# admin 仍要看到客户最新号码
- ev.contact_info = confirmed
+ ev.contact_info = _panel_contact_info(confirmed, cc_map)
self.db.add(ev)
async def _collect_push(
diff --git a/app/services/email_service.py b/app/services/email_service.py
index 2c86df4..98bcbd8 100644
--- a/app/services/email_service.py
+++ b/app/services/email_service.py
@@ -6,6 +6,7 @@ from email.mime.text import MIMEText
import aiosmtplib
from app.config import settings
+from app.services.cc_completion import cc_state_for, outbound_e164, outbound_number
logger = logging.getLogger(__name__)
@@ -33,6 +34,29 @@ def _contact_rows(contacts: dict) -> str:
)
+def _cc_views(contacts: dict, cc_info: dict | None) -> tuple[dict, dict]:
+ """Task 6(spec §5)出站双视图:_cc 感知的正文/主题两套联系方式值。
+
+ 正文视图(body):phone/whatsapp 有态 → e164 + 按 confirmed 标注;
+ 无 e164 → 裸号 +(缺国家区号);canon 不匹配的残留态在 cc_state_for
+ 内判 None 回退原文。主题视图(subj):同状态一律纯 e164 无标注
+ (主题拥挤)。其余渠道(email/wechat)原样。
+ """
+ body: dict = {}
+ subj: dict = {}
+ for k, v in (contacts or {}).items():
+ if not v:
+ continue
+ if k in ("phone", "whatsapp"):
+ state = cc_state_for(cc_info, k, str(v))
+ body[k] = outbound_number(str(v), state)
+ subj[k] = outbound_e164(str(v), state)
+ else:
+ body[k] = str(v)
+ subj[k] = str(v)
+ return body, subj
+
+
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")
@@ -56,16 +80,19 @@ async def send_lead_email(
reasoning: str,
conversation_messages: list[dict],
intent_level: str = "",
+ cc_info: dict | None = None,
) -> bool:
+ """cc_info = raw_info["_cc"](Task 6, spec §5):正文联系方式行渲染
+ 补全号+标注、主题行号码用 e164 无标注;缺省(无 _cc/显式 + 号)行为不变。"""
tag = TAG_LABELS.get(intent_level, "未评估")
- contacts = dict(contact_info or {})
+ contacts, subj_contacts = _cc_views(contact_info, cc_info)
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)}: {_flat(v)}" for k, v in contacts.items() if v
+ f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in subj_contacts.items()
]
subject = f"[新询盘·{tag}] {_flat(country)}"
if contact_parts:
@@ -101,10 +128,13 @@ async def send_lead_email(
return await _send(msg)
-async def send_returning_lead_email(contact_info: dict) -> bool:
- """D5: 24h 内同联系方式再访,只发一行轻提醒。"""
+async def send_returning_lead_email(
+ contact_info: dict, cc_info: dict | None = None,
+) -> bool:
+ """D5: 24h 内同联系方式再访,只发一行轻提醒。主题行号码走 e164(Task 6)。"""
+ _body, subj_contacts = _cc_views(contact_info, cc_info)
contact_parts = [
- f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in (contact_info or {}).items() if v
+ f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in subj_contacts.items()
]
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[线索回访] {' | '.join(contact_parts) or '已知客户'}再次来访"
@@ -116,14 +146,18 @@ async def send_returning_lead_email(contact_info: dict) -> bool:
return await _send(msg)
-async def send_correction_email(contact_info: dict, count: int = 1) -> bool:
- """D4: 联系方式变化(改号/新增/无提示换值)后的轻量更正邮件,每会话上限3封。"""
+async def send_correction_email(
+ contact_info: dict, count: int = 1, cc_info: dict | None = None,
+) -> bool:
+ """D4: 联系方式变化(改号/新增/无提示换值)后的轻量更正邮件,每会话上限3封。
+ 正文联系方式行渲染补全号+标注(Task 6, spec §5 更正路径)。"""
+ body_contacts, _subj = _cc_views(contact_info, cc_info)
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[询盘·更正{count}] 客户联系方式已更新(以本封为准)"
msg["From"] = f"【HESUPIPE】 <{settings.SMTP_USER}>"
msg["To"] = settings.NOTIFY_EMAIL
msg.attach(MIMEText(
f"
这是本会话第 {count} 次更正(上限 3 次),请以本封为准:
"
- f"{_contact_rows(contact_info)}
"
+ f"{_contact_rows(body_contacts)}
"
f"", "html", "utf-8"))
return await _send(msg)
diff --git a/app/services/lead_push_service.py b/app/services/lead_push_service.py
index cbaf450..1a3c4a7 100644
--- a/app/services/lead_push_service.py
+++ b/app/services/lead_push_service.py
@@ -23,6 +23,7 @@ from app.models.database import (
Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
Message, MessageRole,
)
+from app.services.cc_completion import cc_state_for, completion_note, outbound_e164
from app.services.ip_service import resolve_country
logger = logging.getLogger(__name__)
@@ -86,6 +87,23 @@ async def _build_payload(
raw_info = profile.raw_info or {}
client_info = conv.client_info or {}
+ # Task 6(spec §5 先消费后过滤,顺序钉死): 先读 raw_info 的 _cc 派生——
+ # contact 的 phone/whatsapp 换成对应渠道 e164(首封即补全号,确认与否都给;
+ # 无 e164 态用原文),标注进"号码补全说明"派生键;之后才做 _ 前缀过滤,
+ # _cc 本体绝不进 payload,派生键(无 _ 前缀)存活。canon 不匹配的残留态
+ # 在 cc_state_for 内判 None——旧补全永不跟随新号。
+ cc_map = raw_info.get("_cc") or {}
+ contact_out: dict = {}
+ cc_notes: list[str] = []
+ for k, v in (confirmed or {}).items():
+ if k in ("phone", "whatsapp") and v:
+ state = cc_state_for(cc_map, k, str(v))
+ contact_out[k] = outbound_e164(str(v), state)
+ note = completion_note(str(v), state)
+ if note:
+ cc_notes.append(note)
+ else:
+ contact_out[k] = v
# _ 前缀内部状态(一次性计数等)不进推送画像(spec §4.3——渲染侧
# 三处有过滤(enrich/reminder/email_service),推送端曾整体拷贝泄漏)
profile_out = {k: v for k, v in raw_info.items()
@@ -94,6 +112,8 @@ async def _build_payload(
profile_out["source"] = conv.source.value
if client_info.get("lang"):
profile_out["语言"] = client_info["lang"]
+ if cc_notes:
+ profile_out["号码补全说明"] = ";".join(cc_notes)
reasoning = (evaluation.get("reasoning") or "") if evaluated else ""
@@ -103,10 +123,10 @@ async def _build_payload(
"occurred_at": _fmt_cst(datetime.now(timezone.utc)),
"contact": {
"name": None, # 我方 contact_info 快照无称呼字段,契约可空
- "phone": confirmed.get("phone"),
- "whatsapp": confirmed.get("whatsapp"),
- "email": confirmed.get("email"),
- "wechat": confirmed.get("wechat"),
+ "phone": contact_out.get("phone"),
+ "whatsapp": contact_out.get("whatsapp"),
+ "email": contact_out.get("email"),
+ "wechat": contact_out.get("wechat"),
"skype": None,
},
"intent_level": map_intent(evaluation.get("intent_level"), evaluated),
diff --git a/tests/test_email_rendering.py b/tests/test_email_rendering.py
index e14f818..88eb58f 100644
--- a/tests/test_email_rendering.py
+++ b/tests/test_email_rendering.py
@@ -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
diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py
index 5f297c6..e003f4c 100644
--- a/tests/test_lead_email_trigger.py
+++ b/tests/test_lead_email_trigger.py
@@ -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())
diff --git a/tests/test_lead_push_service.py b/tests/test_lead_push_service.py
index 33bc142..9c17a45 100644
--- a/tests/test_lead_push_service.py
+++ b/tests/test_lead_push_service.py
@@ -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"]
diff --git a/tests/test_lead_push_wiring.py b/tests/test_lead_push_wiring.py
index 81b5d08..40dac99 100644
--- a/tests/test_lead_push_wiring.py
+++ b/tests/test_lead_push_wiring.py
@@ -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