"""分级决策组合(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.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 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-X3:live 模式下对 (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-X5:conformal 之外的统计闸门;仅 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-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", 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