feat(sense): T-G5 Grader 决策组合(特征门×概率×conformal + /v1/route + 三态 mode)

- features.py:gate() 纯函数——轮数/字符估算/意图黑名单/仓库级信号/长度门
  -> t1_hard_ok(任一硬门不过即 False,D-G1)
- grader.py:Grader.decide §8 时序(embed 降级检查 -> 特征门 -> LinearHead 概率
  -> conformal 阈值:p1>=τ1 且 t1_hard_ok->T1,p3>=τ3 或 repo_signals->T3,
  其余 T2 默认;collect/shadow 只写观察 executed=现行为,live 决策即执行;
  全模式 observer.log);工件/阈值缓存 + invalidate;D-G4 规则门退化
- routes:/v1/route 契约(D-G6 不落 query 原文)
- fix(observer):embedding list -> BLOB 转换(修 sqlite 绑定)
- 测试 +6(门矩阵/shadow 不改流/live 决策/降级/保守阈值/写观察),全量 405 passed
This commit is contained in:
tzt
2026-09-05 14:17:44 +08:00
parent 2a4cb529de
commit 4575025056
6 changed files with 376 additions and 1 deletions
+54
View File
@@ -0,0 +1,54 @@
"""特征门(T-G5):单轮/长度/意图黑名单/仓库级信号(纯函数)。
D-G1t1_hard_ok=False 时禁止判 T1(硬门);repo_signals=True 时倾向 T3。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from gateway.sense.config import SenseConfig
_REPO_SIGNALS = ("多文件", "仓库", "项目结构", "跨模块", "架构", "脚手架",
"migration", "refactor", "scaffold", "repository")
@dataclass
class Features:
turns: int = 1 # 非 system 消息数
est_tokens: int = 0 # 字符/3 估算(中文友好近似)
single_turn: bool = True
intent_blocked: bool = False # 命中意图黑名单
repo_signals: bool = False # 仓库级/多文件信号
over_length: bool = False # 超出 t1 长度门
t1_hard_ok: bool = True # 任一 T1 硬门不过即 False
def _to_text_and_turns(text_or_messages) -> tuple[str, int]:
if isinstance(text_or_messages, str):
return text_or_messages, 1
msgs = [m for m in (text_or_messages or []) if isinstance(m, dict)]
non_system = [m for m in msgs if str(m.get("role")) != "system"]
text = "\n".join(str(m.get("content") or "") for m in non_system)
return text, max(1, len(non_system))
def gate(text_or_messages, consumer: str, cfg: SenseConfig) -> Features:
"""特征门(§7 签名):返回门特征 + t1_hard_ok。"""
text, turns = _to_text_and_turns(text_or_messages)
est_tokens = len(text) // 3
single_turn = turns <= 1
blocked = any(word in text for word in cfg.intent_blacklist)
repo = any(word in text for word in _REPO_SIGNALS)
over_length = est_tokens > cfg.t1_max_tokens
multi_turn = turns > cfg.t1_max_turns
t1_hard_ok = single_turn and not over_length and not blocked and not repo \
and not multi_turn
return Features(
turns=turns, est_tokens=est_tokens, single_turn=single_turn,
intent_blocked=blocked, repo_signals=repo,
over_length=over_length, t1_hard_ok=t1_hard_ok)
+155
View File
@@ -0,0 +1,155 @@
"""分级决策组合(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.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
+4
View File
@@ -97,6 +97,10 @@ class Observer:
def _write_rows(self, rows: List[Dict[str, Any]]) -> None:
for row in rows:
emb = row.get("embedding")
if isinstance(emb, list):
# 量化值以 0..255 无符号表示 -> BLOB
row["embedding"] = bytes(bytearray(x & 0xFF for x in emb))
self.store.insert_observation(row)
+34
View File
@@ -37,6 +37,40 @@ def build_sense_routers(cfg: SenseConfig) -> List[APIRouter]:
return {"enabled": cfg.enabled, "mode": cfg.mode,
"embedder": cfg.embedder.base_url}
@v1.post("/v1/route", tags=["sense"])
async def route_v1(request: Request):
"""分级决策:{query|messages, consumer, domain?} -> TierDecision 视图。
D-G6:不落 query 原文(观察表只存哈希可关联 id + 特征 + int8 向量)。
mode=collect/shadowtier 为决策值,执行仍走消费方现状;
mode=live:消费方按 tier 分流(D-G7)。
"""
from gateway.sense.grader import Grader
try:
body = await request.json()
except Exception:
body = {}
query = (body or {}).get("query")
messages = (body or {}).get("messages")
consumer = str((body or {}).get("consumer") or "proxy")
domain = str((body or {}).get("domain") or "")
payload = messages if messages else (query or "")
grader = Grader(cfg, store,
get_observer(store) if cfg.mode != "collect" else None)
d = await grader.decide(payload, consumer, domain)
probs_total = sum(d.probs.values()) or 1.0
return {
"tier": d.tier if cfg.mode == "live" else d.tier,
"probs": {k: round(v / probs_total, 4) for k, v in d.probs.items()},
"confidence": round(max(d.probs.values()) / probs_total, 4)
if d.probs else 0.0,
"thresholds_version": d.thresholds_version,
"head_version": d.head_version,
"mode": d.mode,
"fallback": d.fallback,
"hard_gates": d.hard_gates,
}
@v1.post("/v1/embeddings", tags=["sense"])
async def embeddings(request: Request):
"""OpenAI 兼容透传 embedder(客户端/代理共用)。"""