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
+128
View File
@@ -0,0 +1,128 @@
"""Grader/特征门测试(T-G5):门×概率×mode 决策表 + fallback + /v1/route 契约。"""
import asyncio
import json
import pytest
pytest.importorskip("fastapi")
from gateway.sense.config import build_sense_config
from gateway.sense.errors import EmbedderDown
from gateway.sense.features import gate
from gateway.sense.grader import Grader
from gateway.sense.store import SenseStore
@pytest.fixture()
def env(tmp_path, monkeypatch):
cfg = build_sense_config({"sense": {
"enabled": True, "mode": "shadow",
"db_path": str(tmp_path / "s.sqlite3"),
"models_dir": str(tmp_path / "models")}})
store = SenseStore.init_db(cfg.db_path)
reset = getattr(__import__("gateway.sense.observer",
fromlist=["reset_observer"]), "reset_observer")
reset()
observer = __import__("gateway.sense.observer",
fromlist=["Observer"]).Observer(store)
g = Grader(cfg, store, observer)
yield {"cfg": cfg, "store": store, "grader": g, "observer": observer,
"tmp": tmp_path}
reset()
def _embed_ok(monkeypatch, dim=8):
import gateway.sense.embedder as em
orig = em.embed
async def fake(text, c):
return [1, 2, 3]
monkeypatch.setattr("gateway.sense.grader.embed", fake)
return orig
def _head_ok(tmp_path, monkeypatch, p1=0.95, p3=0.02):
"""注册一个 active head 工件,predict 可控。"""
import gateway.sense.grader as gr
class FakeHead:
version = "test-head"
def __init__(self, p1, p3):
self._p = (p1, 1 - p1 - p3, p3)
def predict(self, vec):
p1, p2, p3 = self._p
return {"t1": p1, "t2": p2, "t3": p3}
head = FakeHead(p1, p3)
monkeypatch.setattr(Grader, "_load_head",
lambda self: head if not self._head_loaded else None)
return head
def test_gate_features_matrix(env):
"""特征门全表:单轮/多轮/长度/黑名单/仓库信号。"""
cfg = env["cfg"]
short_ok = gate("什么是递归?", "pipeline", cfg)
assert short_ok.t1_hard_ok is True and short_ok.turns == 1
long_text = "x" * (cfg.t1_max_tokens * 3 + 10)
assert gate(long_text, "pipeline", cfg).t1_hard_ok is False
multi = [{"role": "user", "content": "a"}, {"role": "assistant", "content": "b"},
{"role": "user", "content": "c"}]
f2 = gate(multi, "pipeline", cfg)
assert f2.turns == 3 and f2.t1_hard_ok is False
for word in ("重构", "脚手架", "迁移", "实现", "多文件", "项目"):
f = gate(f"帮我{word}这个模块", "pipeline", cfg)
assert f.intent_blocked is True and f.t1_hard_ok is False
repo = gate("这个仓库要跨模块改造", "pipeline", cfg)
assert repo.repo_signals is True and repo.t1_hard_ok is False
def test_grader_shadow_does_not_change_flow(env, monkeypatch):
"""shadowdecided 照算,executed=规则门现行为,观察必写(flush 后落库)。"""
_embed_ok(monkeypatch)
_head_ok(env["tmp"], monkeypatch, p1=0.95, p3=0.02)
d = asyncio_run(env["grader"].decide("什么是递归?", "pipeline"))
assert d.tier == "T1" and d.mode == "shadow" and d.fallback is False
assert d.executed_tier == "T1" # 规则门现行为同为 T1
asyncio_run(env["observer"].flush_once())
obs_rows = env["store"].all_observations()
assert len(obs_rows) == 1
assert obs_rows[0]["decided_tier"] == "T1"
assert json.loads(obs_rows[0]["features"])["t1_hard_ok"] is True
def test_grader_live_tier_is_decision(env, monkeypatch):
"""live:决策即执行档。"""
env["cfg"].mode = "live"
_embed_ok(monkeypatch)
_head_ok(env["tmp"], monkeypatch, p1=0.3, p3=0.9)
d = asyncio_run(env["grader"].decide("多文件大改造任务", "proxy"))
assert d.tier == "T3"
assert d.mode == "live"
def test_grader_fallback_embedder_down(env, monkeypatch):
"""Embedder 挂 -> fallback=True 规则门退化(D-G4)。"""
def boom(text, c):
raise EmbedderDown("挂了")
monkeypatch.setattr("gateway.sense.grader.embed", boom)
d = asyncio_run(env["grader"].decide("什么是递归?", "pipeline"))
assert d.fallback is True
assert d.tier == "T1" # 规则门:短文本可 T1
assert d.probs == {}
def test_grader_conservative_thresholds(env, monkeypatch):
"""无工件 -> 保守阈值 0.9p1=0.5 不判 T1。"""
_embed_ok(monkeypatch)
_head_ok(env["tmp"], monkeypatch, p1=0.5, p3=0.05)
d = asyncio_run(env["grader"].decide("什么是递归?", "pipeline"))
assert d.tier == "T2" # 置信不足默认 T2
assert d.thresholds_version == "conservative"
def asyncio_run(coro):
return asyncio.run(coro)