diff --git a/app/services/conversation.py b/app/services/conversation.py index 91f243d..88ea152 100644 --- a/app/services/conversation.py +++ b/app/services/conversation.py @@ -98,15 +98,24 @@ def _lang_note_for(msg: str) -> str: # Task 3(spec §4.1-2/§4.4, 2026-10-08): 缺失渠道轻补逐字模板——与 system.md # 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的 # 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。 +# CC 强制墙(2026-10-08 增补设计 §2)复用同源正文: _CC_ASK_* 是注入指引里 +# "请逐字使用这句"的正文;_CC_WALL_* 是墙的整段替换句(EN 加 "Got it — " +# 衔接头,设计 §2 定稿文案;ZH 与正文同句)——两处逐字锚点不漂移。 +_CC_ASK_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_ASK_ZH = ( + "麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?" + "客户经理直接加你。") +_CC_WALL_EN = "Got it — " + _CC_ASK_EN +_CC_WALL_ZH = _CC_ASK_ZH _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_ASK_EN + "\" " + "仅此一次,客户不给就不再提。") _CC_NOTE_ZH = ( "⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含" - "国家代码,客户经理无法直接回拨。请逐字使用这句:\"麻烦重发一下带国家代码" - "的完整号码呗(比如 +966 5x xxx xxxx)?客户经理直接加你。\" " + "国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_ZH + "\" " "仅此一次,客户不给就不再提。") _EMAIL_NOTE_EN = ( "⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没" @@ -285,6 +294,11 @@ class ConversationManager: # (confirmed/suspect 已在 Task 3 写回处算好,此处复用) has_contact = bool(_confirmed_now) _confirmed_channels = set(_confirmed_now) + # CC 强制墙(2026-10-08 增补设计 §2): 本轮注入的是国家代码追问时, + # 输出侧确定性强制。流式无法撤回 → 触发轮不逐句流出,整段缓冲到 + # 流末统一判 "country code" 在场与否(缓冲期间价格/零号码/再索要 + # 三墙照常逐句检查,优先级不变)。提示词层全部保留,墙只是兜底。 + cc_wall = completion_marks == ["_cc_asked"] yield {"type": "stream_start"} emitted = "" @@ -292,6 +306,7 @@ class ConversationManager: violated = False dropped_ask = False # Task 5b: 本轮是否有索要句被再索要墙丢弃 dirty = "" + cc_buffer: list[str] = [] # CC 触发轮: 已过墙句子的延迟流出缓冲 # 15.3: 双墙裁决——价格墙检查在前: 同句双违规时价格路径话术优先 # (职能边界话术更具体,且自带留资引导);仅纯号码/邮箱违规时 # violation=="no_contact",用零号码墙替换句 @@ -318,6 +333,9 @@ class ConversationManager: conv.id, sentence[:60]) dropped_ask = True continue + if cc_wall: + cc_buffer.append(sentence) # 墙照检,流出延后到流末 + continue for i in range(0, len(sentence), 40): yield {"type": "stream_chunk", "content": sentence[i:i + 40]} emitted += sentence @@ -337,6 +355,8 @@ class ConversationManager: logger.warning("Re-ask wall dropped tail (conv=%s): %r", conv.id, pending[:60]) dropped_ask = True + elif cc_wall: + cc_buffer.append(pending) else: for i in range(0, len(pending), 40): yield {"type": "stream_chunk", "content": pending[i:i + 40]} @@ -361,6 +381,29 @@ class ConversationManager: for i in range(0, len(closing), 40): yield {"type": "stream_chunk", "content": closing[i:i + 40]} emitted += closing + # CC 强制墙裁决(2026-10-08 增补设计 §2): 触发轮缓冲全文在此判定—— + # 含 "country code"(中文轮认 "国家代码",与 _CC_NOTE_ZH 注入的逐字 + # 追问句同词)→ GLM 自发服从,缓冲原样流出,零干预;不含 → 整段替换 + # 为 CC 模板句正文。violated(价格/零号码墙)优先于本墙——沿用双墙 + # 裁决"更具体话术优先"惯例;_cc_asked 已在流前落库,一次性消耗不受 + # 替换影响。缓冲轮全部实义句被再索要墙丢弃时 cc_buffer 为空 → 同样 + # 走替换(模板句即本轮唯一实义输出,天然不触发下方兜底)。 + if cc_wall and not violated: + cc_full = "".join(cc_buffer) + if "country code" in cc_full.lower() or "国家代码" in cc_full: + for i in range(0, len(cc_full), 40): + yield {"type": "stream_chunk", "content": cc_full[i:i + 40]} + emitted = cc_full + else: + logger.warning( + "CC enforcement wall replaced reply (conv=%s): %r", + conv.id, cc_full[:60]) + cc_template = (_CC_WALL_ZH if _CJK_RE.search(content or "") + else _CC_WALL_EN) + for i in range(0, len(cc_template), 40): + yield {"type": "stream_chunk", + "content": cc_template[i:i + 40]} + emitted = cc_template # Task 5b 评审修正: 整条回复只有索要句(全被丢)→ 兜底流出"经理主动 # 联系"句,绝不空回复。与上方墙收尾互斥——violated 路径已自带收尾, # 此处仅在无任何实义内容流出时接管;已流出的纯空白前缀一并作废, diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py index 4b9b26d..3595d3e 100644 --- a/tests/test_lead_email_trigger.py +++ b/tests/test_lead_email_trigger.py @@ -1051,3 +1051,131 @@ def test_contact_completion_marks_filtered_from_push(async_db, monkeypatch): profile_out = log.request_payload["profile"] assert not [k for k in profile_out if str(k).startswith("_")] # 内部键零泄漏 asyncio.run(manager.db.close()) + + +# ---- CC 强制墙(2026-10-08 增补设计 §2: 场景①输出侧确定性强制) ---- +# 前情: 三轮提示词修复后真实 GLM 验收 0/8——GLM 对"反惯性"的 CC 重发追问 +# 结构性无视。墙层兜底: 触发轮整段缓冲,流末判 "country code" 在场与否。 +class _ConfirmAI(FakeAI): + """CC 触发轮 GLM 抗命典型形态: 纯确认句,零追问(生产验收复刻)。""" + + async def stream_chat(self, messages, context=""): + for token in ("Sure! ", "Got it — our manager will reach out to you " + "shortly."): + yield token + + +_CONFIRM_REPLY = "Sure! Got it — our manager will reach out to you shortly." + + +def test_cc_wall_replaces_confirmation_reply(async_db, monkeypatch): + """触发轮 + 纯确认句 → 客户所见/落库均为 CC 模板句正文,原句零残留; + _cc_asked 计数照常落库(一次性消耗不受替换影响)。""" + from app.services.conversation import _CC_WALL_EN + (manager, conv), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), + source=ConversationSource.standalone) + events = [] + + async def run(): + async for e in manager.stream_process_message( + conv.session_id, "my whatsapp is 0501234567"): + events.append(e) + asyncio.run(run()) + streamed = "".join(e.get("content", "") for e in events + if e["type"] == "stream_chunk") + assert streamed == _CC_WALL_EN # 客户所见 = 模板句 + assert "country code" in streamed # 追问语义在场 + assert "reach out to you shortly" not in streamed # 原确认句零残留 + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == _CC_WALL_EN # 落库与流出一致 + profile = asyncio.run(manager._get_profile(conv.id)) + assert (profile.raw_info or {}).get("_cc_asked") is True # 替换不吞计数 + asyncio.run(manager.db.close()) + + +def test_cc_wall_passes_through_asking_reply(async_db, monkeypatch): + """触发轮 + 回复已含 "country code" → GLM 自发服从,零干预逐字节原样。""" + class ObedientAI(FakeAI): + async def stream_chat(self, messages, context=""): + for token in ("Could you send the full number with the country " + "code, like +966 5x xxx xxxx? ", + "Just so our manager can reach you directly."): + yield token + + reply = ("Could you send the full number with the country code, " + "like +966 5x xxx xxxx? Just so our manager can reach you " + "directly.") + (manager, conv), _ = _setup(async_db, monkeypatch, ai=ObedientAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == reply # 原样(缓冲后整段流出,不增不减一字) + asyncio.run(manager.db.close()) + + +def test_cc_wall_passes_through_chinese_asking_reply(async_db, monkeypatch): + """中文触发轮 + 回复含 "国家代码" → 同样视为服从,零干预 + (检测词表含中文形态——ZH 轮 GLM 逐字复述的是中文追问句)。""" + class ObedientZhAI(FakeAI): + async def stream_chat(self, messages, context=""): + for token in ("麻烦重发一下带国家代码的完整号码呗(比如 " + "+966 5x xxx xxxx)?", "客户经理直接加你。"): + yield token + + reply = ("麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?" + "客户经理直接加你。") + (manager, conv), _ = _setup(async_db, monkeypatch, + ai=ObedientZhAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "我的WhatsApp是0501234567")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == reply + asyncio.run(manager.db.close()) + + +def test_cc_wall_inactive_on_non_trigger_turns(async_db, monkeypatch): + """非触发轮(带前缀号/_cc_asked 已耗/双渠道齐)→ 确认句原样流出。""" + from app.services.conversation import _CC_WALL_EN + # (a) 带前缀号: 首轮即留 +966… → 邮箱轻补轮,非 CC 触发 + (m1, c1), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(m1, c1.session_id, "my whatsapp is +966501234567")) + t1 = [m["content"] for m in asyncio.run(m1._load_history(c1.id)) + if m["role"] == "assistant"][-1] + assert t1 == _CONFIRM_REPLY + asyncio.run(m1.db.close()) + # (b) _cc_asked 已耗: 首轮裸号(CC 轮被替换),次轮号码仍裸 → 邮箱轻补轮 + (m2, c2), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(m2, c2.session_id, "my whatsapp is 0501234567")) + asyncio.run(_send(m2, c2.session_id, "so how about that pvc pipe?")) + tails = [m["content"] for m in asyncio.run(m2._load_history(c2.id)) + if m["role"] == "assistant"] + assert tails[0] == _CC_WALL_EN # 前提: 第一轮确是 CC 触发轮(被墙替换) + assert tails[1] == _CONFIRM_REPLY # 第二轮墙不再触发 → 确认句原样 + asyncio.run(m2.db.close()) + # (c) 双渠道齐: 号码带前缀 + 邮箱 → 无任何轻补,墙关闭 + (m3, c3), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(m3, c3.session_id, + "reach me on whatsapp +966501234567 or a@b.com")) + t3 = [m["content"] for m in asyncio.run(m3._load_history(c3.id)) + if m["role"] == "assistant"][-1] + assert t3 == _CONFIRM_REPLY + asyncio.run(m3.db.close()) + + +def test_cc_wall_chinese_template(async_db, monkeypatch): + """中文触发轮 + 英文纯确认句 → 中文模板句正文替换(语言随本轮判定)。""" + from app.services.conversation import _CC_WALL_ZH + (manager, conv), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "我的WhatsApp是0501234567")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == _CC_WALL_ZH + assert "国家代码" in tail + asyncio.run(manager.db.close())