"""分级决策组合(T-G5,§8 主时序): embedder.embed(挂 -> fallback=T2+规则门,D-G4) -> features.gate(t1_hard_ok) -> LinearHead.predict -> {p1,p2,p3} -> conformal 阈值:p1>=τ1 且 t1_hard_ok -> T1;p3>=τ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.embedder import embed from gateway.sense.errors import EmbedderDown from gateway.sense.features import gate @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" class Grader: """决策器(持有 active 工件缓存;工件/阈值切换后调 invalidate)。""" def __init__(self, cfg: SenseConfig, store, observer=None): self.cfg = cfg self.store = store self.observer = observer self._head: Optional[LinearHead] = None self._head_loaded = False self._thresholds: Optional[Dict[str, Any]] = None 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 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 时序;全模式写观察)。""" ts = time.time() rid = request_id or ("rt" + uuid.uuid4().hex[:10]) feats = gate(query_or_messages, consumer, self.cfg) probs: Dict[str, float] = {} head_version = "" fallback = False vec: Optional[List[int]] = None try: vec = await embed(feats and (query_or_messages if isinstance(query_or_messages, str) else "\n".join( str(m.get("content") or "") for m in query_or_messages)), 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 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" # ---- 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}, thresholds_version=th_version, head_version=head_version, mode=self.cfg.mode, fallback=fallback, executed_tier=executed) # ---- 观察必写(全模式)---- 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