feat: CC强制墙——触发轮整段缓冲+流末country code判定+模板句整段替换
场景①三轮提示词修复 0/8 后的需求方决策(增补设计 §2): GLM 对反惯性的 CC 重发追问结构性无视,升级为输出侧确定性强制。 - 触发: 本轮 completion_marks == ["_cc_asked"](裸号在场且未问过) - 流式无法撤回 → 触发轮跳过逐句流出,整段缓冲(缓冲期间价格/零号码/ 再索要三墙照常逐句检查,优先级不变);流末统一判定 - 完整回复含 "country code"(中文轮认"国家代码",与注入的逐字追问句 同词)→ 零干预原样流出;不含 → 整段替换为 CC 模板句正文,logger.warning 留痕 - 模板句与 _CC_NOTE_* 同源化: _CC_ASK_* 抽出为共享正文,_CC_WALL_EN 加 "Got it — " 衔接头(_CC_WALL_ZH 与正文同句)——注入/替换两处 逐字锚点不漂移 - _cc_asked 计数照常流前落库,一次性消耗不受替换影响 - 测试 5 项: 替换(EN/ZH)+计数落库 / 含 country code 零干预(EN/ZH) / 非触发轮三形态(带前缀号/已耗/双渠道齐)原样流出 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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 路径已自带收尾,
|
||||
# 此处仅在无任何实义内容流出时接管;已流出的纯空白前缀一并作废,
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user