From 2228ebe08198f09638249e1c56a207b951fae83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 8 Oct 2026 12:04:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BD=E5=AE=B6=E4=BB=A3=E7=A0=81/?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E5=8F=8C=E8=A6=81=E7=8A=B6=E6=80=81=E6=8F=90?= =?UTF-8?q?=E9=86=92+=E4=B8=80=E6=AC=A1=E6=80=A7=E8=AE=A1=E6=95=B0(?= =?UTF-8?q?=E4=B8=B2=E8=A1=8C=E4=BC=98=E5=85=88=E7=BA=A7+=E9=98=B2?= =?UTF-8?q?=E8=87=AA=E9=94=81=E7=A6=81=E4=BB=A4=E6=94=B9=E5=86=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- app/services/conversation.py | 98 ++++++++++++++++++++++++++++++-- tests/test_lead_email_trigger.py | 96 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 6 deletions(-) diff --git a/app/services/conversation.py b/app/services/conversation.py index 4a5ab27..de6435f 100644 --- a/app/services/conversation.py +++ b/app/services/conversation.py @@ -95,6 +95,60 @@ def _lang_note_for(msg: str) -> str: return "" +# Task 3(spec §4.1-2/§4.4, 2026-10-08): 缺失渠道轻补逐字模板——与 system.md +# 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的 +# 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。 +_CC_NOTE_EN = ( + "⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含" + "国家代码,客户经理无法直接回拨。请逐字使用这句:\"Could you send the " + "full number with the country code, like +966 5x xxx xxxx? Just so our " + "manager can reach you directly.\" 仅此一次,客户不给就不再提。") +_CC_NOTE_ZH = ( + "⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含" + "国家代码,客户经理无法直接回拨。请逐字使用这句:\"麻烦重发一下带国家代码" + "的完整号码呗(比如 +966 5x xxx xxxx)?客户经理直接加你。\" " + "仅此一次,客户不给就不再提。") +_EMAIL_NOTE_EN = ( + "⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没" + "留邮箱。请逐字使用这句:\"Got it! And an email address would help too — " + "our manager will send the formal quotation PDF there.\" " + "仅此一次,客户不给就不再提。") +_EMAIL_NOTE_ZH = ( + "⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没" + "留邮箱。请逐字使用这句:\"收到!邮箱也留一个呗?客户经理把正式报价单 PDF " + "发你邮箱。\" 仅此一次,客户不给就不再提。") + + +def _contact_completion_note( + raw_info: dict | None, confirmed: dict | None, suspect: dict | None, + lang: str = "en", +) -> tuple[str, list[str]]: + """一次性缺失渠道轻补提醒(spec §4.2/§4.3/§4.3b)。纯函数,返回(注入文案,计数键)。 + + 串行优先级(§4.3b——两提醒同轮并发时一次只注入一个,顺序固定): + 1. cc 优先: confirmed 号码渠道(whatsapp/phone)均有值且均不以 +/00 开头、 + _cc_asked 未标记 → 国家代码追问(引导重发完整号,走改号覆盖机制, + 管道零改动) + 2. email 轻补: (任一号码已带前缀 或 _cc_asked 已消耗)且 confirmed 含号码 + 渠道、不含 email、_email_asked 未标记 → 邮箱轻补 + 3. 其余(双渠道齐/仅 email/两计数均耗尽/confirmed 无号码渠道) → ("", []) + suspect 仅为与调用处画像判定同签名,规则只看 confirmed(spec §4.2)。 + """ + info = raw_info or {} + numbers = [str(v).strip() for k, v in (confirmed or {}).items() + if k in ("whatsapp", "phone") and v] + if not numbers: + return "", [] + all_bare = all(not v.startswith(("+", "00")) for v in numbers) + if all_bare and not info.get("_cc_asked"): + return (_CC_NOTE_ZH if lang == "zh" else _CC_NOTE_EN), ["_cc_asked"] + if (not all_bare or info.get("_cc_asked")) \ + and "email" not in (confirmed or {}) \ + and not info.get("_email_asked"): + return (_EMAIL_NOTE_ZH if lang == "zh" else _EMAIL_NOTE_EN), ["_email_asked"] + return "", [] + + # 方案B(13.6): 迂回话术轮换序号——连续破防时不至于复读同一句 _boundary_seq = 0 @@ -197,6 +251,25 @@ class ConversationManager: if CONTACT_ASK_TRIGGER_RE.search(content or ""): logger.info("Contact-ask reminder fired (source=%s)", conv.source.value) + # Task 3(spec §4.3): 一次性计数写回——_build_context_reminder 是 + # staticmethod 收内存副本,计数键须由调用方在流式开始前显式落库; + # 轮末 extractor 合并起点是 dict(profile.raw_info or {}),提前提交可存活。 + # 与 reminder 内部同参调用(上方纯函数),保证写回与注入永不漂移。 + _contacts_now = normalize_contacts(effective_raw.get("联系方式")) + _confirmed_now, _suspect_now = split_validate(_contacts_now) + _, completion_marks = _contact_completion_note( + effective_raw, _confirmed_now, _suspect_now, + "zh" if _CJK_RE.search(content or "") else "en") + if completion_marks: + raw = dict(profile.raw_info or {}) + for key in completion_marks: + raw[key] = True + profile.raw_info = raw + self.db.add(profile) + await self.db.commit() + logger.info("Contact completion note injected (conv=%s): %s", + conv.id, completion_marks) + # Enrich short user messages with context so AI doesn't lose track history_for_ai = list(history) enriched = self._enrich_short_message(content, profile.raw_info or {}, history) @@ -209,10 +282,9 @@ class ConversationManager: # F6(D6联动): 客户已留联系方式时,收尾话术不得再索要——路由 contact 版 # 渠道精确化(2026-10-08): confirmed 渠道集合传给再索要墙—— # 索要缺失渠道(如已有 phone 问邮箱)放行,是 Task 3 邮箱轻补的前提 - _confirmed_contacts, _ = split_validate( - normalize_contacts(effective_raw.get("联系方式"))) - has_contact = bool(_confirmed_contacts) - _confirmed_channels = set(_confirmed_contacts) + # (confirmed/suspect 已在 Task 3 写回处算好,此处复用) + has_contact = bool(_confirmed_now) + _confirmed_channels = set(_confirmed_now) yield {"type": "stream_start"} emitted = "" @@ -648,13 +720,18 @@ class ConversationManager: f"{k}: {' '.join(str(v).split())[:60]}" for k, v in confirmed.items()) # 逐字话术直接引用 NO_CONTACT_ALREADY 常量(Task 5 已 import)—— # 与零号码墙替换句同源,三处"经理主动联系"话术不漂移 - parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要任何联系方式!" + # Task 3 防自锁改写(spec 4.1-6): 原"绝对禁止再索要任何联系方式!" + # 会抑制缺失渠道轻补——只禁已提供过的渠道,缺失渠道放行一次 + parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要客户已提供过" + f"的联系方式;缺失渠道仅可按轻补指引提一次。" f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。" f"确认收到后继续解答客户问题即可,不必急着结束对话。") if boundary_note: parts.append(boundary_note) + # Task 3 防自锁改写: 尾部补缺失渠道豁免(与上方改写同因) parts.append("客户已留过联系方式——边界请求直接告知'客户经理会把准确报价/" - "安排发到你 WhatsApp'即可,禁止再索要联系方式。") + "安排发到你 WhatsApp'即可,禁止再索要联系方式" + "(缺失渠道可按轻补指引提一次)。") elif suspect: shown = ", ".join( f"{k}: {' '.join(str(v).split())[:60]}" for k, v in suspect.items()) @@ -666,6 +743,15 @@ class ConversationManager: if boundary_note and not confirmed and not suspect: parts.append(boundary_note) # 未留资:边界提醒引导留资(职能边界话术自带) + # Task 3(spec §4.2/§4.3b): 缺失渠道一次性轻补——cc 优先、邮箱次之,串行 + # 注入。计数键(_marks)此处弃用: 本函数是 staticmethod 收内存副本,写回由 + # stream 调用方以同参调用纯函数取回并落库(纯函数保证两处判定不漂移)。 + completion_note, _marks = _contact_completion_note( + raw_info, confirmed, suspect, + lang="zh" if _CJK_RE.search(latest_msg or "") else "en") + if completion_note: + parts.append(completion_note) + # 15.2: 索要我方联系方式提醒注入(镜像 boundary_note 的挂载位)。 # 互留提示仅限 web 来源且客户未留资(confirmed/suspect 都算"已留"); # App 的反向留资话术本身就是主话术,不再叠加 diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py index dfc6480..5c62413 100644 --- a/tests/test_lead_email_trigger.py +++ b/tests/test_lead_email_trigger.py @@ -473,6 +473,8 @@ def test_reminder_boundary_branches_on_contact_state(): raw = {"联系方式": {"whatsapp": "+254712345678"}} text = ConversationManager._build_context_reminder(raw, "多少钱", []) assert "职能边界" in text and "禁止再索要联系方式" in text + # Task 3 防自锁改写: 本分支尾部补缺失渠道豁免(子串由尾补保留,非 :651 改写行) + assert "缺失渠道可按轻补指引提一次" in text # 未留联系方式: 引导留资 text2 = ConversationManager._build_context_reminder({}, "多少钱", []) assert "职能边界" in text2 and "禁止再索要联系方式" not in text2 @@ -904,3 +906,97 @@ def test_e2e_no_reasking_after_contact_provided(async_db, monkeypatch): # AI 回复里没有索要话术(StrictAI 不索要;此断言守住提醒确实在场, # 索要与否由提醒+墙两层守,墙的确定性已由前序任务覆盖) asyncio.run(manager.db.close()) + + +# ---- Task 3: 缺失渠道一次性轻补提醒(spec §4.2/§4.3/§4.3b) ---- +def test_contact_completion_note_serial_priority(): + """spec §4.3b: 裸号+无邮箱(主场景)只注入国家代码追问;号码闭环后才可能 + 注入邮箱轻补;各计数消耗后不再注入。""" + from app.services.conversation import _contact_completion_note + wa_bare = {"whatsapp": "0501234567"} + wa_full = {"whatsapp": "+966501234567"} + wa_email = {"whatsapp": "+966501234567", "email": "a@b.com"} + note, marks = _contact_completion_note({"联系方式": wa_bare}, wa_bare, {}) + assert "country code" in note and marks == ["_cc_asked"] + note, marks = _contact_completion_note( + {"联系方式": wa_bare, "_cc_asked": True}, wa_bare, {}) + assert "email" in note and marks == ["_email_asked"] + note, marks = _contact_completion_note({"联系方式": wa_full}, wa_full, {}) + assert "email" in note and marks == ["_email_asked"] + note, marks = _contact_completion_note({"联系方式": wa_email}, wa_email, {}) + assert note == "" and marks == [] + note, marks = _contact_completion_note( + {"联系方式": {"email": "a@b.com"}}, {"email": "a@b.com"}, {}) + assert note == "" and marks == [] + note, marks = _contact_completion_note( + {"联系方式": wa_bare, "_cc_asked": True, "_email_asked": True}, wa_bare, {}) + assert note == "" and marks == [] + # lang="zh": 注入中文版逐字模板句原文 + note, marks = _contact_completion_note( + {"联系方式": wa_bare}, wa_bare, {}, lang="zh") + assert "带国家代码的完整号码" in note and marks == ["_cc_asked"] + note, marks = _contact_completion_note( + {"联系方式": wa_full}, wa_full, {}, lang="zh") + assert "邮箱也留一个呗" in note and marks == ["_email_asked"] + + +def test_contact_completion_two_round_serial_and_persistence(async_db, monkeypatch): + """spec §6-6 计数持久化集成: 裸号两轮——第一轮 cc 追问注入且 _cc_asked 落库; + 第二轮客户没配合(号码仍裸),按 §4.3b 串行规则转邮箱轻补并落 _email_asked。 + 防"写回失效退化为每轮都问"与"写了计数没拼进 reminder"两类事故。""" + class CtxAI(FakeAI): + def __init__(self, extract): + super().__init__(extract=extract) + self.contexts = [] + + async def stream_chat(self, messages, context=""): + self.contexts.append(context) + async for t in super().stream_chat(messages, context): + yield t + + ai = CtxAI(extract={}) # extractor 失明,联系方式全靠同轮正则扫描进画像 + (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) + # 第一轮正向断言: cc 追问句原文确实拼进了 reminder(非仅写计数) + assert "Could you send the full number with the country code" in ai.contexts[0] + assert "email address would help too" not in ai.contexts[0] # 串行: cc 优先 + profile = asyncio.run(manager._get_profile(conv.id)) + assert (profile.raw_info or {}).get("_cc_asked") is True # 计数已落库 + # 第二轮: 号码仍裸 → cc 不再问,串行规则转邮箱轻补(_cc_asked 已消耗) + asyncio.run(_send(manager, conv.session_id, "so how much is the pvc pipe?")) + assert "Could you send the full number" not in ai.contexts[1] + assert "email address would help too" in ai.contexts[1] + profile = asyncio.run(manager._get_profile(conv.id)) + assert (profile.raw_info or {}).get("_email_asked") is True + asyncio.run(manager.db.close()) + + +def test_contact_completion_marks_filtered_from_push(async_db, monkeypatch): + """Task 2 过滤 × Task 3 真实计数键: 流程写入的 _cc_asked 穿过 dispatch 后, + 推送 payload 画像无任何 _ 前缀键(不泄漏内部状态进生产 CRM)。""" + from app.services import lead_push_service as svc + + (manager, conv), _ = _setup(async_db, monkeypatch, ai=FakeAI(extract={})) + asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) + profile = asyncio.run(manager._get_profile(conv.id)) + assert (profile.raw_info or {}).get("_cc_asked") is True # 前提: 计数确已落库 + + # 流程跑完才开推送开关——避免 fire_pending_pushes 在流程内派生后台 HTTP 任务 + monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True) + + async def push(): + ev = (await manager.db.execute( + select(Evaluation).where(Evaluation.conversation_id == conv.id)) + ).scalar_one() + log = await svc.dispatch_lead_push( + manager.db, ev, conv, profile, {"whatsapp": "0501234567"}, + "lead", {"intent_level": "interested"}, True) + await manager.db.commit() + return log + + log = asyncio.run(push()) + assert log is not None and log.status == "pending" + profile_out = log.request_payload["profile"] + assert not [k for k in profile_out if str(k).startswith("_")] # 内部键零泄漏 + asyncio.run(manager.db.close())