Files
projectAIpopular/gateway/sense/grader.py
T
tzt 1aaa05cacc feat(sense): T-X5 云端评判晋升表×conformal 双闸门——本地档自动接管(采纳 cortiq promotion)
- gateway/sense/promotion.py:PromotionTable 状态机(candidate/promoted/demoted)
  * 晋升门:n_total>=n_min 且 通过率>=promote_lb 且 soak 浸泡期满足
  * 退化:promoted 期间通过率<demote_lb 自动降级;降级后可凭数据恢复
  * 一票否决:tier=T3 观察不进通过率统计,已晋升者立即降级
  * 持久化 sense_promotion 表(CREATE IF NOT EXISTS 幂等)
- grader:live 模式对已晋升标签的 T2 决策(无 T3 信号/非 fallback)升级 T1,
  hard_gates.promotion_applied 如实标注;T1/T3/fallback 路径不受影响
- 回填链路:T1 审计抽样带 promo:<label> 标签 -> 人工 verdict approve=ok
  经 /review/{id} 提交时回填晋升表(失败不影响审核主流程)
- 双闸门语义:conformal 阈值保证单条决策风险率,晋升表保证标签级接管节奏;
  collect 数据不足(n_min 未满)不晋升,全部迁移可经 T-X4 留痕审计

pytest 474 passed(T-X4 后 466 + 8)
2026-09-18 23:01:52 +08:00

257 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分级决策组合(T-G5,§8 主时序):
embedder.embed(挂 -> fallback=T2+规则门,D-G4
-> features.gatet1_hard_ok
-> LinearHead.predict -> {p1,p2,p3}
-> conformal 阈值:p1>=τ1 且 t1_hard_ok -> T1p3>=τ3 或 repo_signals -> T3;其余 T2
-> mode 裁剪:collect/shadow 只写观察(decided≠executed),live 返回决策
-> observer.log(全模式必写)
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from gateway.sense.calibrate import load_active
from gateway.sense.classifier import LinearHead
from gateway.sense.config import SenseConfig
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.promotion import promotion_label
from gateway.sense.store import json_dumps
@dataclass
class TierDecision:
tier: str # 决策档(live 时即执行档的候选)
probs: Dict[str, float]
hard_gates: Dict[str, Any]
thresholds_version: str = ""
head_version: str = ""
mode: str = "collect"
fallback: bool = False # True = embedder/工件故障,规则门退化
executed_tier: str = "" # 现行为执行的档位(collect/shadow 记录用)
def _rule_tier(feats) -> str:
"""规则门兜底(无模型时的决策):仓库级 -> T3;t1 硬门过 -> T1;否则 T2。"""
if feats.repo_signals:
return "T3"
if feats.t1_hard_ok:
return "T1"
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)。"""
def __init__(self, cfg: SenseConfig, store, observer=None, promotion=None):
self.cfg = cfg
self.store = store
self.observer = observer
self._promotion = promotion # T-X5:晋升表(可 None
self._head: Optional[LinearHead] = None
self._head_loaded = False
self._thresholds: Optional[Dict[str, Any]] = None
self._dcache = DecisionCache() # T-X3live 模式决策缓存
def _load_head(self):
if not self._head_loaded:
art = self.store.active_artifact("head")
if art:
try:
self._head = LinearHead.load(art["path"], version=art["version"])
except Exception: # noqa: BLE001
self._head = None
self._head_loaded = True
return self._head
def invalidate(self) -> None:
"""工件 promote 后调用(重载 active 工件与阈值;同步清空决策缓存)。"""
self._head = None
self._head_loaded = False
self._thresholds = None
self._dcache.clear()
def _thresholds_cached(self) -> Dict[str, Any]:
if self._thresholds is None:
self._thresholds = load_active(self.store, self.cfg.models_dir)
return self._thresholds
async def decide(self, query_or_messages, consumer: str,
domain: str = "", request_id: str = "",
executed_tier: str = "") -> TierDecision:
"""分级决策(§8 时序;全模式写观察)。
T-X3live 模式下对 (consumer, 文本) 的纯决策负载(probs/head_version/vec
做 60s LRU 缓存,命中时跳过 embed + 线性头;观察/审计照常落盘。
collect/shadow 模式不走缓存(校准数据必须全量产出)。
"""
ts = time.time()
rid = request_id or ("rt" + uuid.uuid4().hex[:10])
text = (query_or_messages if isinstance(query_or_messages, str)
else "\n".join(str(m.get("content") or "")
for m in query_or_messages))
feats = gate(query_or_messages, consumer, self.cfg)
probs: Dict[str, float] = {}
head_version = ""
fallback = False
vec: Optional[List[int]] = None
cached = self._dcache.get(consumer, text) \
if self.cfg.mode == "live" else None
if cached is not None:
probs = dict(cached["probs"])
head_version = str(cached["head_version"])
vec = cached["vec"]
else:
try:
vec = await embed(text, self.cfg.embedder)
except EmbedderDown:
fallback = True
head = None if fallback else self._load_head()
if head is None:
fallback = True
if not fallback and vec is not None:
probs = head.predict([float(v) for v in vec])
head_version = head.version
if self.cfg.mode == "live":
self._dcache.put(consumer, text,
{"probs": dict(probs),
"head_version": head_version,
"vec": vec})
th = self._thresholds_cached()
th_version = str(th.get("version") or "")
# ---- 决策 ----
if fallback:
tier = _rule_tier(feats) # D-G4 规则门退化
else:
t1_ok = (probs.get("t1", 0.0) >= float(th.get("t1", 0.9))
and feats.t1_hard_ok)
t3_ok = (probs.get("t3", 0.0) >= float(th.get("t3", 0.9))
or feats.repo_signals)
if t3_ok and not t1_ok:
tier = "T3"
elif t1_ok:
tier = "T1"
else:
tier = "T2"
# ---- 晋升表应用(T-X5conformal 之外的统计闸门;仅 live----
# 仅对 T2 决策且无任何 T3 信号时,允许已晋升标签升级 T1;
# T1/T3 决策与 fallback 路径不受晋升表影响。
promotion_applied = False
if (self._promotion is not None and self.cfg.mode == "live"
and not fallback and tier == "T2"
and not feats.repo_signals
and probs.get("t3", 0.0) < float(th.get("t3", 0.9))):
if self._promotion.is_promoted(promotion_label(consumer, domain)):
tier = "T1"
promotion_applied = True
# ---- mode 裁剪(D-G7----
if self.cfg.mode == "live":
executed = tier # live:决策即执行
else:
# collect/shadow:现行为——规则门等价(T1 门/T3 信号)近似 v2 现状
executed = executed_tier or _rule_tier(feats)
decision = TierDecision(
tier=tier, probs=probs,
hard_gates={"turns": feats.turns, "est_tokens": feats.est_tokens,
"single_turn": feats.single_turn,
"intent_blocked": feats.intent_blocked,
"repo_signals": feats.repo_signals,
"over_length": feats.over_length,
"t1_hard_ok": feats.t1_hard_ok,
"promotion_applied": promotion_applied},
thresholds_version=th_version, head_version=head_version,
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",
fromlist=["Observation"]).Observation(
request_id=rid, consumer=consumer, decided_tier=tier,
executed_tier=executed, probs=probs,
policy_version=f"head:{head_version}|th:{th_version}",
features=decision.hard_gates, embedding=vec,
bucket="default", domain=domain, ts=ts))
return decision