585 lines
25 KiB
Python
585 lines
25 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""模型网关:并发、背压、熔断、幂等、兜底。
|
||
|
||
这些不是"锦上添花"的特性,是网关能不能上线的底线。每一条对应一种线上事故:
|
||
并发不真 → 两个 7 秒的调用变成 14 秒,客户等不了
|
||
没有背压 → 请求堆到超时,用户等 60 秒拿到失败
|
||
没有熔断 → 每个请求都去撞一个已经挂了的上游
|
||
没有幂等 → 桌面端超时重发,双倍扣费
|
||
兜底不全 → 上游抖一下,客户就收不到回复
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import sqlite3
|
||
import tempfile
|
||
import time
|
||
from pathlib import Path
|
||
from unittest import TestCase, main, mock
|
||
|
||
import httpx
|
||
|
||
import model_gateway as gw
|
||
import model_protocol as mp
|
||
|
||
|
||
def _outlet(name="A", kind="openai", inflight=32, **cfg):
|
||
config = {
|
||
"id": name.lower(), "name": name, "kind": kind,
|
||
"base_url": "https://up/v1", "api_key": "k", "model": "m",
|
||
"max_tokens": 500, "temperature": 0.35, "timeout_ms": 30000,
|
||
"max_inflight": inflight,
|
||
}
|
||
config.update(cfg)
|
||
return gw.Outlet(config=config, gate=asyncio.Semaphore(inflight))
|
||
|
||
|
||
def _openai_body(text):
|
||
return {"choices": [{"message": {"content": text}}]}
|
||
|
||
|
||
def _client(handler):
|
||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||
|
||
|
||
class ProtocolShapeTest(TestCase):
|
||
"""协议层是同步和异步两个传输方共用的,形状错了两边一起错。"""
|
||
|
||
def test_each_kind_lands_on_its_own_endpoint(self):
|
||
self.assertEqual(
|
||
mp.endpoint_url("claude", "https://api.anthropic.com"),
|
||
"https://api.anthropic.com/v1/messages")
|
||
self.assertEqual(
|
||
mp.endpoint_url("dify", "http://ai/v1"), "http://ai/v1/chat-messages")
|
||
self.assertEqual(
|
||
mp.endpoint_url("openai", "https://api.x.com"),
|
||
"https://api.x.com/chat/completions")
|
||
|
||
def test_a_fully_written_url_is_not_appended_to(self):
|
||
for kind, url in (
|
||
("claude", "https://api.anthropic.com/v1/messages"),
|
||
("dify", "http://ai/v1/chat-messages"),
|
||
("openai", "https://api.x.com/chat/completions"),
|
||
):
|
||
self.assertEqual(mp.endpoint_url(kind, url), url)
|
||
|
||
def test_claude_payload_lifts_system_and_forces_max_tokens(self):
|
||
payload = mp.chat_payload(
|
||
"claude", model="c", max_tokens=0, temperature=0.3,
|
||
messages=[{"role": "system", "content": "家规"},
|
||
{"role": "user", "content": "在吗"}])
|
||
self.assertEqual(payload["system"], "家规")
|
||
self.assertGreaterEqual(payload["max_tokens"], 1)
|
||
self.assertNotIn("system", [m["role"] for m in payload["messages"]])
|
||
|
||
def test_dify_payload_carries_query_and_user_not_messages(self):
|
||
payload = mp.chat_payload(
|
||
"dify", model="", max_tokens=500, temperature=0.3,
|
||
messages=[{"role": "system", "content": "忽略"},
|
||
{"role": "user", "content": "一天吃几次"}])
|
||
self.assertEqual(payload["query"], "一天吃几次")
|
||
self.assertIn("user", payload)
|
||
self.assertNotIn("messages", payload)
|
||
|
||
def test_parsers_raise_instead_of_returning_an_empty_string(self):
|
||
for kind, body in (("openai", {}), ("claude", {"content": []}), ("dify", {})):
|
||
with self.assertRaises(ValueError):
|
||
mp.parse_chat(kind, body)
|
||
|
||
def test_comfyui_is_not_a_chat_kind(self):
|
||
self.assertIn("comfyui", mp.ALL_KINDS)
|
||
self.assertNotIn("comfyui", mp.CHAT_KINDS)
|
||
|
||
|
||
class OutletCallTest(TestCase):
|
||
def test_a_normal_call_returns_text_and_latency(self):
|
||
async def run():
|
||
def handler(request):
|
||
return httpx.Response(200, json=_openai_body("好的"))
|
||
|
||
async with _client(handler) as client:
|
||
return await gw.call_outlet(client, _outlet(), [{"role": "user", "content": "在"}])
|
||
|
||
out = asyncio.run(run())
|
||
self.assertEqual(out["text"], "好的")
|
||
self.assertEqual(out["error"], "")
|
||
self.assertGreaterEqual(out["latency_ms"], 0)
|
||
|
||
def test_a_retriable_status_is_retried_once(self):
|
||
calls = []
|
||
|
||
async def run():
|
||
def handler(request):
|
||
calls.append(1)
|
||
if len(calls) == 1:
|
||
return httpx.Response(503, text="busy")
|
||
return httpx.Response(200, json=_openai_body("好的"))
|
||
|
||
async with _client(handler) as client:
|
||
return await gw.call_outlet(client, _outlet(), [{"role": "user", "content": "在"}])
|
||
|
||
out = asyncio.run(run())
|
||
self.assertEqual(out["text"], "好的")
|
||
self.assertEqual(len(calls), 2)
|
||
|
||
def test_a_4xx_is_never_retried(self):
|
||
calls = []
|
||
|
||
async def run():
|
||
def handler(request):
|
||
calls.append(1)
|
||
return httpx.Response(401, text="bad key")
|
||
|
||
async with _client(handler) as client:
|
||
return await gw.call_outlet(client, _outlet(), [{"role": "user", "content": "在"}])
|
||
|
||
out = asyncio.run(run())
|
||
self.assertEqual(len(calls), 1, "确定性故障重试只会错三遍还多花钱")
|
||
self.assertIn("401", out["error"])
|
||
|
||
def test_a_failing_outlet_eventually_trips_the_breaker(self):
|
||
async def run():
|
||
def handler(request):
|
||
return httpx.Response(401, text="bad key")
|
||
|
||
outlet = _outlet()
|
||
async with _client(handler) as client:
|
||
for _ in range(gw.BREAKER_THRESHOLD):
|
||
await gw.call_outlet(client, outlet, [{"role": "user", "content": "在"}])
|
||
tripped = await gw.call_outlet(client, outlet, [{"role": "user", "content": "在"}])
|
||
return outlet, tripped
|
||
|
||
outlet, tripped = asyncio.run(run())
|
||
self.assertEqual(outlet.breaker.state, "open")
|
||
self.assertIn("熔断", tripped["error"])
|
||
|
||
def test_the_breaker_half_opens_after_cooling_down(self):
|
||
outlet = _outlet()
|
||
for _ in range(gw.BREAKER_THRESHOLD):
|
||
outlet.breaker.record(False)
|
||
self.assertFalse(outlet.breaker.allow())
|
||
outlet.breaker.opened_at -= gw.BREAKER_COOLDOWN + 1
|
||
self.assertTrue(outlet.breaker.allow())
|
||
self.assertEqual(outlet.breaker.state, "half_open")
|
||
|
||
def test_the_inflight_gate_caps_concurrency_per_outlet(self):
|
||
peak = []
|
||
live = []
|
||
|
||
async def run():
|
||
async def handler(request):
|
||
live.append(1)
|
||
peak.append(len(live))
|
||
await asyncio.sleep(0.05)
|
||
live.pop()
|
||
return httpx.Response(200, json=_openai_body("好"))
|
||
|
||
outlet = _outlet(inflight=2)
|
||
async with _client(handler) as client:
|
||
await asyncio.gather(*[
|
||
gw.call_outlet(client, outlet, [{"role": "user", "content": "在"}])
|
||
for _ in range(6)
|
||
])
|
||
|
||
asyncio.run(run())
|
||
self.assertLessEqual(max(peak), 2, "一家变慢不能把所有 worker 吃光")
|
||
|
||
def test_a_hung_upstream_is_cut_at_the_deadline(self):
|
||
async def run():
|
||
async def handler(request):
|
||
await asyncio.sleep(5)
|
||
return httpx.Response(200, json=_openai_body("太慢了"))
|
||
|
||
async with _client(handler) as client:
|
||
return await gw.call_outlet(
|
||
client, _outlet(), [{"role": "user", "content": "在"}], deadline=0.3)
|
||
|
||
started = time.monotonic()
|
||
out = asyncio.run(run())
|
||
self.assertLess(time.monotonic() - started, 3.0)
|
||
self.assertTrue(out["error"])
|
||
self.assertEqual(out["text"], "")
|
||
|
||
|
||
class JudgeTest(TestCase):
|
||
def _judge(self, body, second="乙说"):
|
||
async def run():
|
||
def handler(request):
|
||
return httpx.Response(200, json=_openai_body(body))
|
||
|
||
async with _client(handler) as client:
|
||
return await gw.run_judge(client, _outlet("裁判"), "一天吃几次", "甲说", second)
|
||
|
||
return asyncio.run(run())
|
||
|
||
def test_reads_the_verdict(self):
|
||
v = self._judge('{"winner":"B","score":0.88,"risk":"low","reason":"更短"}')
|
||
self.assertEqual(v["winner"], "B")
|
||
self.assertAlmostEqual(v["score"], 0.88)
|
||
self.assertTrue(v["participated"])
|
||
|
||
def test_unparseable_output_does_not_participate(self):
|
||
self.assertFalse(self._judge("我觉得 B 好")["participated"])
|
||
|
||
def test_a_single_candidate_always_wins(self):
|
||
v = self._judge('{"winner":"B","score":0.7,"risk":"low"}', second="")
|
||
self.assertEqual(v["winner"], "A")
|
||
|
||
def test_no_judge_configured_is_a_cheap_no_op(self):
|
||
async def run():
|
||
async with _client(lambda r: httpx.Response(500)) as client:
|
||
return await gw.run_judge(client, None, "x", "甲说")
|
||
|
||
self.assertFalse(asyncio.run(run())["participated"])
|
||
|
||
|
||
class GatewayEndpointTest(TestCase):
|
||
"""端到端:起真的 app,用 MockTransport 假冒上游。"""
|
||
|
||
def setUp(self):
|
||
import admin_backend
|
||
|
||
self.root = Path(tempfile.mkdtemp())
|
||
self.db_path = self.root / "t.db"
|
||
db = admin_backend.Database(self.db_path)
|
||
db.initialize("InitialAdmin123")
|
||
uid = db.authenticate("admin", "InitialAdmin123")["id"]
|
||
for pid, name in (("a", "模型A"), ("b", "模型B"), ("j", "裁判")):
|
||
db.save_model_provider(
|
||
{"id": pid, "name": name, "kind": "openai",
|
||
"base_url": "https://up/v1", "api_key": "sk-x", "model": "m"},
|
||
uid, "1.1.1.1")
|
||
db.save_model_roles(
|
||
{"answer_ids": "a,b", "judge_id": "j", "judge_mode": "arbitrate"},
|
||
uid, "1.1.1.1")
|
||
self.app = gw.create_app(self.db_path, "sync-key")
|
||
|
||
def _post(self, handler, *, headers=None, body=None):
|
||
async def run():
|
||
from httpx import ASGITransport
|
||
|
||
async with httpx.AsyncClient(
|
||
transport=ASGITransport(app=self.app), base_url="http://gw"
|
||
) as caller:
|
||
# app 启动时建的真实 client 换成假冒上游
|
||
await self.app.gateway_startup()
|
||
await self.app.state.client.aclose()
|
||
self.app.state.client = _client(handler)
|
||
try:
|
||
started = time.monotonic()
|
||
response = await caller.post(
|
||
"/v1/answer",
|
||
headers={"X-Desktop-Sync-Key": "sync-key", **(headers or {})},
|
||
json=body or {"customer_text": "一天吃几次", "task_id": "t1"},
|
||
)
|
||
# 只计请求本身。gateway_startup/shutdown 每次要 0.3~0.4 秒
|
||
# (建目录缓存、开关 httpx 连接池),把它们算进去的话,任何
|
||
# 耗时断言测的其实是"今天这台机器忙不忙"。
|
||
self.request_seconds = time.monotonic() - started
|
||
return response
|
||
finally:
|
||
await self.app.gateway_shutdown()
|
||
await self.app.state.client.aclose()
|
||
|
||
return asyncio.run(run())
|
||
|
||
def _upstream(self, judge_json='{"winner":"B","score":0.9,"risk":"low"}'):
|
||
def handler(request):
|
||
body = json.loads(request.content.decode())
|
||
text = str(body.get("messages", [{}])[-1].get("content") or "")
|
||
if "评审" in text:
|
||
return httpx.Response(200, json=_openai_body(judge_json))
|
||
return httpx.Response(200, json=_openai_body("上游回复"))
|
||
|
||
return handler
|
||
|
||
def test_a_missing_sync_key_is_rejected(self):
|
||
response = self._post(self._upstream(), headers={"X-Desktop-Sync-Key": "wrong"})
|
||
self.assertEqual(response.status_code, 401)
|
||
|
||
def test_a_normal_request_returns_reply_candidates_and_verdict(self):
|
||
response = self._post(self._upstream())
|
||
self.assertEqual(response.status_code, 200)
|
||
data = response.json()
|
||
self.assertEqual(data["reply"], "上游回复")
|
||
self.assertEqual(len(data["candidates"]), 2)
|
||
self.assertTrue(data["judge"]["participated"])
|
||
self.assertEqual(data["judge_mode"], "arbitrate")
|
||
self.assertGreater(data["roles_version"], 0)
|
||
|
||
def _post_then_drain(self, handler, *, body):
|
||
"""发一次请求,等异步落库真的写完,再把那一行读回来。
|
||
|
||
专门补这条是因为用户问"为什么调用记录里客户消息和模型回复全是空"。
|
||
在这之前,测试只直接调 `_write_log`,从没验证过 `/v1/answer` 这条真实
|
||
路径到底往队列里放了什么——写日志的那段代码等于没有测试覆盖。
|
||
"""
|
||
async def run():
|
||
from httpx import ASGITransport
|
||
|
||
async with httpx.AsyncClient(
|
||
transport=ASGITransport(app=self.app), base_url="http://gw"
|
||
) as caller:
|
||
await self.app.gateway_startup()
|
||
await self.app.state.client.aclose()
|
||
self.app.state.client = _client(handler)
|
||
try:
|
||
resp = await caller.post(
|
||
"/v1/answer",
|
||
headers={"X-Desktop-Sync-Key": "sync-key", "X-Device-Id": "desk-1"},
|
||
json=body,
|
||
)
|
||
await self.app.gateway_log_queue.join()
|
||
return resp
|
||
finally:
|
||
await self.app.gateway_shutdown()
|
||
await self.app.state.client.aclose()
|
||
|
||
response = asyncio.run(run())
|
||
con = sqlite3.connect(self.db_path)
|
||
con.row_factory = sqlite3.Row
|
||
try:
|
||
row = con.execute(
|
||
"SELECT * FROM model_calls WHERE task_id = ?",
|
||
(str(body.get("task_id") or ""),),
|
||
).fetchone()
|
||
finally:
|
||
con.close()
|
||
return response, row
|
||
|
||
def test_a_real_request_stores_what_the_customer_said_and_what_the_model_answered(self):
|
||
"""这就是"记录模型返回的话"这件事的端到端证明。
|
||
|
||
接口返回 200、界面上却是两个「(空)」,问题就出在这一段:请求路径拿到的
|
||
文本有没有真的进到那一行里。断言直接对着数据库读,不看接口返回。
|
||
"""
|
||
response, row = self._post_then_drain(
|
||
self._upstream(),
|
||
body={"customer_text": "我这几天总是失眠", "task_id": "e2e-1"},
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertIsNotNone(row, "这一次调用必须留下一行记录")
|
||
self.assertEqual(row["customer_text"], "我这几天总是失眠")
|
||
self.assertEqual(row["reply_text"], "上游回复")
|
||
self.assertEqual(row["purpose"], "chat", "没标 purpose 的默认是客服对话")
|
||
self.assertEqual(row["device_id"], "desk-1")
|
||
|
||
def test_a_guard_request_is_stored_as_an_internal_call(self):
|
||
_resp, row = self._post_then_drain(
|
||
self._upstream(),
|
||
body={"customer_text": "判断界面", "task_id": "e2e-2", "purpose": "guard"},
|
||
)
|
||
self.assertEqual(row["purpose"], "guard", "界面识别不能混进客服对话日志")
|
||
|
||
def test_all_upstreams_down_returns_502_not_a_fake_reply(self):
|
||
response = self._post(lambda r: httpx.Response(500, text="down"))
|
||
self.assertEqual(response.status_code, 502)
|
||
self.assertEqual(response.json()["reply"], "")
|
||
|
||
def test_the_same_idempotency_key_is_not_charged_twice(self):
|
||
hits = []
|
||
|
||
def handler(request):
|
||
hits.append(1)
|
||
body = json.loads(request.content.decode())
|
||
text = str(body.get("messages", [{}])[-1].get("content") or "")
|
||
if "评审" in text:
|
||
return httpx.Response(200, json=_openai_body('{"winner":"A","score":0.8,"risk":"low"}'))
|
||
return httpx.Response(200, json=_openai_body("上游回复"))
|
||
|
||
async def run():
|
||
from httpx import ASGITransport
|
||
|
||
async with httpx.AsyncClient(
|
||
transport=ASGITransport(app=self.app), base_url="http://gw"
|
||
) as caller:
|
||
await self.app.gateway_startup()
|
||
await self.app.state.client.aclose()
|
||
self.app.state.client = _client(handler)
|
||
try:
|
||
headers = {"X-Desktop-Sync-Key": "sync-key", "X-Idempotency-Key": "k1"}
|
||
first = await caller.post("/v1/answer", headers=headers,
|
||
json={"customer_text": "x", "task_id": "t"})
|
||
second = await caller.post("/v1/answer", headers=headers,
|
||
json={"customer_text": "x", "task_id": "t"})
|
||
return first.json(), second.json(), len(hits)
|
||
finally:
|
||
await self.app.gateway_shutdown()
|
||
await self.app.state.client.aclose()
|
||
|
||
first, second, upstream_calls = asyncio.run(run())
|
||
self.assertEqual(first, second)
|
||
self.assertLessEqual(upstream_calls, 3, "重发不该再打一遍上游")
|
||
|
||
def test_health_reports_breakers_and_the_active_plan(self):
|
||
async def run():
|
||
from httpx import ASGITransport
|
||
|
||
async with httpx.AsyncClient(
|
||
transport=ASGITransport(app=self.app), base_url="http://gw"
|
||
) as caller:
|
||
await self.app.gateway_startup()
|
||
try:
|
||
return (await caller.get("/health")).json()
|
||
finally:
|
||
await self.app.gateway_shutdown()
|
||
await self.app.state.client.aclose()
|
||
|
||
data = asyncio.run(run())
|
||
self.assertEqual(data["status"], "ok")
|
||
self.assertEqual(sorted(data["roles"]["answer"]), ["模型A", "模型B"])
|
||
self.assertEqual(data["roles"]["judge"], "裁判")
|
||
self.assertTrue(all(o["breaker"] == "closed" for o in data["outlets"]))
|
||
|
||
def test_candidates_are_asked_in_parallel(self):
|
||
# 上游延迟。断言用它算出串行基线,不写死秒数——改这一个常量,
|
||
# 下面的判据自动跟着走。
|
||
delay = 0.08
|
||
live, peak = [], []
|
||
|
||
async def handler(request):
|
||
body = json.loads(request.content.decode())
|
||
text = str(body.get("messages", [{}])[-1].get("content") or "")
|
||
if "评审" in text:
|
||
return httpx.Response(200, json=_openai_body('{"winner":"A","score":0.8,"risk":"low"}'))
|
||
live.append(1)
|
||
peak.append(len(live))
|
||
await asyncio.sleep(delay)
|
||
live.pop()
|
||
return httpx.Response(200, json=_openai_body("上游回复"))
|
||
|
||
response = self._post(handler)
|
||
self.assertEqual(response.status_code, 200)
|
||
# 这一条是并发的确证:同一时刻有两路在飞。它是计数,不是计时,
|
||
# 机器再忙也不会动摇。
|
||
self.assertEqual(max(peak), 2, "两路必须同时在飞")
|
||
# 再补一条耗时判据,挡住"确实同时发出去了、但下游还是被某处串起来等"
|
||
# 这种情况。基线由 delay 现算:串行至少 2×delay,并发应该接近 1×。
|
||
self.assertLess(
|
||
self.request_seconds,
|
||
delay * 2,
|
||
f"两路答题若是串行,光这一段就要 {delay * 2:.2f}s;"
|
||
f"实测 {self.request_seconds:.3f}s 说明没并起来",
|
||
)
|
||
|
||
|
||
class CatalogTest(TestCase):
|
||
def setUp(self):
|
||
import admin_backend
|
||
|
||
self.root = Path(tempfile.mkdtemp())
|
||
self.db_path = self.root / "t.db"
|
||
self.db = admin_backend.Database(self.db_path)
|
||
self.db.initialize("InitialAdmin123")
|
||
self.uid = self.db.authenticate("admin", "InitialAdmin123")["id"]
|
||
|
||
def test_keys_are_decrypted_for_the_gateway_only(self):
|
||
self.db.save_model_provider(
|
||
{"id": "a", "name": "A", "kind": "openai", "base_url": "https://u/v1",
|
||
"api_key": "sk-REAL"}, self.uid, "1.1.1.1")
|
||
catalog = gw.Catalog(self.db_path)
|
||
catalog.refresh()
|
||
self.assertEqual(catalog.outlets["a"].config["api_key"], "sk-REAL")
|
||
|
||
def test_comfyui_never_enters_the_answer_pool(self):
|
||
self.db.save_model_provider(
|
||
{"id": "img", "name": "文生图", "kind": "comfyui",
|
||
"base_url": "http://127.0.0.1:8188"}, self.uid, "1.1.1.1")
|
||
self.db.save_model_provider(
|
||
{"id": "a", "name": "A", "kind": "openai", "base_url": "https://u/v1",
|
||
"api_key": "k"}, self.uid, "1.1.1.1")
|
||
self.db.save_model_roles(
|
||
{"answer_ids": "a,img", "judge_id": "img"}, self.uid, "1.1.1.1")
|
||
catalog = gw.Catalog(self.db_path)
|
||
catalog.refresh()
|
||
answers, judge, _mode, _v = catalog.plan()
|
||
self.assertEqual([o.name for o in answers], ["A"])
|
||
self.assertIsNone(judge)
|
||
|
||
def test_a_disabled_outlet_disappears_from_the_plan(self):
|
||
self.db.save_model_provider(
|
||
{"id": "a", "name": "A", "kind": "openai", "base_url": "https://u/v1",
|
||
"api_key": "k", "enabled": False}, self.uid, "1.1.1.1")
|
||
catalog = gw.Catalog(self.db_path)
|
||
catalog.refresh()
|
||
self.assertNotIn("a", catalog.outlets)
|
||
|
||
def test_a_broken_database_keeps_the_previous_catalog(self):
|
||
self.db.save_model_provider(
|
||
{"id": "a", "name": "A", "kind": "openai", "base_url": "https://u/v1",
|
||
"api_key": "k"}, self.uid, "1.1.1.1")
|
||
catalog = gw.Catalog(self.db_path)
|
||
catalog.refresh()
|
||
catalog.db_path = self.root / "没有这个文件.db"
|
||
catalog.refresh()
|
||
self.assertIn("a", catalog.outlets, "读库失败要降级,不能失效")
|
||
self.assertTrue(catalog.last_error)
|
||
|
||
def test_the_call_log_lands_in_the_shared_table(self):
|
||
gw._write_log(self.db_path, {
|
||
"device_id": "d1", "task_id": "t1", "roles_version": 1,
|
||
"judge_mode": "arbitrate", "chosen": "模型B",
|
||
"judge": {"winner": "B", "score": 0.9, "risk": "low"},
|
||
"candidates": [{"provider": "A"}], "total_ms": 5200})
|
||
con = sqlite3.connect(self.db_path)
|
||
try:
|
||
row = con.execute(
|
||
"SELECT device_id,chosen,judge_winner,judge_score FROM model_calls"
|
||
).fetchone()
|
||
finally:
|
||
con.close()
|
||
self.assertEqual(row, ("d1", "模型B", "B", 0.9))
|
||
|
||
def test_the_call_log_also_keeps_customer_and_reply_text(self):
|
||
"""跟踪问题要能看到"客户说了什么、模型答了什么",不能只有分数。"""
|
||
gw._write_log(self.db_path, {
|
||
"device_id": "d1", "task_id": "t1", "chosen": "模型B",
|
||
"judge": {}, "candidates": [], "total_ms": 100,
|
||
"customer_text": "我这几天总是失眠", "reply_text": "建议您注意休息",
|
||
})
|
||
con = sqlite3.connect(self.db_path)
|
||
try:
|
||
row = con.execute(
|
||
"SELECT customer_text,reply_text FROM model_calls"
|
||
).fetchone()
|
||
finally:
|
||
con.close()
|
||
self.assertEqual(row, ("我这几天总是失眠", "建议您注意休息"))
|
||
|
||
def test_a_second_write_with_the_same_task_id_refreshes_data_but_keeps_review_reason(self):
|
||
"""桌面端可能先落盘(它知道审核规则命中原因,网关不知道)——网关补写权威数据时不能把这个原因擦掉。"""
|
||
gw._write_log(self.db_path, {
|
||
"device_id": "d1", "task_id": "t1", "chosen": "旧值",
|
||
"judge": {}, "candidates": [], "total_ms": 1,
|
||
"customer_text": "旧客户消息", "reply_text": "旧回复",
|
||
})
|
||
con = sqlite3.connect(self.db_path)
|
||
try:
|
||
con.execute(
|
||
"UPDATE model_calls SET review_reason=? WHERE task_id='t1'",
|
||
("命中审核规则「诊断」",),
|
||
)
|
||
con.commit()
|
||
finally:
|
||
con.close()
|
||
|
||
gw._write_log(self.db_path, {
|
||
"device_id": "d1", "task_id": "t1", "chosen": "新值(服务端权威)",
|
||
"judge": {"winner": "A", "score": 0.5}, "candidates": [{"provider": "A"}],
|
||
"total_ms": 800, "customer_text": "新客户消息", "reply_text": "新回复",
|
||
})
|
||
con = sqlite3.connect(self.db_path)
|
||
try:
|
||
rows = con.execute("SELECT * FROM model_calls WHERE task_id='t1'").fetchall()
|
||
row = con.execute(
|
||
"SELECT chosen,reply_text,review_reason FROM model_calls WHERE task_id='t1'"
|
||
).fetchone()
|
||
finally:
|
||
con.close()
|
||
self.assertEqual(len(rows), 1, "同一次调用只能落一行")
|
||
self.assertEqual(row, ("新值(服务端权威)", "新回复", "命中审核规则「诊断」"))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|