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
+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())