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:
@@ -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()
|
||||
Reference in New Issue
Block a user