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)
This commit is contained in:
tzt
2026-09-18 22:55:49 +08:00
parent 5078721df4
commit 7e01dc9b16
6 changed files with 371 additions and 25 deletions
+200
View File
@@ -0,0 +1,200 @@
"""决策留痕测试(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()