diff --git a/app/services/boundary_filter.py b/app/services/boundary_filter.py index b174564..22f7b45 100644 --- a/app/services/boundary_filter.py +++ b/app/services/boundary_filter.py @@ -273,6 +273,39 @@ REASK_PATTERNS = [ ] -def asks_contact(sentence: str) -> bool: - """输出句是否在索要联系方式(仅在客户已留资时用于丢弃)。""" - return any(p.search(sentence or "") for p in REASK_PATTERNS) +# 渠道精确化(2026-10-08 spec §4.4): REASK 模式命中后按渠道词分辨—— +# 索要已在手的渠道才丢,索要缺失渠道放行(双要补邮箱场景);泛渠道词 +# 只要手上有任何号码渠道即丢(宁丢勿放);精确+泛同句共存时按①②自然 +# 实现任一命中判丢。中文泛词不用 \b(CJK 无词边界,"电话"前 \b 永不成立)。 +_PRECISE_CHANNEL_RE = re.compile(r"whatsapp|e-?mail|邮箱|微信|wechat", re.I) +_GENERIC_CONTACT_RE = re.compile(r"\b(?:number|phone|contact)\b|电话|联系方式|号码", re.I) + + +def _precise_channels_in(s: str) -> set: + out = set() + for m in _PRECISE_CHANNEL_RE.finditer(s): + w = m.group(0).lower() + out.add("email" if w in ("e-mail", "email", "邮箱") + else "wechat" if w in ("微信", "wechat") else "whatsapp") + return out + + +def asks_contact(sentence: str, confirmed_channels: set | None = None) -> bool: + """输出句是否在索要联系方式(已留资时用于丢弃)。 + + confirmed_channels=None → 原行为(命中即丢,向后兼容); + 传入已确认渠道集合 → 渠道精确化(①②③④判定,见上注释)。""" + s = sentence or "" + hit = any(p.search(s) for p in REASK_PATTERNS) + if confirmed_channels is None or not hit: + return hit + has_number = bool(confirmed_channels & {"whatsapp", "phone", "wechat"}) + precise = _precise_channels_in(s) + has_generic = bool(_GENERIC_CONTACT_RE.search(s)) + if precise & confirmed_channels: # ① 句中有在手精确渠道词 + return True + if has_number and has_generic: # ② 泛词 + 手上有号码渠道 + return True + if not precise and not has_generic: # ③ 命中但无渠道词 → 保守丢 + return True + return False # ④ 精确词全缺失/泛词但无号码 → 放行 diff --git a/app/services/conversation.py b/app/services/conversation.py index 8e1d79b..4a5ab27 100644 --- a/app/services/conversation.py +++ b/app/services/conversation.py @@ -207,8 +207,12 @@ class ConversationManager: # 方案B'(13.7): 句子粒度门控——干净句子逐句流出(恢复打字机), # 违规句永不流出:已流出内容全部干净,违规时接过渡话术收尾并掐断生成。 # F6(D6联动): 客户已留联系方式时,收尾话术不得再索要——路由 contact 版 - has_contact = bool(split_validate( - normalize_contacts(effective_raw.get("联系方式")))[0]) + # 渠道精确化(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) yield {"type": "stream_start"} emitted = "" @@ -235,8 +239,9 @@ class ConversationManager: violation = "no_contact" break # Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断, - # 其余句子照常流出——软于两墙的掐断+替换) - if has_contact and asks_contact(sentence): + # 其余句子照常流出——软于两墙的掐断+替换); + # 渠道精确化: 只丢索要已在手渠道的句子 + if has_contact and asks_contact(sentence, _confirmed_channels): logger.warning("Re-ask wall dropped sentence (conv=%s): %r", conv.id, sentence[:60]) dropped_ask = True @@ -255,7 +260,7 @@ class ConversationManager: violated = True dirty = pending violation = "no_contact" - elif has_contact and asks_contact(pending): + elif has_contact and asks_contact(pending, _confirmed_channels): # Task 5b 再索要墙: 尾巴是索要句 → 静默丢弃(不流出不掐断) logger.warning("Re-ask wall dropped tail (conv=%s): %r", conv.id, pending[:60]) diff --git a/tests/test_boundary_filter.py b/tests/test_boundary_filter.py index 560497f..12498a3 100644 --- a/tests/test_boundary_filter.py +++ b/tests/test_boundary_filter.py @@ -195,6 +195,23 @@ def test_asks_contact_fragment_followup_still_pass(): assert not asks_contact("I sent the quote to your WhatsApp, could you check it?") +def test_reask_wall_channel_precision(): + """渠道精确化(2026-10-08 spec §4.4): 索要已在手的渠道才丢, + 索要缺失渠道放行;泛渠道词只要手上有任何号码渠道即丢(宁丢勿放)。""" + wa = {"whatsapp"} + em = {"email"} + assert asks_contact("What's your WhatsApp number?", wa) is True + assert asks_contact("留个邮箱吧", em) is True + assert asks_contact("And an email address would help too", wa) is False + assert asks_contact("你WhatsApp多少?", em) is False + assert asks_contact("What's your number?", wa) is True + assert asks_contact("留个电话呗", wa) is True + assert asks_contact("What's your number?", em) is False + assert asks_contact("leave your number or email", wa) is True + assert asks_contact("What's your WhatsApp number?") is True + assert asks_contact("hello there") is False + + def test_warranty_commitment_violates(): # 政策变更(2026-09-10 用户定调: 不清楚的留资转经理): AI 输出的保修期限 # 是编造的公司政策承诺——从"白名单放行"改为墙级违规 diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py index acef599..dfc6480 100644 --- a/tests/test_lead_email_trigger.py +++ b/tests/test_lead_email_trigger.py @@ -796,20 +796,24 @@ def test_no_contact_wall_already_contact_no_reask(async_db, monkeypatch): # ---- Task 5b: 再索要墙(已留资时输出侧确定性拦截索要句) ---- def test_reask_wall_drops_ask_sentence_when_contact(async_db, monkeypatch): # 生产日志复现: 已留资 + GLM 仍在回复中夹索要句 → 该句静默丢弃,前后句照常 + # 渠道精确化(2026-10-08): 客户留的是 phone,故问 phone 的索要句才丢; + # 问 WhatsApp(缺失渠道)的索要句放行流出——同轮钉死两个方向 class ReAskAI(FakeAI): async def stream_chat(self, messages, context=""): - for token in ("Great choice! ", "Got your WhatsApp? ", + for token in ("Great choice! ", "Got your phone number? ", + "Got your WhatsApp? ", "Manager will send the details."): yield token (manager, conv), _ = _setup(async_db, monkeypatch, ai=ReAskAI(extract={}), source=ConversationSource.standalone) - asyncio.run(_send(manager, conv.session_id, "00963944526529")) # 留资 + asyncio.run(_send(manager, conv.session_id, "00963944526529")) # 留资(phone) asyncio.run(_send(manager, conv.session_id, "i want 2model")) msgs = asyncio.run(manager._load_history(conv.id)) tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] - assert "Got your WhatsApp" not in tail # 索要句被丢 + assert "Got your phone number" not in tail # 索要已在手渠道 → 丢 + assert "Got your WhatsApp" in tail # 索要缺失渠道 → 放行流出 assert "Great choice" in tail # 前句保留 assert "send the details" in tail # 后句保留 asyncio.run(manager.db.close()) @@ -838,11 +842,13 @@ def test_reask_wall_closed_without_contact(async_db, monkeypatch): def test_reask_wall_all_dropped_falls_back_to_closing(async_db, monkeypatch): # 整条回复只有一句索要 → 全被丢 → 兜底流出"经理主动联系"句,不能空回复 + # 渠道精确化(2026-10-08): 索要**已在手**渠道才全丢兜底——客户留的是 + # phone,语料改为问 phone;问缺失渠道(WhatsApp)放行流出的方向见第二段 from app.services.boundary_filter import NO_CONTACT_ALREADY class OnlyAskAI(FakeAI): async def stream_chat(self, messages, context=""): - for token in ("Got your WhatsApp? ",): + for token in ("Got your phone number? ",): yield token (manager, conv), _ = _setup(async_db, monkeypatch, @@ -854,6 +860,22 @@ def test_reask_wall_all_dropped_falls_back_to_closing(async_db, monkeypatch): assert tails[-1] == NO_CONTACT_ALREADY asyncio.run(manager.db.close()) + # 同一场景反向钉死: 有 phone 在手问 WhatsApp(缺失渠道) → 放行并流出, + # 不触发兜底(Task 3 邮箱轻补句依赖此放行,否则会被墙吞成兜底句) + class OnlyAskMissingAI(FakeAI): + async def stream_chat(self, messages, context=""): + for token in ("Got your WhatsApp? ",): + yield token + + (manager2, conv2), _ = _setup(async_db, monkeypatch, + ai=OnlyAskMissingAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager2, conv2.session_id, "00963944526529")) + msgs2 = asyncio.run(manager2._load_history(conv2.id)) + tails2 = [m["content"] for m in msgs2 if m["role"] == "assistant"] + assert "Got your WhatsApp?" in tails2[-1] # 放行流出,非兜底句 + asyncio.run(manager2.db.close()) + # ---- Task 9: 端到端回归(用户报告场景复现) ---- def test_e2e_no_reasking_after_contact_provided(async_db, monkeypatch):