feat: jc 引擎主线搬入 app/engine(315 测试基线全绿:320 去掉 admin 5 用例)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 16:25:32 +08:00
parent 0ae5952002
commit 603c03eb4a
55 changed files with 10912 additions and 0 deletions
View File
+120
View File
@@ -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
+453
View File
@@ -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
+238
View File
@@ -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"
+58
View File
@@ -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
+42
View File
@@ -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
+84
View File
@@ -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()
+381
View File
@@ -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")
+45
View File
@@ -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",
}
+206
View File
@@ -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 "&lt;script&gt;" in body
assert "_internal" not in body # 内部 key 不渲染
assert "<b>bold</b>" not in body
assert "&lt;b&gt;bold&lt;/b&gt;" in body
assert "<i>wx</i>" not in body
assert "&lt;i&gt;wx&lt;/i&gt;" 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
+6
View File
@@ -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"
+104
View File
@@ -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 中英皆可
+111
View File
@@ -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 规则之后"
+18
View File
@@ -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
+634
View File
@@ -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"]
+291
View File
@@ -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"}
+21
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
def test_health(client):
assert client.get("/health").status_code == 200
+107
View File
@@ -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 # 好例禁令
+172
View File
@@ -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"