Files
projectAIpopular/tests/test_sense_decisions.py
T
tzt 7e01dc9b16 feat(sense): T-X4 可解释决策留痕三元结构 + 修复 sense live 分流潜伏 bug(采纳 ai-model-router 决策模型)
- store:新增 sense_decisions 表(CREATE IF NOT EXISTS 幂等迁移),
  insert_decision / list_decisions(JSON 字段反序列化),q_hash 不落原文(D-G6)
- grader:全模式落库决策三元——reasons(§8 判定顺序最小完备集)、
  candidate_scores(线性头 probs)、rejected(落选档位+原因);
  留痕失败静默不影响决策主链路
- 修复:chat_completions 的 sense 分流引用未导入的 settings_store,NameError
  被 except 吞掉导致 D-G7 live 分流从未生效;改为 build_proxy_router 显式注入
  settings_provider(api.py 传 settings_store),缺省 None 行为安全
- 新增 /proxy/admin/sense-decisions 管理面查询端点(sense 未启用返回空集)
- 回归测试:注入 settings_provider 后 x-campus-tier 头出现且 T1 决策经
  管理面可查(修复前该头永远缺失)

pytest 466 passed(T-X6 后 461 + 5)
2026-09-18 22:55:49 +08:00

201 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""决策留痕测试(T-X4):store 三元结构落库 + grader 留痕 + admin 查询端点。"""
import asyncio
import pytest
pytest.importorskip("fastapi")
from fastapi import FastAPI
from fastapi.testclient import TestClient
import gateway.model_pool as mp
import gateway.sense.grader as gr
from gateway.sense.config import build_sense_config
from gateway.sense.grader import Grader
from gateway.sense.store import SenseStore
from gateway.proxy.config import build_proxy_config
from gateway.proxy.routes import build_proxy_router
class _CountingObserver:
def __init__(self):
self.logs = []
def log(self, obs):
self.logs.append(obs)
class _FakeHead:
version = "test-head"
def predict(self, vec):
return {"t1": 0.95, "t2": 0.03, "t3": 0.03}
def _make_grader(tmp_path, mode="live", embed_raises=False):
cfg = build_sense_config({"sense": {
"enabled": True, "mode": mode,
"db_path": str(tmp_path / "s.sqlite3"),
"models_dir": str(tmp_path / "models")}})
store = SenseStore.init_db(cfg.db_path)
g = Grader(cfg, store, _CountingObserver())
g._load_head = lambda: _FakeHead()
async def fake_embed(text, c):
if embed_raises:
raise __import__("gateway.sense.errors",
fromlist=["EmbedderDown"]).EmbedderDown("down")
return [1, 2, 3]
orig = gr.embed
gr.embed = fake_embed
return g, store, orig
def test_store_decision_roundtrip(tmp_path):
store = SenseStore.init_db(tmp_path / "s.sqlite3")
store.insert_decision({
"ts": 1789874000, "request_id": "rt1", "consumer": "proxy",
"decided_tier": "T1", "executed_tier": "T1", "mode": "live",
"fallback": False, "policy_version": "head:test|th:conservative",
"q_hash": "abc123",
"reasons": '["t1_ok", "p1>=threshold"]',
"candidate_scores": '{"t1": 0.95, "t2": 0.03, "t3": 0.03}',
"rejected": '[{"tier": "T3", "reason": "p3<threshold 且无 repo_signals"}]',
})
rows = store.list_decisions(limit=10)
assert len(rows) == 1
r = rows[0]
assert r["decided_tier"] == "T1" and r["mode"] == "live"
assert r["reasons"][0] == "t1_ok"
assert r["candidate_scores"]["t1"] == 0.95
assert r["rejected"][0]["tier"] == "T3"
def test_grader_records_decision_triplet(tmp_path):
"""正常决策:reasons/candidate_scores/rejected 全量落库。"""
g, store, orig = _make_grader(tmp_path)
try:
asyncio.run(g.decide("什么是递归", "proxy"))
finally:
gr.embed = orig
rows = store.list_decisions()
assert len(rows) == 1
r = rows[0]
assert r["decided_tier"] == "T1"
assert "t1_ok" in r["reasons"] and "p1>=threshold" in r["reasons"]
assert r["rejected"][0]["tier"] == "T3"
assert r["candidate_scores"]["t1"] == 0.95
assert r["q_hash"] and len(r["q_hash"]) == 32
def test_grader_records_fallback_reasons(tmp_path):
"""embedder 故障:fallback 规则门决策同样留痕并标注 fallback。"""
g, store, orig = _make_grader(tmp_path, embed_raises=True)
try:
d = asyncio.run(g.decide("什么是递归", "proxy"))
finally:
gr.embed = orig
assert d.fallback is True
rows = store.list_decisions()
assert len(rows) == 1
r = rows[0]
assert r["fallback"] == 1
assert "fallback:rule_gate" in r["reasons"]
assert {x["reason"] for x in r["rejected"]} == {"fallback"}
def test_admin_sense_decisions_endpoint(tmp_path, monkeypatch):
"""管理面查询端点:sense 未注入/未启用 -> enabled False;不报错。"""
import gateway.model_pool as mp
mp.reset_pool()
monkeypatch.setattr(mp, "_store",
__import__("gateway.model_pool",
fromlist=["PoolStore"]).PoolStore(
path=tmp_path / "pool.json"))
cfg = build_proxy_config({"proxy": {"enabled": True,
"db_path": str(tmp_path / "p.sqlite3")}})
app = FastAPI()
app.include_router(build_proxy_router(cfg, mp.get_pool()))
client = TestClient(app)
r = client.get("/proxy/admin/sense-decisions")
assert r.status_code == 200
body = r.json()
assert body["enabled"] is False and body["decisions"] == []
def test_proxy_routes_sense_live_regression(tmp_path, monkeypatch):
"""T-X4 回归:settings_provider 注入后,sense live 分流真正生效
(修复前 NameError 被 except 吞掉,x-campus-tier 永远缺失)。"""
import httpx
import gateway.proxy.upstream as up
from gateway.proxy.auth import issue_key
from gateway.proxy.ledger import Ledger
sense_dir = tmp_path / "sense"
cfg = build_proxy_config({"proxy": {
"enabled": True, "db_path": str(tmp_path / "p.sqlite3"),
"pricing": {"m1": {"in_miss": 1.0, "out": 2.0}}}})
# 签发学生 key(代理面鉴权链:Bearer 形态 -> 哈希 -> 日限额 -> rpm
led = Ledger.init_db(tmp_path / "p.sqlite3")
sid = led.upsert_student("测试生", "软件2201", balance_yuan=10.0,
daily_cap_yuan=10.0)
issued = issue_key(led, sid, rpm_cap=100, day_cap_req=1000)
auth_header = {"Authorization": f"Bearer {issued['key']}"}
import gateway.model_pool as mp
mp.reset_pool()
pool = mp.PoolStore(path=tmp_path / "pool.json")
pool.upsert({"id": "t1e", "name": "本地", "tier": "local",
"backend": "openai", "base_url": "http://mockup",
"model": "local-m", "enabled": True})
class _Settings:
def to_dict(self):
return {"sense": {
"enabled": True, "mode": "live",
"db_path": str(sense_dir / "s.sqlite3"),
"models_dir": str(sense_dir / "models"),
"consumers": {"proxy": {"t1": "local", "t2": "budget",
"t3": "premium"}}}}
app = FastAPI()
app.include_router(build_proxy_router(cfg, pool, settings_provider=_Settings))
def handler(request: httpx.Request) -> httpx.Response:
body = ("\n\n".join([
'data: {"choices":[{"delta":{"content":"hi"}}]}',
"data: [DONE]",
]) + "\n\n").encode("utf-8")
return httpx.Response(200, content=body)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
orig_client = up._client
up._client = client
async def fake_embed(text, c):
return [1, 2, 3]
orig_embed = gr.embed
gr.embed = fake_embed
try:
c = TestClient(app)
r = c.post("/proxy/v1/chat/completions",
json={"model": "local-m",
"messages": [{"role": "user", "content": "什么是递归"}]},
headers=auth_header)
assert r.status_code == 200
# 短文本 + t1_hard_ok + p1>=0.9 -> T1 生效并替换为 local 档模型
assert r.headers.get("x-campus-tier") == "T1"
assert r.json()["model"] == "local-m"
# 决策留痕可经管理面查询
d = c.get("/proxy/admin/sense-decisions")
assert d.status_code == 200
decisions = d.json()["decisions"]
assert len(decisions) == 1 and decisions[0]["decided_tier"] == "T1"
finally:
up._client = orig_client
gr.embed = orig_embed
mp.reset_pool()