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:
@@ -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-X4:reasons / 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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user