feat: jc 引擎主线搬入 app/engine(315 测试基线全绿:320 去掉 admin 5 用例)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import os
|
||||
import tempfile
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.engine.models import Base
|
||||
|
||||
# `chat_rest` is created in Task A4. Tolerate its absence so earlier tasks
|
||||
# (e.g. A0's /health smoke) can use the `client` fixture without it.
|
||||
# Import `app.main` FIRST so the natural main -> chat_rest load order resolves
|
||||
# (chat_rest does `from app.main import async_session`; if conftest imports
|
||||
# chat_rest before main is loaded, main's `from chat_rest import router` hits
|
||||
# a partially-initialized module -> circular ImportError).
|
||||
_HAS_CHAT_REST = False
|
||||
chat_rest_mod = None
|
||||
try:
|
||||
import app.main # noqa: F401
|
||||
import app.engine.routes.chat_rest as chat_rest_mod
|
||||
_HAS_CHAT_REST = True
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
async def _create_all(engine):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _dispose_main_engine():
|
||||
"""Dispose `app.main.engine` at session end.
|
||||
|
||||
Best-effort cleanup: explicitly disposing the app-level engine before
|
||||
interpreter shutdown reduces (but does NOT eliminate) the noisy
|
||||
`Windows fatal exception: access violation` traces that aiosqlite worker
|
||||
threads emit when interrupted mid-cleanup at interpreter exit.
|
||||
|
||||
Reality check on Windows:
|
||||
- All tests pass and pytest exits 0; the traces are written to stderr
|
||||
AFTER the run completes and do not affect pass/fail or the exit code.
|
||||
- They originate from aiosqlite background threads being torn down by the
|
||||
interpreter and are harmless.
|
||||
- They do not occur on Linux/CI.
|
||||
"""
|
||||
yield
|
||||
from app.main import engine as main_engine
|
||||
try:
|
||||
asyncio.run(main_engine.dispose())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def async_db():
|
||||
"""Fresh temp-file SQLite sessionmaker for direct async-DB tests
|
||||
(e.g. testing ConversationManager without HTTP). Isolated per test.
|
||||
"""
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
engine = create_async_engine(
|
||||
f"sqlite+aiosqlite:///{path}", poolclass=NullPool
|
||||
)
|
||||
asyncio.run(_create_all(engine))
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
yield sm
|
||||
asyncio.run(engine.dispose())
|
||||
try:
|
||||
os.remove(path)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limit():
|
||||
"""Reset the in-memory rate limiter before and after every test so that
|
||||
module-global bucket state can never leak across tests."""
|
||||
from app.engine.services.rate_limit import _reset_for_test
|
||||
_reset_for_test()
|
||||
yield
|
||||
_reset_for_test()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
"""Temporary file-SQLite TestClient with `chat_rest.async_session` overridden.
|
||||
|
||||
Design notes:
|
||||
- File DB (not :memory:) avoids the StaticPool cross-loop reuse pitfall.
|
||||
- `NullPool` closes each connection when the session returns it, so no
|
||||
aiosqlite worker thread outlives the event loop that created it.
|
||||
Together with `_dispose_main_engine`, this REDUCES but does NOT
|
||||
eliminate the harmless Windows access-violation stderr traces described
|
||||
above; it has no effect on test correctness or on Linux/CI.
|
||||
- Monkeypatching the module-level `async_session` binding in `chat_rest`
|
||||
works because the route handlers resolve it as a module global at call.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
engine = create_async_engine(
|
||||
f"sqlite+aiosqlite:///{path}", poolclass=NullPool
|
||||
)
|
||||
asyncio.run(_create_all(engine))
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
if _HAS_CHAT_REST:
|
||||
monkeypatch.setattr(chat_rest_mod, "async_session", sm)
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
asyncio.run(engine.dispose())
|
||||
try:
|
||||
os.remove(path)
|
||||
except PermissionError:
|
||||
pass
|
||||
@@ -0,0 +1,453 @@
|
||||
from app.engine.services.boundary_filter import (
|
||||
deflection_for, pivot_for, split_sentences, violates_boundary,
|
||||
)
|
||||
|
||||
|
||||
def test_dollar_form_violates():
|
||||
assert violates_boundary("the Tang starts at $25k")
|
||||
assert violates_boundary("around $ 25,000 for the EV")
|
||||
|
||||
|
||||
def test_currency_unit_violates():
|
||||
assert violates_boundary("that's 25000 usd")
|
||||
assert violates_boundary("大概是2万左右")
|
||||
assert violates_boundary("20万块")
|
||||
|
||||
|
||||
def test_k_form_violates():
|
||||
assert violates_boundary("a budget of 20K sounds good")
|
||||
assert violates_boundary("around 25k")
|
||||
|
||||
|
||||
def test_verb_adjacency_violates():
|
||||
assert violates_boundary("the price is 25 for that one")
|
||||
|
||||
|
||||
def test_time_promise_violates():
|
||||
assert violates_boundary("shipping takes about 3 weeks")
|
||||
assert violates_boundary("2-3 days to Mombasa")
|
||||
|
||||
|
||||
def test_whitelist_clean():
|
||||
assert not violates_boundary("the 2024 model is our best seller")
|
||||
assert not violates_boundary("I'll send you a tailored quote for the L7 SUV")
|
||||
assert not violates_boundary("600km range on the Tang EV")
|
||||
assert not violates_boundary("85kWh battery, 7-seater layout")
|
||||
assert not violates_boundary("we got tons of them, any preference on year?")
|
||||
|
||||
|
||||
def test_deflection_language_routing():
|
||||
assert deflection_for("多少钱") in __import__(
|
||||
"app.engine.services.boundary_filter", fromlist=["DEFLECTIONS_ZH"]).DEFLECTIONS_ZH
|
||||
assert deflection_for("how much?").endswith("?")
|
||||
|
||||
|
||||
def test_deflection_rotates():
|
||||
a = deflection_for("how much?", 0)
|
||||
b = deflection_for("how much?", 1)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_mileage_forms_clean():
|
||||
assert not violates_boundary("under 30k km on the clock")
|
||||
assert not violates_boundary("45k miles, mostly highway")
|
||||
assert not violates_boundary("这车表显3万公里")
|
||||
|
||||
|
||||
def test_bare_budget_echo_violates():
|
||||
assert violates_boundary("with your budget of 20000 you have options")
|
||||
assert violates_boundary("你的预算是20000,可以看看这几款")
|
||||
assert violates_boundary("预算两万的话选择不少")
|
||||
|
||||
|
||||
def test_financing_violates():
|
||||
assert violates_boundary("down payment is 5,000")
|
||||
assert violates_boundary("monthly payment of 450")
|
||||
|
||||
|
||||
def test_warranty_clean():
|
||||
# 政策变更(2026-09-10): 编造的保修期限承诺改为墙级违规(原先白名单放行)
|
||||
assert violates_boundary("6 months warranty on the engine")
|
||||
|
||||
|
||||
def test_deflection_contact_aware():
|
||||
from app.engine.services.boundary_filter import DEFLECTIONS_EN_CONTACT
|
||||
d = deflection_for("how much?", 0, has_contact=True)
|
||||
assert d in DEFLECTIONS_EN_CONTACT
|
||||
assert "WhatsApp?" not in d # 不再索要
|
||||
|
||||
|
||||
# ---- 13.7: 句子粒度门控 ----
|
||||
def test_split_sentences_en_and_tail():
|
||||
complete, tail = split_sentences("Nice pick! The Tang comes in EV and DM-i.")
|
||||
assert complete == ["Nice pick!"]
|
||||
# 句间空白随下句保留(无损): 干净回复逐句流出后逐字还原,不多字不少字
|
||||
assert tail == " The Tang comes in EV and DM-i."
|
||||
assert "".join(complete) + tail == "Nice pick! The Tang comes in EV and DM-i."
|
||||
|
||||
|
||||
def test_split_sentences_cjk_no_space_needed():
|
||||
complete, tail = split_sentences("好的。下句")
|
||||
assert complete == ["好的。"]
|
||||
assert tail == "下句"
|
||||
|
||||
|
||||
def test_split_sentences_decimal_not_split():
|
||||
# 意图锚定: 小数点不得把 "2.5..." 碎成 "2" + "5 engine..."(碎片会
|
||||
# 误触发价格动词邻接模式)。整句保持完整即达标;尾巴必然是 "Next one"。
|
||||
complete, tail = split_sentences("2.5 engine is strong. Next one")
|
||||
assert complete == ["2.5 engine is strong."]
|
||||
assert tail.strip() == "Next one"
|
||||
|
||||
|
||||
def test_pivot_contact_aware():
|
||||
assert "WhatsApp?" in pivot_for("how much?")
|
||||
assert "WhatsApp?" not in pivot_for("how much?", has_contact=True)
|
||||
assert "客户经理" in pivot_for("多少钱")
|
||||
|
||||
|
||||
# ---- 15.3: 零号码墙(doctrine v2) ----
|
||||
from app.engine.services.boundary_filter import (
|
||||
NO_CONTACT_APP, NO_CONTACT_WEB, no_contact_value,
|
||||
)
|
||||
|
||||
|
||||
def test_no_contact_wall_blocks_any_number_or_email():
|
||||
assert no_contact_value("call us at +86 138 0013 8000")
|
||||
assert no_contact_value("our email is sales@example.com")
|
||||
|
||||
|
||||
def test_no_contact_wall_echo_no_longer_exempt():
|
||||
# 回显豁免收窄(2026-10-08 增补设计 §3): 客户自己的号码被 AI 回显也拦——
|
||||
# 红线早已规定"确认收到即可,不回显",旧豁免与红线矛盾且被 GLM 稳定利用
|
||||
# (回显 3/3)。user_history 参数已随豁免一并移除。
|
||||
assert no_contact_value("Got it, 07012345678!")
|
||||
|
||||
|
||||
def test_no_contact_wall_whitelist_safe():
|
||||
assert not no_contact_value("the 2024 model with 45,000 km, VIN LFV3A23C8K3123456")
|
||||
assert not no_contact_value("85kWh battery, 600km range")
|
||||
|
||||
|
||||
def test_no_contact_templates_contain_no_numbers():
|
||||
# 替换句自身不得含号码/邮箱(防递归与自破)
|
||||
assert not no_contact_value(NO_CONTACT_WEB)
|
||||
assert not no_contact_value(NO_CONTACT_APP)
|
||||
|
||||
|
||||
def test_no_contact_already_template_clean():
|
||||
from app.engine.services.boundary_filter import NO_CONTACT_ALREADY
|
||||
assert not no_contact_value(NO_CONTACT_ALREADY) # 模板自身无号码/邮箱
|
||||
assert "WhatsApp?" not in NO_CONTACT_ALREADY # 不索要
|
||||
|
||||
|
||||
# ---- Task 5b: 再索要墙 ----
|
||||
from app.engine.services.boundary_filter import asks_contact
|
||||
|
||||
|
||||
def test_asks_contact_detects_production_log_phrasings():
|
||||
# 2026-09 生产日志 4 次违规索要原句 + system.md 全部留资模板同型句,逐字钉死
|
||||
# (⚠️ 首版模式曾漏 "I'll need your WhatsApp"/"Tell me your WhatsApp" 两句
|
||||
# system.md 模板原句——GLM 背的就是这些,必须全收;已实证 13/13)
|
||||
assert asks_contact("What's your WhatsApp or email?")
|
||||
assert asks_contact("Could you leave your WhatsApp?")
|
||||
assert asks_contact("Got your WhatsApp so our manager can send the details?")
|
||||
assert asks_contact("Got your WhatsApp?")
|
||||
assert asks_contact("Drop your WhatsApp and our manager will reach out to you directly with the full quote!")
|
||||
assert asks_contact("Want me to get you a proper quote? I'll need your WhatsApp so our manager can reach you.")
|
||||
assert asks_contact("Tell me your WhatsApp and our manager will send you an accurate quote for your budget.")
|
||||
assert asks_contact("leave your contact and our manager will give you an exact schedule")
|
||||
assert asks_contact("Drop yours too and our manager will send you the full quote!")
|
||||
assert asks_contact("留个WhatsApp或者邮箱吧?")
|
||||
assert asks_contact("你WhatsApp多少?")
|
||||
assert asks_contact("你给我个联系方式,我让客户经理给你推几个产品方案。")
|
||||
|
||||
|
||||
def test_asks_contact_clean_phrasings_pass():
|
||||
assert not asks_contact("Got your number, thanks!") # 确认,非问句
|
||||
assert not asks_contact("Just tap the WhatsApp button at the top of this chat — our manager will reply within 12 hours!")
|
||||
assert not asks_contact("Which color do you prefer?")
|
||||
assert not asks_contact("What's your budget range for this one?")
|
||||
assert not asks_contact("Can you send me more details about the Tang?") # 泛请求无渠道词
|
||||
assert not asks_contact("Our manager will send you all the details.")
|
||||
assert not asks_contact("I want your advice on the Song L EV.") # want+非渠道词
|
||||
assert not asks_contact("Could you drop it here once more?") # 核对恢复话术(suspect场景)
|
||||
assert not asks_contact("Our manager needs your config and destination.") # 合法迂回句
|
||||
|
||||
|
||||
def test_asks_contact_bare_fragment_asks():
|
||||
# Ghana 生产案例: "Your number, please?" 曾穿透(只覆盖动词/疑问词形态)
|
||||
assert asks_contact("Your number, please?")
|
||||
assert asks_contact("Your WhatsApp?")
|
||||
assert asks_contact("So... your number?")
|
||||
assert asks_contact("And your contact?")
|
||||
# 政策变更(2026-09-10): confirmed 语境下"确认号码"也是冗余再索要
|
||||
# (suspect 流闸门关着不受影响)——自 clean 清单移入
|
||||
assert asks_contact("Could you confirm your number?")
|
||||
|
||||
|
||||
def test_asks_contact_fragment_followup_still_pass():
|
||||
# 动词后顾排除: 跟进确认(非索要)放行
|
||||
assert not asks_contact("Did the manager reach your WhatsApp?")
|
||||
assert not asks_contact("Got your number, thanks!")
|
||||
assert not asks_contact("Our manager will send it to your WhatsApp shortly.")
|
||||
# "to your <渠道>" 是送达/核对语境,不是索要(评审修正: 补 (?<!to ) 后顾)
|
||||
assert not asks_contact("I've sent it to your WhatsApp, ok?")
|
||||
assert not asks_contact("Our manager already sent it to your WhatsApp, right?")
|
||||
assert not asks_contact("I sent the quote to your WhatsApp, could you check it?")
|
||||
|
||||
|
||||
def test_reask_wall_channel_precision():
|
||||
"""渠道精确化(2026-10-08 spec §4.4): 索要已在手的渠道才丢,
|
||||
索要缺失渠道放行;泛渠道词只要手上有任何号码渠道即丢(宁丢勿放)。"""
|
||||
wa = {"whatsapp"}
|
||||
em = {"email"}
|
||||
# 精确渠道词: ①在手→丢 / ④缺失→放行
|
||||
assert asks_contact("What's your WhatsApp number?", wa) is True
|
||||
assert asks_contact("留个邮箱吧", em) is True
|
||||
assert asks_contact("What's your email address?", wa) is False
|
||||
assert asks_contact("你WhatsApp多少?", em) is False
|
||||
# 泛渠道词: ②手上有号码渠道→丢 / ④无号码渠道(只 email)→放行
|
||||
assert asks_contact("What's your number?", wa) is True
|
||||
assert asks_contact("留个电话呗", wa) is True
|
||||
assert asks_contact("What's your number?", em) is False
|
||||
# 精确+泛同句共存: 按①②自然实现,任一命中判丢
|
||||
assert asks_contact("leave your number or email", wa) is True
|
||||
# ③保底: 命中 REASK 但无任何渠道词("Drop yours too"式重复纠缠)→保守丢
|
||||
assert asks_contact("Drop yours too and our manager will send the quote!",
|
||||
{"phone"}) is True
|
||||
# 无参: 原行为向后兼容(命中即丢/未命中放行)
|
||||
assert asks_contact("What's your WhatsApp number?") is True
|
||||
assert asks_contact("hello there") is False
|
||||
|
||||
|
||||
def test_new_templates_pass_reask_wall():
|
||||
"""新模板句(与 conversation.py 四常量同源导入)在所有渠道状态下均不命中
|
||||
asks_contact(spec §6-4): 墙只拦意译索要,不得拦系统注入话术——若扩REASK
|
||||
模式(如往动词表加send)或改模板措辞致命中,此断言报警,防功能自锁。
|
||||
|
||||
⚠️ 断言对象是常量正文而非全文: 外壳是给AI的系统指令(含"留邮箱""留下的
|
||||
号码"等渠道词,本就命中中文REASK——那不是AI说出口的话); AI逐字复述的
|
||||
正文才过墙。正文提取方式与 test_system_prompt_dual_contact._note_body
|
||||
同款(请逐字使用这句:"正文")。"""
|
||||
from app.engine.services.conversation import (
|
||||
_CC_NOTE_EN, _CC_NOTE_ZH, _EMAIL_NOTE_EN, _EMAIL_NOTE_ZH)
|
||||
|
||||
def _body(note):
|
||||
marker = "请逐字使用这句:\""
|
||||
assert marker in note, note[:40]
|
||||
return note.split(marker, 1)[1].split("\"", 1)[0]
|
||||
|
||||
for note in (_CC_NOTE_EN, _CC_NOTE_ZH, _EMAIL_NOTE_EN, _EMAIL_NOTE_ZH):
|
||||
body = _body(note)
|
||||
assert body
|
||||
# CC追问注入时号码渠道恰已确认(whatsapp/phone),邮箱轻补注入时
|
||||
# 号码在手缺邮箱——全渠道状态含 None/空集一起钉死
|
||||
for chans in (None, set(), {"whatsapp"}, {"phone"},
|
||||
{"whatsapp", "email"}, {"whatsapp", "phone"}):
|
||||
assert not asks_contact(body, chans), (body[:40], chans)
|
||||
|
||||
|
||||
def test_warranty_commitment_violates():
|
||||
# 政策变更(2026-09-10 用户定调: 不清楚的留资转经理): AI 输出的保修期限
|
||||
# 是编造的公司政策承诺——从"白名单放行"改为墙级违规
|
||||
assert violates_boundary("we offer 6 months warranty on every pipe")
|
||||
assert violates_boundary("battery has 8 years warranty") # year 靠定向模式
|
||||
assert violates_boundary("warranty of 8 years on battery") # EN 倒装
|
||||
assert violates_boundary("电池保修8个月")
|
||||
assert violates_boundary("8年质保")
|
||||
assert violates_boundary("整车质保3年10万公里") # CN 倒装(质保在前)
|
||||
assert violates_boundary("质保3年或10万公里")
|
||||
|
||||
|
||||
def test_chinese_time_commitment_violates():
|
||||
# 既有缺口: 中文时限承诺此前零拦截(时间模式只有英文单位)
|
||||
assert violates_boundary("3天到货")
|
||||
assert violates_boundary("6个月质保")
|
||||
|
||||
|
||||
def test_age_and_year_talk_still_clean():
|
||||
# 误杀防护: 库货年限/年款/规格数字/无数字保修讨论必须放行(实证 13 条全清)
|
||||
assert not violates_boundary("under 5 years old")
|
||||
assert not violates_boundary("5 years old stock pipe")
|
||||
assert not violates_boundary("2024年款")
|
||||
assert not violates_boundary("5座SUV")
|
||||
assert not violates_boundary("600km续航")
|
||||
assert not violates_boundary("does it come with warranty?")
|
||||
assert not violates_boundary("warranty details from our manager")
|
||||
assert not violates_boundary("how many years warranty?") # 无承诺数字的反问
|
||||
|
||||
|
||||
# ---- Task 5: 多语言价格墙(建材市场: 欧/英/南美/俄语区/中东) ----
|
||||
# GLM 多语言回复的最后防线: 宁可模式少而准,不可误杀规格数字
|
||||
# (DN800 被墙替换成迂回话术对客户是灾难)
|
||||
MULTILINGUAL_PRICE_HITS = [
|
||||
"El precio es 500 dólares", # 西语 词前置
|
||||
"Costo por tonelada 320", # 西语
|
||||
"O preço é 1200 reais", # 葡语(巴西)
|
||||
"Цена 500 за тонну", # 俄语
|
||||
"Стоимость 800 долларов", # 俄语
|
||||
"السعر 500 دولار", # 阿语
|
||||
"€500 per ton", # 欧元符号
|
||||
"£420 per container", # 英镑符号
|
||||
"500 dólares", # 数字+货币词后缀
|
||||
"Delivery in 15 días", # 西语时间承诺
|
||||
"Срок 2 недели", # 俄语时间承诺
|
||||
# 复数/变格形态(spec 审查加固: 单数 \b 形态天然排除复数)
|
||||
"los precios son 500", # 西语复数
|
||||
"preços 500", # 葡语复数
|
||||
"Цены: 1200", # 俄语复数
|
||||
"الأسعار 500 دولار", # 阿语复数(定冠词+复数)
|
||||
# 俄语动词+西里尔美元变格(质量审查: 前置组只有名词,стоит 句兜不住)
|
||||
"Стоит примерно 500 долларов",
|
||||
"будет стоить 800 долларов",
|
||||
# 大写时间形态(质量审查: 时间组补 re.I 前实测漏检)
|
||||
"entrega en 15 Días",
|
||||
"30 SEMANAS",
|
||||
]
|
||||
|
||||
# 建材规格形态: 不得误杀
|
||||
SPEC_NON_HITS = [
|
||||
"DN800 double wall corrugated pipe",
|
||||
"PN16 pressure rating",
|
||||
"SDR11 and SDR17 both available",
|
||||
"PE100 material",
|
||||
"600mm diameter",
|
||||
"SCH40 and SCH80",
|
||||
"3/4 inch and 2 inch sizes",
|
||||
"ISO4427 certified",
|
||||
"20ft container load", "40ft HQ",
|
||||
"5.8m and 11.8m lengths",
|
||||
"PE80 PE100 grades",
|
||||
# Task 3 质量审查发现: customs 是 custo 超集,高频外贸词,不得误杀
|
||||
"customs paperwork for 20ft containers",
|
||||
"HS code 3917 for customs clearance",
|
||||
# euro pallet(欧标托盘)是建材物流高频词,"50 euro pallets" 非价格
|
||||
"50 euro pallets loading",
|
||||
# 价格词邻接窗口内的规格数字: (?<![a-z]) 字母后顾放行(自审发现——
|
||||
# "precio del DN800" 型句数字前是拉丁字母,与英文组 L7 保护同款)
|
||||
"El precio del DN800 PE100 depende del volumen y el flete",
|
||||
"El costo de las tuberías SDR11 se envía por correo",
|
||||
"Цена труб PN16 зависит от объёма",
|
||||
# hours/horas 豁免锁: system.md 的 "reply within 12 hours" 时效话术
|
||||
# 依赖时间组不收小时——往时间模式加 horas/hours 时这两条必须红
|
||||
"Respondemos en 12 horas",
|
||||
"reply within 12 hours",
|
||||
]
|
||||
|
||||
|
||||
def test_multilingual_price_hits_violate():
|
||||
for s in MULTILINGUAL_PRICE_HITS:
|
||||
assert violates_boundary(s), s
|
||||
|
||||
|
||||
def test_pipe_spec_forms_still_clean():
|
||||
for s in SPEC_NON_HITS:
|
||||
assert not violates_boundary(s), s
|
||||
|
||||
|
||||
def test_deflection_and_pivot_spanish_routing():
|
||||
from app.engine.services.boundary_filter import (
|
||||
DEFLECTIONS_ES, DEFLECTIONS_ES_CONTACT, PIVOTS_ES, PIVOTS_ES_CONTACT,
|
||||
)
|
||||
assert deflection_for("¿Cuánto cuesta?") in DEFLECTIONS_ES
|
||||
assert deflection_for("¿Cuánto cuesta?", has_contact=True) in DEFLECTIONS_ES_CONTACT
|
||||
# 葡语 ç/ã 特征加固(spec 审查: "qual o preço?" 纯 ç,曾路由到 EN 池)
|
||||
assert deflection_for("qual o preço?") in DEFLECTIONS_ES
|
||||
assert pivot_for("qual o preço?") in PIVOTS_ES
|
||||
assert pivot_for("¿Me pasas el precio?") in PIVOTS_ES
|
||||
assert pivot_for("¿Me pasas el precio?", has_contact=True) in PIVOTS_ES_CONTACT
|
||||
# 既有二分支路由不回归: 汉字→ZH, 无西语特征→EN
|
||||
assert deflection_for("多少钱") not in DEFLECTIONS_ES
|
||||
assert deflection_for("how much?") not in DEFLECTIONS_ES
|
||||
|
||||
|
||||
def test_spanish_pools_are_wall_clean():
|
||||
# ES 替换句自身不得触发价格墙/零号码墙(防递归与自破)
|
||||
from app.engine.services.boundary_filter import (
|
||||
DEFLECTIONS_ES, DEFLECTIONS_ES_CONTACT, PIVOTS_ES, PIVOTS_ES_CONTACT,
|
||||
)
|
||||
for t in (DEFLECTIONS_ES + DEFLECTIONS_ES_CONTACT
|
||||
+ PIVOTS_ES + PIVOTS_ES_CONTACT):
|
||||
assert not violates_boundary(t), t
|
||||
assert not no_contact_value(t), t
|
||||
|
||||
|
||||
def test_no_auto_wording_left_in_any_pool():
|
||||
# 行业残留清零: "cars"/"什么车" 不得出现在任何话术池(含零号码墙替换句)
|
||||
import app.engine.services.boundary_filter as bf
|
||||
for name in [n for n in dir(bf)
|
||||
if n.startswith(("DEFLECTIONS", "PIVOTS", "NO_CONTACT"))]:
|
||||
pool = getattr(bf, name)
|
||||
for t in (pool if isinstance(pool, list) else [pool]):
|
||||
assert "cars" not in t.lower(), f"{name}: {t}"
|
||||
assert "什么车" not in t, f"{name}: {t}"
|
||||
|
||||
|
||||
def test_no_contact_wall_blocks_echo_in_all_digit_scripts():
|
||||
# 回显豁免收窄(2026-10-08): 阿语数字形态与 ASCII 形态一律拦——
|
||||
# Unicode \d 本就匹配阿语-印度数字,转 ASCII 后同为 ≥10 位。
|
||||
# 旧豁免(Task 6)曾按"客户消息出现过"放行两形态,现全部拦截。
|
||||
# 阿语数字用码位构造,避免源文件编码事故。
|
||||
ar_num = "".join(chr(c) for c in
|
||||
(0x0669, 0x0666, 0x0666, 0x0665, 0x0660, 0x0661,
|
||||
0x0662, 0x0663, 0x0664, 0x0665, 0x0666, 0x0667)) # 966501234567
|
||||
assert no_contact_value("Got it — messaging you at " + ar_num)
|
||||
assert no_contact_value("Got it — messaging you at +966501234567")
|
||||
assert no_contact_value("Reach me at 11987654321") # 11位裸串
|
||||
|
||||
|
||||
def test_no_contact_wall_email_echo_blocked():
|
||||
# 邮箱回显同规则收窄: 确认句只需"收到",不需复述地址
|
||||
assert no_contact_value("Got it — a@b.com, manager will write you")
|
||||
|
||||
|
||||
def test_no_contact_wall_spec_forms_still_pass():
|
||||
# 误杀防护(增补设计 §3): 建材规格数字形态不构成 ≥10 位连续数字
|
||||
assert not no_contact_value("DN110 PE100 pipes, 5000m total, 20ft container")
|
||||
assert not no_contact_value("PN16 SDR11 to ISO4427, batches of 5000")
|
||||
assert not no_contact_value("DN800双壁波纹管,5000米一批")
|
||||
|
||||
|
||||
def test_no_contact_wall_ten_digit_number_blocked():
|
||||
# Important-1(审查修复): 旧 {9,17} 最小匹配 11 字符,纯 10 位号穿透——
|
||||
# 沙特裸号 0501234567 恰是 CC 模板范例市场本体。收窄 {8,17} 后命中。
|
||||
assert no_contact_value("Got it — 0501234567!")
|
||||
assert no_contact_value("my whatsapp is 0501234567")
|
||||
# 千分位行为钉死(审查预估与实际正则的差异): ',' 不在 LONG_NUMBER_RE
|
||||
# 字符类 [\d\s\-().] 内,"50,000,000" 逗号形态天然放行——与 "45,000 km"
|
||||
# 放行同机制(既有误杀防线,不动);空格/横线分隔的 ≥10 位数字串命中。
|
||||
# 若需求方要拦千分位大数量(MOQ 复述),显式把 ',' 加进 class 再扩测试。
|
||||
assert not no_contact_value("MOQ is 50,000,000 for this grade")
|
||||
assert no_contact_value("MOQ is 50 000 000 for this grade")
|
||||
# 规格形态复测(修复后回归锚): 逗号千分位+短数字段仍全放行
|
||||
assert not no_contact_value("DN110 PE100, 5000m per batch, 20ft container")
|
||||
assert not no_contact_value("45,000 km, VIN LFV3A23C8K3123456")
|
||||
|
||||
|
||||
# ---- Task 4: 白名单逐段放行(spec §4 回显确认轮) ----
|
||||
def test_no_contact_wall_allow_number_whitelist():
|
||||
"""白名单逐段放行(spec §4): 只放行系统注入的精确号码,逐匹配段digits比对,
|
||||
混入异号整句拦,无参照照拦(4b-6 防自锁报警线)。"""
|
||||
from app.engine.services.boundary_filter import no_contact_value
|
||||
ok = "reach you at +254 712 345 678 — is that right?"
|
||||
assert no_contact_value(ok, allow_number="+254712345678") is False # 白名单号放行
|
||||
assert no_contact_value(ok) is True # 无参照拦
|
||||
bad = "reach you at +254 712 345 678 or call 11987654321" # 混入异号
|
||||
assert no_contact_value(bad, allow_number="+254712345678") is True # 整句拦
|
||||
assert no_contact_value("my mail a@b.com", allow_number="+254712345678") is True # 邮箱照拦
|
||||
|
||||
|
||||
def test_no_contact_wall_allow_number_reformatted_echo():
|
||||
"""digits 等值口径: GLM 重排分组的回显形态(无分隔/横线分隔)同样放行——
|
||||
白名单比对的归一化与墙裁决的 digits 等值同口径,分组形态不误拦。"""
|
||||
assert no_contact_value(
|
||||
"confirm +254712345678 please", allow_number="+254712345678") is False
|
||||
assert no_contact_value(
|
||||
"confirm +254-712-345-678 please", allow_number="+254712345678") is False
|
||||
# 客户裸号原文(canon)不是 e164 参照——回显裸号照拦(canon≠补全号)
|
||||
assert no_contact_value(
|
||||
"got it, 0712345678!", allow_number="+254712345678") is True
|
||||
# 无数字句 + 白名单在场: 不构成长号,照常放行(白名单只作用于长号段)
|
||||
assert no_contact_value(
|
||||
"plain sentence", allow_number="+254712345678") is False
|
||||
@@ -0,0 +1,238 @@
|
||||
"""cc_completion 判定树 + 状态机测试矩阵(spec §3/§4)。
|
||||
|
||||
纯函数离线确定性测试:无 LLM、无 DB、无网络——phonenumberslite 全离线。
|
||||
样本值均经 phonenumbers 9.0.40 实测钉死(勿凭直觉改断言,先跑库验证)。
|
||||
"""
|
||||
|
||||
from app.engine.services import ip_service
|
||||
from app.engine.services.cc_completion import (
|
||||
HAS_CC,
|
||||
NEED_CC,
|
||||
NAME_TO_REGION,
|
||||
cc_due,
|
||||
cc_settled,
|
||||
classify,
|
||||
format_intl,
|
||||
name_to_region,
|
||||
transit,
|
||||
)
|
||||
|
||||
|
||||
def _sa_state():
|
||||
"""标准等待态样本:沙特 IP + 沙特本地格式号(c 分支 ip_proposed)。"""
|
||||
return classify("0501234567", "SA")[1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 判定树 §3
|
||||
|
||||
|
||||
def test_classify_tree():
|
||||
# a: 显式国际前缀(+/00)→ 现有流程直走,无确认态
|
||||
assert classify("+966501234567", "SA")[0] == HAS_CC
|
||||
assert classify("00966501234567", "SA")[0] == HAS_CC
|
||||
assert classify("+966501234567", "SA")[1] is None
|
||||
|
||||
# b: 号恰以 IP 国码开头 + 剩余段对该国 is_valid → 静默规范化(confirmed=true)
|
||||
r = classify("966501234567", "SA")
|
||||
assert r[0] == HAS_CC and r[1]["e164"] == "+966501234567"
|
||||
assert r[1]["source"] == "prefix_normalized" and r[1]["confirmed"] is True
|
||||
assert r[1]["canon"] == "966501234567"
|
||||
# b 多形态:尼日利亚式裸写国码(234 + 本地号)
|
||||
r = classify("2348012345678", "NG")
|
||||
assert r[0] == HAS_CC and r[1]["source"] == "prefix_normalized"
|
||||
assert r[1]["e164"] == "+2348012345678"
|
||||
|
||||
# c: region=IP 国解析合法 → 高置信建议(confirmed=false,待回显确认)
|
||||
r = classify("0501234567", "SA")
|
||||
assert r[0] == NEED_CC and r[1]["source"] == "ip_proposed"
|
||||
assert r[1]["e164"] == "+966501234567" and r[1]["confirmed"] is False
|
||||
assert r[1]["attempts"] == 0 and r[1]["canon"] == "0501234567"
|
||||
assert r[1]["intl"] == "+966 50 123 4567"
|
||||
# c: 中国号裸写
|
||||
r = classify("13800138000", "CN")
|
||||
assert r[0] == NEED_CC and r[1]["e164"] == "+8613800138000"
|
||||
|
||||
# d: 均不匹配 → naive(去前导 0 + IP 国码;回显靠客户纠错)。
|
||||
# 注意:0501234567 在 KE 被 phonenumbers 判为合法固话段(走 c 高置信),
|
||||
# 真正的 d 样本必须是 region 解析不合法的号——如沙特号配肯尼亚 IP。
|
||||
r = classify("966501234567", "KE")
|
||||
assert r[0] == NEED_CC and r[1]["source"] == "naive"
|
||||
assert r[1]["e164"] == "+254966501234567"
|
||||
r = classify("0501234567", "NG") # NG 解析不合法(固话 01/02 段为主)
|
||||
assert r[0] == NEED_CC and r[1]["source"] == "naive"
|
||||
assert r[1]["e164"] == "+234501234567" and r[1]["confirmed"] is False
|
||||
# d 的对照证明:同一号在 KE 下实走 c,e164 与 naive 拼接恰好一致
|
||||
r = classify("0501234567", "KE")
|
||||
assert r[0] == NEED_CC and r[1]["source"] == "ip_proposed"
|
||||
assert r[1]["e164"] == "+254501234567"
|
||||
|
||||
# e: 无 IP → 无建议国(no_ip),只问一次"方便发带区号完整号吗"
|
||||
r = classify("0501234567", None)
|
||||
assert r[0] == NEED_CC and r[1]["source"] == "no_ip"
|
||||
assert r[1].get("suggest_ask_full") is True and "e164" not in r[1]
|
||||
|
||||
# 输入防御:空号/垃圾号不炸(调用方契约外场景)
|
||||
assert classify("", "SA")[0] == NEED_CC and classify("", "SA")[1] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 状态机 §4
|
||||
|
||||
|
||||
def test_transit_affirm():
|
||||
st = _sa_state()
|
||||
st2 = transit(st, {"affirm": True, "country": None})
|
||||
assert st2["confirmed"] is True and st2["source"] == "ip_confirm"
|
||||
assert st2["e164"] == "+966501234567" # e164 不变
|
||||
assert cc_settled(st2) is True and cc_due(st2) is False
|
||||
# 纯函数:入参不被变异
|
||||
assert st["confirmed"] is False
|
||||
|
||||
|
||||
def test_transit_country_recalc():
|
||||
st = classify("0501234567", "KE")[1]
|
||||
assert st["source"] == "ip_proposed"
|
||||
st2 = transit(st, {"affirm": False, "country": "Egypt"})
|
||||
assert st2["e164"] == "+20501234567" # 按客户报国(EG=+20)重算
|
||||
assert st2["intl"] == "+20 50 1234567"
|
||||
assert st2["source"] == "customer_stated"
|
||||
assert st2["confirmed"] is False and st2["attempts"] == 1
|
||||
assert cc_due(st2) is True and cc_settled(st2) is False # 再等待(回显新号)
|
||||
# 中文国名同样命中
|
||||
st3 = transit(_sa_state(), {"affirm": False, "country": "肯尼亚"})
|
||||
assert st3["e164"] == "+254501234567" and st3["source"] == "customer_stated"
|
||||
# 报国后确认:source 保持 customer_stated(号源如实标注)
|
||||
st4 = transit(st2, {"affirm": True, "country": None})
|
||||
assert st4["confirmed"] is True and st4["source"] == "customer_stated"
|
||||
# 未知国名:不重算不撒谎,等同无回应(attempts 递增走兜底,spec §9)
|
||||
st5 = transit(_sa_state(), {"affirm": False, "country": "亚特兰蒂斯"})
|
||||
assert st5["e164"] == "+966501234567" and st5["source"] == "ip_proposed"
|
||||
assert st5["attempts"] == 1
|
||||
|
||||
|
||||
def test_transit_canon_change_reset():
|
||||
st = _sa_state()
|
||||
# 换号(canon 变化)→ RESET(返回 None,调用方重走判定树)
|
||||
assert transit(st, {"affirm": True}, new_canon="0712345678") is None
|
||||
assert transit(st, None, new_canon="0712345678") is None
|
||||
# 同 canon 正常推进
|
||||
st2 = transit(st, {"affirm": True}, new_canon="0501234567")
|
||||
assert st2 is not None and st2["confirmed"] is True
|
||||
# 未提供 new_canon(本轮无比对号)→ 按同 canon 推进
|
||||
assert transit(st, None)["attempts"] == 1
|
||||
|
||||
|
||||
def test_transit_attempts_exhaust():
|
||||
st = _sa_state()
|
||||
st2 = transit(st, None)
|
||||
assert st2["attempts"] == 1 and cc_due(st2) is True # 第 1 轮仍等待
|
||||
st3 = transit(st2, None)
|
||||
assert st3["attempts"] == 2
|
||||
assert st3["confirmed"] is False and st3["source"] == "fallback"
|
||||
assert st3["e164"] == "+966501234567" # 落定但保持现有建议(不无限纠缠)
|
||||
assert cc_settled(st3) is True and cc_due(st3) is False
|
||||
|
||||
|
||||
def test_transit_no_reply():
|
||||
st = _sa_state()
|
||||
st2 = transit(st, None)
|
||||
assert st2["attempts"] == 1 and st2["confirmed"] is False
|
||||
assert st2["source"] == "ip_proposed" and cc_due(st2) is True
|
||||
# extractor 漏填兜底:有 reply 但无 affirm 无 country → 等同无回应
|
||||
st3 = transit(st, {"affirm": False, "country": None})
|
||||
assert st3["attempts"] == 1 and cc_due(st3) is True
|
||||
|
||||
|
||||
def test_transit_country_repeat_not_reset():
|
||||
st = classify("0501234567", "KE")[1]
|
||||
st2 = transit(st, {"affirm": False, "country": "Egypt"})
|
||||
assert st2["attempts"] == 1
|
||||
st3 = transit(st2, {"affirm": False, "country": "Egypt"}) # 国名重述
|
||||
assert st3["attempts"] == 2 # 照加不重置(attempts 只被 canon 变化重置)
|
||||
assert st3["source"] == "fallback" # 达上限落定,防"无限换国名"
|
||||
assert cc_settled(st3) is True
|
||||
|
||||
|
||||
def test_cc_due_and_settled():
|
||||
# 无态:不等待 + 已落定(带+号客户不进流程,邮箱轻补不回归)
|
||||
assert cc_due(None) is False and cc_settled(None) is True
|
||||
assert cc_due({}) is False # 空字典防御同无态
|
||||
# 等待态
|
||||
waiting = _sa_state()
|
||||
assert cc_due(waiting) is True and cc_settled(waiting) is False
|
||||
# 落定:confirmed(ip_confirm)
|
||||
assert cc_settled(transit(waiting, {"affirm": True})) is True
|
||||
# 落定:attempts 耗尽(fallback)
|
||||
assert cc_settled(transit(transit(waiting, None), None)) is True
|
||||
# 落定:prefix_normalized 即便 confirmed 字段缺失也视为落定(码是客户自己写的)
|
||||
prefix = {"source": "prefix_normalized", "confirmed": False,
|
||||
"attempts": 0, "canon": "966501234567"}
|
||||
assert cc_due(prefix) is False and cc_settled(prefix) is True
|
||||
# b 分支产物天然落定
|
||||
b = classify("966501234567", "SA")[1]
|
||||
assert cc_due(b) is False and cc_settled(b) is True
|
||||
# no_ip 新鲜态:等 1 轮(未落定)
|
||||
no_ip = classify("0501234567", None)[1]
|
||||
assert cc_due(no_ip) is True and cc_settled(no_ip) is False
|
||||
|
||||
|
||||
def test_no_ip_attempt_cap_one():
|
||||
# no_ip 路径上限 1 轮(spec §4 统一口径)——问一次"方便发带区号完整号吗"
|
||||
st = classify("0501234567", None)[1]
|
||||
assert st["source"] == "no_ip" and cc_due(st) is True
|
||||
st2 = transit(st, None) # 一轮无回应即落定
|
||||
assert st2["attempts"] == 1
|
||||
assert cc_settled(st2) is True and cc_due(st2) is False
|
||||
assert st2["source"] == "no_ip" # 无 e164 不改写 fallback(邮件标"缺国家区号")
|
||||
# 对照:常规路径一轮不落定
|
||||
st3 = transit(_sa_state(), None)
|
||||
assert cc_due(st3) is True and cc_settled(st3) is False
|
||||
# no_ip 态的肯定答语无可确认对象 → 按一轮消耗处理(非确认)
|
||||
st4 = transit(classify("0501234567", None)[1], {"affirm": True, "country": None})
|
||||
assert st4["confirmed"] is False and cc_settled(st4) is True
|
||||
# no_ip 态客户主动报国名 → 重算出 e164,转常规确认流(cap 升为 2)
|
||||
st5 = transit(classify("0501234567", None)[1],
|
||||
{"affirm": False, "country": "Saudi Arabia"})
|
||||
assert st5["e164"] == "+966501234567" and st5["source"] == "customer_stated"
|
||||
assert cc_due(st5) is True # attempts=1 < cap 2,回显确认继续
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 国名映射 §4
|
||||
|
||||
|
||||
def test_name_to_region():
|
||||
assert NAME_TO_REGION["沙特"] == "SA" and NAME_TO_REGION["Saudi Arabia"] == "SA"
|
||||
assert NAME_TO_REGION["埃及"] == "EG" and NAME_TO_REGION["Egypt"] == "EG"
|
||||
assert NAME_TO_REGION["肯尼亚"] == "KE" and NAME_TO_REGION["Kenya"] == "KE"
|
||||
# COUNTRY_NAMES 全集形态(中文全称)必须可直查
|
||||
assert NAME_TO_REGION["沙特阿拉伯"] == "SA"
|
||||
|
||||
|
||||
def test_name_to_region_covers_country_names():
|
||||
# ip_service.COUNTRY_NAMES 全集中文名必须可直查——新增国家时强制同步本映射
|
||||
for iso, zh in ip_service.COUNTRY_NAMES.items():
|
||||
assert NAME_TO_REGION.get(zh) == iso, (
|
||||
f"COUNTRY_NAMES 新增 {iso}/{zh} 未同步 NAME_TO_REGION")
|
||||
|
||||
|
||||
def test_name_to_region_lookup_tolerant():
|
||||
# 大小写/空白/弯撇号/别称容错(extractor 回传原样字符串)
|
||||
assert name_to_region(" egypt ") == "EG"
|
||||
assert name_to_region("SAUDI ARABIA") == "SA"
|
||||
assert name_to_region("Cote d'Ivoire") == "CI" # 弯撇号归一
|
||||
assert name_to_region("迪拜") == "AE" and name_to_region("Dubai") == "AE"
|
||||
assert name_to_region("UK") == "GB" and name_to_region("USA") == "US"
|
||||
assert name_to_region("不存在的国家") is None and name_to_region(None) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 展示格式化
|
||||
|
||||
|
||||
def test_format_intl():
|
||||
# 按该国官方分组(phonenumbers INTERNATIONAL;勿手工 3-3-3 拼组)
|
||||
assert format_intl("+254712345678") == "+254 712 345678"
|
||||
assert format_intl("+966501234567") == "+966 50 123 4567"
|
||||
assert format_intl("+8613800138000") == "+86 138 0013 8000"
|
||||
# 异常防御:非法/空输入原样返回(parse 抛错不外溢)
|
||||
assert format_intl("+999999") == "+999999"
|
||||
assert format_intl("") == ""
|
||||
assert format_intl("not-a-number") == "not-a-number"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Task A5 — REST message route.
|
||||
|
||||
The route consumes `ConversationManager.stream_process_message` and returns
|
||||
the collected reply plus `ended`/`intent_level`. Lead email is triggered
|
||||
inside `ConversationManager` when contact info is detected
|
||||
(contact-driven-lead-email plan) — the route carries no email logic.
|
||||
"""
|
||||
from app.main import app # noqa: F401 — ensure router registration
|
||||
from app.engine.services import conversation as conv_mod
|
||||
|
||||
|
||||
def test_message_replies_and_ended_flag(client, monkeypatch):
|
||||
async def fake_stream(self, sid, content):
|
||||
yield {"type": "stream_start"}
|
||||
yield {"type": "stream_chunk", "content": "Hi"}
|
||||
yield {"type": "stream_chunk", "content": " there"}
|
||||
yield {"type": "stream_end"}
|
||||
yield {"type": "evaluated", "ended": False, "intent_level": "pending"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
conv_mod.ConversationManager, "stream_process_message", fake_stream
|
||||
)
|
||||
|
||||
sid = client.post("/api/chat/session", json={}).json()["session_id"]
|
||||
r = client.post(
|
||||
"/api/chat/message", json={"session_id": sid, "content": "hello"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d["reply"] == "Hi there"
|
||||
assert d["ended"] is False
|
||||
|
||||
|
||||
def test_404_unknown_session(client):
|
||||
r = client.post(
|
||||
"/api/chat/message", json={"session_id": "nope", "content": "x"}
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_rate_limit_429(client, monkeypatch):
|
||||
async def fake_stream(self, sid, content):
|
||||
yield {"type": "stream_chunk", "content": "ok"}
|
||||
yield {"type": "evaluated", "ended": False, "intent_level": "pending"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
conv_mod.ConversationManager, "stream_process_message", fake_stream
|
||||
)
|
||||
|
||||
sid = client.post("/api/chat/session", json={}).json()["session_id"]
|
||||
for _ in range(30):
|
||||
client.post(
|
||||
"/api/chat/message", json={"session_id": sid, "content": "x"}
|
||||
).raise_for_status()
|
||||
over = client.post(
|
||||
"/api/chat/message", json={"session_id": sid, "content": "x"}
|
||||
)
|
||||
assert over.status_code == 429
|
||||
@@ -0,0 +1,42 @@
|
||||
# `client` fixture from conftest (temp SQLite + overrides chat_rest.async_session).
|
||||
|
||||
|
||||
def test_session_creates_new(client):
|
||||
r = client.post("/api/chat/session", json={})
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert "session_id" in d and d["is_new"] is True and d["ended"] is False
|
||||
|
||||
|
||||
def test_session_resume_active(client):
|
||||
sid = client.post("/api/chat/session", json={}).json()["session_id"]
|
||||
r = client.post("/api/chat/session", json={"session_id": sid})
|
||||
assert r.json()["is_new"] is False
|
||||
|
||||
|
||||
def test_history_new_session_empty(client):
|
||||
sid = client.post("/api/chat/session", json={}).json()["session_id"]
|
||||
r = client.get(f"/api/chat/{sid}/messages")
|
||||
assert r.status_code == 200 and r.json() == []
|
||||
|
||||
|
||||
def test_history_404_unknown_sid(client):
|
||||
assert client.get("/api/chat/nope/messages").status_code == 404
|
||||
|
||||
|
||||
def test_history_rejects_non_app_session(client):
|
||||
# widget-source conversations are not readable via this public endpoint
|
||||
# (narrow the read surface to app only).
|
||||
import asyncio
|
||||
from app.engine.routes import chat_rest
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
from app.engine.models import ConversationSource
|
||||
|
||||
async def _make_widget():
|
||||
async with chat_rest.async_session() as db:
|
||||
mgr = ConversationManager(db)
|
||||
c = await mgr.create_conversation(source=ConversationSource.widget)
|
||||
return c.session_id
|
||||
|
||||
sid = asyncio.run(_make_widget())
|
||||
assert client.get(f"/api/chat/{sid}/messages").status_code == 404
|
||||
@@ -0,0 +1,84 @@
|
||||
"""公司事实卡: 拼接位置与墙安全性静态测试。"""
|
||||
from pathlib import Path
|
||||
|
||||
from app.engine.services.ai_service import AIService, COMPANY_FACTS, SYSTEM_PROMPT
|
||||
|
||||
FACTS_PATH = Path("app/engine/prompts/company_facts.md")
|
||||
|
||||
|
||||
class _CapturingProvider:
|
||||
def __init__(self):
|
||||
self.prompts = []
|
||||
|
||||
async def chat(self, messages, system_prompt):
|
||||
self.prompts.append(system_prompt)
|
||||
return "ok"
|
||||
|
||||
async def stream_chat(self, messages, system_prompt):
|
||||
self.prompts.append(system_prompt)
|
||||
for t in ("ok",):
|
||||
yield t
|
||||
|
||||
|
||||
def test_facts_concatenated_between_system_and_context():
|
||||
# 顺序: 行为 prompt → 事实卡 → 当轮提醒(提醒必须最后,保持最新鲜)
|
||||
svc = AIService(provider=_CapturingProvider())
|
||||
svc.provider.prompts.clear()
|
||||
import asyncio
|
||||
asyncio.run(svc.chat([{"role": "user", "content": "hi"}], context="⚠️ 提醒"))
|
||||
p = svc.provider.prompts[0]
|
||||
assert "HESUPIPE" in p and "HDPE" in p # 建材事实在场
|
||||
assert p.index(SYSTEM_PROMPT) < p.index(COMPANY_FACTS) < p.index("⚠️ 提醒")
|
||||
|
||||
|
||||
def test_facts_absent_from_extractor_prompt():
|
||||
# extractor/evaluator 不拼事实卡(它们有自己的 prompt,不该看到公司事实)
|
||||
# Task 4 起 extractor/evaluator 含通用品类词(HDPE/PVC等字段示例)——
|
||||
# 隔离断言只针对品牌/公司事实标记(HESUPIPE),不再卡通用品类词
|
||||
from app.engine.services.ai_service import EVALUATOR_PROMPT, EXTRACTOR_PROMPT
|
||||
assert "HESUPIPE" not in EXTRACTOR_PROMPT
|
||||
assert "HESUPIPE" not in EVALUATOR_PROMPT
|
||||
# 河南和硕/和硕集团 等公司主体信息同样不得泄入
|
||||
assert "和硕" not in EXTRACTOR_PROMPT
|
||||
assert "和硕" not in EVALUATOR_PROMPT
|
||||
|
||||
|
||||
def test_facts_no_auto_words():
|
||||
# 建材化红线: 事实卡不得残留汽车项目词汇
|
||||
# (只查 COMPANY_FACTS——SYSTEM_PROMPT 属 Task 3,不在本测试管辖)
|
||||
# 全部 lowercase 匹配: 大小写混杂的 "BYD"/"bYd" 一网打尽,不分策略
|
||||
for word in ("byd", "hesu auto", "toyota", "vehicle", "sedan", "suv",
|
||||
"zeeker", "nissan", "geely"):
|
||||
assert word not in COMPANY_FACTS.lower(), word
|
||||
|
||||
|
||||
from app.engine.services.boundary_filter import (
|
||||
no_contact_value, split_sentences, violates_boundary,
|
||||
)
|
||||
|
||||
|
||||
def _fact_sentences():
|
||||
# 注: 事实卡是无句读的 markdown 列表,多行会合并成大句块——无害,
|
||||
# 检测是 search 式,违规在任何块内都能命中(草案已实证过两墙全清)
|
||||
complete, tail = split_sentences(COMPANY_FACTS)
|
||||
return complete + ([tail] if tail else [])
|
||||
|
||||
|
||||
def test_facts_pass_both_output_walls():
|
||||
# AI 原样引用事实也必须安全: 每句过价格墙+零号码墙
|
||||
# (陷阱实证: "五万"会被裸预算模式击杀、"累计出口五万台"→整句被替换成要号话术)
|
||||
for s in _fact_sentences():
|
||||
assert not violates_boundary(s), s[:60]
|
||||
assert not no_contact_value(s), s[:60]
|
||||
|
||||
|
||||
def test_facts_length_capped():
|
||||
# 防营销手册化: 事实卡是卡片不是 brochure——超限=该精简或走检索方案
|
||||
assert len(COMPANY_FACTS) < 2500
|
||||
|
||||
|
||||
def test_facts_no_contact_channel():
|
||||
# 我方联系方式绝不入卡(红线: web 指按钮/App 反向留资)
|
||||
# 只查确定性标记: 邮箱@与 wa.me 链接("WhatsApp"品牌词是合法内容,不作标记)
|
||||
assert "@" not in COMPANY_FACTS
|
||||
assert "wa.me" not in COMPANY_FACTS.lower()
|
||||
@@ -0,0 +1,381 @@
|
||||
from app.engine.services.contact_pipeline import (
|
||||
merge_contacts, normalize_contacts, split_validate,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_chinese_and_english_keys():
|
||||
raw = {"手机": "+8613800138000", "邮箱": "a@b.com", "微信": "wxid_1",
|
||||
"WhatsApp号码": "+254712345678", "空": ""}
|
||||
out = normalize_contacts(raw)
|
||||
assert out == {
|
||||
"phone": "+8613800138000", "email": "a@b.com",
|
||||
"wechat": "wxid_1", "whatsapp": "+254712345678",
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_later_alias_wins_on_same_key():
|
||||
# 同 key 别名冲突时后写者覆盖(上游 extractor 单轮内一般不会同时给 手机 与 tel,
|
||||
# 但语义必须被钉死并被测试)
|
||||
assert normalize_contacts({"手机": "1111111", "tel": "2222222"}) == {"phone": "2222222"}
|
||||
|
||||
|
||||
def test_normalize_coerces_bare_string_extractor_output():
|
||||
# Task 11 真人验收实测: 真实 GLM extractor 偶发把联系方式压成裸字符串
|
||||
# (mock 测不出)——唯一事实源处兜形,不崩溃不丢号:
|
||||
# 含 @ → email;裸号码 → phone;列表逐项;其他非 dict 按空
|
||||
assert normalize_contacts("+966 50 123 4567") == {"phone": "+966 50 123 4567"}
|
||||
assert normalize_contacts("buyer@example.com") == {"email": "buyer@example.com"}
|
||||
assert normalize_contacts(["+966501234567", "a@b.com"]) == {
|
||||
"phone": "+966501234567", "email": "a@b.com"}
|
||||
assert normalize_contacts(" ") == {}
|
||||
assert normalize_contacts(12345) == {}
|
||||
# merge_contacts 两侧裸串同样不崩、正常深合并
|
||||
assert merge_contacts("+966501234567", {"whatsapp": "+966509876543"}) == {
|
||||
"phone": "+966501234567", "whatsapp": "+966509876543"}
|
||||
|
||||
|
||||
def test_merge_new_value_wins_same_key():
|
||||
old = {"phone": "+254700000000"}
|
||||
new = {"手机": "+254711111111"} # 客户改号
|
||||
assert merge_contacts(old, new) == {"phone": "+254711111111"}
|
||||
|
||||
|
||||
def test_merge_empty_new_keeps_old():
|
||||
# T1 场景: extractor 某轮返回空联系方式,不得抹掉已有值
|
||||
old = {"phone": "+254700000000"}
|
||||
assert merge_contacts(old, {}) == {"phone": "+254700000000"}
|
||||
assert merge_contacts(old, None) == {"phone": "+254700000000"}
|
||||
|
||||
|
||||
def test_split_validate_confirmed_and_suspect():
|
||||
confirmed, suspect = split_validate({
|
||||
"email": "a@b.com", # 合法邮箱
|
||||
"phone": "+254 712 345 678", # 数字 12 位 → confirmed
|
||||
"whatsapp": "12345", # 数字 5 位 → suspect
|
||||
})
|
||||
assert confirmed == {"email": "a@b.com", "phone": "+254 712 345 678"}
|
||||
assert suspect == {"whatsapp": "12345"}
|
||||
|
||||
|
||||
def test_split_validate_bad_email_is_suspect():
|
||||
confirmed, suspect = split_validate({"email": "not-an-email"})
|
||||
assert confirmed == {} and suspect == {"email": "not-an-email"}
|
||||
|
||||
|
||||
def test_split_validate_wechat_id_is_confirmed():
|
||||
# 微信号无数字格式(wxid_x 等),非空≥4字符即 confirmed——否则只留微信的
|
||||
# 客户永远无法触发邮件
|
||||
confirmed, suspect = split_validate({"wechat": "wxid_1"})
|
||||
assert confirmed == {"wechat": "wxid_1"} and suspect == {}
|
||||
|
||||
|
||||
def test_split_validate_wechat_short_is_suspect():
|
||||
confirmed, suspect = split_validate({"wechat": "abc"})
|
||||
assert confirmed == {} and suspect == {"wechat": "abc"}
|
||||
|
||||
|
||||
def test_normalize_coerces_int_values():
|
||||
# extractor 偶尔会把号码输出成 int,不得崩溃且要能归一化
|
||||
assert normalize_contacts({"手机": 254712345678}) == {"phone": "254712345678"}
|
||||
|
||||
|
||||
def test_merge_none_old_keeps_new():
|
||||
assert merge_contacts(None, {"email": "a@b.com"}) == {"email": "a@b.com"}
|
||||
|
||||
|
||||
def test_normalize_phone_alias_variants():
|
||||
# 审查补充: 电话号码 / phone number 等带空格、中文长名变体必须命中 phone
|
||||
assert normalize_contacts({"电话号码": "13800138000"}) == {"phone": "13800138000"}
|
||||
assert normalize_contacts({"phone number": "+12345678901"}) == {"phone": "+12345678901"}
|
||||
|
||||
|
||||
def test_normalize_fuzzy_key_variants():
|
||||
# extractor 常见即兴 key(精确别名表外)不得静默丢值
|
||||
assert normalize_contacts({"联系电话": "13800138000"}) == {"phone": "13800138000"}
|
||||
assert normalize_contacts({"手机号": "13800138000"}) == {"phone": "13800138000"}
|
||||
assert normalize_contacts({"WhatsApp账号": "+254712345678"}) == {
|
||||
"whatsapp": "+254712345678"}
|
||||
|
||||
|
||||
def test_normalize_unknown_key_still_dropped():
|
||||
# 完全不含渠道词的 key 仍然丢弃(如 "备注")
|
||||
assert normalize_contacts({"备注": "13800138000"}) == {}
|
||||
|
||||
|
||||
def test_normalize_slash_combined_key_deterministic():
|
||||
# "电话/WhatsApp" 双渠道复合 key: 按 KEY_ALIASES 声明序取第一个命中(phone 先于 whatsapp)
|
||||
assert normalize_contacts({"电话/WhatsApp": "13800138000"}) == {
|
||||
"phone": "13800138000"}
|
||||
|
||||
|
||||
from app.engine.services.contact_pipeline import scan_user_messages
|
||||
|
||||
|
||||
def m(*texts):
|
||||
return [{"role": "user", "content": t} for t in texts] + [
|
||||
{"role": "assistant", "content": "call me at +999111222333"}]
|
||||
|
||||
|
||||
def test_scan_email():
|
||||
found = scan_user_messages(m("my email is john.doe@x.co send quotes"))
|
||||
assert found == {"email": "john.doe@x.co"}
|
||||
|
||||
|
||||
def test_scan_intl_phone():
|
||||
found = scan_user_messages(m("you can reach me +254712345678"))
|
||||
assert found == {"phone": "+254712345678"}
|
||||
|
||||
|
||||
def test_scan_whatsapp_context_word():
|
||||
found = scan_user_messages(m("whatsapp: 0712 345 678"))
|
||||
assert found.get("whatsapp") == "0712 345 678"
|
||||
|
||||
|
||||
def test_scan_wa_me_link():
|
||||
found = scan_user_messages(m("chat https://wa.me/254712345678"))
|
||||
assert found == {"whatsapp": "254712345678"}
|
||||
|
||||
|
||||
def test_scan_ignores_prices_years_and_ai_messages():
|
||||
texts = m(
|
||||
"budget $25,000 for 2024 PE100 pipes", # 价格+年份
|
||||
"50,000 km mileage", # 里程
|
||||
"I want 5 units", # 数量
|
||||
)
|
||||
# 上一条含 assistant 消息,确保只扫 user
|
||||
found = scan_user_messages(texts)
|
||||
assert found == {}
|
||||
|
||||
|
||||
def test_scan_full_history_not_only_last():
|
||||
# S4: 扫全量历史,早轮的联系方式也要能捞到
|
||||
msgs = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hey"},
|
||||
{"role": "user", "content": "mail me a@b.com"},
|
||||
{"role": "assistant", "content": "got it"},
|
||||
{"role": "user", "content": "how much is pvc"},
|
||||
]
|
||||
assert scan_user_messages(msgs) == {"email": "a@b.com"}
|
||||
|
||||
|
||||
def test_scan_context_word_does_not_grab_unrelated_numbers():
|
||||
# S1: 上下文词必须与数字邻接(同一匹配内),消息任意位置的7位数字不得被抓
|
||||
assert scan_user_messages(m("call me on delivery, order 1234567")) == {}
|
||||
assert scan_user_messages(m("车架号码是 LFV3A23C8K3123456 吗")) == {}
|
||||
assert scan_user_messages(m("engine number 2ZRFE1234567?")) == {}
|
||||
assert scan_user_messages(m("我手机坏了 budget 1500000 ksh")) == {}
|
||||
|
||||
|
||||
def test_scan_adjacent_context_phone():
|
||||
assert scan_user_messages(m("my phone number is 0712 345 678")) == {
|
||||
"phone": "0712 345 678"}
|
||||
assert scan_user_messages(m("call me at +86 138 0013 8000")) == {
|
||||
"phone": "+86 138 0013 8000"}
|
||||
|
||||
|
||||
def test_canon_normalizes_for_dedup():
|
||||
from app.engine.services.contact_pipeline import canon
|
||||
assert canon("A@B.COM") == "a@b.com"
|
||||
assert canon("+254 712-345 678") == "254712345678"
|
||||
|
||||
|
||||
from app.engine.services.contact_pipeline import latest_stated_contacts
|
||||
|
||||
|
||||
def test_latest_stated_correction_context():
|
||||
msgs = m("my phone is +8613800138000", "wrong number, use +8613999999999")
|
||||
assert latest_stated_contacts(msgs) == {"phone": "+8613999999999"}
|
||||
|
||||
|
||||
def test_latest_stated_new_number_no_correction_words():
|
||||
msgs = m("my number is +254712345678", "my new number is +254700111222")
|
||||
assert latest_stated_contacts(msgs) == {"phone": "+254700111222"}
|
||||
|
||||
|
||||
def test_latest_stated_bare_number_has_no_authority():
|
||||
# 更正语境缺席时,裸 +号 无覆盖权(价格/单号防误翻新)
|
||||
msgs = m("my number is +254712345678", "total is +254700111222 usd wait no")
|
||||
assert latest_stated_contacts(msgs) == {"phone": "+254712345678"}
|
||||
|
||||
|
||||
def test_latest_stated_same_value_no_conflict():
|
||||
msgs = m("my number is +254712345678", "my number is +254712345678")
|
||||
assert latest_stated_contacts(msgs) == {"phone": "+254712345678"}
|
||||
|
||||
|
||||
def test_scan_my_phone_is_local_number():
|
||||
# 评审恢复项: "my phone is <无+本地号>" 曾从门控模式掉落(extractor 漏时彻底失联)
|
||||
found = scan_user_messages(m("my phone is 07123456789"))
|
||||
assert found == {"phone": "07123456789"}
|
||||
|
||||
|
||||
def test_latest_stated_my_phone_is_form():
|
||||
msgs = m("hi", "my phone is 07123456789")
|
||||
assert latest_stated_contacts(msgs) == {"phone": "07123456789"}
|
||||
|
||||
|
||||
from app.engine.services.contact_pipeline import resolve_by_recency
|
||||
|
||||
|
||||
def _u(*texts):
|
||||
return [{"role": "user", "content": t} for t in texts]
|
||||
|
||||
|
||||
def test_recency_mirror_lag_newest_wins():
|
||||
# Task 12b 场景: extractor 只翻新 phone,镜像 whatsapp 滞后 → 最新提及统一
|
||||
before = {"phone": "+8613800138000", "whatsapp": "+8613800138000"}
|
||||
after = {"phone": "+8613999999999", "whatsapp": "+8613800138000"}
|
||||
msgs = _u("my phone is +8613800138000", "wrong number, use +8613999999999")
|
||||
assert resolve_by_recency(before, after, msgs) == {
|
||||
"phone": "+8613999999999", "whatsapp": "+8613999999999"}
|
||||
|
||||
|
||||
def test_recency_kills_extractor_oscillation():
|
||||
# 实测bug场景: extractor 翻回更早消息里的旧号 → 裁决回最新的 ...004
|
||||
before = {"phone": "+8613800138004", "whatsapp": "+8613800138004"}
|
||||
after = {"phone": "+8613800138000", "whatsapp": "+8613800138004"} # extractor 振荡
|
||||
msgs = _u("my whatsapp is +8613800138000", "my whatsapp is +8613800138004",
|
||||
"any pipe talk")
|
||||
assert resolve_by_recency(before, after, msgs) == before # 无变化→不触发更正
|
||||
|
||||
|
||||
def test_recency_suppresses_hallucinated_value():
|
||||
# 幻觉值从未出现在消息里(last_idx=-1) → 真实陈述胜
|
||||
before = {"phone": "+8613800138004", "whatsapp": "+8613800138004"}
|
||||
after = {"phone": "+8613999999999", "whatsapp": "+8613800138004"}
|
||||
msgs = _u("my whatsapp is +8613800138004")
|
||||
assert resolve_by_recency(before, after, msgs) == before
|
||||
|
||||
|
||||
def test_recency_bare_newest_number_wins():
|
||||
# 用户实测终局: 最晚消息里的裸新号(extractor 抓到)胜过更早的陈述
|
||||
before = {"phone": "+8613800138004", "whatsapp": "+8613800138004"}
|
||||
after = {"phone": "18325698745", "whatsapp": "+8613800138004"}
|
||||
msgs = _u("my whatsapp is +8613800138004", "chat", "18325698745")
|
||||
assert resolve_by_recency(before, after, msgs) == {
|
||||
"phone": "18325698745", "whatsapp": "18325698745"}
|
||||
|
||||
|
||||
def test_recency_genuinely_different_untouched():
|
||||
before = {"phone": "+8613800138000", "whatsapp": "+254712345678"}
|
||||
after = {"phone": "+8613999999999", "whatsapp": "+254712345678"}
|
||||
msgs = _u("my phone is +8613800138000", "my whatsapp is +254712345678",
|
||||
"wrong number, use +8613999999999")
|
||||
assert resolve_by_recency(before, after, msgs) == after # 不同组不动
|
||||
|
||||
|
||||
from app.engine.services.contact_pipeline import standalone_number
|
||||
|
||||
|
||||
def test_standalone_number_captures_cn_and_ke_mobiles():
|
||||
assert standalone_number("15236541128") == "15236541128"
|
||||
assert standalone_number("0712345678") == "0712345678"
|
||||
assert standalone_number("+86 138 0013 8000") == "+86 138 0013 8000"
|
||||
|
||||
|
||||
def test_standalone_number_rejects_prices_years_and_prose():
|
||||
assert standalone_number("2024") is None # 年份
|
||||
assert standalone_number("25000") is None # 美元价
|
||||
assert standalone_number("1500000") is None # 先令价(7位)
|
||||
assert standalone_number("50,000 km") is None # 含字母逗号
|
||||
assert standalone_number("I want 5 units") is None
|
||||
|
||||
|
||||
def test_latest_stated_standalone_overrides():
|
||||
msgs = m("my number is +254712345678", "chat", "15236541128")
|
||||
assert latest_stated_contacts(msgs) == {"phone": "15236541128"}
|
||||
|
||||
|
||||
# ---- P0 捕获缺口扩充: 号码在前/无is/msg me/wa冒号 ----
|
||||
# 全部经 scan_user_messages 断言,不直接 import 新正则符号
|
||||
|
||||
|
||||
def test_scan_number_before_whatsapp():
|
||||
# 倒装: "0712 345 678 is my whatsapp"
|
||||
assert scan_user_messages(m("0712 345 678 is my whatsapp")) == {
|
||||
"whatsapp": "0712 345 678"}
|
||||
|
||||
|
||||
def test_scan_number_before_wechat_cn():
|
||||
# 中文倒装(我的后无空格——正则必须用 \s* 而非 \s+,实证 \s+ 永不命中)
|
||||
assert scan_user_messages(m("13800138000 我的微信")) == {
|
||||
"wechat": "13800138000"}
|
||||
assert scan_user_messages(m("13800138000 这是我的微信")) == {
|
||||
"wechat": "13800138000"}
|
||||
|
||||
|
||||
def test_scan_my_number_without_is():
|
||||
# "my number 0712345678"(无 is/冒号)
|
||||
assert scan_user_messages(m("my number 0712345678")) == {
|
||||
"phone": "0712345678"}
|
||||
|
||||
|
||||
def test_scan_text_me_forms():
|
||||
assert scan_user_messages(m("text me at 07123456789")) == {
|
||||
"phone": "07123456789"}
|
||||
assert scan_user_messages(m("msg me 07123456789")) == {
|
||||
"phone": "07123456789"}
|
||||
|
||||
|
||||
def test_scan_wa_colon_local_number():
|
||||
assert scan_user_messages(m("wa: 07123456789")) == {
|
||||
"whatsapp": "07123456789"}
|
||||
|
||||
|
||||
def test_scan_new_forms_do_not_grab_prices():
|
||||
# 误报防护: 价格/单号即使与 whatsapp 词同现也不得捕获(无 my 指称/无邻接)
|
||||
assert scan_user_messages(m("paid 2500000 via whatsapp transfer")) == {}
|
||||
assert scan_user_messages(m("my budget 25000 number one priority")) == {}
|
||||
assert scan_user_messages(m("order 1234567 is my order number")) == {}
|
||||
# 排位表述(实证: 若倒装正则收 number/phone,"2500000 is my number" 被误捕,
|
||||
# 且 latest_stated_contacts 覆盖权会把真实号码翻新成价格)——倒装只收显式渠道词
|
||||
assert scan_user_messages(m("2500000 is my number one choice")) == {}
|
||||
# wa 词界守卫: "my watch/way" 的 wa 后无词边界,不得命中
|
||||
assert scan_user_messages(m("0712345678 my watch")) == {}
|
||||
assert scan_user_messages(m("0712345678 my way home")) == {}
|
||||
|
||||
|
||||
def test_bare_my_number_has_no_latest_authority():
|
||||
# 回归钉死: 裸 "my number X"(无 is/冒号)只进扫描辅源,不得进
|
||||
# latest_stated_contacts 获覆盖权——否则首轮旧值长期压制 extractor
|
||||
# 后续改号(实证: test_correction_up_to_three 两封更正邮件全灭)。
|
||||
# 预期 {} 依赖 "wrong,"(逗号后无 number/phone)不命中 CORRECTION_CTX_RE。
|
||||
msgs = m("my number +8613800138000", "wrong, use +8613800139999",
|
||||
"again +8613800122222")
|
||||
assert latest_stated_contacts(msgs) == {}
|
||||
|
||||
|
||||
def test_number_first_whatsapp_whitespace_variants():
|
||||
# 回归(评审): whats\s*app 捕获的任意空白变体(双空格/NBSP)不得让
|
||||
# _NUMBER_FIRST_CHANNEL 查表 KeyError——scan 在每条用户消息的热路径上
|
||||
# 运行(conversation.py 无 try/except),崩溃即断流整轮对话。
|
||||
assert scan_user_messages(m("0712345678 is my whats app")) == {
|
||||
"whatsapp": "0712345678"}
|
||||
assert scan_user_messages(m("0712345678 is my whats\xa0app")) == {
|
||||
"whatsapp": "0712345678"}
|
||||
# 两个接线位(scan + latest)都要走空白归一,任一崩都算回归
|
||||
assert latest_stated_contacts(m("0712345678 is my whats app")) == {
|
||||
"whatsapp": "0712345678"}
|
||||
|
||||
|
||||
# ---- 阿语/波斯语数字归一化(中东买家): 存库与比对全链 ASCII ----
|
||||
|
||||
|
||||
def test_arabic_digits_normalized():
|
||||
# 阿语数字(中东买家)提取后应转 ASCII,CRM 可直接拨
|
||||
confirmed, _ = split_validate({"whatsapp": "+٩٦٦٥٠١٢٣٤٥٦٧"})
|
||||
assert confirmed["whatsapp"] == "+966501234567"
|
||||
|
||||
|
||||
def test_arabic_digits_scan_latest_translated():
|
||||
# 捕获源头即转 ASCII: 用户消息里的阿语号码,scan/latest 的落库形态
|
||||
# 直接可拨;canon 比对也与 ASCII 形态同口径(不触发假性"改号"更正邮件)
|
||||
msgs = [{"role": "user", "content": "whatsapp: ٩٦٦٥٠١٢٣٤٥٦٧"}]
|
||||
assert scan_user_messages(msgs) == {"whatsapp": "966501234567"}
|
||||
assert latest_stated_contacts(msgs) == {"whatsapp": "966501234567"}
|
||||
|
||||
|
||||
def test_arabic_digits_canon_equals_ascii_form():
|
||||
from app.engine.services.contact_pipeline import canon
|
||||
assert canon("+٩٦٦ ٥٠١ ٢٣٤ ٥٦٧") == canon("+966 501 234 567")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Red-line: `context` injection must be CONDITIONAL + WHITELIST only.
|
||||
|
||||
- No `context` passed -> `raw_info` stays `{}` (widget WS path unchanged).
|
||||
- `context` passed -> only `product_name`/`product_id` map to `app_*`.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.engine.models import CustomerProfile
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
|
||||
|
||||
async def _raw_info_for(async_db, conv_id) -> dict:
|
||||
async with async_db() as db:
|
||||
result = await db.execute(
|
||||
select(CustomerProfile).where(
|
||||
CustomerProfile.conversation_id == conv_id
|
||||
)
|
||||
)
|
||||
profile = result.scalar_one()
|
||||
return profile.raw_info or {}
|
||||
|
||||
|
||||
async def test_no_context_keeps_raw_info_empty(async_db):
|
||||
async with async_db() as db:
|
||||
mgr = ConversationManager(db)
|
||||
conv = await mgr.create_conversation(context=None)
|
||||
raw = await _raw_info_for(async_db, conv.id)
|
||||
assert raw == {}
|
||||
|
||||
|
||||
async def test_context_seeds_whitelist_only(async_db):
|
||||
async with async_db() as db:
|
||||
mgr = ConversationManager(db)
|
||||
conv = await mgr.create_conversation(
|
||||
context={
|
||||
"product_name": "HDPE PE100 Pipe DN110",
|
||||
"product_id": "p123",
|
||||
"evil_key": "should_be_ignored",
|
||||
}
|
||||
)
|
||||
raw = await _raw_info_for(async_db, conv.id)
|
||||
assert raw == {
|
||||
"app_product_name": "HDPE PE100 Pipe DN110",
|
||||
"app_product_id": "p123",
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"""邮件渲染单测: 标签主题/HTML转义/内部key过滤(D2/D12)。不触网。"""
|
||||
import asyncio
|
||||
|
||||
from app.engine.services import email_service
|
||||
|
||||
|
||||
def _capture_send(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(msg):
|
||||
captured["msg"] = msg
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(email_service, "_send", fake_send)
|
||||
return captured
|
||||
|
||||
|
||||
def _html(msg) -> str:
|
||||
return msg.get_payload()[0].get_payload(decode=True).decode("utf-8")
|
||||
|
||||
|
||||
def test_lead_email_subject_tag_and_escaped_body(monkeypatch):
|
||||
captured = _capture_send(monkeypatch)
|
||||
ok = asyncio.run(email_service.send_lead_email(
|
||||
customer_profile={"目标国家": "肯尼亚", "_internal": "x", "备注": "<b>bold</b>"},
|
||||
contact_info={"phone": "+254712345678", "wechat": "<i>wx</i>"},
|
||||
reasoning="<b>强意向</b>",
|
||||
conversation_messages=[
|
||||
{"role": "user", "content": "<script>alert(1)</script>"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
],
|
||||
intent_level="interested",
|
||||
))
|
||||
assert ok is True
|
||||
subject = str(captured["msg"]["Subject"])
|
||||
assert "[新询盘·意向强]" in subject and "肯尼亚" in subject
|
||||
body = _html(captured["msg"])
|
||||
assert "<script>" not in body # D12: 转义生效
|
||||
assert "<script>" in body
|
||||
assert "_internal" not in body # 内部 key 不渲染
|
||||
assert "<b>bold</b>" not in body
|
||||
assert "<b>bold</b>" in body
|
||||
assert "<i>wx</i>" not in body
|
||||
assert "<i>wx</i>" in body
|
||||
assert captured["msg"].as_string().isascii()
|
||||
|
||||
|
||||
def test_lead_email_unevaluated_tag(monkeypatch):
|
||||
captured = _capture_send(monkeypatch)
|
||||
asyncio.run(email_service.send_lead_email(
|
||||
customer_profile={}, contact_info={"email": "a@b.com"},
|
||||
reasoning="", conversation_messages=[], intent_level="",
|
||||
))
|
||||
assert "[新询盘·未评估]" in str(captured["msg"]["Subject"])
|
||||
|
||||
|
||||
def test_returning_and_correction_emails(monkeypatch):
|
||||
captured = _capture_send(monkeypatch)
|
||||
assert asyncio.run(
|
||||
email_service.send_returning_lead_email({"whatsapp": "+254700000000"})
|
||||
) is True
|
||||
assert "[线索回访]" in str(captured["msg"]["Subject"])
|
||||
assert asyncio.run(
|
||||
email_service.send_correction_email({"email": "a@b.com"})
|
||||
) is True
|
||||
assert "[询盘·更正1]" in str(captured["msg"]["Subject"])
|
||||
|
||||
|
||||
# ---- Task 6(spec §5 首封补全号矩阵): (confirmed, source, 有无 e164) × 显示值+标注 ----
|
||||
|
||||
# 等待/未确认态: 裸号 + SA IP → 高置信建议(attempts=1 = 追问轮轮末已耗一轮)
|
||||
_CC_IP_PROPOSED = {"e164": "+966501234567", "intl": "+966 50 123 4567",
|
||||
"confirmed": False, "attempts": 1, "source": "ip_proposed",
|
||||
"canon": "0501234567"}
|
||||
# 裸写 IP 国码前缀,静默规范化(confirmed=true,码是客户自己写的)
|
||||
_CC_PREFIX_NORM = {"e164": "+966501234567", "intl": "+966 50 123 4567",
|
||||
"confirmed": True, "attempts": 0, "source": "prefix_normalized",
|
||||
"canon": "966501234567"}
|
||||
# 客户报国名重算后确认(埃及 = +20)
|
||||
_CC_CUSTOMER_STATED = {"e164": "+20501234567", "intl": "+20 50 123 4567",
|
||||
"confirmed": True, "attempts": 1, "source": "customer_stated",
|
||||
"canon": "0501234567"}
|
||||
# attempts 耗尽落定(标注保留"未经客户确认",spec §4)
|
||||
_CC_FALLBACK = {"e164": "+966501234567", "intl": "+966 50 123 4567",
|
||||
"confirmed": False, "attempts": 2, "source": "fallback",
|
||||
"canon": "0501234567"}
|
||||
# 无建议国(IP 无效): 无 e164
|
||||
_CC_NO_IP = {"confirmed": False, "attempts": 1, "source": "no_ip",
|
||||
"canon": "0501234567"}
|
||||
# Task 3 调和防御: fallback-无-e164(迁移分支可产出,有机状态机不产生)
|
||||
_CC_FALLBACK_NO_E164 = {"confirmed": False, "attempts": 0, "source": "fallback",
|
||||
"canon": "0501234567"}
|
||||
|
||||
|
||||
def test_lead_email_cc_unconfirmed_e164_with_note(monkeypatch):
|
||||
"""首封渲染(未确认态): 正文 e164 +(区号按 IP 推测,未经客户确认);
|
||||
主题行 e164 无标注(主题拥挤);裸号原文被补全号替换。"""
|
||||
captured = _capture_send(monkeypatch)
|
||||
assert asyncio.run(email_service.send_lead_email(
|
||||
customer_profile={"目标国家": "沙特阿拉伯"},
|
||||
contact_info={"whatsapp": "0501234567"},
|
||||
reasoning="", conversation_messages=[],
|
||||
intent_level="interested",
|
||||
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)},
|
||||
)) is True
|
||||
subject = str(captured["msg"]["Subject"])
|
||||
body = _html(captured["msg"])
|
||||
assert "+966501234567(区号按 IP 推测,未经客户确认)" in body
|
||||
assert "+966501234567" in subject # 主题行号码用 e164
|
||||
assert "区号按 IP 推测" not in subject # 主题不加标注
|
||||
assert "(缺国家区号)" not in body
|
||||
assert "0501234567" not in body # 裸号原文不再出现
|
||||
|
||||
|
||||
def test_lead_email_cc_confirmed_sources_no_note(monkeypatch):
|
||||
"""confirmed=true(prefix_normalized/customer_stated): e164 无标注——
|
||||
码是客户自己写的/客户确认过的,零披露。"""
|
||||
for state, raw in ((dict(_CC_PREFIX_NORM), "966501234567"),
|
||||
(dict(_CC_CUSTOMER_STATED), "0501234567")):
|
||||
captured = _capture_send(monkeypatch)
|
||||
asyncio.run(email_service.send_lead_email(
|
||||
customer_profile={}, contact_info={"whatsapp": raw},
|
||||
reasoning="", conversation_messages=[],
|
||||
cc_info={"whatsapp": state},
|
||||
))
|
||||
body = _html(captured["msg"])
|
||||
assert state["e164"] in body
|
||||
assert "未经客户确认" not in body
|
||||
assert "(缺国家区号)" not in body
|
||||
|
||||
|
||||
def test_lead_email_cc_fallback_keeps_note(monkeypatch):
|
||||
"""fallback 落定(confirmed=false 有 e164): e164 + 未经确认标注。"""
|
||||
captured = _capture_send(monkeypatch)
|
||||
asyncio.run(email_service.send_lead_email(
|
||||
customer_profile={}, contact_info={"whatsapp": "0501234567"},
|
||||
reasoning="", conversation_messages=[],
|
||||
cc_info={"whatsapp": dict(_CC_FALLBACK)},
|
||||
))
|
||||
body = _html(captured["msg"])
|
||||
assert "+966501234567(区号按 IP 推测,未经客户确认)" in body
|
||||
|
||||
|
||||
def test_lead_email_cc_no_e164_states_raw_with_missing_note(monkeypatch):
|
||||
"""无 e164 态: no_ip 与 fallback-无-e164(Task 3 调和防御)→ 裸号原文 +
|
||||
(缺国家区号),不崩;主题行同样裸号。"""
|
||||
for state in (dict(_CC_NO_IP), dict(_CC_FALLBACK_NO_E164)):
|
||||
captured = _capture_send(monkeypatch)
|
||||
asyncio.run(email_service.send_lead_email(
|
||||
customer_profile={}, contact_info={"whatsapp": "0501234567"},
|
||||
reasoning="", conversation_messages=[],
|
||||
cc_info={"whatsapp": state},
|
||||
))
|
||||
body = _html(captured["msg"])
|
||||
assert "0501234567(缺国家区号)" in body
|
||||
assert "未经客户确认" not in body
|
||||
assert "+966" not in body
|
||||
assert "0501234567" in str(captured["msg"]["Subject"])
|
||||
|
||||
|
||||
def test_lead_email_merged_phone_shares_whatsapp_state(monkeypatch):
|
||||
"""canon 组归并渲染: phone 与 whatsapp 同号(phone 无独立态)共享 whatsapp
|
||||
组状态——两行都出补全号,不出现一行 e164 一行裸号的自相矛盾。"""
|
||||
captured = _capture_send(monkeypatch)
|
||||
asyncio.run(email_service.send_lead_email(
|
||||
customer_profile={}, contact_info={"whatsapp": "0501234567",
|
||||
"phone": "0501234567"},
|
||||
reasoning="", conversation_messages=[],
|
||||
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)},
|
||||
))
|
||||
body = _html(captured["msg"])
|
||||
assert body.count("+966501234567(区号按 IP 推测,未经客户确认)") == 2
|
||||
|
||||
|
||||
def test_lead_email_stale_cc_state_ignored(monkeypatch):
|
||||
"""canon 不匹配的残留态(换号后旧 _cc)不渲染: 裸号原文、无任何标注——
|
||||
旧补全永不跟随新号。"""
|
||||
captured = _capture_send(monkeypatch)
|
||||
asyncio.run(email_service.send_lead_email(
|
||||
customer_profile={}, contact_info={"whatsapp": "0512345678"},
|
||||
reasoning="", conversation_messages=[],
|
||||
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)}, # canon=0501234567 ≠ 新号
|
||||
))
|
||||
body = _html(captured["msg"])
|
||||
assert "0512345678" in body
|
||||
assert "+966501234567" not in body
|
||||
assert "(缺国家区号)" not in body
|
||||
|
||||
|
||||
def test_correction_and_returning_render_cc(monkeypatch):
|
||||
"""更正邮件正文/回访邮件主题行同样 _cc 感知: 更正正文带标注 e164;
|
||||
回访主题纯 e164 无标注。"""
|
||||
captured = _capture_send(monkeypatch)
|
||||
assert asyncio.run(email_service.send_correction_email(
|
||||
{"whatsapp": "0501234567"}, count=2,
|
||||
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)},
|
||||
)) is True
|
||||
assert "[询盘·更正2]" in str(captured["msg"]["Subject"])
|
||||
assert "+966501234567(区号按 IP 推测,未经客户确认)" in _html(captured["msg"])
|
||||
assert asyncio.run(email_service.send_returning_lead_email(
|
||||
{"whatsapp": "0501234567"},
|
||||
cc_info={"whatsapp": dict(_CC_IP_PROPOSED)},
|
||||
)) is True
|
||||
subject = str(captured["msg"]["Subject"])
|
||||
assert "+966501234567" in subject
|
||||
assert "区号按 IP 推测" not in subject
|
||||
@@ -0,0 +1,6 @@
|
||||
from app.engine.models import ConversationSource
|
||||
|
||||
|
||||
def test_app_source_exists():
|
||||
assert ConversationSource("app") is ConversationSource.app
|
||||
assert ConversationSource.app.value == "app"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""提取/评估提示词建材化: 字段与占位符静态断言(Task 4)。"""
|
||||
from app.engine.services.ai_service import EVALUATOR_PROMPT, EXTRACTOR_PROMPT
|
||||
|
||||
# 建材字段清单(与 system.md "关注的信息"节口径一致)
|
||||
PIPE_FIELDS = (
|
||||
"意向品类", # HDPE给水管/PVC排水管/PPR…
|
||||
"规格要求", # 自由文本保留客户原话
|
||||
"标准要求", # ISO4427/ASTM/EN/SCH 单列
|
||||
"采购数量",
|
||||
"用途", # 给水/燃气/排污/灌溉…
|
||||
"项目类型",
|
||||
"目标国家", # email_service.send_lead_email 按此键取国家
|
||||
"预算提及",
|
||||
"联系方式",
|
||||
)
|
||||
|
||||
# 汽车项目遗留字段——建材化后不得残留
|
||||
AUTO_WORDS = ("车型", "意向品牌", "车况要求", "年份要求", "运输方式", "购车", "试驾")
|
||||
|
||||
|
||||
def test_extractor_has_pipe_fields():
|
||||
for field in PIPE_FIELDS:
|
||||
assert field in EXTRACTOR_PROMPT, field
|
||||
|
||||
|
||||
def test_extractor_no_auto_fields():
|
||||
for word in AUTO_WORDS:
|
||||
assert word not in EXTRACTOR_PROMPT, word
|
||||
|
||||
|
||||
def test_extractor_spec_is_free_text():
|
||||
# 规格要求 = 自由文本保留客户原话,不拆分不过度结构化
|
||||
assert "原话" in EXTRACTOR_PROMPT
|
||||
assert "不拆分" in EXTRACTOR_PROMPT
|
||||
|
||||
|
||||
def test_extractor_standard_only_when_mentioned():
|
||||
# 标准要求客户提到才记,不猜测
|
||||
assert "客户提到才记" in EXTRACTOR_PROMPT
|
||||
|
||||
|
||||
def test_extractor_placeholders_and_contact_contract_intact():
|
||||
# 占位符(ai_service.replace 依赖)与联系方式四层合并的 extractor 层契约
|
||||
assert "$existing_info" in EXTRACTOR_PROMPT
|
||||
assert "$messages" in EXTRACTOR_PROMPT
|
||||
assert '{"手机": "xxx", "WhatsApp": "xxx", "邮箱": "xxx", "微信": "xxx"}' in EXTRACTOR_PROMPT
|
||||
assert "整条消息只有号码" in EXTRACTOR_PROMPT
|
||||
|
||||
|
||||
def test_evaluator_keeps_three_levels_and_placeholders():
|
||||
for level in ("interested", "not_interested", "pending"):
|
||||
assert level in EVALUATOR_PROMPT, level
|
||||
assert "$profile" in EVALUATOR_PROMPT
|
||||
assert "$recent_messages" in EVALUATOR_PROMPT
|
||||
# _normalize_evaluation 消费的输出字段
|
||||
for key in ("intent_level", "reasoning", "contact_info", "info_sufficient"):
|
||||
assert key in EVALUATOR_PROMPT, key
|
||||
|
||||
|
||||
def test_evaluator_no_auto_fields():
|
||||
for word in AUTO_WORDS:
|
||||
assert word not in EVALUATOR_PROMPT, word
|
||||
|
||||
|
||||
def test_evaluator_pipe_procurement_examples():
|
||||
# 管道采购语义示例: 品类+规格+数量=interested; 泛泛浏览=pending; 无采购计划=not_interested
|
||||
assert "采购需求" in EVALUATOR_PROMPT
|
||||
assert "随便看看" in EVALUATOR_PROMPT # not_interested: 明确不买
|
||||
assert "泛泛" in EVALUATOR_PROMPT # pending: 仅泛泛浏览
|
||||
|
||||
|
||||
# ---- Task 5/6b: 质量审查移交的提示词微修 ----
|
||||
def test_extractor_field_values_chinese_summary():
|
||||
# 字段值统一中文概括;唯一例外是规格要求原样保留
|
||||
assert "中文概括" in EXTRACTOR_PROMPT
|
||||
assert "绝不翻译" in EXTRACTOR_PROMPT
|
||||
|
||||
|
||||
def test_extractor_standard_example_with_number_kept():
|
||||
# 标准要求示例 ASTM D3035(具体编号)+ 编号原样保留
|
||||
assert "ASTM D3035" in EXTRACTOR_PROMPT
|
||||
assert "编号原样保留" in EXTRACTOR_PROMPT
|
||||
|
||||
|
||||
def test_evaluator_positive_signals_are_bonus_not_required():
|
||||
# 询价/索联系方式移出 interested 必要条件,单独"积极信号"小节
|
||||
assert "积极信号(加分项,非必要条件)" in EVALUATOR_PROMPT
|
||||
assert "非必要条件" in EVALUATOR_PROMPT
|
||||
|
||||
|
||||
def test_evaluator_interested_example_mentions_rounds():
|
||||
# interested 示例补"聊了几轮后"(轮次语境)
|
||||
assert "聊了几轮后" in EVALUATOR_PROMPT
|
||||
|
||||
|
||||
# ---- Task 5(spec §2/§4b): extractor cc_reply 单轮答语分类 ----
|
||||
def test_extractor_cc_reply_field_and_double_gating():
|
||||
"""cc_reply 可选字段说明 + 双门控声明(提示词侧; Python 侧等待态门控在
|
||||
conversation._cc_round_end)——防止对无关问题的 yes 被误当号码确认。"""
|
||||
assert "cc_reply" in EXTRACTOR_PROMPT
|
||||
assert '{"affirm": true}' in EXTRACTOR_PROMPT
|
||||
assert '{"affirm": false, "country": "Egypt"}' in EXTRACTOR_PROMPT
|
||||
assert "仅当客户在回应号码确认时填写" in EXTRACTOR_PROMPT # 双门控声明
|
||||
assert "自由文本国名" in EXTRACTOR_PROMPT # country 中英皆可
|
||||
@@ -0,0 +1,111 @@
|
||||
"""邀请卡静态回归网:不变量"未展示 ⇒ 零占位、零拦截"。
|
||||
|
||||
背景(2026-09-01 bug):点 X 写入 permanent 后刷新,startInviteTimer 在
|
||||
storage 守卫早退 ⇒ showInvite 永不执行 ⇒ invitePanel 引用恒为 null ⇒
|
||||
hideInvite 提前返回 ⇒ .invite-panel 停留"display:block + opacity:0"的
|
||||
隐形占位态,.invite-container(z-index:99998、无 pointer-events:none)
|
||||
拦截页面右下角所有点击。修复 = 出厂即折叠(A)+ 容器点击穿透(C)。
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
STATIC = Path(__file__).resolve().parent.parent / "static"
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (STATIC / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_function(src, name):
|
||||
"""花括号计数提取函数源码(与 test_theme_static.py 同款)。"""
|
||||
start = src.index("function " + name + "(")
|
||||
i = src.index("{", start)
|
||||
depth = 0
|
||||
for j in range(i, len(src)):
|
||||
if src[j] == "{":
|
||||
depth += 1
|
||||
elif src[j] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start:j + 1]
|
||||
raise AssertionError("unbalanced braces in " + name)
|
||||
|
||||
|
||||
def test_invite_panel_ships_hidden():
|
||||
"""A:面板出厂即带 hide —— 任何绕过 showInvite 的路径都零占位。"""
|
||||
src = _read("widget.js")
|
||||
assert '<div class="invite-panel hide">' in src, \
|
||||
"invite 模板必须出厂带 hide(未展示 ⇒ 无布局占位)"
|
||||
|
||||
|
||||
def test_invite_container_click_through():
|
||||
"""C:容器 pointer-events:none —— 卡片未展示时整棵子树点击穿透。"""
|
||||
# 行首引号锚定原始定义行——位置联动规则(.widget-container.pos-* .invite-container)
|
||||
# 同样匹配宽松正则,须排除(2026-09-30 邀请卡联动修复时的提取器缺陷)
|
||||
m = re.search(r'"\.invite-container \{[^}]*\}', _read("widget.js"))
|
||||
assert m, "widget.js 缺 .invite-container 规则"
|
||||
assert "pointer-events:none" in m.group(0), \
|
||||
".invite-container 必须 pointer-events:none(防隐形拦截点击)"
|
||||
|
||||
|
||||
def test_show_invite_reflow_between_unhide_and_show():
|
||||
"""入场动画:remove(hide) 与 add(show) 之间必须强制回流。
|
||||
|
||||
出厂带 hide 后,若 remove 与 add 同帧执行,display:none→block 与
|
||||
opacity 变化合并进同一次样式重算,0.3s 淡入退化为瞬现。
|
||||
"""
|
||||
fn = _extract_function(_read("widget.js"), "showInvite")
|
||||
for token in ('remove("hide")', "offsetWidth", 'add("show")'):
|
||||
assert token in fn, f"showInvite 缺 {token}"
|
||||
assert fn.index('remove("hide")') < fn.index("offsetWidth") < fn.index('add("show")'), \
|
||||
"showInvite 须 remove(hide) → offsetWidth 回流 → add(show) 顺序执行"
|
||||
|
||||
|
||||
def test_autoinvite_specialcase_removed_and_guard_kept():
|
||||
"""A 落地后 !autoInvite 预折叠特判删除;startInviteTimer 的配置守卫保留。"""
|
||||
src = _read("widget.js")
|
||||
assert "disabledInvitePanel" not in src, \
|
||||
"!autoInvite 预折叠特判应删除(出厂 hide 统一覆盖该不变量)"
|
||||
fn = _extract_function(src, "startInviteTimer")
|
||||
assert "if (!autoInvite) return;" in fn, \
|
||||
"startInviteTimer 必须保留 data-auto-invite=false 永不自动弹的守卫"
|
||||
|
||||
|
||||
def test_gdpr_privacy_notice_present():
|
||||
"""GDPR 三入口结构锁(Task 9 质量审查): 邀请卡/聊天面板/独立页的
|
||||
privacy 提示是纯模板字符串,无此断言则重构时会无声丢失。"""
|
||||
wjs = _read("widget.js")
|
||||
assert "invite-privacy" in wjs and "chat-footer" in wjs
|
||||
assert 'href="https://hesupipe.com/privacy-policy/"' in wjs
|
||||
assert 'target="_blank" rel="noopener"' in wjs
|
||||
chat = _read("chat.html")
|
||||
assert "privacy policy" in chat and "privacy-policy/" in chat
|
||||
|
||||
|
||||
def test_position_class_panel_alignment():
|
||||
"""位置联动(2026-09-30 线上bug): 面板对齐必须随 data-position 翻转。
|
||||
根因: .chat-panel 硬编码 right:0(为 bottom-right 设计), positionStyles
|
||||
只应用到按钮容器——bottom-left 时 400px 面板右缘贴 196px 容器右缘,
|
||||
向左溢出屏幕 184px(线上实测 x=-183.78); top-* 模式面板 bottom:80px
|
||||
同理向上溢出。修法: 容器挂 pos-<position> class + CSS 联动规则。"""
|
||||
src = _read("widget.js")
|
||||
# 1) 无效 position 归一化(防 "pos-垃圾" class 与查表错位)
|
||||
assert "if (!positionStyles[position])" in src, "position 需归一化为合法值"
|
||||
# 2) 容器挂载位置 class
|
||||
assert '"widget-container pos-" + position' in src, "container 需挂 pos-<position> class"
|
||||
# 3) CSS 联动规则: 左侧模式面板左对齐, 顶部模式面板从顶部向下弹
|
||||
# ⚠️ chat-panel 与 invite-container(邀请卡)是同款 absolute+right:0 定位,
|
||||
# 必须一起联动(2026-09-30 二次线上反馈: 邀请卡漏修同样溢出)
|
||||
for rule_sel, rule_body in [
|
||||
(".widget-container.pos-bottom-left .chat-panel,", None),
|
||||
(".widget-container.pos-bottom-left .invite-container { left:0; right:auto; }", None),
|
||||
(".widget-container.pos-top-right .chat-panel,", None),
|
||||
(".widget-container.pos-top-right .invite-container { top:0; bottom:auto; }", None),
|
||||
(".widget-container.pos-top-left .chat-panel,", None),
|
||||
(".widget-container.pos-top-left .invite-container { left:0; right:auto; top:0; bottom:auto; }", None),
|
||||
]:
|
||||
assert rule_sel in src, f"缺联动规则: {rule_sel}"
|
||||
# 4) pos 规则须在 mobile-host 规则之后注入(同特异性靠顺序决胜, 不得被 bottom:84px 覆盖打断)
|
||||
mobile_rule = ".widget-container.mobile-host .chat-panel { bottom:84px; }"
|
||||
pos_rule = ".widget-container.pos-bottom-left .chat-panel"
|
||||
assert src.index(mobile_rule) < src.index(pos_rule), "pos 规则须在 mobile 规则之后"
|
||||
@@ -0,0 +1,18 @@
|
||||
def test_resolve_region_code():
|
||||
from app.engine.services.ip_service import resolve_region_code
|
||||
# 公网IP返回 ISO alpha-2 或 None(查不到),绝不返回中文/"本地"/"未知"
|
||||
r = resolve_region_code("104.28.41.169")
|
||||
assert r is None or (isinstance(r, str) and len(r) == 2 and r.isupper() and r.isalpha())
|
||||
assert resolve_region_code("") is None
|
||||
assert resolve_region_code("127.0.0.1") is None
|
||||
assert resolve_region_code("::1") is None
|
||||
|
||||
def test_country_names_ca_bug_fixed():
|
||||
from app.engine.services import ip_service
|
||||
assert ip_service.COUNTRY_NAMES.get("CA") == "加拿大"
|
||||
assert ip_service.COUNTRY_NAMES.get("AU") == "澳大利亚"
|
||||
|
||||
|
||||
def test_resolve_region_code_known_ip_deterministic():
|
||||
from app.engine.services.ip_service import resolve_region_code
|
||||
assert resolve_region_code("8.8.8.8") == "US" # Google DNS, 离线库确定性
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,634 @@
|
||||
"""询盘推送 UniService:模型/服务单元测试(设计 2026-09-28-lead-push-to-uniservice)。"""
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.engine.models import (
|
||||
Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
|
||||
Message, MessageRole,
|
||||
)
|
||||
|
||||
|
||||
def test_lead_push_log_roundtrip(async_db):
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
conv = Conversation(session_id="s1")
|
||||
db.add(conv)
|
||||
await db.flush()
|
||||
ev = Evaluation(
|
||||
conversation_id=conv.id,
|
||||
intent_level=IntentLevel.pending, contact_info={},
|
||||
)
|
||||
db.add(ev)
|
||||
await db.flush()
|
||||
db.add(LeadPushLog(
|
||||
evaluation_id=ev.id, conversation_id=conv.id,
|
||||
push_type="lead", status="pending",
|
||||
))
|
||||
await db.commit()
|
||||
row = (await db.execute(select(LeadPushLog))).scalar_one()
|
||||
assert row.id and len(row.id) == 36 # UUID 主键,同既有四表
|
||||
assert row.status == "pending" and row.attempts == 0
|
||||
assert row.outcome is None and row.response_status is None
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_push_settings_defaults():
|
||||
from app.engine.config import Settings
|
||||
s = Settings(_env_file=None) # 隔离本机 .env(联调时会写入 PUSH_* 真值)
|
||||
assert s.PUSH_ENABLED is False
|
||||
assert s.PUSH_API_URL == (
|
||||
"https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push")
|
||||
assert s.PUSH_API_KEY == ""
|
||||
assert s.PUSH_EXTERNAL_KEY_PREFIX == "ai-kefu-jc" # 与汽车项目线索键空间隔离
|
||||
assert s.PUSH_TIMEOUT_SECONDS == 10.0
|
||||
assert s.PUSH_MAX_ATTEMPTS == 3
|
||||
assert s.PUSH_RETRY_BACKOFF_BASE == 1.0
|
||||
|
||||
|
||||
def test_fmt_cst_converts_utc_and_naive():
|
||||
from datetime import datetime, timezone
|
||||
from app.engine.services.lead_push_service import _fmt_cst
|
||||
aware = datetime(2026, 9, 28, 2, 30, 0, tzinfo=timezone.utc)
|
||||
assert _fmt_cst(aware) == "2026-09-28T10:30:00+08:00" # UTC→+08:00,绝不发 UTC
|
||||
naive = datetime(2026, 9, 28, 2, 30, 0) # SQLite 读回形态,按 UTC 补
|
||||
assert _fmt_cst(naive) == "2026-09-28T10:30:00+08:00"
|
||||
micro = datetime(2026, 9, 28, 2, 30, 0, 123456, tzinfo=timezone.utc)
|
||||
assert _fmt_cst(micro) == "2026-09-28T10:30:00+08:00" # 微秒归一整秒(契约示例精度)
|
||||
|
||||
|
||||
def test_map_intent():
|
||||
from app.engine.services.lead_push_service import map_intent
|
||||
assert map_intent("interested", True) == "high"
|
||||
assert map_intent("pending", True) == "medium"
|
||||
assert map_intent("not_interested", True) == "low" # 销售优先级解读,设计 7.2 定案
|
||||
assert map_intent("pending", False) == "unknown" # 未评估≠待确认(D2 语义)
|
||||
assert map_intent("bogus", True) == "unknown"
|
||||
assert map_intent(None, True) == "unknown"
|
||||
assert map_intent(IntentLevel.pending, True) == "medium" # DB 读回枚举成员形态
|
||||
|
||||
|
||||
def _seed_push_ctx(async_db, client_info=None, sid="s-push"):
|
||||
"""建 conv/profile/ev 三件套,返回 (conv, profile, ev)。"""
|
||||
from app.engine.models import ConversationSource
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
conv = Conversation(
|
||||
session_id=sid, source=ConversationSource.widget,
|
||||
client_info=client_info or {"ip": "8.8.8.8", "lang": "en"},
|
||||
)
|
||||
db.add(conv)
|
||||
await db.flush()
|
||||
profile = CustomerProfile(
|
||||
conversation_id=conv.id, raw_info={"目标国家": "肯尼亚"})
|
||||
ev = Evaluation(
|
||||
conversation_id=conv.id,
|
||||
intent_level=IntentLevel.interested, contact_info={})
|
||||
db.add_all([profile, ev])
|
||||
await db.commit()
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_dispatch_disabled_returns_none(async_db, monkeypatch):
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", False)
|
||||
_seed_push_ctx(async_db)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, CustomerProfile, Evaluation
|
||||
conv = (await db.execute(select(Conversation))).scalar_one()
|
||||
profile = (await db.execute(select(CustomerProfile))).scalar_one()
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
return await svc.dispatch_lead_push(
|
||||
db, ev, conv, profile, {"phone": "+254722111117"},
|
||||
"lead", {"intent_level": "interested"}, True)
|
||||
assert asyncio.run(run()) is None
|
||||
assert asyncio.run(_select_logs(async_db)) == [] # 开关关闭零落库
|
||||
|
||||
|
||||
def test_dispatch_wechat_only_marks_skipped(async_db, monkeypatch):
|
||||
"""wechat-only: 我方发邮件(wechat 算 confirmed)但对方必拒三选一 → skipped 留痕不派发。"""
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
|
||||
_seed_push_ctx(async_db)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, CustomerProfile, Evaluation
|
||||
conv = (await db.execute(select(Conversation))).scalar_one()
|
||||
profile = (await db.execute(select(CustomerProfile))).scalar_one()
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
result = await svc.dispatch_lead_push(
|
||||
db, ev, conv, profile, {"wechat": "li_ming_88"},
|
||||
"lead", {"intent_level": "interested"}, True)
|
||||
await db.commit()
|
||||
return result
|
||||
assert asyncio.run(run()) is None
|
||||
logs = asyncio.run(_select_logs(async_db))
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == "skipped"
|
||||
assert "phone/whatsapp/email" in logs[0].error_message
|
||||
|
||||
|
||||
async def _select_logs(async_db):
|
||||
async with async_db() as db:
|
||||
return list((await db.execute(select(LeadPushLog))).scalars())
|
||||
|
||||
|
||||
def test_dispatch_pending_payload_structure(async_db, monkeypatch):
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
|
||||
_seed_push_ctx(async_db)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, CustomerProfile, Evaluation
|
||||
conv = (await db.execute(select(Conversation))).scalar_one()
|
||||
profile = (await db.execute(select(CustomerProfile))).scalar_one()
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
db.add(Message(conversation_id=conv.id, role=MessageRole.user,
|
||||
content="my whatsapp is +254722111117"))
|
||||
await db.commit()
|
||||
confirmed = {"phone": "+254722111117", "whatsapp": "+254722111117"}
|
||||
log = await svc.dispatch_lead_push(
|
||||
db, ev, conv, profile, confirmed, "lead",
|
||||
{"intent_level": "interested", "reasoning": "询库存留号"}, True)
|
||||
await db.commit()
|
||||
return conv, log
|
||||
conv, log = asyncio.run(run())
|
||||
assert log is not None and log.status == "pending"
|
||||
p = log.request_payload
|
||||
assert p["event"] == "new"
|
||||
assert p["external_key"] == f"{svc.settings.PUSH_EXTERNAL_KEY_PREFIX}-{conv.id}" # 不硬编码前缀: 联调 .env 会写真实 PUSH_* 值(同 test_push_settings_defaults 的隔离教训)
|
||||
assert p["occurred_at"].endswith("+08:00")
|
||||
assert p["contact"]["phone"] == "+254722111117"
|
||||
assert p["contact"]["skype"] is None
|
||||
assert p["intent_level"] == "high" and p["intent_reason"] == "询库存留号"
|
||||
assert p["profile"]["目标国家"] == "肯尼亚"
|
||||
assert p["profile"]["source"] == "widget"
|
||||
assert p["profile"]["country"] # 8.8.8.8 → 国名非空
|
||||
assert p["conversation"][0]["role"] == "customer"
|
||||
assert p["conversation"][0]["at"].endswith("+08:00")
|
||||
|
||||
|
||||
def test_build_payload_filters_internal_underscore_keys(async_db, monkeypatch):
|
||||
"""内部状态键(_cc_asked/_email_asked 等)不得泄进推送画像(spec §4.3——
|
||||
渲染侧两处有过滤,推送端曾整体拷贝泄漏)。"""
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
|
||||
_seed_push_ctx(async_db)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, CustomerProfile, Evaluation
|
||||
conv = (await db.execute(select(Conversation))).scalar_one()
|
||||
profile = (await db.execute(select(CustomerProfile))).scalar_one()
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
profile.raw_info = { # 模拟 Task3 写入的 _ 前缀一次性计数键
|
||||
"目标国家": "肯尼亚", "意向品类": "PE 给水管",
|
||||
"_cc_asked": 1, "_email_asked": 2,
|
||||
}
|
||||
await db.commit()
|
||||
log = await svc.dispatch_lead_push(
|
||||
db, ev, conv, profile, {"phone": "+254722111117"},
|
||||
"lead", {"intent_level": "interested"}, True)
|
||||
await db.commit()
|
||||
return log
|
||||
log = asyncio.run(run())
|
||||
assert log is not None and log.status == "pending"
|
||||
profile_out = log.request_payload["profile"]
|
||||
assert not [k for k in profile_out if str(k).startswith("_")] # 内部键零泄漏
|
||||
assert profile_out["目标国家"] == "肯尼亚" # 正常键保留
|
||||
assert profile_out["意向品类"] == "PE 给水管"
|
||||
assert profile_out["source"] == "widget" # country/source 追加逻辑不受影响
|
||||
|
||||
|
||||
def test_dispatch_correction_event_rules(async_db, monkeypatch):
|
||||
"""7.1 保护规则: 同会话有 success 前科 → update;没有 → new。"""
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
|
||||
_seed_push_ctx(async_db)
|
||||
|
||||
async def run(with_prior_success):
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, CustomerProfile, Evaluation
|
||||
conv = (await db.execute(select(Conversation))).scalar_one()
|
||||
profile = (await db.execute(select(CustomerProfile))).scalar_one()
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
if with_prior_success:
|
||||
db.add(LeadPushLog(
|
||||
evaluation_id=ev.id, conversation_id=conv.id,
|
||||
push_type="lead", status="success"))
|
||||
await db.flush()
|
||||
log = await svc.dispatch_lead_push(
|
||||
db, ev, conv, profile, {"phone": "+254700000001"},
|
||||
"correction", {"intent_level": "interested"}, True)
|
||||
await db.commit()
|
||||
return log.request_payload["event"]
|
||||
assert asyncio.run(run(False)) == "new"
|
||||
assert asyncio.run(run(True)) == "update"
|
||||
|
||||
|
||||
def test_dispatch_swallows_exceptions(async_db, monkeypatch):
|
||||
"""红线: dispatch 内部炸(如 payload 组装抛错)绝不向上抛。"""
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
|
||||
_seed_push_ctx(async_db)
|
||||
async def boom(*a, **kw):
|
||||
raise RuntimeError("boom")
|
||||
monkeypatch.setattr(svc, "_build_payload", boom)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, CustomerProfile, Evaluation
|
||||
conv = (await db.execute(select(Conversation))).scalar_one()
|
||||
profile = (await db.execute(select(CustomerProfile))).scalar_one()
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
return await svc.dispatch_lead_push(
|
||||
db, ev, conv, profile, {"phone": "+254700000001"},
|
||||
"lead", {}, True)
|
||||
assert asyncio.run(run()) is None
|
||||
|
||||
|
||||
def test_dispatch_db_failure_does_not_poison_caller_tx(async_db, monkeypatch):
|
||||
"""红线(DB 层): flush 失败只回滚推送写入,调用方事务仍可提交(防重复发邮件)。"""
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
|
||||
_seed_push_ctx(async_db)
|
||||
|
||||
async def bad_payload(*a, **kw):
|
||||
return {"bad": object()} # JSON 不可序列化 → flush 必炸
|
||||
|
||||
monkeypatch.setattr(svc, "_build_payload", bad_payload)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, CustomerProfile, Evaluation
|
||||
conv = (await db.execute(select(Conversation))).scalar_one()
|
||||
profile = (await db.execute(select(CustomerProfile))).scalar_one()
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
ev.reasoning = "caller update after dispatch failure"
|
||||
db.add(ev)
|
||||
result = await svc.dispatch_lead_push(
|
||||
db, ev, conv, profile, {"phone": "+254700000001"},
|
||||
"lead", {}, True)
|
||||
logs = list((await db.execute(select(LeadPushLog))).scalars())
|
||||
await db.commit()
|
||||
return result, logs
|
||||
result, logs = asyncio.run(run())
|
||||
assert result is None and logs == []
|
||||
|
||||
async def reload():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Evaluation
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
return ev.reasoning
|
||||
assert asyncio.run(reload()) == "caller update after dispatch failure"
|
||||
|
||||
|
||||
def test_dispatch_returning_with_prior_success_still_new(async_db, monkeypatch):
|
||||
"""不变量: 仅 correction 可 update——returning 即使同会话已 success 也走 new。"""
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
|
||||
_seed_push_ctx(async_db)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, CustomerProfile, Evaluation
|
||||
conv = (await db.execute(select(Conversation))).scalar_one()
|
||||
profile = (await db.execute(select(CustomerProfile))).scalar_one()
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
db.add(LeadPushLog(
|
||||
evaluation_id=ev.id, conversation_id=conv.id,
|
||||
push_type="lead", status="success"))
|
||||
await db.flush()
|
||||
log = await svc.dispatch_lead_push(
|
||||
db, ev, conv, profile, {"phone": "+254700000001"},
|
||||
"returning", {"intent_level": "interested"}, True)
|
||||
await db.commit()
|
||||
return log.request_payload["event"]
|
||||
assert asyncio.run(run()) == "new"
|
||||
|
||||
|
||||
def _mk_push_log(async_db, status="pending"):
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import ConversationSource
|
||||
conv = Conversation(session_id=f"s-{status}-{id(async_db)}",
|
||||
source=ConversationSource.widget)
|
||||
db.add(conv)
|
||||
await db.flush()
|
||||
ev = Evaluation(conversation_id=conv.id,
|
||||
intent_level=IntentLevel.pending, contact_info={})
|
||||
db.add(ev)
|
||||
await db.flush()
|
||||
log = LeadPushLog(evaluation_id=ev.id, conversation_id=conv.id,
|
||||
push_type="lead", status=status,
|
||||
request_payload={"external_key": "ai-kefu-x",
|
||||
"event": "new"})
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
return log.id
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def _run_push_case(async_db, monkeypatch, do_post, log_id):
|
||||
"""注入 _do_post/_sleep/async_session 后驱动 _run_push,返回退避序列。"""
|
||||
from app.engine.services import lead_push_service as svc
|
||||
sleeps = []
|
||||
|
||||
async def fake_sleep(s):
|
||||
sleeps.append(s)
|
||||
|
||||
monkeypatch.setattr(svc, "_do_post", do_post)
|
||||
monkeypatch.setattr(svc, "_sleep", fake_sleep)
|
||||
monkeypatch.setattr(svc, "async_session", async_db)
|
||||
|
||||
async def run():
|
||||
await svc._run_push(log_id, {"external_key": "ai-kefu-x", "event": "new"})
|
||||
asyncio.run(run())
|
||||
return sleeps
|
||||
|
||||
|
||||
def _get_log(async_db, log_id):
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
return await db.get(LeadPushLog, log_id)
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def _resp(status, body=None):
|
||||
import httpx
|
||||
return httpx.Response(status, json=body or {})
|
||||
|
||||
|
||||
def test_run_push_success_accepted(async_db, monkeypatch):
|
||||
async def ok(payload):
|
||||
return _resp(200, {"success": True, "data": {
|
||||
"accepted": 1, "duplicated": 0, "failed": []}}), None
|
||||
lid = _mk_push_log(async_db)
|
||||
_run_push_case(async_db, monkeypatch, ok, lid)
|
||||
log = _get_log(async_db, lid)
|
||||
assert log.status == "success" and log.outcome == "accepted"
|
||||
assert log.response_status == 200 and log.attempts == 1
|
||||
|
||||
|
||||
def test_run_push_success_duplicated(async_db, monkeypatch):
|
||||
async def dup(payload):
|
||||
return _resp(200, {"success": True, "data": {
|
||||
"accepted": 0, "duplicated": 1, "failed": []}}), None
|
||||
lid = _mk_push_log(async_db)
|
||||
_run_push_case(async_db, monkeypatch, dup, lid)
|
||||
log = _get_log(async_db, lid)
|
||||
assert log.status == "success" and log.outcome == "duplicated"
|
||||
|
||||
|
||||
def test_run_push_200_item_rejected_no_retry(async_db, monkeypatch):
|
||||
"""200 但本条在 failed[](如联系方式无效): failed+对方 reason,不重试。"""
|
||||
calls = []
|
||||
|
||||
async def rejected(payload):
|
||||
calls.append(1)
|
||||
return _resp(200, {"success": True, "data": {
|
||||
"accepted": 0, "duplicated": 0,
|
||||
"failed": [{"external_key": "ai-kefu-x", "reason": "无有效联系方式"}]}}), None
|
||||
lid = _mk_push_log(async_db)
|
||||
sleeps = _run_push_case(async_db, monkeypatch, rejected, lid)
|
||||
log = _get_log(async_db, lid)
|
||||
assert log.status == "failed" and "无有效联系方式" in log.error_message
|
||||
assert len(calls) == 1 and sleeps == []
|
||||
|
||||
|
||||
def test_run_push_5xx_retries_then_failed(async_db, monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def always_500(payload):
|
||||
calls.append(1)
|
||||
return _resp(500), None
|
||||
lid = _mk_push_log(async_db)
|
||||
sleeps = _run_push_case(async_db, monkeypatch, always_500, lid)
|
||||
log = _get_log(async_db, lid)
|
||||
assert len(calls) == 3 and log.attempts == 3 and log.status == "failed"
|
||||
assert log.response_status == 500
|
||||
assert sleeps == [1.0, 2.0] # 指数退避: 3 次尝试 2 次间隔
|
||||
|
||||
|
||||
def test_run_push_401_fails_fast(async_db, monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def unauthorized(payload):
|
||||
calls.append(1)
|
||||
return _resp(401), None
|
||||
lid = _mk_push_log(async_db)
|
||||
sleeps = _run_push_case(async_db, monkeypatch, unauthorized, lid)
|
||||
log = _get_log(async_db, lid)
|
||||
assert len(calls) == 1 and log.attempts == 1 and log.status == "failed"
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_run_push_transport_error_then_success(async_db, monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def flaky(payload):
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
return None, "ConnectError('timeout')"
|
||||
return _resp(200, {"success": True, "data": {
|
||||
"accepted": 1, "duplicated": 0, "failed": []}}), None
|
||||
lid = _mk_push_log(async_db)
|
||||
sleeps = _run_push_case(async_db, monkeypatch, flaky, lid)
|
||||
log = _get_log(async_db, lid)
|
||||
assert log.status == "success" and log.attempts == 2 and sleeps == [1.0]
|
||||
|
||||
|
||||
def test_run_push_crash_guard(async_db, monkeypatch):
|
||||
"""红线: _do_post 抛非 HTTP 异常也不得向外传播。"""
|
||||
async def boom(payload):
|
||||
raise RuntimeError("boom")
|
||||
lid = _mk_push_log(async_db)
|
||||
_run_push_case(async_db, monkeypatch, boom, lid) # 不 raise 即通过
|
||||
log = _get_log(async_db, lid)
|
||||
assert log.status == "pending" # 结果未写,留给启动清扫兜底
|
||||
|
||||
|
||||
def test_run_push_200_nondict_json_normalized(async_db, monkeypatch):
|
||||
"""防御: 200 但 body 是合法 JSON 非对象(如数组)——归一为空 data,不 crash。"""
|
||||
async def weird(payload):
|
||||
return _resp(200, ["not", "a", "dict"]), None
|
||||
lid = _mk_push_log(async_db)
|
||||
_run_push_case(async_db, monkeypatch, weird, lid)
|
||||
log = _get_log(async_db, lid)
|
||||
assert log.status == "success" and log.outcome == "accepted"
|
||||
|
||||
|
||||
def test_run_push_200_html_body_fails(async_db, monkeypatch):
|
||||
"""200 但 body 非 JSON(如重定向到 HTML 登录页)——不得误记 success。"""
|
||||
import httpx
|
||||
|
||||
async def html(payload):
|
||||
return httpx.Response(200, text="<html>login</html>"), None
|
||||
lid = _mk_push_log(async_db)
|
||||
_run_push_case(async_db, monkeypatch, html, lid)
|
||||
log = _get_log(async_db, lid)
|
||||
assert log.status == "failed" and "non-JSON" in log.error_message
|
||||
assert log.response_status == 200
|
||||
|
||||
|
||||
def test_fire_pending_creates_tasks(monkeypatch):
|
||||
"""create_task 派发(不阻塞语义的结构保证);非 pending 不派。"""
|
||||
from types import SimpleNamespace
|
||||
from app.engine.services import lead_push_service as svc
|
||||
pushed = []
|
||||
|
||||
async def fake_run(log_id, payload):
|
||||
pushed.append((log_id, payload))
|
||||
|
||||
monkeypatch.setattr(svc, "_run_push", fake_run)
|
||||
logs = [
|
||||
SimpleNamespace(id="id-pending", status="pending",
|
||||
request_payload={"event": "new"}),
|
||||
SimpleNamespace(id="id-skipped", status="skipped",
|
||||
request_payload=None),
|
||||
]
|
||||
|
||||
async def run():
|
||||
svc.fire_pending_pushes(logs)
|
||||
await asyncio.sleep(0) # 同 loop 内让 create_task 跑一轮
|
||||
asyncio.run(run())
|
||||
assert pushed == [("id-pending", {"event": "new"})]
|
||||
|
||||
|
||||
def test_sweep_stale_pending(async_db, monkeypatch):
|
||||
from datetime import datetime, timezone
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc, "async_session", async_db)
|
||||
_seed_push_ctx(async_db)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, Evaluation
|
||||
conv = (await db.execute(select(Conversation))).scalar_one()
|
||||
ev = (await db.execute(select(Evaluation))).scalar_one()
|
||||
stale = LeadPushLog(
|
||||
evaluation_id=ev.id, conversation_id=conv.id,
|
||||
push_type="lead", status="pending",
|
||||
updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc))
|
||||
fresh = LeadPushLog(
|
||||
evaluation_id=ev.id, conversation_id=conv.id,
|
||||
push_type="lead", status="pending")
|
||||
done = LeadPushLog(
|
||||
evaluation_id=ev.id, conversation_id=conv.id,
|
||||
push_type="lead", status="success",
|
||||
updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc))
|
||||
db.add_all([stale, fresh, done])
|
||||
await db.commit()
|
||||
n = await svc.sweep_stale_pending(stale_minutes=30)
|
||||
assert n == 1
|
||||
async with async_db() as db:
|
||||
logs = list((await db.execute(select(LeadPushLog))).scalars())
|
||||
by = {(l.status, l.error_message or ""): l for l in logs}
|
||||
assert ("failed", "interrupted by restart") in by
|
||||
statuses = sorted(l.status for l in logs)
|
||||
assert statuses == ["failed", "pending", "success"]
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---- Task 6(spec §5 首封即补全号): 推送 contact 纯 e164 + profile 派生标注 ----
|
||||
|
||||
_PUSH_CC_WA = {"e164": "+966501234567", "intl": "+966 50 123 4567",
|
||||
"confirmed": False, "attempts": 1, "source": "ip_proposed",
|
||||
"canon": "0501234567"}
|
||||
_PUSH_CC_PHONE = {"e164": "+96613800138000", "intl": "+966 138 001 3800",
|
||||
"confirmed": False, "attempts": 0, "source": "naive",
|
||||
"canon": "13800138000"}
|
||||
|
||||
|
||||
def _dispatch_with_cc(async_db, monkeypatch, raw_info, confirmed, sid="s-cc"):
|
||||
"""三件套 + 指定 raw_info(_cc) → dispatch,返回 pending log。"""
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
|
||||
_seed_push_ctx(async_db, sid=sid)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
from app.engine.models import Conversation, CustomerProfile, Evaluation
|
||||
conv = (await db.execute(
|
||||
select(Conversation).where(Conversation.session_id == sid))
|
||||
).scalar_one()
|
||||
profile = (await db.execute(
|
||||
select(CustomerProfile).where(
|
||||
CustomerProfile.conversation_id == conv.id))).scalar_one()
|
||||
ev = (await db.execute(
|
||||
select(Evaluation).where(
|
||||
Evaluation.conversation_id == conv.id))).scalar_one()
|
||||
profile.raw_info = raw_info
|
||||
await db.commit()
|
||||
log = await svc.dispatch_lead_push(
|
||||
db, ev, conv, profile, confirmed, "lead",
|
||||
{"intent_level": "interested"}, True)
|
||||
await db.commit()
|
||||
return log
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_push_contact_cc_e164_and_derived_note(async_db, monkeypatch):
|
||||
"""contact.phone/whatsapp = 纯 e164(confirmed 与否都给——首封即补全号,
|
||||
契约要求"带国际区号且有效");标注进 profile"号码补全说明"派生键
|
||||
(出站时由 _cc 派生,不落正常键);_cc 本体不泄漏(先消费后过滤)。"""
|
||||
raw_info = {"目标国家": "肯尼亚",
|
||||
"_cc": {"whatsapp": dict(_PUSH_CC_WA), "phone": dict(_PUSH_CC_PHONE)}}
|
||||
log = _dispatch_with_cc(
|
||||
async_db, monkeypatch, raw_info,
|
||||
{"whatsapp": "0501234567", "phone": "13800138000"})
|
||||
assert log is not None and log.status == "pending"
|
||||
p = log.request_payload
|
||||
assert p["contact"]["whatsapp"] == "+966501234567" # 补全号直发
|
||||
assert p["contact"]["phone"] == "+96613800138000"
|
||||
note = p["profile"]["号码补全说明"]
|
||||
assert "0501234567→+966501234567(区号按 IP 推测,未经客户确认)" in note
|
||||
assert "13800138000→+96613800138000(区号按 IP 推测,未经客户确认)" in note
|
||||
assert "_cc" not in p["profile"] # 本体不泄漏
|
||||
assert not [k for k in p["profile"] if str(k).startswith("_")]
|
||||
assert p["profile"]["目标国家"] == "肯尼亚" # 正常键不受影响
|
||||
|
||||
|
||||
def test_push_confirmed_state_e164_without_note(async_db, monkeypatch):
|
||||
"""confirmed 态(prefix_normalized): contact 给 e164,但无需披露——
|
||||
"号码补全说明"键不出现。"""
|
||||
state = {"e164": "+966501234567", "intl": "+966 50 123 4567",
|
||||
"confirmed": True, "attempts": 0, "source": "prefix_normalized",
|
||||
"canon": "966501234567"}
|
||||
log = _dispatch_with_cc(
|
||||
async_db, monkeypatch, {"_cc": {"whatsapp": state}},
|
||||
{"whatsapp": "966501234567"})
|
||||
p = log.request_payload
|
||||
assert p["contact"]["whatsapp"] == "+966501234567"
|
||||
assert "号码补全说明" not in p["profile"]
|
||||
|
||||
|
||||
def test_push_no_e164_state_raw_contact_and_missing_note(async_db, monkeypatch):
|
||||
"""无 e164 态(no_ip/fallback-无-e164): contact 给裸号原文(拿不出补全号
|
||||
不硬造),"号码补全说明"披露(缺国家区号)。"""
|
||||
for source in ("no_ip", "fallback"):
|
||||
state = {"confirmed": False, "attempts": 1, "source": source,
|
||||
"canon": "0501234567"}
|
||||
log = _dispatch_with_cc(
|
||||
async_db, monkeypatch, {"_cc": {"whatsapp": dict(state)}},
|
||||
{"whatsapp": "0501234567"}, sid=f"s-cc-{source}")
|
||||
p = log.request_payload
|
||||
assert p["contact"]["whatsapp"] == "0501234567" # 裸号原文
|
||||
assert p["profile"]["号码补全说明"] == "0501234567(缺国家区号)"
|
||||
|
||||
|
||||
def test_push_stale_cc_canon_ignored(async_db, monkeypatch):
|
||||
"""canon 不匹配的残留态(换号后旧 _cc): contact 用原文、零派生——
|
||||
旧补全永不跟随新号。"""
|
||||
log = _dispatch_with_cc(
|
||||
async_db, monkeypatch, {"_cc": {"whatsapp": dict(_PUSH_CC_WA)}},
|
||||
{"whatsapp": "0512345678"})
|
||||
p = log.request_payload
|
||||
assert p["contact"]["whatsapp"] == "0512345678"
|
||||
assert "号码补全说明" not in p["profile"]
|
||||
assert "_cc" not in p["profile"]
|
||||
@@ -0,0 +1,291 @@
|
||||
"""询盘推送接线集成测试: 跟随邮件三类型/SMTP解耦/commit后派发(设计 §8)。"""
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.engine.models import ConversationSource, LeadPushLog
|
||||
from app.engine.services import email_service, lead_push_service
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
|
||||
|
||||
class FakeAI:
|
||||
"""同 test_lead_email_trigger 的确定性桩。"""
|
||||
|
||||
def __init__(self, extract=None):
|
||||
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
|
||||
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("ok ", "reply"):
|
||||
yield token
|
||||
|
||||
async def extract_info(self, messages, existing_info):
|
||||
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):
|
||||
return {"intent_level": "interested", "reasoning": "留号",
|
||||
"contact_info": {}, "info_sufficient": True}
|
||||
|
||||
|
||||
def _setup(async_db, monkeypatch, ai, lead_succeeds=True):
|
||||
"""邮件桩 + 推送桩: fire 只收集不真发(避免测试触网)。"""
|
||||
sent = []
|
||||
|
||||
async def fake_lead(customer_profile, contact_info, reasoning,
|
||||
conversation_messages, intent_level="", cc_info=None):
|
||||
sent.append(("lead", dict(contact_info)))
|
||||
return lead_succeeds
|
||||
|
||||
async def fake_nudge(contact_info, cc_info=None):
|
||||
sent.append(("returning", dict(contact_info)))
|
||||
return lead_succeeds
|
||||
|
||||
async def fake_fix(contact_info, count=1, cc_info=None):
|
||||
sent.append(("correction", dict(contact_info)))
|
||||
return lead_succeeds
|
||||
|
||||
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)
|
||||
|
||||
fired = []
|
||||
monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
|
||||
lambda logs: fired.extend(logs))
|
||||
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", True)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
manager = ConversationManager(db, ai_service=ai)
|
||||
conv = await manager.create_conversation(
|
||||
source=ConversationSource.widget)
|
||||
return manager, conv
|
||||
return asyncio.run(run()), sent, fired
|
||||
|
||||
|
||||
def _send(manager, sid, text):
|
||||
async def run():
|
||||
async for _ in manager.stream_process_message(sid, text):
|
||||
pass
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def _logs(async_db):
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
return list((await db.execute(select(LeadPushLog))).scalars())
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_lead_email_triggers_pending_push(async_db, monkeypatch):
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
(manager, conv), sent, fired = _setup(async_db, monkeypatch, ai)
|
||||
_send(manager, conv.session_id, "my whatsapp is +254722111117")
|
||||
assert [s[0] for s in sent] == ["lead"]
|
||||
assert len(fired) == 1 and fired[0].status == "pending"
|
||||
assert fired[0].push_type == "lead"
|
||||
assert fired[0].request_payload["event"] == "new"
|
||||
assert fired[0].request_payload["contact"]["phone"] == "+254722111117"
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_smtp_failure_still_pushes(async_db, monkeypatch):
|
||||
"""设计定案: 推送与 SMTP 成败解耦——邮件失败询盘照推。"""
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
(manager, conv), sent, fired = _setup(
|
||||
async_db, monkeypatch, ai, lead_succeeds=False)
|
||||
_send(manager, conv.session_id, "my whatsapp is +254722111117")
|
||||
assert sent and sent[0][0] == "lead" # fake 返回 False,email_sent 不置位
|
||||
assert len(fired) == 1 and fired[0].status == "pending"
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_wechat_only_push_marked_skipped(async_db, monkeypatch):
|
||||
ai = FakeAI(extract={"联系方式": {"微信": "li_ming_88"}})
|
||||
(manager, conv), sent, fired = _setup(async_db, monkeypatch, ai)
|
||||
_send(manager, conv.session_id, "add my wechat li_ming_88")
|
||||
assert [s[0] for s in sent] == ["lead"] # 我方邮件照发(wechat 算 confirmed)
|
||||
assert fired == [] # 无后台任务
|
||||
logs = _logs(async_db)
|
||||
assert len(logs) == 1 and logs[0].status == "skipped"
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_correction_push_event_update_after_success(async_db, monkeypatch):
|
||||
"""改号 → 更正推送;前次已 success(手动置位模拟 _run_push)→ event=update。"""
|
||||
ai = FakeAI(extract=[{"联系方式": {"手机": "+254722111117"}},
|
||||
{"联系方式": {"手机": "+254799999999"}}])
|
||||
(manager, conv), sent, fired = _setup(async_db, monkeypatch, ai)
|
||||
_send(manager, conv.session_id, "my whatsapp is +254722111117")
|
||||
assert len(fired) == 1 and fired[0].push_type == "lead"
|
||||
|
||||
async def mark_success():
|
||||
async with async_db() as db:
|
||||
for row in (await db.execute(select(LeadPushLog))).scalars():
|
||||
row.status = "success"
|
||||
await db.commit()
|
||||
asyncio.run(mark_success())
|
||||
|
||||
_send(manager, conv.session_id, "wrong, use +254799999999")
|
||||
assert [s[0] for s in sent] == ["lead", "correction"]
|
||||
assert len(fired) == 2
|
||||
assert fired[1].push_type == "correction"
|
||||
assert fired[1].request_payload["event"] == "update"
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_returning_push_follows_nudge(async_db, monkeypatch):
|
||||
"""24h 内同值另已发过 → 回访轻提醒 → 推送 push_type=returning,event=new。"""
|
||||
ai1 = FakeAI(extract={"联系方式": {"email": "same@x.com"}})
|
||||
(m1, c1), _s1, f1 = _setup(async_db, monkeypatch, ai1)
|
||||
_send(m1, c1.session_id, "mail same@x.com")
|
||||
assert len(f1) == 1 and f1[0].push_type == "lead"
|
||||
asyncio.run(m1.db.close())
|
||||
|
||||
ai2 = FakeAI(extract={"联系方式": {"email": "same@x.com"}})
|
||||
(m2, c2), sent2, f2 = _setup(async_db, monkeypatch, ai2)
|
||||
_send(m2, c2.session_id, "im back, same@x.com")
|
||||
assert [s[0] for s in sent2] == ["returning"]
|
||||
assert len(f2) == 1 and f2[0].push_type == "returning"
|
||||
assert f2[0].request_payload["event"] == "new"
|
||||
asyncio.run(m2.db.close())
|
||||
|
||||
|
||||
def test_push_disabled_writes_nothing(async_db, monkeypatch):
|
||||
"""PUSH_ENABLED=false(默认): 零日志零派发,既有行为完全不变。"""
|
||||
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", False)
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
|
||||
sent = []
|
||||
|
||||
async def fake_lead(customer_profile, contact_info, reasoning,
|
||||
conversation_messages, intent_level="", cc_info=None):
|
||||
sent.append("lead")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
|
||||
fired = []
|
||||
monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
|
||||
lambda logs: fired.extend(logs))
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
manager = ConversationManager(db, ai_service=ai)
|
||||
conv = await manager.create_conversation(
|
||||
source=ConversationSource.widget)
|
||||
return manager, conv
|
||||
(manager, conv) = asyncio.run(run())
|
||||
_send(manager, conv.session_id, "my whatsapp is +254722111117")
|
||||
assert sent == ["lead"] and fired == [] and _logs(async_db) == []
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_fire_end_to_end_single_loop(async_db, monkeypatch):
|
||||
"""真 fire + 假 _run_push: 同一事件循环内 stream 完成后任务确实被派发执行。"""
|
||||
pushed = []
|
||||
|
||||
async def fake_run(log_id, payload):
|
||||
pushed.append(log_id)
|
||||
|
||||
monkeypatch.setattr(lead_push_service, "_run_push", fake_run)
|
||||
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", True)
|
||||
|
||||
sent = []
|
||||
|
||||
async def fake_lead(customer_profile, contact_info, reasoning,
|
||||
conversation_messages, intent_level="", cc_info=None):
|
||||
sent.append("lead")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
manager = ConversationManager(db, ai_service=ai)
|
||||
conv = await manager.create_conversation(
|
||||
source=ConversationSource.widget)
|
||||
async for _ in manager.stream_process_message(
|
||||
conv.session_id, "my whatsapp is +254722111117"):
|
||||
pass
|
||||
await asyncio.sleep(0) # 让 create_task 的任务跑一轮
|
||||
asyncio.run(run())
|
||||
assert sent == ["lead"] and len(pushed) == 1
|
||||
|
||||
|
||||
def test_fire_happens_only_after_commit(async_db, monkeypatch):
|
||||
"""顺序钉住(设计 8.1): fire 在主事务 commit 之后——后台任务从独立连接
|
||||
必须能读到该 pending 行;若 fire 被挪到 commit 前,此处 NoResultFound 变红。
|
||||
|
||||
加固(变异实测): fire-and-forget 任务里的异常只会被 shutdown 吞掉、
|
||||
单次 sleep(0) 也不够任务跑完真实 aiosqlite I/O——原形态变异后仍绿。
|
||||
改为: 任务把读取结果记回 read_result;fire 后的首次 commit 让位
|
||||
(gate,≤50ms)给后台读先行——broken 序(fire 在 commit 前)时读窗口
|
||||
确定性落在提交前 → NoResultFound 被记录 → 变红;正确序 gate 不触发。
|
||||
"""
|
||||
from sqlalchemy import select as sa_select
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
sent = []
|
||||
read_result = {}
|
||||
commit_gate: asyncio.Event | None = None
|
||||
fired = []
|
||||
|
||||
async def fake_lead(customer_profile, contact_info, reasoning,
|
||||
conversation_messages, intent_level="", cc_info=None):
|
||||
sent.append("lead")
|
||||
return True
|
||||
|
||||
async def fake_run(log_id, payload):
|
||||
async with async_db() as db:
|
||||
try:
|
||||
row = (await db.execute(
|
||||
sa_select(LeadPushLog).where(LeadPushLog.id == log_id)
|
||||
)).scalar_one()
|
||||
read_result["status"] = row.status
|
||||
except Exception as exc: # NoResultFound → fire 先于 commit
|
||||
read_result["error"] = repr(exc)
|
||||
finally:
|
||||
if commit_gate is not None:
|
||||
commit_gate.set()
|
||||
|
||||
real_fire = lead_push_service.fire_pending_pushes
|
||||
|
||||
def recording_fire(logs):
|
||||
fired.append(True)
|
||||
real_fire(logs)
|
||||
|
||||
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
|
||||
monkeypatch.setattr(lead_push_service, "_run_push", fake_run)
|
||||
monkeypatch.setattr(lead_push_service, "fire_pending_pushes", recording_fire)
|
||||
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", True)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
manager = ConversationManager(db, ai_service=ai)
|
||||
conv = await manager.create_conversation(
|
||||
source=ConversationSource.widget)
|
||||
|
||||
# commit 闸门: 仅当 fire 已发生(broken 序才会如此)时,主事务
|
||||
# commit 让位给后台读(否则后台读与 commit 落地是毫秒级竞态)
|
||||
nonlocal commit_gate
|
||||
commit_gate = asyncio.Event()
|
||||
real_commit = manager.db.commit
|
||||
|
||||
async def gated_commit():
|
||||
if fired:
|
||||
try:
|
||||
await asyncio.wait_for(commit_gate.wait(), timeout=0.05)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
await real_commit()
|
||||
manager.db.commit = gated_commit
|
||||
|
||||
async for _ in manager.stream_process_message(
|
||||
conv.session_id, "my whatsapp is +254722111117"):
|
||||
pass
|
||||
for _ in range(400): # 等后台任务读完成(≤2s,实际毫秒级)
|
||||
if read_result:
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
asyncio.run(run())
|
||||
assert sent == ["lead"]
|
||||
assert read_result == {"status": "pending"}
|
||||
@@ -0,0 +1,21 @@
|
||||
from app.engine.services.rate_limit import check, _reset_for_test
|
||||
|
||||
|
||||
def test_allows_under_cap():
|
||||
_reset_for_test()
|
||||
for _ in range(30):
|
||||
assert check("1.2.3.4", "dev1") is True
|
||||
|
||||
|
||||
def test_blocks_over_cap():
|
||||
_reset_for_test()
|
||||
for _ in range(30):
|
||||
assert check("1.2.3.4", "dev1") is True
|
||||
assert check("1.2.3.4", "dev1") is False
|
||||
|
||||
|
||||
def test_isolated_buckets():
|
||||
_reset_for_test()
|
||||
for _ in range(30):
|
||||
check("1.2.3.4", "dev1")
|
||||
assert check("5.6.7.8", "dev2") is True # different ip+device unaffected
|
||||
@@ -0,0 +1,2 @@
|
||||
def test_health(client):
|
||||
assert client.get("/health").status_code == 200
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Task 5(spec §4b-1/2/5 + 计划 Step 3b, 2026-10-08): system.md 回显确认语义
|
||||
+ 语言规则放开静态断言。
|
||||
|
||||
- test_system_md_dual_contact_wording: 双要+首轮带码话术锚点(§4.1 仍有效部分;
|
||||
追问句锚点随 4b-6 拆除而移除)
|
||||
- test_system_md_echo_exception_in_all_prohibitions: 禁令例外句全量改写为
|
||||
回显补全语义(grep 计数 ≥5, 按命中计数而非固定清单)
|
||||
- test_system_md_red_line_carries_system_number_exception: :56 零号码红线
|
||||
唯一例外(高权重锚②——不改则 GLM 拒写号、确认句墙轮轮整段替换)
|
||||
- test_system_md_template_list_contains_echo_sentence: :7 模板句清单成员
|
||||
追问句→回显确认句(高权重锚①)
|
||||
- test_system_md_language_rule_relaxed: 语言规则放开(Step 3b, 需求方确认)
|
||||
- test_system_md_echo_good_bad_examples: 好差示例回显组+自编号差例(§4b-2)
|
||||
- test_system_md_contains_verbatim_echo_bodies: 同源断言迁移——回显句以
|
||||
样例号 format 后逐字在场(与 reminder/墙三方同源)
|
||||
- test_system_md_pseudo_echo_bad_example: 伪回显差例(掩码样例)保留
|
||||
"""
|
||||
|
||||
|
||||
def _read_system_md() -> str:
|
||||
with open("app/engine/prompts/system.md", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _note_body(note: str) -> str:
|
||||
"""从注入常量提取 AI 逐字复述的正文(剥"⚠️ 轻补指引"系统指令外壳)。
|
||||
|
||||
常量形态: 外壳说明 + 请逐字使用这句:"正文" + 尾注。外壳含"留邮箱"等
|
||||
渠道词,会误触再索要墙的中文模式——正文才是 AI 真正说出口的部分,
|
||||
同源断言与过墙断言都以正文为准。"""
|
||||
marker = "请逐字使用这句:\""
|
||||
assert marker in note, note[:40]
|
||||
body = note.split(marker, 1)[1].split("\"", 1)[0]
|
||||
assert body, note[:40]
|
||||
return body
|
||||
|
||||
|
||||
def test_system_md_dual_contact_wording():
|
||||
src = _read_system_md()
|
||||
assert "with the country code" in src # 首轮带码话术
|
||||
assert "formal quotation PDF" in src and "轻补" in src # 邮箱轻补
|
||||
assert src.count("国家代码") >= 2
|
||||
assert "已在手的渠道" in src and "缺失渠道" in src
|
||||
assert "邮箱轻补句" in src # 模板句枚举
|
||||
|
||||
|
||||
def test_system_md_echo_exception_in_all_prohibitions():
|
||||
"""spec §4b-1: 禁令例外句(语言规则/红线尾/对话流程/提醒配合/注意, grep
|
||||
全量命中)从"可请客户重发完整号一次"改写为按系统提醒回显补全语义。"""
|
||||
src = _read_system_md()
|
||||
assert src.count("回显补全后的完整号码") >= 5
|
||||
assert "重发完整号一次" not in src # 旧措辞清零
|
||||
|
||||
|
||||
def test_system_md_red_line_carries_system_number_exception():
|
||||
""":56 红线"绝不能在回复里写出号码"加唯一例外=系统提供的补全号原文。"""
|
||||
src = _read_system_md()
|
||||
assert ("回复中不能出现任何电话号码或邮箱地址(**唯一例外**:系统提供的"
|
||||
"补全号码原文,逐字照抄)") in src
|
||||
|
||||
|
||||
def test_system_md_template_list_contains_echo_sentence():
|
||||
"""spec 4b-6: :7 模板句清单成员——国家代码追问句改为回显确认句。"""
|
||||
src = _read_system_md()
|
||||
assert "回显确认句" in src
|
||||
assert "号码由系统提醒提供,逐字照抄" in src
|
||||
assert "国家代码追问句" not in src # 旧成员清零
|
||||
|
||||
|
||||
def test_system_md_language_rule_relaxed():
|
||||
"""计划 Step 3b(需求方确认 2026-10-08): 模板句跟随客户语言自然表达,
|
||||
"绝不翻译成第三种语言"整段拆除。"""
|
||||
src = _read_system_md()
|
||||
assert "跟随客户语言自然表达" in src
|
||||
assert "绝不翻译成" not in src
|
||||
|
||||
|
||||
def test_system_md_echo_good_bad_examples():
|
||||
"""spec §4b-2: 好差示例裸号组改回显确认形态 + 新增自编号/改数字差例。"""
|
||||
src = _read_system_md()
|
||||
assert "直接\"Got it, thanks!\"收下" in src # 差: 裸号直接收下
|
||||
assert "自己拼号码" in src and "改动数字都是违规" in src # 差: 自编/改数字
|
||||
assert "一个数字都不改" in src # 好: 逐字照抄
|
||||
|
||||
|
||||
def test_system_md_contains_verbatim_echo_bodies():
|
||||
"""同源断言迁移(Task 5/4b-6): 回显确认句以统一样例号 format 后逐字在
|
||||
system.md(引导策略节+好差示例——两处逐字指令漂移会让 GLM 混合输出两种
|
||||
措辞); 邮箱轻补句正文沿用旧断言。"""
|
||||
from app.engine.services.conversation import (
|
||||
_CC_ECHO_EN, _CC_ECHO_ZH, _EMAIL_NOTE_EN, _EMAIL_NOTE_ZH)
|
||||
src = _read_system_md()
|
||||
sample = "+966 50 123 4567"
|
||||
assert _CC_ECHO_EN.format(intl=sample) in src
|
||||
assert _CC_ECHO_ZH.format(intl=sample) in src
|
||||
for note in (_EMAIL_NOTE_EN, _EMAIL_NOTE_ZH):
|
||||
body = _note_body(note)
|
||||
assert body in src, body[:40]
|
||||
|
||||
|
||||
def test_system_md_pseudo_echo_bad_example():
|
||||
"""生产验收 r2 实测伪回显: AI 把 +966 5x xxx xxxx 掩码样例当客户号码回显
|
||||
("Got your number +966 5x xxx xxxx")——好差示例补组钉死禁止。"""
|
||||
src = _read_system_md()
|
||||
assert "Got your number +966 5x xxx xxxx" in src # 差例逐字在场
|
||||
assert "那不是客户的号码" in src # 差例定性
|
||||
assert "掩码样例当号码回显" in src # 好例禁令
|
||||
@@ -0,0 +1,172 @@
|
||||
"""动态主题色静态回归网(spec §8.1、§12.7)。"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
STATIC = Path(__file__).resolve().parent.parent / "static"
|
||||
|
||||
SLOTS = [
|
||||
"--w-primary", "--w-primary-rgb", "--w-gradient-end", "--w-deep-text",
|
||||
"--w-caption-text", "--w-soft-bg", "--w-border-soft",
|
||||
]
|
||||
|
||||
# 现状全部橙色系字面量(spec §4 表格逆向)
|
||||
ORANGE = re.compile(
|
||||
r"#(f08a31|F08A34|EC8C2F|f59e0b|8C4D1D|C16C2E|A8591F|d97706"
|
||||
r"|F0D5BF|EECFB6|E8C4A9|F1CCAE|fef3c7"
|
||||
r"|FFF8F3|fef9f3|FFF9F4|FFF7F1|faf7f3)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
STRAY_RGBA = re.compile(r"rgba?\(\s*(240,\s*138,\s*49|196,\s*91,\s*25)")
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (STATIC / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_theme_js_exists_and_exports():
|
||||
src = _read("js/theme.js")
|
||||
for token in ("derivePalette", "DEFAULT_COLOR", "DEFAULT_PALETTE"):
|
||||
assert token in src, f"theme.js 缺少 {token}"
|
||||
|
||||
|
||||
def _extract_function(src, name):
|
||||
"""花括号计数提取函数源码(要求函数体内字符串字面量不含大括号)。"""
|
||||
start = src.index("function " + name + "(")
|
||||
i = src.index("{", start)
|
||||
depth = 0
|
||||
for j in range(i, len(src)):
|
||||
if src[j] == "{":
|
||||
depth += 1
|
||||
elif src[j] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start:j + 1]
|
||||
raise AssertionError("unbalanced braces in " + name)
|
||||
|
||||
|
||||
SYNCED_FUNCTIONS = ["clampNum", "hexToRgb", "rgbToHsl", "toHex2", "hslToHex", "derivePalette"]
|
||||
|
||||
|
||||
def test_widget_and_theme_derive_identical():
|
||||
# 注:归一化会抹掉字符串字面量内的空白——已接受的盲区(唯一含空格字面量是 warn 消息)
|
||||
norm = lambda s: re.sub(r"\s+", "", s)
|
||||
for name in SYNCED_FUNCTIONS:
|
||||
a = _extract_function(_read("js/theme.js"), name)
|
||||
b = _extract_function(_read("widget.js"), name)
|
||||
na, nb = norm(a), norm(b)
|
||||
if na != nb:
|
||||
limit = min(len(na), len(nb))
|
||||
i = next((k for k in range(limit) if na[k] != nb[k]), limit)
|
||||
assert False, (
|
||||
f"两份 {name} 漂移,首个差异位置 {i}: "
|
||||
f"theme.js ...{na[max(0, i - 20):i + 20]!r} vs widget.js ...{nb[max(0, i - 20):i + 20]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_widget_and_theme_defaults_identical():
|
||||
norm = lambda s: re.sub(r"\s+", "", s)
|
||||
for rel in ("js/theme.js", "widget.js"):
|
||||
m = re.search(r"var DEFAULT_PALETTE = \{.*?\};", _read(rel), re.S)
|
||||
assert m, f"{rel} 缺 DEFAULT_PALETTE 块"
|
||||
a = re.search(r"var DEFAULT_PALETTE = \{.*?\};", _read("js/theme.js"), re.S).group(0)
|
||||
b = re.search(r"var DEFAULT_PALETTE = \{.*?\};", _read("widget.js"), re.S).group(0)
|
||||
assert norm(a) == norm(b), "两份 DEFAULT_PALETTE 漂移"
|
||||
ca = re.search(r'var DEFAULT_COLOR = "[^"]*"', _read("js/theme.js")).group(0)
|
||||
cb = re.search(r'var DEFAULT_COLOR = "[^"]*"', _read("widget.js")).group(0)
|
||||
assert ca == cb, "两份 DEFAULT_COLOR 漂移"
|
||||
|
||||
|
||||
def test_widget_host_injection_complete():
|
||||
src = _read("widget.js")
|
||||
for slot in SLOTS:
|
||||
assert slot + ":" in src, f"widget.js :host 注入缺 {slot}"
|
||||
|
||||
|
||||
def test_style_root_defines_all_slots():
|
||||
src = _read("css/style.css")
|
||||
for slot in SLOTS:
|
||||
assert re.search(re.escape(slot) + r"\s*:", src), f"style.css :root 缺 {slot}"
|
||||
|
||||
|
||||
def test_var_refs_within_known_slots():
|
||||
for rel in ("widget.js", "css/style.css"):
|
||||
used = set(re.findall(r"var\((--w-[a-z-]+)", _read(rel)))
|
||||
unknown = used - set(SLOTS)
|
||||
assert not unknown, f"{rel} 引用未定义槽位: {unknown}"
|
||||
|
||||
|
||||
def _strip_defaults(src):
|
||||
m = re.search(r"var DEFAULT_PALETTE = \{.*?\};", src, re.S)
|
||||
assert m, "DEFAULT_PALETTE 块不存在"
|
||||
src = src.replace(m.group(0), "")
|
||||
return re.sub(r'var DEFAULT_COLOR = "[^"]*"', "", src)
|
||||
|
||||
|
||||
def test_widget_no_stray_orange():
|
||||
src = _strip_defaults(_read("widget.js"))
|
||||
assert not ORANGE.findall(src), "widget.js 残留橙色 hex: " + str(ORANGE.findall(src)[:5])
|
||||
assert not STRAY_RGBA.findall(src), "widget.js 残留橙色 rgba: " + str(STRAY_RGBA.findall(src)[:5])
|
||||
assert "c.primary" not in _read("widget.js"), "c.primary 应已替换为 var(--w-primary)"
|
||||
|
||||
|
||||
def test_style_no_stray_orange():
|
||||
bad = [
|
||||
ln for ln in _read("css/style.css").splitlines()
|
||||
if (ORANGE.search(ln) or STRAY_RGBA.search(ln)) and "--w-" not in ln
|
||||
]
|
||||
assert not bad, "style.css 残留橙色(非 :root 槽位行): " + "; ".join(bad[:5])
|
||||
src = _read("css/style.css")
|
||||
assert "--user-bubble" not in src, "旧变量 --user-bubble 应已迁移为 --w-primary"
|
||||
assert "--primary-dark" not in src, "死变量 --primary-dark 应已删除"
|
||||
|
||||
|
||||
def test_widget_avatar_src_uses_wsbase():
|
||||
src = _read("widget.js")
|
||||
assert 'DEFAULT_AVATAR_PATH = "/static/kefu-small.png"' in src
|
||||
assert 'src="/static/kefu-small.png"' not in src, "挂件禁用相对路径头像(客户站会 404)"
|
||||
assert "wsBase + DEFAULT_AVATAR_PATH" in src
|
||||
|
||||
|
||||
def test_style_root_defaults_match_theme_palette():
|
||||
""":root 槽位字面量与 theme.js DEFAULT_PALETTE 一致(spec §6 层④兜底)。"""
|
||||
root_m = re.search(r":root \{(.*?)\}", _read("css/style.css"), re.S)
|
||||
assert root_m, "style.css 缺 :root 块"
|
||||
root = root_m.group(1)
|
||||
pal_m = re.search(r"var DEFAULT_PALETTE = \{(.*?)\};", _read("js/theme.js"), re.S)
|
||||
assert pal_m, "theme.js 缺 DEFAULT_PALETTE"
|
||||
palette = pal_m.group(1)
|
||||
mapping = {
|
||||
"--w-primary": "primary", "--w-primary-rgb": "primaryRgb",
|
||||
"--w-gradient-end": "gradientEnd", "--w-deep-text": "deepText",
|
||||
"--w-caption-text": "captionText", "--w-soft-bg": "softBg",
|
||||
"--w-border-soft": "borderSoft",
|
||||
}
|
||||
for slot, key in mapping.items():
|
||||
css_m = re.search(re.escape(slot) + r"\s*:\s*([^;]+);", root)
|
||||
assert css_m, f":root 缺 {slot}"
|
||||
js_m = re.search(key + r':\s*"([^"]+)"', palette)
|
||||
assert js_m, f"DEFAULT_PALETTE 缺 {key}"
|
||||
assert re.sub(r"\s", "", css_m.group(1)) == re.sub(r"\s", "", js_m.group(1)), \
|
||||
f"{slot} 与 DEFAULT_PALETTE.{key} 不一致"
|
||||
|
||||
|
||||
def test_widget_derivation_unit_banners_present():
|
||||
"""主题派生单元横幅存在且框住全部同步单元(防孤儿横幅误导后来者)。"""
|
||||
src = _read("widget.js")
|
||||
assert "主题派生单元:" in src, "widget.js 缺主题派生单元起始横幅"
|
||||
assert "主题派生单元结束" in src, "widget.js 缺主题派生单元结束横幅"
|
||||
start = src.index("主题派生单元:")
|
||||
end = src.index("主题派生单元结束")
|
||||
assert start < end, "主题派生单元横幅顺序颠倒"
|
||||
body = src[start:end]
|
||||
for name in SYNCED_FUNCTIONS:
|
||||
assert ("function " + name + "(") in body, f"横幅区间缺函数 {name}"
|
||||
for const in ["DEFAULT_COLOR", "DEFAULT_PALETTE"]:
|
||||
assert ("var " + const) in body, f"横幅区间缺少常量 {const}"
|
||||
|
||||
|
||||
def test_chat_js_sets_all_slots():
|
||||
"""独立页 bootstrap 必须覆盖全部 7 槽位(防槽位改名后独立页静默滞后)。"""
|
||||
src = _read("js/chat.js")
|
||||
for slot in SLOTS:
|
||||
assert 'setProperty("' + slot + '"' in src, f"chat.js 缺 {slot} 的 setProperty"
|
||||
Reference in New Issue
Block a user