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
+2 -1
View File
@@ -208,7 +208,8 @@ try:
from gateway.proxy.config import build_proxy_config from gateway.proxy.config import build_proxy_config
_proxy_cfg = build_proxy_config(settings_store().to_dict()) _proxy_cfg = build_proxy_config(settings_store().to_dict())
if _proxy_cfg.enabled: if _proxy_cfg.enabled:
app.include_router(build_proxy_router(_proxy_cfg, get_pool())) app.include_router(build_proxy_router(_proxy_cfg, get_pool(),
settings_provider=settings_store))
from gateway.proxy.routes import install_error_handlers from gateway.proxy.routes import install_error_handlers
install_error_handlers(app) install_error_handlers(app)
except Exception as _pe: # pragma: no cover - 代理层装配失败不拖垮主应用 except Exception as _pe: # pragma: no cover - 代理层装配失败不拖垮主应用
+33 -4
View File
@@ -46,8 +46,12 @@ def install_error_handlers(app) -> None:
headers={"WWW-Authenticate": "Bearer"} if exc.status_code == 401 else None) headers={"WWW-Authenticate": "Bearer"} if exc.status_code == 401 else None)
def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter: def build_proxy_router(cfg: ProxyConfig, pool, settings_provider=None) -> APIRouter:
"""组装代理面路由(唯一组装点)。""" """组装代理面路由(唯一组装点)。
settings_provider:返回设置对象的回调(api.py 注入 settings_store)。
未注入时语义分析器分流不启用(None 安全,既有测试不受影响)。
"""
router = APIRouter(prefix="/proxy") router = APIRouter(prefix="/proxy")
ledger = __import__("gateway.proxy.ledger", fromlist=["Ledger"]).Ledger.init_db(cfg.db_path) ledger = __import__("gateway.proxy.ledger", fromlist=["Ledger"]).Ledger.init_db(cfg.db_path)
# ---------------- 学生面 ---------------- # ---------------- 学生面 ----------------
@@ -80,14 +84,17 @@ def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
if not _limits().acquire_slot(ctx["key_id"]): if not _limits().acquire_slot(ctx["key_id"]):
raise QuotaError("并发请求已达该 key 上限") raise QuotaError("并发请求已达该 key 上限")
# 语义分析器 live 分流(D-G7:mode=live 才启用;任何异常不影响代理可用性) # 语义分析器 live 分流(D-G7:mode=live 才启用;任何异常不影响代理可用性)
# T-X4 修复:settings_provider 由 api.py 注入(原实现引用未导入的
# settings_storeNameError 被 except 吞掉,分流从未实际生效)。
tier_used = None tier_used = None
if settings_provider is not None:
try: try:
from gateway.sense.config import build_sense_config from gateway.sense.config import build_sense_config
from gateway.sense.grader import Grader from gateway.sense.grader import Grader
from gateway.sense.observer import get_observer from gateway.sense.observer import get_observer
from gateway.sense.store import SenseStore from gateway.sense.store import SenseStore
scfg = build_sense_config(settings_store().to_dict()) scfg = build_sense_config(settings_provider().to_dict())
if scfg.enabled and scfg.mode == "live": if scfg.enabled and scfg.mode == "live":
sstore = SenseStore.init_db(scfg.db_path) sstore = SenseStore.init_db(scfg.db_path)
grader = Grader(scfg, sstore, get_observer(sstore)) grader = Grader(scfg, sstore, get_observer(sstore))
@@ -208,6 +215,28 @@ def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
"today": today, "today": today,
"upstream_failover": upstream_failover_stats()} "upstream_failover": upstream_failover_stats()}
@router.get("/admin/sense-decisions", tags=["proxy-admin"])
async def admin_sense_decisions(request: Request, limit: int = 50):
"""路由决策留痕查询(T-X4):reasons/candidate_scores/rejected 三元结构。
管理面鉴权同 /admin/stats;sense 未启用时返回空集(不报错,便于前端
统一渲染)。
"""
_guard_admin(request)
try:
from gateway.sense.config import build_sense_config
from gateway.sense.store import SenseStore
if settings_provider is None:
return {"enabled": False, "decisions": []}
scfg = build_sense_config(settings_provider().to_dict())
if not scfg.enabled:
return {"enabled": False, "decisions": []}
sstore = SenseStore.init_db(scfg.db_path)
return {"enabled": True,
"decisions": sstore.list_decisions(limit=limit)}
except Exception:
return {"enabled": False, "decisions": []}
@router.get("/admin/ledger", tags=["proxy-admin"]) @router.get("/admin/ledger", tags=["proxy-admin"])
async def admin_ledger(request: Request, student_id: int = 0, async def admin_ledger(request: Request, student_id: int = 0,
limit: int = 50, offset: int = 0): limit: int = 50, offset: int = 0):
+68
View File
@@ -21,6 +21,7 @@ from gateway.sense.decision_cache import DecisionCache
from gateway.sense.embedder import embed from gateway.sense.embedder import embed
from gateway.sense.errors import EmbedderDown from gateway.sense.errors import EmbedderDown
from gateway.sense.features import gate from gateway.sense.features import gate
from gateway.sense.store import json_dumps
@dataclass @dataclass
@@ -44,6 +45,57 @@ def _rule_tier(feats) -> str:
return "T2" return "T2"
def _decision_reasons(fallback: bool, probs: Dict[str, float],
th: Dict[str, Any], feats) -> List[str]:
"""决策理由(T-X4):按 §8 判定顺序产出,可解释路由的最小完备集。"""
if fallback:
r = ["fallback:rule_gate"]
if feats.repo_signals:
r.append("repo_signals")
if feats.t1_hard_ok:
r.append("t1_hard_ok")
return r
p1 = probs.get("t1", 0.0) >= float(th.get("t1", 0.9))
p3 = probs.get("t3", 0.0) >= float(th.get("t3", 0.9))
t1_ok = p1 and feats.t1_hard_ok
t3_ok = p3 or feats.repo_signals
r: List[str] = []
if t3_ok and not t1_ok:
r.append("t3_ok")
elif t1_ok:
r.append("t1_ok")
else:
r.append("default_t2")
if p1:
r.append("p1>=threshold")
if not feats.t1_hard_ok:
r.append("gate:t1_hard_ok=0")
if p3:
r.append("p3>=threshold")
if feats.repo_signals:
r.append("repo_signals")
return r
def _rejected_tiers(fallback: bool, probs: Dict[str, float],
th: Dict[str, Any], feats) -> List[Dict[str, str]]:
"""落选档位及原因(T2 为缺省档不记录;fallback 时两条均给 fallback 原因)。"""
if fallback:
return [{"tier": "T1", "reason": "fallback"},
{"tier": "T3", "reason": "fallback"}]
p1 = probs.get("t1", 0.0) >= float(th.get("t1", 0.9))
p3 = probs.get("t3", 0.0) >= float(th.get("t3", 0.9))
t1_ok = p1 and feats.t1_hard_ok
t3_ok = p3 or feats.repo_signals
out: List[Dict[str, str]] = []
if not t3_ok:
out.append({"tier": "T3", "reason": "p3<threshold 且无 repo_signals"})
if not t1_ok:
out.append({"tier": "T1",
"reason": "p1<threshold" if not p1 else "t1_hard_ok=0"})
return out
class Grader: class Grader:
"""决策器(持有 active 工件缓存;工件/阈值切换后调 invalidate)。""" """决策器(持有 active 工件缓存;工件/阈值切换后调 invalidate)。"""
@@ -161,6 +213,22 @@ class Grader:
mode=self.cfg.mode, fallback=fallback, mode=self.cfg.mode, fallback=fallback,
executed_tier=executed) executed_tier=executed)
# ---- 决策留痕(T-X4reasons / candidate_scores / rejected 三元结构)----
try:
self.store.insert_decision({
"ts": ts, "request_id": rid, "consumer": consumer,
"decided_tier": tier, "executed_tier": executed,
"mode": self.cfg.mode, "fallback": fallback,
"policy_version": f"head:{head_version}|th:{th_version}",
"q_hash": __import__("hashlib").sha256(
text.encode("utf-8")).hexdigest()[:32],
"reasons": json_dumps(_decision_reasons(fallback, probs, th, feats)),
"candidate_scores": json_dumps(probs),
"rejected": json_dumps(_rejected_tiers(fallback, probs, th, feats)),
})
except Exception: # noqa: BLE001
pass # 留痕失败不影响决策(观察与代理主链路优先)
# ---- 观察必写(全模式)---- # ---- 观察必写(全模式)----
if self.observer is not None: if self.observer is not None:
self.observer.log(__import__("gateway.sense.observer", self.observer.log(__import__("gateway.sense.observer",
+47
View File
@@ -7,6 +7,7 @@ WAL;全部访问经全局锁 + 每操作新连接(ReviewQueue 模式),
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
import json
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
@@ -29,6 +30,17 @@ CREATE TABLE IF NOT EXISTS sense_artifacts(
version TEXT PRIMARY KEY, kind TEXT NOT NULL, version TEXT PRIMARY KEY, kind TEXT NOT NULL,
path TEXT NOT NULL, metrics TEXT NOT NULL, created_ts INTEGER NOT NULL, path TEXT NOT NULL, metrics TEXT NOT NULL, created_ts INTEGER NOT NULL,
active INTEGER DEFAULT 0); active INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS sense_decisions(
id INTEGER PRIMARY KEY, ts INTEGER NOT NULL,
request_id TEXT NOT NULL, consumer TEXT NOT NULL,
decided_tier TEXT NOT NULL, executed_tier TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'collect', fallback INTEGER NOT NULL DEFAULT 0,
policy_version TEXT DEFAULT '', q_hash TEXT DEFAULT '',
reasons TEXT NOT NULL DEFAULT '[]',
candidate_scores TEXT NOT NULL DEFAULT '{}',
rejected TEXT NOT NULL DEFAULT '[]');
CREATE INDEX IF NOT EXISTS idx_dec_ts ON sense_decisions(ts);
""" """
@@ -131,6 +143,41 @@ class SenseStore:
"DELETE FROM tier_observations WHERE ts < ?", (int(ts),)) "DELETE FROM tier_observations WHERE ts < ?", (int(ts),))
return cur.rowcount return cur.rowcount
# ---------- 决策留痕(T-X4:可解释路由三元结构) ----------
def insert_decision(self, rec: Dict[str, Any]) -> None:
"""写入一条路由决策记录(reasons/candidate_scores/rejected 为 JSON 串)。"""
with self._lock, self._connect() as conn:
conn.execute(
"""INSERT INTO sense_decisions
(ts, request_id, consumer, decided_tier, executed_tier, mode,
fallback, policy_version, q_hash, reasons, candidate_scores,
rejected)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(int(rec.get("ts", time.time())), rec["request_id"],
rec["consumer"], rec["decided_tier"],
rec.get("executed_tier", ""), rec.get("mode", "collect"),
1 if rec.get("fallback") else 0,
rec.get("policy_version", ""), rec.get("q_hash", ""),
rec.get("reasons", "[]"), rec.get("candidate_scores", "{}"),
rec.get("rejected", "[]")))
def list_decisions(self, limit: int = 50) -> List[Dict[str, Any]]:
"""最近 N 条决策(新在前;JSON 字段反序列化为对象)。"""
with self._lock, self._connect() as conn:
rows = conn.execute(
"SELECT * FROM sense_decisions ORDER BY id DESC LIMIT ?",
(max(1, int(limit)),)).fetchall()
out: List[Dict[str, Any]] = []
for r in rows:
d = dict(r)
for f in ("reasons", "candidate_scores", "rejected"):
try:
d[f] = json.loads(d.get(f) or ("{}" if f == "candidate_scores" else "[]"))
except json.JSONDecodeError:
pass
out.append(d)
return out
# ---------- 工件 ---------- # ---------- 工件 ----------
def register_artifact(self, version: str, kind: str, path: str, def register_artifact(self, version: str, kind: str, path: str,
metrics: Dict[str, Any], active: bool = False, metrics: Dict[str, Any], active: bool = False,
+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()
+1
View File
@@ -169,3 +169,4 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T-X2 | 上游有序降级链(外部采纳 cortiq tier 链):池内候选按档位/单价排序、首 token 前 failover、X-Upstream-Fallback 三元组响应头 + admin/stats failover 聚合 | ✅ 完成 | T-X2 | | T-X2 | 上游有序降级链(外部采纳 cortiq tier 链):池内候选按档位/单价排序、首 token 前 failover、X-Upstream-Fallback 三元组响应头 + admin/stats failover 聚合 | ✅ 完成 | T-X2 |
| T-X3 | 路由决策缓存(外部采纳 cortiq 决策哈希缓存):DecisionCachesha256+LRU+TTL60s4096 条)前置 grader live 模式,embed+线性头去重;观察/审计不跳过、invalidate 同步清空、collect/shadow 不缓存 | ✅ 完成 | T-X3 | | T-X3 | 路由决策缓存(外部采纳 cortiq 决策哈希缓存):DecisionCachesha256+LRU+TTL60s4096 条)前置 grader live 模式,embed+线性头去重;观察/审计不跳过、invalidate 同步清空、collect/shadow 不缓存 | ✅ 完成 | T-X3 |
| T-X6 | 模型池能力位过滤(外部采纳 cortiq capabilities):条目 capabilities{vision,tools,context_window}(缺省全兼容)+ filter_by_capabilities 硬过滤 + 代理请求需求推断(图片→vision/tools/上下文)与重定向 | ✅ 完成 | T-X6 | | T-X6 | 模型池能力位过滤(外部采纳 cortiq capabilities):条目 capabilities{vision,tools,context_window}(缺省全兼容)+ filter_by_capabilities 硬过滤 + 代理请求需求推断(图片→vision/tools/上下文)与重定向 | ✅ 完成 | T-X6 |
| T-X4 | 可解释决策留痕(外部采纳 ai-model-router 决策三元):sense_decisions 表(reasons/candidate_scores/rejected+ grader 全模式落库 + /proxy/admin/sense-decisions 查询;附带修复 settings_provider 未注入导致 sense live 分流失效的潜伏 bug | ✅ 完成 | T-X4 |