1084 lines
49 KiB
Python
1084 lines
49 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""配置后台与桌面端同步的本地闭环测试。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import hashlib
|
||
import shutil
|
||
import model_protocol
|
||
import socket
|
||
import sqlite3
|
||
import tempfile
|
||
import threading
|
||
import unittest
|
||
import urllib.error
|
||
import urllib.request
|
||
from unittest import mock
|
||
from pathlib import Path
|
||
|
||
from test_support import local_state_redirect
|
||
import admin_backend
|
||
import app_version
|
||
import backend_client
|
||
|
||
|
||
class BackendIntegrationTest(unittest.TestCase):
|
||
|
||
|
||
def test_development_diagnostics_are_cloud_controlled_and_redacted(self) -> None:
|
||
config = {
|
||
"AI_DEVELOPMENT_MODE": True,
|
||
"AI_PROVIDER_TYPE": "openai",
|
||
"AI_API_BASE": "https://api.example/v1",
|
||
"AI_API_KEY": "top-secret-key",
|
||
"AI_MODEL": "test-model",
|
||
"AI_MCP_SERVERS": [
|
||
{
|
||
"headers": {"Authorization": "Bearer hidden-auth"},
|
||
"env": {"ACCESS_TOKEN": "hidden-token"},
|
||
"args": [
|
||
"--token",
|
||
"hidden-argument",
|
||
"--password=hidden-inline",
|
||
"Bearer hidden-bearer",
|
||
],
|
||
}
|
||
],
|
||
}
|
||
response = {"version": 8, "updated_at": "now"}
|
||
diagnostics = backend_client._config_diagnostics(
|
||
config,
|
||
response,
|
||
"https://cloud.example/api/v1/desktop/config?api_key=query-secret",
|
||
)
|
||
rendered = "\n".join(diagnostics)
|
||
self.assertIn("https://cloud.example/api/v1/desktop/config", rendered)
|
||
self.assertIn("云端配置版本: v8", rendered)
|
||
self.assertIn('"AI_MODEL": "test-model"', rendered)
|
||
for secret in (
|
||
"top-secret-key",
|
||
"hidden-auth",
|
||
"hidden-token",
|
||
"hidden-argument",
|
||
"hidden-inline",
|
||
"hidden-bearer",
|
||
"query-secret",
|
||
):
|
||
self.assertNotIn(secret, rendered)
|
||
self.assertEqual(
|
||
backend_client._config_diagnostics(
|
||
{**config, "AI_DEVELOPMENT_MODE": False}, response, "https://cloud.example/config"
|
||
),
|
||
[],
|
||
)
|
||
|
||
def test_model_request_diagnostics_never_print_the_api_key(self) -> None:
|
||
import ai_chat
|
||
import ai_config
|
||
|
||
previous = {
|
||
"AI_DEVELOPMENT_MODE": getattr(ai_config, "AI_DEVELOPMENT_MODE", False),
|
||
"AI_API_BASE": ai_config.AI_API_BASE,
|
||
"AI_API_KEY": ai_config.AI_API_KEY,
|
||
"AI_MODEL": ai_config.AI_MODEL,
|
||
}
|
||
try:
|
||
ai_config.AI_DEVELOPMENT_MODE = True
|
||
ai_config.AI_API_BASE = "https://api.example/v1"
|
||
ai_config.AI_API_KEY = "never-print-this-key"
|
||
ai_config.AI_MODEL = "diagnostic-model"
|
||
with mock.patch("builtins.print") as printer:
|
||
ai_chat._log_request_diagnostics(
|
||
"https://api.example/v1/chat/completions", "OpenAI 兼容"
|
||
)
|
||
rendered = "\n".join(
|
||
" ".join(str(item) for item in call.args)
|
||
for call in printer.call_args_list
|
||
)
|
||
self.assertIn("https://api.example/v1/chat/completions", rendered)
|
||
self.assertIn("diagnostic-model", rendered)
|
||
self.assertNotIn("never-print-this-key", rendered)
|
||
finally:
|
||
for key, value in previous.items():
|
||
setattr(ai_config, key, value)
|
||
|
||
def test_model_connection_uses_unsaved_values_and_saved_key(self) -> None:
|
||
current = {
|
||
"AI_API_BASE": "https://saved.example/v1",
|
||
"AI_API_KEY": "saved-secret",
|
||
"AI_MODEL": "saved-model",
|
||
"AI_TIMEOUT": 120,
|
||
}
|
||
config = admin_backend.model_test_config(
|
||
{
|
||
"AI_API_BASE": "https://new.example/v1",
|
||
"AI_API_KEY": "",
|
||
"AI_MODEL": "new-model",
|
||
"AI_TIMEOUT": "180",
|
||
},
|
||
current,
|
||
)
|
||
self.assertEqual(config["endpoint"], "https://new.example/v1/chat/completions")
|
||
self.assertEqual(config["api_key"], "saved-secret")
|
||
self.assertEqual(config["model"], "new-model")
|
||
self.assertEqual(config["timeout"], 60)
|
||
|
||
def test_model_connection_success_does_not_expose_key(self) -> None:
|
||
config = admin_backend.model_test_config(
|
||
{
|
||
"AI_API_BASE": "https://api.example/v1",
|
||
"AI_API_KEY": "top-secret-key",
|
||
"AI_MODEL": "test-model",
|
||
"AI_TIMEOUT": 10,
|
||
},
|
||
{},
|
||
)
|
||
response = json.dumps(
|
||
{"choices": [{"message": {"content": "OK"}}]}
|
||
).encode("utf-8")
|
||
with mock.patch(
|
||
"admin_backend._perform_http_request", return_value=(200, response)
|
||
) as call:
|
||
result = admin_backend.test_model_connection(config)
|
||
self.assertTrue(result["ok"])
|
||
self.assertEqual(result["http_status"], 200)
|
||
self.assertNotIn("top-secret-key", json.dumps(result, ensure_ascii=False))
|
||
self.assertEqual(call.call_args.args[0], config["endpoint"])
|
||
self.assertEqual(
|
||
call.call_args.kwargs["headers"]["Authorization"], "Bearer top-secret-key"
|
||
)
|
||
payload = call.call_args.kwargs["payload"]
|
||
self.assertEqual(payload["model"], "test-model")
|
||
|
||
def test_model_connection_error_redacts_key(self) -> None:
|
||
config = admin_backend.model_test_config(
|
||
{
|
||
"AI_API_BASE": "https://api.example/v1",
|
||
"AI_API_KEY": "top-secret-key",
|
||
"AI_MODEL": "test-model",
|
||
"AI_TIMEOUT": 10,
|
||
},
|
||
{},
|
||
)
|
||
response = json.dumps(
|
||
{"error": {"message": "invalid top-secret-key"}}
|
||
).encode("utf-8")
|
||
with mock.patch(
|
||
"admin_backend._perform_http_request", return_value=(401, response)
|
||
):
|
||
result = admin_backend.test_model_connection(config)
|
||
self.assertFalse(result["ok"])
|
||
self.assertEqual(result["http_status"], 401)
|
||
self.assertIn("API Key 无效", result["message"])
|
||
self.assertNotIn("top-secret-key", json.dumps(result, ensure_ascii=False))
|
||
|
||
def test_dify_and_comfyui_use_provider_specific_endpoints(self) -> None:
|
||
dify = admin_backend.model_test_config(
|
||
{
|
||
"AI_PROVIDER_TYPE": "dify",
|
||
"AI_API_BASE": "https://dify.example/v1",
|
||
"AI_API_KEY": "app-secret",
|
||
"AI_MODEL": "",
|
||
"AI_TIMEOUT": 10,
|
||
},
|
||
{},
|
||
)
|
||
self.assertEqual(dify["endpoint"], "https://dify.example/v1/chat-messages")
|
||
self.assertEqual(dify["provider_type"], "dify")
|
||
|
||
comfyui = admin_backend.model_test_config(
|
||
{
|
||
"AI_PROVIDER_TYPE": "comfyui",
|
||
"AI_API_BASE": "http://127.0.0.1:8188",
|
||
"AI_API_KEY": "",
|
||
"AI_MODEL": "",
|
||
"AI_TIMEOUT": 10,
|
||
},
|
||
{},
|
||
)
|
||
self.assertEqual(comfyui["endpoint"], "http://127.0.0.1:8188/system_stats")
|
||
with mock.patch(
|
||
"admin_backend._perform_http_request",
|
||
return_value=(200, b'{"system": {"os": "windows"}, "devices": []}'),
|
||
) as call:
|
||
result = admin_backend.test_model_connection(comfyui)
|
||
self.assertTrue(result["ok"])
|
||
self.assertEqual(call.call_args.kwargs["method"], "GET")
|
||
self.assertIsNone(call.call_args.kwargs["payload"])
|
||
|
||
def test_desktop_ai_respects_explicit_dify_provider(self) -> None:
|
||
import ai_chat
|
||
import ai_config
|
||
|
||
old_provider = ai_config.AI_PROVIDER_TYPE
|
||
old_base = ai_config.AI_API_BASE
|
||
try:
|
||
ai_config.AI_PROVIDER_TYPE = "dify"
|
||
ai_config.AI_API_BASE = "https://dify.example/v1"
|
||
self.assertTrue(ai_chat._is_dify_endpoint())
|
||
self.assertEqual(
|
||
ai_chat._completions_url(),
|
||
"https://dify.example/v1/chat-messages",
|
||
)
|
||
ai_config.AI_PROVIDER_TYPE = "openai"
|
||
self.assertFalse(ai_chat._is_dify_endpoint())
|
||
finally:
|
||
ai_config.AI_PROVIDER_TYPE = old_provider
|
||
ai_config.AI_API_BASE = old_base
|
||
|
||
|
||
def test_release_status_detects_optional_and_forced_updates(self) -> None:
|
||
optional = app_version.release_status(
|
||
{"latest_version": "1.0.1", "force_upgrade": False}
|
||
)
|
||
self.assertTrue(optional["update_available"])
|
||
self.assertFalse(optional["force_upgrade"])
|
||
|
||
forced = app_version.release_status(
|
||
{"latest_version": "1.0.1", "force_upgrade": True}
|
||
)
|
||
self.assertTrue(forced["force_upgrade"])
|
||
|
||
current = app_version.release_status(
|
||
{"latest_version": app_version.APP_VERSION, "force_upgrade": True}
|
||
)
|
||
self.assertFalse(current["update_available"])
|
||
self.assertFalse(current["force_upgrade"])
|
||
|
||
def test_release_form_validation(self) -> None:
|
||
release = admin_backend.validate_release_form(
|
||
{
|
||
"latest_version": "v1.2.3",
|
||
"download_url": "https://example.com/client.exe",
|
||
"release_notes": "修复已知问题",
|
||
"force_upgrade": "1",
|
||
}
|
||
)
|
||
self.assertEqual(release["latest_version"], "1.2.3")
|
||
self.assertTrue(release["force_upgrade"])
|
||
with self.assertRaises(ValueError):
|
||
admin_backend.validate_release_form(
|
||
{"latest_version": "1.2", "force_upgrade": "1"}
|
||
)
|
||
with self.assertRaises(ValueError):
|
||
admin_backend.validate_release_form(
|
||
{"latest_version": "1.2.3", "force_upgrade": "1"}
|
||
)
|
||
|
||
def test_desktop_sync_key_matches_server(self) -> None:
|
||
self.assertEqual(
|
||
backend_client.DESKTOP_SYNC_KEY,
|
||
admin_backend.DEFAULT_DESKTOP_SYNC_KEY,
|
||
)
|
||
|
||
def test_pbkdf2_fallback_matches_standard_library(self) -> None:
|
||
password = b"FallbackPassword123"
|
||
salt = b"0123456789abcdef"
|
||
expected = hashlib.pbkdf2_hmac("sha256", password, salt, 1_000)
|
||
native_pbkdf2 = admin_backend.hashlib.pbkdf2_hmac
|
||
try:
|
||
admin_backend.hashlib.pbkdf2_hmac = None
|
||
actual = admin_backend.pbkdf2_sha256(password, salt, 1_000)
|
||
finally:
|
||
admin_backend.hashlib.pbkdf2_hmac = native_pbkdf2
|
||
self.assertEqual(actual, expected)
|
||
|
||
def test_stale_runtime_file_is_ignored(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
root = Path(directory)
|
||
runtime_file = root / "backend_runtime.json"
|
||
runtime_file.write_text(
|
||
json.dumps(
|
||
{
|
||
"pid": 0,
|
||
"host": "127.0.0.1",
|
||
"port": 8765,
|
||
"server_url": "http://127.0.0.1:8765",
|
||
"local_sync_token": "stale-token",
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
old_runtime_file = backend_client.RUNTIME_FILE
|
||
try:
|
||
backend_client.RUNTIME_FILE = runtime_file
|
||
self.assertEqual(backend_client.discover_local_runtime(), {})
|
||
self.assertEqual(backend_client.discover_local_sync_token(), "")
|
||
finally:
|
||
backend_client.RUNTIME_FILE = old_runtime_file
|
||
|
||
def test_local_agent_name_overrides_cloud_and_blank_follows_cloud(self) -> None:
|
||
import ai_config
|
||
|
||
old_agent = ai_config.AI_AGENT_NAME
|
||
old_cloud = ai_config.AI_CLOUD_AGENT_NAME
|
||
old_local = ai_config.AI_LOCAL_AGENT_NAME
|
||
old_prompt = ai_config.AI_SYSTEM_PROMPT
|
||
try:
|
||
ai_config.AI_CLOUD_AGENT_NAME = "旧云端客服"
|
||
ai_config.AI_LOCAL_AGENT_NAME = ""
|
||
ai_config.AI_AGENT_NAME = "旧云端客服"
|
||
ai_config.set_local_agent_name_override("本机客服", persist=False)
|
||
ai_config.apply_settings({"AI_AGENT_NAME": "新云端客服"}, persist=False)
|
||
self.assertEqual(ai_config.AI_CLOUD_AGENT_NAME, "新云端客服")
|
||
self.assertEqual(ai_config.AI_AGENT_NAME, "本机客服")
|
||
ai_config.set_local_agent_name_override("", persist=False)
|
||
self.assertEqual(ai_config.AI_AGENT_NAME, "新云端客服")
|
||
finally:
|
||
ai_config.AI_AGENT_NAME = old_agent
|
||
ai_config.AI_CLOUD_AGENT_NAME = old_cloud
|
||
ai_config.AI_LOCAL_AGENT_NAME = old_local
|
||
ai_config.AI_SYSTEM_PROMPT = old_prompt
|
||
|
||
|
||
class DesktopConfigFallbackTest(unittest.TestCase):
|
||
"""桌面端拉配置:新接口优先,老接口兜底。
|
||
|
||
客户端和服务端是分批更新的,两边版本对不齐是常态而不是意外。直接把路径改
|
||
成 v2,那些服务端还没升级的部署会当场同步失败——而且失败得很安静,界面上
|
||
只是配置一直显示旧的,没人会立刻发现。
|
||
"""
|
||
|
||
def _fail(self, message: str):
|
||
return backend_client.BackendError(message)
|
||
|
||
def test_the_new_endpoint_is_tried_first(self) -> None:
|
||
calls = []
|
||
|
||
def fake(method, server, path, **kwargs):
|
||
calls.append(path)
|
||
return 200, {"config": {}, "version": 3}
|
||
|
||
with mock.patch.object(backend_client, "_request", fake):
|
||
response, used = backend_client._fetch_desktop_config("http://x", "k", 5.0)
|
||
|
||
self.assertEqual(calls, ["/api/v2/desktop/config"])
|
||
self.assertEqual(used, "/api/v2/desktop/config")
|
||
self.assertEqual(response["version"], 3)
|
||
|
||
def test_a_404_falls_back_to_the_legacy_endpoint(self) -> None:
|
||
"""服务端还是老版本时,必须照常同步,不能报错。"""
|
||
calls = []
|
||
|
||
def fake(method, server, path, **kwargs):
|
||
calls.append(path)
|
||
if path.startswith("/api/v2"):
|
||
raise backend_client.BackendError("HTTP 404", status=404)
|
||
return 200, {"config": {}, "version": 7}
|
||
|
||
with mock.patch.object(backend_client, "_request", fake):
|
||
response, used = backend_client._fetch_desktop_config("http://x", "k", 5.0)
|
||
|
||
self.assertEqual(calls, ["/api/v2/desktop/config", "/api/v1/desktop/config"])
|
||
self.assertEqual(used, "/api/v1/desktop/config")
|
||
self.assertEqual(response["version"], 7)
|
||
|
||
def test_the_fallback_is_decided_by_status_not_by_wording(self) -> None:
|
||
"""老后台对未知 GET 路径回的是"页面不存在",未知 POST 才回"接口不存在"。
|
||
|
||
一个字之差。曾经这里靠文案匹配,结果对着真的老后台跑就整个失效了——
|
||
判据必须是状态码。
|
||
"""
|
||
calls = []
|
||
|
||
def fake(method, server, path, **kwargs):
|
||
calls.append(path)
|
||
if path.startswith("/api/v2"):
|
||
raise backend_client.BackendError("页面不存在", status=404)
|
||
return 200, {"config": {}}
|
||
|
||
with mock.patch.object(backend_client, "_request", fake):
|
||
_, used = backend_client._fetch_desktop_config("http://x", "k", 5.0)
|
||
|
||
self.assertEqual(used, "/api/v1/desktop/config")
|
||
|
||
def test_a_connection_failure_is_not_retried_on_the_old_path(self) -> None:
|
||
"""连不上就是连不上,换个路径同样连不上。
|
||
|
||
真正的危害不是多发一个请求,而是把"服务器宕了"这个明确故障,伪装成
|
||
"接口不存在"往下走——最后一层抛出来的是老接口的错误,人会去查一个根本
|
||
没坏的地方。
|
||
"""
|
||
calls = []
|
||
|
||
def fake(method, server, path, **kwargs):
|
||
calls.append(path)
|
||
# 连不上根本没拿到响应,所以没有状态码
|
||
raise backend_client.BackendError("无法连接后台:timed out")
|
||
|
||
with mock.patch.object(backend_client, "_request", fake):
|
||
with self.assertRaises(backend_client.BackendError) as caught:
|
||
backend_client._fetch_desktop_config("http://x", "k", 5.0)
|
||
|
||
self.assertEqual(calls, ["/api/v2/desktop/config"], "不该退回去重试")
|
||
self.assertIn("无法连接", str(caught.exception))
|
||
|
||
def test_a_bad_sync_key_surfaces_instead_of_falling_back(self) -> None:
|
||
"""401/403 说明服务在、只是凭证不对。这是要人去改配置的错误,不能吞。"""
|
||
calls = []
|
||
|
||
def fake(method, server, path, **kwargs):
|
||
calls.append(path)
|
||
raise backend_client.AuthenticationError("同步凭证无效", status=401)
|
||
|
||
with mock.patch.object(backend_client, "_request", fake):
|
||
with self.assertRaises(backend_client.AuthenticationError):
|
||
backend_client._fetch_desktop_config("http://x", "k", 5.0)
|
||
|
||
|
||
self.assertEqual(calls, ["/api/v2/desktop/config"])
|
||
|
||
def test_a_404_on_both_paths_reports_the_last_error(self) -> None:
|
||
def fake(method, server, path, **kwargs):
|
||
raise backend_client.BackendError("HTTP 404", status=404)
|
||
|
||
with mock.patch.object(backend_client, "_request", fake):
|
||
with self.assertRaises(backend_client.BackendError):
|
||
backend_client._fetch_desktop_config("http://x", "k", 5.0)
|
||
|
||
def test_the_legacy_constant_still_points_at_a_real_path(self) -> None:
|
||
"""DESKTOP_CONFIG_PATH 可能被别处引用,不能变成空字符串。"""
|
||
self.assertIn(backend_client.DESKTOP_CONFIG_PATH, backend_client.DESKTOP_CONFIG_PATHS)
|
||
self.assertTrue(backend_client.DESKTOP_CONFIG_PATH.startswith("/api/"))
|
||
|
||
|
||
class ModelCallReportFallbackTest(unittest.TestCase):
|
||
"""调用留痕上报:和拉配置同一套 v2 → v1 顺序。
|
||
|
||
补这组测试是因为老后台(8765)退役前卡在这一条上:配置同步早有 v2,
|
||
上报却只有 v1。桌面端只要改指到 8766,配置照常同步、回复照常发,唯独
|
||
调用记录一条都不进库——静悄悄地丢掉的正是出事后用来解释"这句话怎么来的"
|
||
那份证据。
|
||
"""
|
||
|
||
def _settings(self):
|
||
return {"server_url": "http://x"}
|
||
|
||
def test_the_new_endpoint_is_tried_first(self) -> None:
|
||
calls = []
|
||
|
||
def fake(method, server, path, **kwargs):
|
||
calls.append(path)
|
||
return 200, {"ok": True}
|
||
|
||
with (
|
||
mock.patch.object(backend_client, "_request", fake),
|
||
mock.patch.object(backend_client, "load_settings", self._settings),
|
||
mock.patch.object(backend_client, "discover_local_sync_token", lambda: ""),
|
||
):
|
||
ok = backend_client.report_model_call({"task_id": "t1"})
|
||
|
||
self.assertTrue(ok)
|
||
self.assertEqual(calls, ["/api/v2/model/calls"], "新接口在就不该再打老的")
|
||
|
||
def test_a_404_falls_back_to_the_legacy_endpoint(self) -> None:
|
||
"""还指着老后台(8765)的客户端必须照常上报,不能悄悄丢数据。"""
|
||
calls = []
|
||
|
||
def fake(method, server, path, **kwargs):
|
||
calls.append(path)
|
||
if path.startswith("/api/v2"):
|
||
raise backend_client.BackendError("接口不存在", status=404)
|
||
return 200, {"ok": True}
|
||
|
||
with (
|
||
mock.patch.object(backend_client, "_request", fake),
|
||
mock.patch.object(backend_client, "load_settings", self._settings),
|
||
mock.patch.object(backend_client, "discover_local_sync_token", lambda: ""),
|
||
):
|
||
ok = backend_client.report_model_call({"task_id": "t1"})
|
||
|
||
self.assertTrue(ok)
|
||
self.assertEqual(calls, ["/api/v2/model/calls", "/api/v1/model/calls"])
|
||
|
||
def test_a_401_does_not_fall_back(self) -> None:
|
||
"""凭证不对换个路径重试同样不对,只会多打一个请求。"""
|
||
calls = []
|
||
|
||
def fake(method, server, path, **kwargs):
|
||
calls.append(path)
|
||
raise backend_client.AuthenticationError("同步凭证无效", status=401)
|
||
|
||
with (
|
||
mock.patch.object(backend_client, "_request", fake),
|
||
mock.patch.object(backend_client, "load_settings", self._settings),
|
||
mock.patch.object(backend_client, "discover_local_sync_token", lambda: ""),
|
||
):
|
||
ok = backend_client.report_model_call({"task_id": "t1"})
|
||
|
||
self.assertFalse(ok)
|
||
self.assertEqual(calls, ["/api/v2/model/calls"])
|
||
|
||
def test_a_failure_never_raises_into_the_reply_path(self) -> None:
|
||
"""上报是观测。它出问题绝不能把已经生成好的回复流程带下水。"""
|
||
def boom(*_args, **_kwargs):
|
||
raise RuntimeError("断网")
|
||
|
||
with (
|
||
mock.patch.object(backend_client, "_request", boom),
|
||
mock.patch.object(backend_client, "load_settings", self._settings),
|
||
mock.patch.object(backend_client, "discover_local_sync_token", lambda: ""),
|
||
):
|
||
self.assertFalse(backend_client.report_model_call({"task_id": "t1"}))
|
||
|
||
|
||
class GatewayUrlDerivationTest(unittest.TestCase):
|
||
"""网关地址由后台算出来告诉客户端。
|
||
|
||
这是"只改一个域名"的关键。桌面端配置里只有后台地址一项,网关在哪、模型怎么
|
||
编排全部由后台决定——运维换网关位置只动后台一处,不用挨个改客户端。
|
||
"""
|
||
|
||
def test_direct_port_access_points_at_the_local_gateway_port(self) -> None:
|
||
"""本机开发:三个服务各占一个端口,网关就在同机的 8770。"""
|
||
self.assertEqual(
|
||
admin_backend.derive_gateway_url("", "http", "127.0.0.1:8765"),
|
||
"http://127.0.0.1:8770/v1/answer",
|
||
)
|
||
self.assertEqual(
|
||
admin_backend.derive_gateway_url("", "http", "192.168.1.20:8766"),
|
||
"http://192.168.1.20:8770/v1/answer",
|
||
)
|
||
|
||
def test_a_proxied_domain_gets_a_same_origin_path(self) -> None:
|
||
"""反代后面只有 80/443 对外,公网根本连不到 8770。
|
||
|
||
这两种推法必须分开。用一套规则套两种部署,总有一边是错的——而且错得很
|
||
安静:客户端拿到一个连不上的地址,表现成"模型不回复"。
|
||
"""
|
||
self.assertEqual(
|
||
admin_backend.derive_gateway_url("", "https", "xchat.example.com"),
|
||
"https://xchat.example.com/gateway/v1/answer",
|
||
)
|
||
|
||
def test_an_absolute_override_is_used_as_is(self) -> None:
|
||
"""网关在别的域名或别的机器上时用这个。"""
|
||
self.assertEqual(
|
||
admin_backend.derive_gateway_url(
|
||
"https://gw.example.com/v1/answer", "https", "xchat.example.com"
|
||
),
|
||
"https://gw.example.com/v1/answer",
|
||
)
|
||
|
||
def test_a_path_override_hangs_off_the_backend_domain(self) -> None:
|
||
self.assertEqual(
|
||
admin_backend.derive_gateway_url(
|
||
"/model-gw/v1/answer", "https", "xchat.example.com"
|
||
),
|
||
"https://xchat.example.com/model-gw/v1/answer",
|
||
)
|
||
|
||
def test_a_missing_host_still_yields_something_usable(self) -> None:
|
||
"""拿不到 Host 时给本机地址,而不是空串。
|
||
|
||
空串会让客户端把 gateway 当成"没配",静默回落到本机单模型——而本机
|
||
没有密钥,最后报的是一个看不懂的 401。
|
||
"""
|
||
self.assertTrue(
|
||
admin_backend.derive_gateway_url("", "http", "").startswith("http://")
|
||
)
|
||
|
||
def test_an_override_without_a_scheme_is_refused_on_save(self) -> None:
|
||
"""漏了 http:// 会被拼成 https://你的域名/gw.example.com/v1/answer。
|
||
|
||
那是个没意义的地址,而且失败要等到桌面端下次发模型请求才暴露。
|
||
"""
|
||
base = {
|
||
"AI_AGENT_NAME": "甲",
|
||
"AI_HOSPITAL_NAME": "乙",
|
||
"AI_CONTEXT_MAX_ROUNDS": "5",
|
||
"AI_MCP_MAX_ROUNDS": "5",
|
||
"AI_MCP_SERVERS": "[]",
|
||
}
|
||
with self.assertRaises(ValueError) as caught:
|
||
admin_backend.validate_config_form(
|
||
{**base, "AI_GATEWAY_URL": "gw.example.com/v1/answer"}, {}
|
||
)
|
||
self.assertIn("http", str(caught.exception))
|
||
|
||
def test_an_empty_override_is_accepted_and_means_auto(self) -> None:
|
||
config = admin_backend.validate_config_form(
|
||
{
|
||
"AI_AGENT_NAME": "甲",
|
||
"AI_HOSPITAL_NAME": "乙",
|
||
"AI_CONTEXT_MAX_ROUNDS": "5",
|
||
"AI_MCP_MAX_ROUNDS": "5",
|
||
"AI_MCP_SERVERS": "[]",
|
||
"AI_GATEWAY_URL": "",
|
||
},
|
||
{},
|
||
)
|
||
self.assertEqual(config["AI_GATEWAY_URL"], "")
|
||
|
||
|
||
class GatewayArrivesBySyncTest(unittest.TestCase):
|
||
"""桌面端不用手工配网关——同步时后台会把地址带过来。"""
|
||
|
||
def _settings(self, response, existing=None):
|
||
"""跑一遍 _apply_config_response,返回它写下的设置。"""
|
||
saved = {}
|
||
base = backend_client.default_settings()
|
||
if existing:
|
||
base.update(existing)
|
||
|
||
with (
|
||
mock.patch.object(backend_client, "load_settings", lambda: dict(base)),
|
||
mock.patch.object(backend_client, "save_settings", saved.update),
|
||
mock.patch("ai_config.apply_settings", lambda cfg, persist=True: cfg),
|
||
):
|
||
backend_client._apply_config_response(response, dict(base))
|
||
return saved
|
||
|
||
def test_the_synced_gateway_address_lands_in_the_settings(self) -> None:
|
||
saved = self._settings(
|
||
{
|
||
"version": 3,
|
||
"config": {},
|
||
"gateway": {"enabled": True, "url": "https://x.example.com/gateway/v1/answer"},
|
||
}
|
||
)
|
||
self.assertEqual(
|
||
saved["gateway"],
|
||
{"enabled": True, "url": "https://x.example.com/gateway/v1/answer"},
|
||
)
|
||
|
||
def test_an_old_backend_without_a_gateway_block_keeps_the_last_one(self) -> None:
|
||
"""老版本后端不返回这一段。清空等于把已经能用的客户端打回"没有网关"。"""
|
||
existing = {"gateway": {"enabled": True, "url": "https://old.example.com/v1/answer"}}
|
||
saved = self._settings({"version": 4, "config": {}}, existing=existing)
|
||
self.assertEqual(saved.get("gateway", existing["gateway"]), existing["gateway"])
|
||
|
||
def test_a_blank_url_from_the_backend_is_ignored(self) -> None:
|
||
"""后台配错回了空地址时,宁可保持原样也不要把客户端弄成没网关。"""
|
||
existing = {"gateway": {"enabled": True, "url": "https://good.example.com/v1/answer"}}
|
||
saved = self._settings(
|
||
{"version": 5, "config": {}, "gateway": {"enabled": True, "url": " "}},
|
||
existing=existing,
|
||
)
|
||
self.assertEqual(saved.get("gateway", existing["gateway"]), existing["gateway"])
|
||
|
||
|
||
class EndpointModeTest(unittest.TestCase):
|
||
"""接口地址:自动补全 vs 原样使用。
|
||
|
||
加这个开关是因为 auto 那套拼接规则只覆盖得了"服务商标准形状"。自建服务的路径
|
||
常常不按套路,比如 `https://api.example.com/custom/llm/invoke`——auto 会把它
|
||
拼成 `.../invoke/chat/completions`,请求发到一个不存在的地址,报 404,而排查的
|
||
人会去怀疑密钥和网络。
|
||
|
||
为什么用显式开关而不是"更聪明的猜测":`https://api.example.com/openai` 到底是
|
||
前缀还是完整端点,光看地址分不出来。猜错的两种方向都会静默地把请求发歪。
|
||
"""
|
||
|
||
def test_auto_completes_the_path_for_each_kind(self) -> None:
|
||
cases = [
|
||
("openai", "https://api.openai.com/v1", "https://api.openai.com/v1/chat/completions"),
|
||
("openai", "https://api.deepseek.com", "https://api.deepseek.com/chat/completions"),
|
||
("claude", "https://api.anthropic.com", "https://api.anthropic.com/v1/messages"),
|
||
("dify", "https://api.dify.ai/v1", "https://api.dify.ai/v1/chat-messages"),
|
||
]
|
||
for kind, base, expected in cases:
|
||
with self.subTest(kind=kind, base=base):
|
||
self.assertEqual(model_protocol.endpoint_url(kind, base), expected)
|
||
|
||
def test_auto_does_not_double_append_a_complete_url(self) -> None:
|
||
"""已经写全的标准地址不该再被拼一次。"""
|
||
full = "https://api.openai.com/v1/chat/completions"
|
||
self.assertEqual(model_protocol.endpoint_url("openai", full), full)
|
||
|
||
def test_exact_uses_the_address_verbatim(self) -> None:
|
||
odd = "https://api.example.com/custom/llm/invoke"
|
||
self.assertEqual(model_protocol.endpoint_url("openai", odd), f"{odd}/chat/completions")
|
||
self.assertEqual(model_protocol.endpoint_url("openai", odd, "exact"), odd)
|
||
|
||
def test_exact_wins_for_every_kind(self) -> None:
|
||
"""exact 是"别动我的地址",不该被接口类型的规则翻掉。"""
|
||
odd = "https://gw.internal/llm"
|
||
for kind in ("openai", "claude", "dify", "comfyui"):
|
||
with self.subTest(kind=kind):
|
||
self.assertEqual(model_protocol.endpoint_url(kind, odd, "exact"), odd)
|
||
|
||
def test_a_trailing_slash_is_trimmed_in_both_modes(self) -> None:
|
||
self.assertEqual(
|
||
model_protocol.endpoint_url("openai", "https://x.com/llm/", "exact"),
|
||
"https://x.com/llm",
|
||
)
|
||
|
||
def test_the_connectivity_test_uses_the_same_rule_as_the_real_call(self) -> None:
|
||
"""两边规则若不一致,测试会去戳一个和实际调用不同的地址。
|
||
|
||
那是最没用的一种测试:测通了照样用不了,或者反过来。
|
||
"""
|
||
odd = "https://api.example.com/custom/llm/invoke"
|
||
for mode in ("auto", "exact"):
|
||
with self.subTest(mode=mode):
|
||
self.assertEqual(
|
||
admin_backend._model_endpoint(odd, "openai", mode),
|
||
model_protocol.endpoint_url("openai", odd, mode),
|
||
)
|
||
|
||
def test_dify_upload_root_follows_the_mode(self) -> None:
|
||
"""Dify 传图要拼 /files/upload,根地址得跟着 exact 走。"""
|
||
self.assertEqual(
|
||
model_protocol.dify_api_root("https://dify.internal/app/chat", "exact"),
|
||
"https://dify.internal/app/chat",
|
||
)
|
||
|
||
|
||
class EndpointModeStorageTest(unittest.TestCase):
|
||
"""存和读。老库要能平滑升级,老记录的行为必须一个字都不变。"""
|
||
|
||
def setUp(self) -> None:
|
||
self.root = Path(tempfile.mkdtemp())
|
||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||
self.db = admin_backend.Database(self.root / "t.db")
|
||
self.db.initialize("Admin@123456")
|
||
self.uid = self.db.authenticate("admin", "Admin@123456")["id"]
|
||
|
||
def _save(self, **extra):
|
||
item = {
|
||
"id": "p1", "name": "出口", "kind": "openai",
|
||
"base_url": "https://api.example.com/custom/llm/invoke",
|
||
"api_key": "sk-x", "model": "m",
|
||
}
|
||
item.update(extra)
|
||
return self.db.save_model_provider(item, self.uid, "1.1.1.1")
|
||
|
||
def test_the_mode_round_trips(self) -> None:
|
||
self._save(endpoint_mode="exact")
|
||
item = next(x for x in self.db.model_providers() if x["id"] == "p1")
|
||
self.assertEqual(item["endpoint_mode"], "exact")
|
||
self.assertEqual(item["endpoint"], "https://api.example.com/custom/llm/invoke")
|
||
|
||
def test_the_default_is_auto(self) -> None:
|
||
"""不填就是老行为。这个默认值是整件事零回归的前提。"""
|
||
self._save()
|
||
item = next(x for x in self.db.model_providers() if x["id"] == "p1")
|
||
self.assertEqual(item["endpoint_mode"], "auto")
|
||
self.assertTrue(item["endpoint"].endswith("/chat/completions"))
|
||
|
||
def test_an_unknown_mode_is_refused(self) -> None:
|
||
with self.assertRaises(ValueError) as caught:
|
||
self._save(endpoint_mode="随便写")
|
||
self.assertIn("地址模式", str(caught.exception))
|
||
|
||
def test_an_old_database_without_the_column_is_upgraded_in_place(self) -> None:
|
||
"""已经在用的库不能因为加一列就要重建表——重建的每一步都可能丢数据。"""
|
||
path = self.root / "old.db"
|
||
con = sqlite3.connect(path)
|
||
con.executescript(
|
||
"""
|
||
CREATE TABLE model_providers (
|
||
id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
|
||
base_url TEXT NOT NULL, api_key_enc TEXT NOT NULL DEFAULT '',
|
||
model TEXT NOT NULL DEFAULT '', capabilities TEXT NOT NULL DEFAULT 'text',
|
||
max_tokens INTEGER NOT NULL DEFAULT 500, temperature REAL NOT NULL DEFAULT 0.35,
|
||
timeout_ms INTEGER NOT NULL DEFAULT 30000, max_inflight INTEGER NOT NULL DEFAULT 32,
|
||
rpm_limit INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1,
|
||
health TEXT NOT NULL DEFAULT 'unknown', health_checked_at TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, updated_by INTEGER);
|
||
INSERT INTO model_providers (id,name,kind,base_url,created_at,updated_at)
|
||
VALUES ('old-1','老出口','openai','https://api.openai.com/v1','x','x');
|
||
"""
|
||
)
|
||
con.commit()
|
||
con.close()
|
||
|
||
with mock.patch("builtins.print"):
|
||
admin_backend.Database(path).initialize("Admin@123456")
|
||
|
||
item = next(
|
||
x for x in admin_backend.Database(path).model_providers() if x["id"] == "old-1"
|
||
)
|
||
self.assertEqual(item["endpoint_mode"], "auto", "老记录必须还是老行为")
|
||
self.assertEqual(item["endpoint"], "https://api.openai.com/v1/chat/completions")
|
||
|
||
def test_running_the_column_migration_twice_is_harmless(self) -> None:
|
||
with mock.patch("builtins.print"):
|
||
admin_backend.Database(self.root / "t.db").initialize("Admin@123456")
|
||
self.assertTrue(self.db.model_providers() is not None)
|
||
|
||
|
||
class ModelCallTraceabilityTest(unittest.TestCase):
|
||
"""`model_calls` 存客户原话、模型原话、审核原因——用来"跟踪问题"。
|
||
|
||
网关和桌面端各自都会往这张表报一次同一次调用(网关看得到候选和裁判,
|
||
桌面端才知道审核规则命中原因)。两边共用 task_id,谁先落盘谁建行,后到
|
||
的一方只补自己独有的字段——这里测的就是这道合并逻辑不多写也不少写。
|
||
"""
|
||
|
||
def setUp(self) -> None:
|
||
self.root = Path(tempfile.mkdtemp())
|
||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||
self.db = admin_backend.Database(self.root / "t.db")
|
||
with mock.patch("builtins.print"):
|
||
self.db.initialize("Admin@123456")
|
||
|
||
def _record(self, **extra) -> dict:
|
||
record = {
|
||
"device_id": "dev-1",
|
||
"task_id": "task-1",
|
||
"roles_version": 1,
|
||
"judge_mode": "arbitrate",
|
||
"chosen": "openai-primary",
|
||
"judge": {"winner": "A", "score": 0.8, "risk": "low"},
|
||
"candidates": [{"provider": "openai-primary", "text": "建议您注意休息"}],
|
||
"total_ms": 900,
|
||
"customer_text": "我这几天总是失眠",
|
||
"reply_text": "建议您注意休息",
|
||
"review_reason": "",
|
||
}
|
||
record.update(extra)
|
||
return record
|
||
|
||
def _row(self, task_id: str) -> sqlite3.Row:
|
||
with self.db.connect() as con:
|
||
return con.execute(
|
||
"SELECT * FROM model_calls WHERE task_id = ?", (task_id,)
|
||
).fetchone()
|
||
|
||
def test_a_call_round_trips_customer_and_reply_text(self) -> None:
|
||
self.db.log_model_call(self._record())
|
||
row = self._row("task-1")
|
||
self.assertEqual(row["customer_text"], "我这几天总是失眠")
|
||
self.assertEqual(row["reply_text"], "建议您注意休息")
|
||
self.assertEqual(row["review_reason"], "")
|
||
|
||
def test_a_second_report_with_the_same_task_id_only_merges_review_reason(self) -> None:
|
||
"""网关先落盘(候选、裁判都是权威数据),桌面端后到——不能把网关的数据覆盖掉。"""
|
||
self.db.log_model_call(self._record())
|
||
self.db.log_model_call(self._record(
|
||
chosen="不该生效的值",
|
||
candidates=[{"provider": "不该生效的值", "text": "x"}],
|
||
total_ms=1,
|
||
review_reason="命中审核规则「诊断」",
|
||
))
|
||
with self.db.connect() as con:
|
||
rows = con.execute(
|
||
"SELECT * FROM model_calls WHERE task_id='task-1'"
|
||
).fetchall()
|
||
self.assertEqual(len(rows), 1, "同一个 task_id 只能落一行,不是两行")
|
||
row = self._row("task-1")
|
||
self.assertEqual(row["chosen"], "openai-primary", "第二次上报不该覆盖已有的候选数据")
|
||
self.assertEqual(row["review_reason"], "命中审核规则「诊断」", "审核原因只有桌面端知道,必须生效")
|
||
|
||
def test_blank_task_ids_never_collide(self) -> None:
|
||
"""本地非网关路径目前还给不出 task_id,留空时按老行为各插一行。"""
|
||
self.db.log_model_call(self._record(task_id="", customer_text="第一条"))
|
||
self.db.log_model_call(self._record(task_id="", customer_text="第二条"))
|
||
with self.db.connect() as con:
|
||
n = con.execute(
|
||
"SELECT COUNT(*) AS n FROM model_calls WHERE task_id=''"
|
||
).fetchone()["n"]
|
||
self.assertEqual(n, 2)
|
||
|
||
def test_list_model_calls_finds_a_keyword_in_either_side_of_the_exchange(self) -> None:
|
||
self.db.log_model_call(self._record(task_id="t1", customer_text="想咨询糖尿病用药"))
|
||
self.db.log_model_call(self._record(task_id="t2", customer_text="今天天气不错", reply_text="是呀"))
|
||
found = self.db.list_model_calls(days=7, q="糖尿病")
|
||
self.assertEqual(found["total"], 1)
|
||
self.assertEqual(found["items"][0]["task_id"], "t1")
|
||
|
||
def test_list_model_calls_parses_candidates_back_into_a_list(self) -> None:
|
||
self.db.log_model_call(self._record(task_id="t1"))
|
||
found = self.db.list_model_calls(days=7)
|
||
self.assertEqual(found["items"][0]["candidates"][0]["provider"], "openai-primary")
|
||
|
||
def test_list_model_calls_respects_the_days_window(self) -> None:
|
||
self.db.log_model_call(self._record(task_id="t1"))
|
||
with self.db.connect() as con:
|
||
con.execute(
|
||
"UPDATE model_calls SET created_at = '2000-01-01 00:00:00' WHERE task_id='t1'"
|
||
)
|
||
con.commit()
|
||
found = self.db.list_model_calls(days=7)
|
||
self.assertEqual(found["total"], 0)
|
||
|
||
def test_an_old_database_with_duplicate_task_ids_is_deduped_before_the_index_goes_on(self) -> None:
|
||
"""老版本拿会话指纹当 task_id,同一个会话十几轮全共享一个值——建唯一索引前必须先清干净。"""
|
||
path = self.root / "old_calls.db"
|
||
con = sqlite3.connect(path)
|
||
con.executescript(
|
||
"""
|
||
CREATE TABLE model_calls (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
device_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '',
|
||
roles_version INTEGER NOT NULL DEFAULT 0, judge_mode TEXT NOT NULL DEFAULT '',
|
||
chosen TEXT NOT NULL DEFAULT '', judge_winner TEXT NOT NULL DEFAULT '',
|
||
judge_score REAL NOT NULL DEFAULT 0, judge_risk TEXT NOT NULL DEFAULT '',
|
||
candidates_json TEXT NOT NULL DEFAULT '[]', total_ms INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
INSERT INTO model_calls (device_id,task_id,chosen,created_at)
|
||
VALUES ('d1','same-session','p1','2026-01-01 00:00:01');
|
||
INSERT INTO model_calls (device_id,task_id,chosen,created_at)
|
||
VALUES ('d1','same-session','p2','2026-01-01 00:00:02');
|
||
INSERT INTO model_calls (device_id,task_id,chosen,created_at)
|
||
VALUES ('d1','same-session','p3','2026-01-01 00:00:03');
|
||
"""
|
||
)
|
||
con.commit()
|
||
con.close()
|
||
|
||
with mock.patch("builtins.print"):
|
||
admin_backend.Database(path).initialize("Admin@123456")
|
||
|
||
db = admin_backend.Database(path)
|
||
with db.connect() as con:
|
||
rows = con.execute(
|
||
"SELECT task_id, chosen FROM model_calls ORDER BY id"
|
||
).fetchall()
|
||
self.assertEqual([r["task_id"] for r in rows], ["", "", "same-session"], "只留最新一行的 task_id")
|
||
self.assertEqual(rows[-1]["chosen"], "p3", "保留的必须是最新那一行")
|
||
# 索引确实建起来了:迁移后再报一次同名 task_id 应该走合并而不是新插一行
|
||
db.log_model_call({"task_id": "same-session", "review_reason": "新原因"})
|
||
with db.connect() as con:
|
||
n = con.execute(
|
||
"SELECT COUNT(*) AS n FROM model_calls WHERE task_id='same-session'"
|
||
).fetchone()["n"]
|
||
merged = con.execute(
|
||
"SELECT chosen, review_reason FROM model_calls WHERE task_id='same-session'"
|
||
).fetchone()
|
||
self.assertEqual(n, 1, "唯一索引必须已经生效,不能再插出第二行")
|
||
self.assertEqual(merged["chosen"], "p3", "合并不该覆盖已有数据")
|
||
self.assertEqual(merged["review_reason"], "新原因")
|
||
|
||
def test_running_the_model_calls_migration_twice_is_harmless(self) -> None:
|
||
with mock.patch("builtins.print"):
|
||
admin_backend.Database(self.root / "t.db").initialize("Admin@123456")
|
||
self.db.log_model_call(self._record(task_id="tx"))
|
||
self.assertEqual(self._row("tx")["chosen"], "openai-primary")
|
||
|
||
|
||
class GuardCallsStayOutOfTheCustomerLogTest(unittest.TestCase):
|
||
"""界面识别调用不能混进"客服对话"的调用日志和调用统计。
|
||
|
||
这是用户真实报上来的 bug:调用记录点开一看,客户消息和模型回复全是「(空)」,
|
||
候选里全是 `{"state":"unknown","navigation_right_ratio":0,...}`——那是机器人
|
||
自己在看企业微信窗口,不是在回客户。守卫每轮轮询都要问一次模型,条数远多于
|
||
真实对话,不分开的话这张表根本没法用来查问题,裁判分布也在给错误的东西打分。
|
||
"""
|
||
|
||
def setUp(self) -> None:
|
||
self.root = Path(tempfile.mkdtemp())
|
||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||
self.db = admin_backend.Database(self.root / "t.db")
|
||
with mock.patch("builtins.print"):
|
||
self.db.initialize("Admin@123456")
|
||
|
||
def _log(self, task_id: str, purpose: str, **extra) -> None:
|
||
record = {
|
||
"device_id": "d1", "task_id": task_id, "chosen": "p1",
|
||
"judge": {"winner": "A", "score": 0.9, "risk": "low"},
|
||
"candidates": [{"provider": "p1", "text": "文本"}], "total_ms": 100,
|
||
"customer_text": "客户说的", "reply_text": "模型答的",
|
||
"purpose": purpose,
|
||
}
|
||
record.update(extra)
|
||
self.db.log_model_call(record)
|
||
|
||
def test_the_default_log_shows_only_customer_chats(self) -> None:
|
||
self._log("c1", "chat")
|
||
self._log("g1", "guard")
|
||
self._log("g2", "guard")
|
||
found = self.db.list_model_calls(days=7)
|
||
self.assertEqual(found["total"], 1)
|
||
self.assertEqual(found["items"][0]["task_id"], "c1")
|
||
|
||
def test_guard_calls_can_still_be_listed_on_purpose(self) -> None:
|
||
"""内部调用照样在花钱,需要的时候得能翻出来看,不能查无此物。"""
|
||
self._log("c1", "chat")
|
||
self._log("g1", "guard")
|
||
self.assertEqual(self.db.list_model_calls(days=7, purpose="guard")["total"], 1)
|
||
self.assertEqual(self.db.list_model_calls(days=7, purpose="")["total"], 2)
|
||
|
||
def test_stats_ignore_guard_calls(self) -> None:
|
||
"""裁判分布要量的是"发给客户的回复够不够好",不是"布局认得准不准"。"""
|
||
self._log("c1", "chat", judge={"winner": "A", "score": 0.9, "risk": "low"})
|
||
for index in range(5):
|
||
self._log(
|
||
f"g{index}", "guard",
|
||
judge={"winner": "B", "score": 0.1, "risk": "high"},
|
||
)
|
||
stats = self.db.model_call_stats(7)
|
||
self.assertEqual(stats["total"], 1, "5 条界面识别不该被算成客服调用")
|
||
self.assertEqual(stats["judged"], 1)
|
||
self.assertAlmostEqual(stats["avg_score"], 0.9, places=3)
|
||
self.assertNotIn("high", stats["risk"], "守卫的低分不该污染风险占比")
|
||
|
||
def test_a_call_without_an_explicit_purpose_counts_as_a_customer_chat(self) -> None:
|
||
"""桌面端补报的那条路不带 purpose——默认必须是 chat,不能凭空消失。"""
|
||
self.db.log_model_call({
|
||
"task_id": "c9", "chosen": "p1", "judge": {}, "candidates": [],
|
||
"customer_text": "在吗", "reply_text": "在的",
|
||
})
|
||
self.assertEqual(self.db.list_model_calls(days=7)["total"], 1)
|
||
|
||
def test_legacy_guard_rows_are_reclassified_by_their_fingerprint(self) -> None:
|
||
"""老库里的守卫调用落的是默认 chat,要按候选内容的指纹认出来并改判。
|
||
|
||
指纹用的是分类器专用的 JSON 字段名(布局守卫的 navigation_right_ratio、
|
||
会话行分类器的 reply_capable)——正常客服回复里不可能出现这些词。
|
||
"""
|
||
path = self.root / "legacy.db"
|
||
con = sqlite3.connect(path)
|
||
con.executescript(
|
||
"""
|
||
CREATE TABLE model_calls (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
device_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '',
|
||
roles_version INTEGER NOT NULL DEFAULT 0, judge_mode TEXT NOT NULL DEFAULT '',
|
||
chosen TEXT NOT NULL DEFAULT '', judge_winner TEXT NOT NULL DEFAULT '',
|
||
judge_score REAL NOT NULL DEFAULT 0, judge_risk TEXT NOT NULL DEFAULT '',
|
||
candidates_json TEXT NOT NULL DEFAULT '[]', total_ms INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
INSERT INTO model_calls (task_id,candidates_json,created_at) VALUES
|
||
('L1','[{"provider":"a","text":"{\\"state\\":\\"unknown\\",\\"navigation_right_ratio\\":0}"}]','2026-01-01 00:00:01'),
|
||
('L2','[{"provider":"a","text":"{\\"kind\\":\\"customer_chat\\",\\"reply_capable\\":true}"}]','2026-01-01 00:00:02'),
|
||
('L3','[{"provider":"a","text":"忙完了,歇会儿吧,最近还好吗"}]','2026-01-01 00:00:03');
|
||
"""
|
||
)
|
||
con.commit()
|
||
con.close()
|
||
|
||
with mock.patch("builtins.print"):
|
||
admin_backend.Database(path).initialize("Admin@123456")
|
||
|
||
db = admin_backend.Database(path)
|
||
with db.connect() as con:
|
||
marks = {
|
||
row["task_id"]: row["purpose"]
|
||
for row in con.execute("SELECT task_id, purpose FROM model_calls")
|
||
}
|
||
self.assertEqual(marks["L1"], "guard", "布局守卫的记录要改判")
|
||
self.assertEqual(marks["L2"], "guard", "会话行分类器的记录也要改判")
|
||
self.assertEqual(marks["L3"], "chat", "真实客服回复绝不能被误判成内部调用")
|
||
|
||
def test_the_reclassification_never_deletes_anything(self) -> None:
|
||
self._log("g1", "chat", candidates=[
|
||
{"provider": "a", "text": '{"navigation_right_ratio":0.1}'}
|
||
])
|
||
before = self.db.list_model_calls(days=7, purpose="")["total"]
|
||
with self.db.connect() as con:
|
||
admin_backend.Database._backfill_guard_purpose(con)
|
||
con.commit()
|
||
after = self.db.list_model_calls(days=7, purpose="")["total"]
|
||
self.assertEqual(before, after, "只改分类标记,一行都不能少")
|
||
self.assertEqual(self.db.list_model_calls(days=7, purpose="guard")["total"], 1)
|
||
|
||
|
||
def setUpModule():
|
||
# 别让测试读到开发机上的真实桌面端配置——配过模型网关的机器会让
|
||
# `ai_chat.current_provider()` 整体改走网关分支,一大片无关测试跟着变行为。
|
||
local_state_redirect.start()
|
||
|
||
|
||
def tearDownModule():
|
||
local_state_redirect.stop()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|