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:
浅念
2026-10-08 21:21:48 +08:00
parent 568c116839
commit fa51e69180
3 changed files with 153 additions and 1 deletions
+1
View File
@@ -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 没问过号码确认时猜测填写。
+46 -1
View File
@@ -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
+106
View File
@@ -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())