feat: 出站补全渲染——首封e164+标注/主题行/推送派生/canon双保险/面板尾注
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"<html><body><p>这是本会话第 {count} 次更正(上限 3 次),请以本封为准:</p>"
|
||||
f"<table>{_contact_rows(contact_info)}</table>"
|
||||
f"<table>{_contact_rows(body_contacts)}</table>"
|
||||
f"</body></html>", "html", "utf-8"))
|
||||
return await _send(msg)
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user