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:
+53
-24
@@ -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_store,NameError 被 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):
|
||||
|
||||
Reference in New Issue
Block a user