d1aed8e722
上游 ai-kefu → phase1 吸收(Task 11 unit 12): - 拷入 7 个测试文件,import/夹具按目标库重锚(app.engine.* / conftest async_db+build_jiancai_runtime / seed_profiles 提示词锚点) - 补实现(同步自上游): chat_rest 服务端可信 IP(_server_ip/ _merge_client_info,直连场景自报假 IP 不再原样落库);widget.js 与 chat.js 的 data-placeholder 读取(叠加 Task 10 管道:data-* > brand > 中性兜底,tests/test_placeholder_static.py 守卫) - 断言重锚以 jc 主线为准: ev.contact_info 存裸号+等待尾注(e164 走 _cc 派生)、source 值 fallback/customer_stated 口径、推送标注键 号码补全说明、prompt 静态锚按租户(seed_profiles)双测 - 全量回归 459 passed(基线 421 + 吸收 38) Co-Authored-By: Claude Code <noreply@anthropic.com>
380 lines
18 KiB
Python
380 lines
18 KiB
Python
"""CC 集成测试(同步自上游 ai-kefu tests/test_cc_integration.py)。
|
|
|
|
惯例复用 tests/test_lead_email_trigger.py: FakeAI 确定性桩 + 邮件 monkeypatch
|
|
捕获 sent 列表 + async_db 独立库。jc 侧适配:
|
|
- ConversationManager 必传 runtime(Task 6)——build_jiancai_runtime() 单例;
|
|
- 邮件桩收 cc_info/config_data 关键字(Task 6/9 出站双视图与租户配置);
|
|
- 存储契约重锚(Task 6 评审钉死): ev.contact_info 存裸号原文+等待态尾注
|
|
"(待确认补全号)",e164 只活在对 _cc 的派生(邮件/推送出站渲染),上游
|
|
"ev.contact_info == e164" 的断言改为裸号形态 + _cc.e164 双断言;
|
|
- source 值: 上游 "ip_fallback" → jc "fallback";报国重算后的确认 jc 保持
|
|
"customer_stated"(号源如实标注),上游记 "ip_confirm";
|
|
- 国名纠正(canon 不变、e164 变)不触发更正邮件: Task 6 _channel_canons
|
|
双保险——同 canon 换形态/换国不误判换号(上游存 e164 于 contact_info,
|
|
canon 随国变才触发 fix;jc 主线接受"已发邮件号码滞后"换取不误发,见
|
|
报告存疑项),状态机仍重算 e164 供后续出站使用。
|
|
|
|
接线注意: 每个用例的全部轮次与断言读取跑在同一个事件循环里(asyncio.run
|
|
一次包到底,经 _scenario)——断言读的是新事务的库值(_get_profile 会因弱
|
|
identity map 重新 SELECT),而非可能残留内存态的实例缓存,状态机推进
|
|
(_cc 深拷贝修复)因此能被真正验证。
|
|
"""
|
|
import asyncio
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.engine.models import Evaluation
|
|
from app.engine.services import email_service
|
|
from app.engine.services.conversation import ConversationManager
|
|
from tests.conftest import build_jiancai_runtime
|
|
|
|
KE_IP = "41.90.64.1" # 肯尼亚段(iptocc 实测 → KE)
|
|
|
|
# Task 6 面板尾注(等待态期间 ev.contact_info 的存储形态)
|
|
PENDING_TAIL = "(待确认补全号)"
|
|
|
|
|
|
class FakeAI:
|
|
"""替代 GLM 的确定性桩: 构造参数即每轮返回值。"""
|
|
|
|
def __init__(self, extract=None, evaluate=None):
|
|
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
|
|
self._evaluate = evaluate or {}
|
|
self.eval_calls = 0
|
|
|
|
async def chat(self, messages, context=""):
|
|
return "ok reply"
|
|
|
|
async def stream_chat(self, messages, context=""):
|
|
for token in ("ok ", "reply"):
|
|
yield token
|
|
|
|
async def extract_info(self, messages, existing_info):
|
|
# 第 N 轮客户消息 → seq[N-1](messages 为 [u,a,u,a...] 全量历史)
|
|
idx = min(max(len(messages) // 2 - 1, 0), len(self._extract_seq) - 1)
|
|
return self._extract_seq[idx]
|
|
|
|
async def evaluate(self, profile, recent_messages):
|
|
self.eval_calls += 1
|
|
result = dict(self._evaluate)
|
|
result.setdefault("intent_level", "pending")
|
|
result.setdefault("reasoning", "")
|
|
result.setdefault("contact_info", {})
|
|
result.setdefault("info_sufficient", False)
|
|
if result["intent_level"] not in ("interested", "not_interested", "pending"):
|
|
result["intent_level"] = "pending"
|
|
return result
|
|
|
|
|
|
async def _send(manager, sid, text):
|
|
async for _ in manager.stream_process_message(sid, text):
|
|
pass
|
|
|
|
|
|
def _scenario(async_db, monkeypatch, ai, steps, ip=KE_IP):
|
|
"""单事件循环跑完整用例: 建会话 → steps(manager, conv) → 读 ev/_cc 库值。
|
|
|
|
steps 为 async callable;返回值原样透传。sent 捕获邮件侧效应(lead 项
|
|
附带 cc_info 快照——jc 出站 e164 走 _cc 派生,不入 contact_info 本体)。
|
|
ev/状态读取与轮次同循环同事务空间,断言看到的是落库真值。
|
|
"""
|
|
sent = []
|
|
|
|
async def fake_lead(customer_profile, contact_info, reasoning,
|
|
conversation_messages, intent_level="",
|
|
cc_info=None, config_data=None):
|
|
sent.append(("lead", dict(contact_info), intent_level,
|
|
{k: dict(v) for k, v in (cc_info or {}).items()}))
|
|
return True
|
|
|
|
async def fake_nudge(contact_info, cc_info=None, config_data=None):
|
|
sent.append(("nudge", dict(contact_info), ""))
|
|
return True
|
|
|
|
async def fake_fix(contact_info, count=1, cc_info=None, config_data=None):
|
|
sent.append(("fix", dict(contact_info), ""))
|
|
return True
|
|
|
|
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
|
|
monkeypatch.setattr(email_service, "send_returning_lead_email", fake_nudge)
|
|
monkeypatch.setattr(email_service, "send_correction_email", fake_fix)
|
|
|
|
async def run():
|
|
async with async_db() as db:
|
|
manager = ConversationManager(
|
|
db, runtime=build_jiancai_runtime(), ai_service=ai)
|
|
conv = await manager.create_conversation(
|
|
client_info={"ip": ip} if ip else None)
|
|
result = await steps(manager, conv)
|
|
ev = (await db.execute(
|
|
select(Evaluation).where(
|
|
Evaluation.conversation_id == conv.id)
|
|
)).scalar_one()
|
|
profile = await manager._get_profile(conv.id)
|
|
cc_state = ((profile.raw_info or {}).get("_cc") or {}).get(
|
|
"whatsapp") or {}
|
|
return result, ev, cc_state
|
|
|
|
return asyncio.run(run()), sent
|
|
|
|
|
|
def test_first_email_carries_e164(async_db, monkeypatch):
|
|
# 首封即发(时序契约): 裸号 confirmed 当轮,邮件/落库即带 IP 补全 e164,
|
|
# 确认流挂 _cc(confirmed=False, source=ip_proposed)。jc 契约: 邮件收
|
|
# 裸号+cc_info(e164 在 _cc,由 email_service 出站渲染),ev.contact_info
|
|
# 存裸号+等待尾注(e164 派生不落本体)。
|
|
|
|
async def steps(manager, conv):
|
|
await _send(manager, conv.session_id, "my whatsapp is 0712345678")
|
|
|
|
(result, ev, st), sent = _scenario(
|
|
async_db, monkeypatch,
|
|
FakeAI(extract={"联系方式": {"WhatsApp": "0712345678"}}), steps)
|
|
assert len(sent) == 1 and sent[0][0] == "lead"
|
|
assert sent[0][1]["whatsapp"] == "0712345678" # 本体=裸号原文
|
|
assert sent[0][3]["whatsapp"]["e164"] == "+254712345678" # 补全号随 _cc 到邮件
|
|
assert ev.contact_info == {"whatsapp": f"0712345678{PENDING_TAIL}"}
|
|
assert st["e164"] == "+254712345678" and st["confirmed"] is False
|
|
assert st["source"] == "ip_proposed"
|
|
|
|
|
|
def test_reminder_cc_pending_overrides_d6():
|
|
# cc-pending 提醒优先于 D6(spec §3.4 拦截1): 回显确认指令在场。
|
|
# jc 措辞演化(Task 3/4): 回显句 = "is that right"(上游 "Is this your
|
|
# full number");D6 禁令改写为"只禁已提供渠道+补全号回显豁免",上游
|
|
# "绝对禁止再索要"整句不复存在——改锚改写后的豁免条款在场。
|
|
raw = {"联系方式": {"whatsapp": "0712345678"},
|
|
"_cc": {"whatsapp": {"e164": "+254712345678", "intl": "+254 712 345678",
|
|
"confirmed": False, "attempts": 0,
|
|
"source": "ip_proposed", "canon": "0712345678"}}}
|
|
text = ConversationManager._build_context_reminder(raw, "yes", [])
|
|
assert "+254 712 345678" in text
|
|
assert "is that right" in text.lower() # 回显确认句(模板同源句式)
|
|
assert "回显补全后的完整号码" in text # D6 改写后的 cc 豁免条款
|
|
assert "缺失渠道仅可按轻补指引提一次" in text # 防自锁改写在位
|
|
|
|
|
|
def test_affirm_confirms_no_resend(async_db, monkeypatch):
|
|
# 客户确认(号码未变): 不补发(首封已可拨),状态升级 confirmed/ip_confirm
|
|
|
|
async def steps(manager, conv):
|
|
await _send(manager, conv.session_id, "my whatsapp is 0712345678")
|
|
await _send(manager, conv.session_id, "yes that's right")
|
|
|
|
(result, ev, st), sent = _scenario(
|
|
async_db, monkeypatch,
|
|
FakeAI(extract=[{"联系方式": {"WhatsApp": "0712345678"}},
|
|
{"联系方式": {"WhatsApp": "0712345678"},
|
|
"cc_reply": {"affirm": True}}]), steps)
|
|
assert len(sent) == 1
|
|
assert ev.correction_count == 0
|
|
# 存储形态: 首封时落的裸号+尾注不再更新(e164 派生口 _cc 已 confirmed)
|
|
assert ev.contact_info == {"whatsapp": f"0712345678{PENDING_TAIL}"}
|
|
assert st["confirmed"] is True and st["source"] == "ip_confirm"
|
|
|
|
|
|
def test_country_correction_recomputes_state_same_canon_no_email(async_db, monkeypatch):
|
|
# 国名纠正(canon 不变、e164 变)——上游断言落入既有更正触发器(fix 邮件);
|
|
# jc 主线(Task 6 _channel_canons 双保险)同 canon 不误判换号: 不发更正
|
|
# 邮件,但状态机照常重算 e164/customer_stated,后续出站(回显/推送)用新号。
|
|
# 已发邮件的旧 e164 滞后是 jc 接受的取舍(见文件头注释与报告存疑项)。
|
|
|
|
async def steps(manager, conv):
|
|
await _send(manager, conv.session_id, "my whatsapp is 0712345678")
|
|
await _send(manager, conv.session_id, "no, I am in Uganda")
|
|
|
|
(result, ev, st), sent = _scenario(
|
|
async_db, monkeypatch,
|
|
FakeAI(extract=[{"联系方式": {"WhatsApp": "0712345678"}},
|
|
{"联系方式": {"WhatsApp": "0712345678"},
|
|
"cc_reply": {"affirm": False, "country": "Uganda"}}]),
|
|
steps)
|
|
assert len(sent) == 1 and sent[0][0] == "lead" # 同 canon: 无更正邮件
|
|
assert ev.correction_count == 0
|
|
assert st["e164"] == "+256712345678" # 乌干达重算
|
|
assert st["source"] == "customer_stated" and st["attempts"] == 1
|
|
|
|
|
|
def test_no_change_no_correction(async_db, monkeypatch):
|
|
# 防误发: 号码与国名均不变 → 不触发更正;连续无回应 → attempts=2 落 fallback
|
|
# (上游 source 值 "ip_fallback",jc 统一为 "fallback")
|
|
|
|
async def steps(manager, conv):
|
|
await _send(manager, conv.session_id, "my whatsapp is 0712345678")
|
|
await _send(manager, conv.session_id, "so how much to ship")
|
|
await _send(manager, conv.session_id, "ok thanks")
|
|
|
|
(result, ev, st), sent = _scenario(
|
|
async_db, monkeypatch,
|
|
FakeAI(extract={"联系方式": {"WhatsApp": "0712345678"}}), steps)
|
|
assert len(sent) == 1 and sent[0][0] == "lead"
|
|
assert ev.correction_count == 0
|
|
assert st["attempts"] == 2 and st["source"] == "fallback"
|
|
|
|
|
|
def test_unrelated_yes_not_consumed(async_db, monkeypatch):
|
|
# 双门控第二道: 已 confirmed 后再来的 affirm 不得再消费(无副作用)
|
|
|
|
async def steps(manager, conv):
|
|
await _send(manager, conv.session_id, "my whatsapp is 0712345678")
|
|
await _send(manager, conv.session_id, "yes correct")
|
|
await _send(manager, conv.session_id, "yes yes")
|
|
|
|
(result, ev, st), sent = _scenario(
|
|
async_db, monkeypatch,
|
|
FakeAI(extract=[{"联系方式": {"WhatsApp": "0712345678"}},
|
|
{"联系方式": {"WhatsApp": "0712345678"},
|
|
"cc_reply": {"affirm": True}},
|
|
{"联系方式": {"WhatsApp": "0712345678"},
|
|
"cc_reply": {"affirm": True}}]), steps)
|
|
assert len(sent) == 1
|
|
assert ev.correction_count == 0
|
|
assert st["confirmed"] is True and st["source"] == "ip_confirm"
|
|
|
|
|
|
def test_extractor_only_capture_still_completed(async_db, monkeypatch):
|
|
# 轮前扫描未命中(消息无数字串),仅 extractor 捕获 → 轮末兜底建态,
|
|
# 首封仍带 e164(裸号本体 + _cc 派生) + ip_proposed 标注态
|
|
|
|
async def steps(manager, conv):
|
|
await _send(manager, conv.session_id, "you can whatsapp me anytime")
|
|
|
|
(result, ev, st), sent = _scenario(
|
|
async_db, monkeypatch,
|
|
FakeAI(extract={"联系方式": {"WhatsApp": "0712345678"}}), steps)
|
|
assert sent and sent[0][0] == "lead"
|
|
assert sent[0][1]["whatsapp"] == "0712345678"
|
|
assert sent[0][3]["whatsapp"]["e164"] == "+254712345678"
|
|
assert st["e164"] == "+254712345678" and st["source"] == "ip_proposed"
|
|
|
|
|
|
def test_echo_sentence_survives_zero_number_wall(async_db, monkeypatch):
|
|
# C1(用户批准): cc-pending 期间系统补全号回显句不得被零号码墙替换——
|
|
# 补全号数字(254…)与客户裸号(071…)永不成子串关系,无白名单必被拦
|
|
# (jc Task 4: 回显轮零号码墙放行注入 e164 的 digits 等值,cc_allow_number)
|
|
class EchoAI(FakeAI):
|
|
def __init__(self, *a, **k):
|
|
super().__init__(*a, **k)
|
|
self._round = 0
|
|
|
|
async def stream_chat(self, messages, context=""):
|
|
self._round += 1
|
|
if self._round == 1:
|
|
for token in ("ok ", "reply"):
|
|
yield token
|
|
return
|
|
for token in ("Is this your full number: ",
|
|
"+254 712 345678", "?"):
|
|
yield token
|
|
|
|
events = []
|
|
|
|
async def steps(manager, conv):
|
|
await _send(manager, conv.session_id, "my whatsapp is 0712345678")
|
|
async for e in manager.stream_process_message(
|
|
conv.session_id, "check please"):
|
|
events.append(e)
|
|
|
|
(result, ev, st), sent = _scenario(
|
|
async_db, monkeypatch,
|
|
EchoAI(extract=[{"联系方式": {"WhatsApp": "0712345678"}},
|
|
{"联系方式": {"WhatsApp": "0712345678"}}]), steps)
|
|
streamed = "".join(e.get("content", "") for e in events
|
|
if e["type"] == "stream_chunk")
|
|
assert "254 712 345678" in streamed
|
|
|
|
|
|
def test_reminder_no_ip_variant_asks_full_number():
|
|
# no_ip 变体: 无建议国 → 请发带区号完整号,不用补全回显句式。
|
|
# 渠道重锚: 上游用 phone,jc 确认流只走 whatsapp 组(phone 态仅供出站
|
|
# 渲染、不进确认流——phone-only no_ip 会落到邮箱轻补分支)。
|
|
# jc(Task 3 D6 改写)已留资值出现在禁令行 shown 中——上游
|
|
# "18325698745 not in text" 随改写调整: 号码仅出现在禁令上下文。
|
|
raw = {"联系方式": {"whatsapp": "18325698745"},
|
|
"_cc": {"whatsapp": {"e164": None, "intl": None, "confirmed": False,
|
|
"attempts": 0, "source": "no_ip",
|
|
"canon": "18325698745"}}}
|
|
text = ConversationManager._build_context_reminder(raw, "yes", [])
|
|
assert "country code" in text
|
|
assert "is that right" not in text.lower() # 无补全号可回显 → 追问句
|
|
assert "18325698745" in text # 仅禁令行 shown 上下文
|
|
|
|
|
|
def test_country_correction_re_echo_then_affirm_confirms(async_db, monkeypatch):
|
|
# 国名纠正成功不烧 attempts(再回显兑现)→ 客户确认采纳: r3 回显新号供
|
|
# 确认,affirm 后落定。jc 主线: 同 canon 不发更正邮件(全程恰一封),
|
|
# 确认后 source 保持 customer_stated(号源如实标注,上游记 ip_confirm)
|
|
class CtxAI(FakeAI):
|
|
def __init__(self, *a, **k):
|
|
super().__init__(*a, **k)
|
|
self.contexts = []
|
|
|
|
async def stream_chat(self, messages, context=""):
|
|
self.contexts.append(context)
|
|
for token in ("ok ", "reply"):
|
|
yield token
|
|
|
|
ai = CtxAI(extract=[{"联系方式": {"WhatsApp": "0712345678"}},
|
|
{"联系方式": {"WhatsApp": "0712345678"},
|
|
"cc_reply": {"affirm": False, "country": "Uganda"}},
|
|
{"联系方式": {"WhatsApp": "0712345678"},
|
|
"cc_reply": {"affirm": True}}])
|
|
|
|
async def steps(manager, conv):
|
|
await _send(manager, conv.session_id, "my whatsapp is 0712345678")
|
|
await _send(manager, conv.session_id, "no, I am in Uganda")
|
|
await _send(manager, conv.session_id, "yes that's right")
|
|
|
|
(result, ev, st), sent = _scenario(async_db, monkeypatch, ai, steps)
|
|
assert len(sent) == 1 and sent[0][0] == "lead" # 同 canon: lead 之外无 fix
|
|
ctx3 = ai.contexts[2]
|
|
assert "+256 712 345678" in ctx3 # 再回显: 新补全号进提醒
|
|
assert "is that right" in ctx3.lower() # 回显确认句(jc 模板句式)
|
|
assert "回显补全后的完整号码" in ctx3 # cc-pending 分支优先(D6 改写形态)
|
|
assert ev.correction_count == 0
|
|
assert ev.contact_info == {"whatsapp": f"0712345678{PENDING_TAIL}"}
|
|
assert st["confirmed"] is True and st["source"] == "customer_stated"
|
|
assert st["attempts"] == 1
|
|
|
|
|
|
def test_d8_annotates_when_email_not_sent(async_db, monkeypatch):
|
|
# D8 面板尾注兜底(Task7评审补钉): SMTP 失败(send_lead_email 返回 False)
|
|
# → email_sent 落不了 True,ev.contact_info 由 D8 分支写存储形态。
|
|
# D8 门控要求 ≥3 条客户消息;前两轮发信失败会逐轮重试(仍失败),
|
|
# 第 3 轮 D8 落面板视图(2 轮无回应后 source=fallback 已落定,无尾注)。
|
|
# jc 存储形态: 裸号原文(e164 派生口 _cc 仍持有,assert 双钉)——上游
|
|
# 把标注写进 contact_info 值本身,jc 的标注只在邮件/推送出站渲染。
|
|
|
|
async def failing_lead(customer_profile, contact_info, reasoning,
|
|
conversation_messages, intent_level="",
|
|
cc_info=None, config_data=None):
|
|
return False
|
|
|
|
monkeypatch.setattr(email_service, "send_lead_email", failing_lead)
|
|
|
|
async def run():
|
|
async with async_db() as db:
|
|
manager = ConversationManager(
|
|
db, runtime=build_jiancai_runtime(),
|
|
ai_service=FakeAI(
|
|
extract={"联系方式": {"WhatsApp": "0712345678"}}))
|
|
conv = await manager.create_conversation(
|
|
client_info={"ip": KE_IP})
|
|
await _send(manager, conv.session_id, "my whatsapp is 0712345678")
|
|
await _send(manager, conv.session_id, "how much to ship")
|
|
await _send(manager, conv.session_id, "ok thanks")
|
|
profile = await manager._get_profile(conv.id)
|
|
st = ((profile.raw_info or {}).get("_cc") or {}).get("whatsapp") or {}
|
|
async with async_db() as db: # fresh-DB read: 新事务读落库真值
|
|
ev = (await db.execute(
|
|
select(Evaluation).where(
|
|
Evaluation.conversation_id == conv.id)
|
|
)).scalar_one()
|
|
return ev, st
|
|
|
|
ev, st = asyncio.run(run())
|
|
assert ev.email_sent is False
|
|
assert ev.contact_info == {"whatsapp": "0712345678"} # 落定态: 裸号无尾注
|
|
assert st["source"] == "fallback" # 2 轮无回应落定
|
|
assert st["e164"] == "+254712345678" # 补全号仍在 _cc 供出站
|