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
_proxy_cfg = build_proxy_config(settings_store().to_dict())
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
install_error_handlers(app)
except Exception as _pe: # pragma: no cover - 代理层装配失败不拖垮主应用
+53 -24
View File
@@ -46,8 +46,12 @@ def install_error_handlers(app) -> 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")
ledger = __import__("gateway.proxy.ledger", fromlist=["Ledger"]).Ledger.init_db(cfg.db_path)
# ---------------- 学生面 ----------------
@@ -80,29 +84,32 @@ def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
if not _limits().acquire_slot(ctx["key_id"]):
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
try:
from gateway.sense.config import build_sense_config
from gateway.sense.grader import Grader
from gateway.sense.observer import get_observer
from gateway.sense.store import SenseStore
scfg = build_sense_config(settings_store().to_dict())
if scfg.enabled and scfg.mode == "live":
sstore = SenseStore.init_db(scfg.db_path)
grader = Grader(scfg, sstore, get_observer(sstore))
qtext = "\n".join(str(m.get("content") or "")
for m in (body.get("messages") or []))
d = await grader.decide(qtext or str(body.get("model") or ""),
"proxy")
tier_used = d.tier
# 档位 -> 池条目(§2 消费方表):t1/t2/t3 映射模型替换请求模型
hint = scfg.tier_pool_hint("proxy", tier_used)
e = _entry_by_tier_hint(pool, hint)
if e is not None:
body = {**body, "model": e["model"]}
except Exception:
pass # D-G4:分级故障不影响代理可用性
if settings_provider is not None:
try:
from gateway.sense.config import build_sense_config
from gateway.sense.grader import Grader
from gateway.sense.observer import get_observer
from gateway.sense.store import SenseStore
scfg = build_sense_config(settings_provider().to_dict())
if scfg.enabled and scfg.mode == "live":
sstore = SenseStore.init_db(scfg.db_path)
grader = Grader(scfg, sstore, get_observer(sstore))
qtext = "\n".join(str(m.get("content") or "")
for m in (body.get("messages") or []))
d = await grader.decide(qtext or str(body.get("model") or ""),
"proxy")
tier_used = d.tier
# 档位 -> 池条目(§2 消费方表):t1/t2/t3 映射模型替换请求模型
hint = scfg.tier_pool_hint("proxy", tier_used)
e = _entry_by_tier_hint(pool, hint)
if e is not None:
body = {**body, "model": e["model"]}
except Exception:
pass # D-G4:分级故障不影响代理可用性
try:
resp = await _run_chat(body, dict(request.headers), ctx, cfg, ledger, pool)
if tier_used:
@@ -208,6 +215,28 @@ def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
"today": today,
"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"])
async def admin_ledger(request: Request, student_id: 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.errors import EmbedderDown
from gateway.sense.features import gate
from gateway.sense.store import json_dumps
@dataclass
@@ -44,6 +45,57 @@ def _rule_tier(feats) -> str:
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:
"""决策器(持有 active 工件缓存;工件/阈值切换后调 invalidate)。"""
@@ -161,6 +213,22 @@ class Grader:
mode=self.cfg.mode, fallback=fallback,
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:
self.observer.log(__import__("gateway.sense.observer",
+47
View File
@@ -7,6 +7,7 @@ WAL;全部访问经全局锁 + 每操作新连接(ReviewQueue 模式),
from __future__ import annotations
import sqlite3
import json
import threading
import time
from pathlib import Path
@@ -29,6 +30,17 @@ CREATE TABLE IF NOT EXISTS sense_artifacts(
version TEXT PRIMARY KEY, kind TEXT NOT NULL,
path TEXT NOT NULL, metrics TEXT NOT NULL, created_ts INTEGER NOT NULL,
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),))
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,
metrics: Dict[str, Any], active: bool = False,