feat: 再索要墙渠道精确化——索要缺失渠道放行(双要前置)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-08 11:24:45 +08:00
parent 7c4f8eb4da
commit 1fc87fbe85
4 changed files with 89 additions and 12 deletions
+36 -3
View File
@@ -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 # ④ 精确词全缺失/泛词但无号码 → 放行
+10 -5
View File
@@ -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])
+17
View File
@@ -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 输出的保修期限
# 是编造的公司政策承诺——从"白名单放行"改为墙级违规
+26 -4
View File
@@ -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):