fix: P4'——等待态画像目标国家新现作为报国确定性信号(不赌extractor分类)
缺陷(复验 2/2 稳定复现): 同轮给号+报国首问轮丢弃(P2,设计内)→次轮再报国 extractor 漏填 cc_reply(画像已记目标国家,对重复陈述倾向不分类)→报国未 触发重算→客户 yes→错国号(+34)被 confirm 零标注出站。 - conversation.py 轮末钩子: cc_reply 未消费时,等待态(cc_due)且非首问轮, 画像"目标国家"新现或值变化(与轮前快照比对,占位集不算)→ name_to_region 转 ISO 走 transit country 分支(与 cc_reply.country 同路径);国名不识别 →忽略;首问轮仍不消费(P2 语义保持)。轮前快照在 stream_process_message 本轮写回前取值传入。 - extractor.md: cc_reply 段补"重复报国仍要分类"强化句(与 Python 侧信号 双保险——画像已记场景 extractor 漏填时由确定性信号兜底)。 - 测试: 主场景重算+20/customer_stated/attempts 消费;对照组: 首问轮不消费+ 同值不变不重复触发(合并流)、占位值忽略、值变化触发、国名不识别忽略。 全量 320 passed(基线 315+5)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
顶层可选字段 cc_reply(号码确认答语分类):仅当客户在回应号码确认时填写 cc_reply——即 AI 上一轮刚问过 "…our manager will reach you at +xxx… — is that right?" / "……这个号码对吗?" 这类确认句,且客户这条消息正是在回答它;其他对话绝对不要填。格式:
|
||||
- 客户确认号码正确("yes" / "yeah that's right" / "对的" / "是这个号")→ {"cc_reply": {"affirm": true}}
|
||||
- 客户否认并给出所在国家("no, I'm in Egypt" / "不是,我在埃及")→ {"cc_reply": {"affirm": false, "country": "Egypt"}}(country 为自由文本国名,中英皆可,如 "Egypt"/"埃及";客户只否认没说在哪 → {"cc_reply": {"affirm": false}})
|
||||
- 客户即使重复了之前说过的国家也要填 cc_reply(重复报国仍算否认+报国,不要因为之前的结果里已有该国家就略过这轮的分类)
|
||||
- 客户答非所问(继续聊产品等其他话题)→ 不要输出 cc_reply 字段
|
||||
cc_reply 是单轮瞬态分类,不是客户资料——不要写入"其他备注"等别的字段,也不要在 AI 没问过号码确认时猜测填写。
|
||||
|
||||
|
||||
@@ -347,6 +347,26 @@ def _channel_canons(confirmed: dict, cc_map: dict | None) -> dict[str, set[str]]
|
||||
return out
|
||||
|
||||
|
||||
# P4'(2026-10-08 复验): 画像"目标国家"报国信号的占位值集——"无"/"未提及"
|
||||
# 是写给 extractor 的指令标记(extractor.md 明令禁止作字段值输出,但 GLM
|
||||
# 不保证),"未知"/"本地" 同为无信息量形态;命中占位集不当作报国消费。
|
||||
_COUNTRY_PLACEHOLDERS = {"无", "未提及", "未知", "本地"}
|
||||
|
||||
|
||||
def _country_signal(cur, pre) -> str | None:
|
||||
"""P4' 画像报国信号(纯函数): 等待态轮画像"目标国家"键**新出现或值
|
||||
变化**(cur=本轮合并后值, pre=轮前快照)且值有效(非空、非占位集)
|
||||
→ 返回该值作为 country 信号;值不变(含轮前已有同值)→ None(去抖:
|
||||
同国重算幂等,重复陈述无新信息)。国名是否可识别(NAME_TO_REGION
|
||||
命中)由调用方判定——不命中时信号整体忽略,不消费。"""
|
||||
c = str(cur or "").strip()
|
||||
if not c or c in _COUNTRY_PLACEHOLDERS:
|
||||
return None
|
||||
if c == str(pre or "").strip():
|
||||
return None
|
||||
return c
|
||||
|
||||
|
||||
# 方案B(13.6): 迂回话术轮换序号——连续破防时不至于复读同一句
|
||||
_boundary_seq = 0
|
||||
|
||||
@@ -448,6 +468,11 @@ class ConversationManager:
|
||||
effective_raw["联系方式"] = merge_contacts(
|
||||
effective_raw.get("联系方式"), current_scan)
|
||||
|
||||
# P4'(2026-10-08 复验) 轮前画像快照: "目标国家"轮末信号判定基线。
|
||||
# 必须在本轮任何 raw_info 写回发生前取值(下方计数写回/轮末 extractor
|
||||
# 合并都会覆盖同一 ORM 实例);值为 str/None 不可变,持引用即安全。
|
||||
pre_turn_country = (profile.raw_info or {}).get("目标国家")
|
||||
|
||||
# 联系方式视图(轮前钩子/计数写回/再索要墙区共用;原在 reminder 之后
|
||||
# 计算,前移只为喂轮前钩子,输入相同取值不变)
|
||||
_contacts_now = normalize_contacts(effective_raw.get("联系方式"))
|
||||
@@ -755,7 +780,10 @@ class ConversationManager:
|
||||
# 触发前——消费 cc_reply 推进状态机,并为本轮才入管道的号码补建 _cc
|
||||
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。cc_wall_fired =
|
||||
# 本轮墙确有触发(P1+P2 首问轮判定输入,见 _cc_round_end)。
|
||||
await self._cc_round_end(conv, profile, cc_reply, cc_wall_fired=cc_wall)
|
||||
# P4': pre_turn_country = 轮前画像快照(本轮 extractor 合并前的
|
||||
# "目标国家"值),画像报国信号的比对基线。
|
||||
await self._cc_round_end(conv, profile, cc_reply, cc_wall_fired=cc_wall,
|
||||
pre_turn_country=pre_turn_country)
|
||||
|
||||
# Round 3: Evaluate (仅 ≥3 条客户消息才有意义, 前两轮必然 pending —— D7)
|
||||
user_msg_count = sum(1 for m in full_history if m["role"] == "user")
|
||||
@@ -858,6 +886,7 @@ class ConversationManager:
|
||||
async def _cc_round_end(
|
||||
self, conv: Conversation, profile: CustomerProfile,
|
||||
cc_reply: dict | None, cc_wall_fired: bool = False,
|
||||
pre_turn_country: str | None = None,
|
||||
) -> None:
|
||||
"""轮末 cc 钩子(spec §6-2): 联系方式四层合并后、D1 邮件前调用。
|
||||
|
||||
@@ -876,6 +905,13 @@ class ConversationManager:
|
||||
source 非 no_ip 耗尽 → transit 重算 e164(客户主动给的信息不
|
||||
丢弃);迟到 affirm 不升级(对已标"未经确认"旧号的确认语义模糊,
|
||||
confirmed 态更不重算——客户改主意请重发完整号,走 canon 重置)。
|
||||
- P4'(2026-10-08 复验)画像报国信号兜底: extractor 对"画像已记、
|
||||
客户重复陈述"倾向漏填 cc_reply(复验 2/2 稳定复现),报国不能赌
|
||||
分类——等待态(cc_due)且非首问轮,本轮画像"目标国家"**新现或值
|
||||
变化**(与轮前快照 pre_turn_country 比对,占位值不算)→ 视为
|
||||
country 信号,name_to_region 转 ISO 后走 transit country 分支
|
||||
(与 cc_reply.country 同路径)。cc_reply 显式分类优先(reply 已定
|
||||
不介入);国名不识别 → 忽略(不重算,该轮按无回应语义消耗)。
|
||||
- canon 变化(换号/号消失)→ RESET 删组状态。
|
||||
- 补建 pass: 对最终联系方式重跑判定树(_cc_pre_turn)——本轮才经
|
||||
extractor 入管道的裸号/换号后的新号当场建状态,不下轮补课。
|
||||
@@ -907,6 +943,15 @@ class ConversationManager:
|
||||
and country and state.get("source") != "no_ip"):
|
||||
reply = cc_reply # 迟到 country 升级口
|
||||
|
||||
# P4' 画像报国信号兜底(仅 cc_reply 未消费时介入): raw 此时已是
|
||||
# extractor 合并后的本轮终态,"目标国家"新现/值变化 = 客户本轮
|
||||
# 陈述了新国家——确定性信号,不赌 extractor 是否把它分类进 cc_reply
|
||||
if (reply is None and not first_ask
|
||||
and cc_completion.cc_due(state)):
|
||||
signal = _country_signal(raw.get("目标国家"), pre_turn_country)
|
||||
if signal and cc_completion.name_to_region(signal):
|
||||
reply = {"affirm": False, "country": signal}
|
||||
|
||||
if state is not None:
|
||||
if not wa_canon_end or wa_canon_end != str(state.get("canon") or ""):
|
||||
cc_map.pop("whatsapp", None) # 换号/号消失 → RESET
|
||||
|
||||
@@ -1976,3 +1976,109 @@ def test_cross_session_dedup_merges_cc_e164_canon(async_db, monkeypatch):
|
||||
asyncio.run(_send(m2, c2.session_id, "my whatsapp is +966501234567"))
|
||||
assert [s[0] for s in sent2] == ["nudge"] # e164 形态同号 → 仍判回访
|
||||
asyncio.run(m2.db.close())
|
||||
|
||||
|
||||
# ---- P4'(2026-10-08 复验): 报国漏填→错国号零标注——画像目标国家确定性信号 ----
|
||||
# 缺陷链: 同轮"给号+报国"首问轮丢弃(P2,设计内)→次轮再报国 extractor 漏填
|
||||
# cc_reply(画像已记目标国家,对重复陈述倾向不分类)→报国未触发重算→客户
|
||||
# "yes"→错国号(+34)被 confirm 零标注出站。修复: 轮末钩子读画像"目标国家"
|
||||
# 新现/值变化作确定性信号(不赌 extractor 分类),走 transit country 同路径。
|
||||
def _wa_of(manager, conv_id):
|
||||
return ((asyncio.run(manager._get_profile(conv_id))
|
||||
.raw_info or {}).get("_cc") or {}).get("whatsapp")
|
||||
|
||||
|
||||
def test_cc_profile_country_signal_recalc(async_db, monkeypatch):
|
||||
"""P4' 主场景: 等待态(asked 已标,非首问)轮 extractor 漏填 cc_reply,但
|
||||
画像"目标国家"本轮新现(轮前无此键)→ 确定性报国信号: name_to_region 转
|
||||
ISO 走 transit country 分支——e164 重算 +20 域、source=customer_stated、
|
||||
attempts 消费(次轮回显新号待确认,错号不再可能被 confirm)。"""
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{}, {"目标国家": "埃及"}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
||||
wa = _wa_of(manager, conv.id)
|
||||
assert wa.get("asked") is True and wa["attempts"] == 0 # 前提: 等待态非首问
|
||||
asyncio.run(_send(manager, conv.session_id, "no, I am in Egypt"))
|
||||
wa = _wa_of(manager, conv.id)
|
||||
assert wa["e164"] == "+20501234567" # 按报国(EG=+20)重算
|
||||
assert wa["source"] == "customer_stated"
|
||||
assert wa["attempts"] == 1 # 信号消费一轮
|
||||
assert wa["confirmed"] is False # 仍待确认(次轮回显新号)
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_profile_country_signal_first_ask_free_and_no_retrigger(
|
||||
async_db, monkeypatch):
|
||||
"""对照组①: 首问轮(asked 未标)同信号新现 → 不消费(P2 语义保持——同轮
|
||||
给号+报国的首问轮丢弃,靠次轮信号触发);对照组④: 轮前已有"目标国家:
|
||||
埃及"且本轮同值不变 → 不重复触发(去抖: 值不变无新信息),该轮按无回应
|
||||
语义消耗 attempts。"""
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{"目标国家": "埃及"}, {"目标国家": "埃及"}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
# 轮1: 同轮给号+报国——首问轮信号新现但不消费
|
||||
asyncio.run(_send(manager, conv.session_id,
|
||||
"my whatsapp is 0501234567, I am in Egypt"))
|
||||
wa = _wa_of(manager, conv.id)
|
||||
assert wa["attempts"] == 0 and wa["source"] == "ip_proposed"
|
||||
assert wa["e164"] == "+966501234567" # 首问轮未按画像重算
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
assert (profile.raw_info or {}).get("目标国家") == "埃及" # 画像字段照常写入
|
||||
# 轮2: 客户再报国,extractor 同值重复输出(画像已记)→ 值不变不触发
|
||||
asyncio.run(_send(manager, conv.session_id, "no, I am in Egypt"))
|
||||
wa = _wa_of(manager, conv.id)
|
||||
assert wa["attempts"] == 1 # 无回应语义消耗(非信号消费)
|
||||
assert wa["e164"] == "+966501234567" and wa["source"] == "ip_proposed"
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_profile_country_signal_placeholder_ignored(async_db, monkeypatch):
|
||||
"""对照组②: 占位值("无")不构成信号——extractor 违约把指令标记输出成
|
||||
字段值(extractor.md 明令禁止但 GLM 不保证)时,不得消费为报国。"""
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{}, {"目标国家": "无"}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
||||
asyncio.run(_send(manager, conv.session_id, "no, I am in Egypt"))
|
||||
wa = _wa_of(manager, conv.id)
|
||||
assert wa["e164"] == "+966501234567" and wa["source"] == "ip_proposed"
|
||||
assert wa["attempts"] == 1 # 无回应语义,信号被忽略
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_profile_country_signal_value_change_recalc(async_db, monkeypatch):
|
||||
"""值变化分支: 轮前"西班牙"、本轮变"埃及"→ 与新现同权触发重算
|
||||
(判据是"新出现或值变化",换国是更强的报国信号)。"""
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{"目标国家": "西班牙"}, {"目标国家": "埃及"}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id,
|
||||
"my whatsapp is 0501234567, I am in Spain"))
|
||||
wa = _wa_of(manager, conv.id)
|
||||
assert wa["e164"] == "+966501234567" # 首问轮不消费(轮1 已记西班牙)
|
||||
asyncio.run(_send(manager, conv.session_id, "no, I am in Egypt"))
|
||||
wa = _wa_of(manager, conv.id)
|
||||
assert wa["e164"] == "+20501234567" # 值变化 → 重算
|
||||
assert wa["source"] == "customer_stated" and wa["attempts"] == 1
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_profile_country_signal_unknown_country_ignored(
|
||||
async_db, monkeypatch):
|
||||
"""NAME_TO_REGION 不命中 → 忽略(不重算不撒谎,与 cc_reply 国名不识别
|
||||
同口径): 信号不触发重算,该轮按无回应语义消耗。"""
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{}, {"目标国家": "Atlantis"}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
||||
asyncio.run(_send(manager, conv.session_id, "no, I live in Atlantis"))
|
||||
wa = _wa_of(manager, conv.id)
|
||||
assert wa["e164"] == "+966501234567" and wa["source"] == "ip_proposed"
|
||||
assert wa["attempts"] == 1 # 无回应语义,信号被忽略
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
Reference in New Issue
Block a user