feat(sense): T-G7 审计+前端(live 分流接线 + tier 指标卡 + Settings 灰度区块)

- proxy/routes:sense.mode=live 时转发前调 Grader 分流,档位 -> 池条目
  (tier_pool_hint 映射),响应头 X-Campus-Tier 透出(可解释性);
  T1 决策按 review.sample_rate 抽样入 ReviewQueue(sense_t1 标签,
  人工核"判 T1 实为更高档");分级故障不影响代理可用性(D-G4)
- /api/metrics:sense 段(enabled/mode/观察数/已标签/档位分布/shadow 一致率)
- 前端:MetricsView「语义分级」卡片(观察/标签进度/一致率/晋升门提示);
  SettingsView「Sense 灰度控制」区块(总开关 + collect/shadow/live 切换 +
  标签进度),npm run build 产物更新
- 全量 410 passed
This commit is contained in:
tzt
2026-09-05 15:06:27 +08:00
parent 204789aed6
commit a636b989e5
7 changed files with 171 additions and 6 deletions
+24
View File
@@ -1177,6 +1177,30 @@ try:
"pending": get_review().count(status="pending"), "pending": get_review().count(status="pending"),
"total": get_review().count(), "total": get_review().count(),
} }
# 语义分析器(T-G7):灰度状态 + 观察统计 + 档位分布 + shadow 一致率
try:
from gateway.sense.config import build_sense_config
from gateway.sense.store import SenseStore
scfg = build_sense_config(settings_store().to_dict())
srows = SenseStore.init_db(scfg.db_path).all_observations(limit=100000)
by_decided: Dict[str, int] = {}
consistent = 0
labeled = 0
for r in srows:
by_decided[r["decided_tier"]] = by_decided.get(r["decided_tier"], 0) + 1
if r["executed_tier"] and r["decided_tier"] == r["executed_tier"]:
consistent += 1
if r["true_tier"]:
labeled += 1
out["sense"] = {
"enabled": scfg.enabled, "mode": scfg.mode,
"observations": len(srows), "labeled": labeled,
"by_decided_tier": by_decided,
"agreement": round(consistent / len(srows), 4) if srows else 0.0,
"min_labels": scfg.min_labels,
}
except Exception:
pass
return out return out
except ImportError: except ImportError:
+49 -1
View File
@@ -78,11 +78,59 @@ def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
_limits(), time.time()) _limits(), time.time())
if not _limits().acquire_slot(ctx["key_id"]): if not _limits().acquire_slot(ctx["key_id"]):
raise QuotaError("并发请求已达该 key 上限") raise QuotaError("并发请求已达该 key 上限")
# 语义分析器 live 分流(D-G7:mode=live 才启用;任何异常不影响代理可用性)
tier_used = None
try: try:
return await _run_chat(body, dict(request.headers), ctx, cfg, ledger, pool) from gateway.sense.config import build_sense_config
from gateway.sense.grader import Grader
from gateway.sense.observer import get_observer
from gateway.sense.store import SenseStore
scfg = build_sense_config(settings_store().to_dict())
if scfg.enabled and scfg.mode == "live":
sstore = SenseStore.init_db(scfg.db_path)
grader = Grader(scfg, sstore, get_observer(sstore))
qtext = "\n".join(str(m.get("content") or "")
for m in (body.get("messages") or []))
d = await grader.decide(qtext or str(body.get("model") or ""),
"proxy")
tier_used = d.tier
# 档位 -> 池条目(§2 消费方表):t1/t2/t3 映射模型替换请求模型
hint = scfg.tier_pool_hint("proxy", tier_used)
e = _entry_by_tier_hint(pool, hint)
if e is not None:
body = {**body, "model": e["model"]}
except Exception:
pass # D-G4:分级故障不影响代理可用性
try:
resp = await _run_chat(body, dict(request.headers), ctx, cfg, ledger, pool)
if tier_used:
resp.headers["x-campus-tier"] = tier_used
# T1 审计抽样(§9.4):按 review.sample_rate 入队人工核
try:
import random as _random
rate = float(load_config().get("review", {}).get("sample_rate", 0.1))
if _random.random() < rate:
get_review().enqueue(
request_id + "-sense", str(body.get("model") or "proxy"),
"(sense T1 审计抽样)", tags=["sense_t1"],
reason="sense_audit")
except Exception:
pass
return resp
finally: finally:
_limits().release_slot(ctx["key_id"]) _limits().release_slot(ctx["key_id"])
def _entry_by_tier_hint(pool, hint: str) -> Optional[Dict[str, Any]]:
"""按档位名选池条目(tier_hint 优先,缺省按 tier 字段映射)。"""
entries = pool.list().get("entries", [])
for e in entries:
if not e.get("enabled") or e.get("backend") == "mock":
continue
if (e.get("tier_hint") or e.get("tier")) == hint:
return e
return None
# ---------------- 管理面 ---------------- # ---------------- 管理面 ----------------
def _guard_admin(request: Request) -> None: def _guard_admin(request: Request) -> None:
host = request.client.host if request.client else "" host = request.client.host if request.client else ""
@@ -1,14 +1,14 @@
{ {
"schemaVersion": "mimosa-hook-status/v1", "schemaVersion": "mimosa-hook-status/v1",
"recordedAt": "2026-09-01T14:21:11.564Z", "recordedAt": "2026-09-05T07:03:44.339Z",
"sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465", "sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465",
"event": "PostToolUse", "event": "PostToolUse",
"toolName": "Edit", "toolName": "Edit",
"file": "src/views/AgentView.vue", "file": "src/views/MetricsView.vue",
"outcome": "clear", "outcome": "clear",
"coverage": "complete", "coverage": "complete",
"findingCount": 0, "findingCount": 0,
"durationMs": 7, "durationMs": 2,
"hostState": "hook_complete", "hostState": "hook_complete",
"reportHint": ".mimosa/reports/" "reportHint": ".mimosa/reports/"
} }
+9
View File
@@ -114,4 +114,13 @@ export interface Metrics {
cache: Record<string, unknown> cache: Record<string, unknown>
v2?: Record<string, unknown> v2?: Record<string, unknown>
review?: { pending: number; total: number } review?: { pending: number; total: number }
sense?: {
enabled: boolean
mode: string
observations: number
labeled: number
by_decided_tier?: Record<string, number>
agreement?: number
min_labels?: number
}
} }
+17
View File
@@ -58,6 +58,21 @@
<p class="hint">单价来自模型池条目$/1M tokens经典设置下的模型成本不计入</p> <p class="hint">单价来自模型池条目$/1M tokens经典设置下的模型成本不计入</p>
</div> </div>
<div v-if="sense && sense.observations" class="metric-card">
<h3>语义分级sense · {{ sense.mode }}</h3>
<div class="kv-list">
<span>观察数</span><b>{{ sense.observations }}</b>
<span>已标签</span><b>{{ sense.labeled }} / {{ sense.min_labels }}</b>
<span>一致率</span><b>{{ ((sense.agreement ?? 0) * 100).toFixed(1) }}%</b>
<template v-for="(v, k) in sense.by_decided_tier" :key="k">
<span> {{ k }}</span><b>{{ v }}</b>
</template>
</div>
<p class="hint">
晋升门一致率 85% + 标签 {{ sense.min_labels }} + 审计无误判collect 攒满前不开 live
</p>
</div>
<div v-if="data.review" class="metric-card review-card"> <div v-if="data.review" class="metric-card review-card">
<h3>人工检验</h3> <h3>人工检验</h3>
<div class="review-stats"> <div class="review-stats">
@@ -115,6 +130,8 @@ const byModel = computed(() => {
return (v2?.by_model as Record<string, ModelBucket>) || null return (v2?.by_model as Record<string, ModelBucket>) || null
}) })
const sense = computed(() => data.value?.sense || null)
async function load() { async function load() {
loading.value = true loading.value = true
error.value = '' error.value = ''
+68 -1
View File
@@ -389,6 +389,45 @@
</div> </div>
</section> </section>
<!-- 🧠 语义分级sense灰度控制 -->
<section class="settings-section">
<div class="section-title">
<span>🧠 语义分级Sense · 三级任务分级</span>
<div class="title-right">
<span :class="['server-badge', senseCfg.enabled ? 'badge-running' : 'badge-stopped']">
{{ senseCfg.enabled ? `${senseCfg.mode}` : '○ 未启用' }}
</span>
</div>
</div>
<div class="section-body">
<p class="role-hint">
灰度纪律collect只记不决策攒满 {{ senseCfg.min_labels }} 条标签并过晋升门
一致率 85% + conformal 覆盖 α+2% + 审计无误判后才可开 live
</p>
<div class="form-grid">
<div class="field">
<label class="checkbox-label">
<input type="checkbox" v-model="senseCfg.enabled" @change="saveSense" />
启用语义分析器总开关改后保存生效
</label>
</div>
<div class="field">
<label>灰度模式</label>
<select v-model="senseCfg.mode" @change="saveSense"
:disabled="!senseCfg.enabled">
<option value="collect">collect 只采集观察</option>
<option value="shadow">shadow 决策只比对不执行</option>
<option value="live">live 真分流需晋升门通过</option>
</select>
</div>
<div class="field">
<label>观察/标签数min_labels={{ senseCfg.min_labels }}</label>
<input :value="`${senseCfg.obs} / ${senseCfg.labeled}`" disabled />
</div>
</div>
</div>
</section>
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="actions"> <div class="actions">
<button class="btn-primary" :disabled="saving || !dirty" @click="save"> <button class="btn-primary" :disabled="saving || !dirty" @click="save">
@@ -412,6 +451,7 @@ import {
getLlamaStatus, listLocalModels, startLlama, stopLlama, getLlamaStatus, listLocalModels, startLlama, stopLlama,
downloadModel, watchDownload, downloadModel, watchDownload,
getPool, upsertPoolEntry, deletePoolEntry, setPoolRoles, testPoolEntry, listPoolModels, getPool, upsertPoolEntry, deletePoolEntry, setPoolRoles, testPoolEntry, listPoolModels,
getMetrics,
} from '@/api' } from '@/api'
import type { import type {
ModelSettings, ModelInfo, LlamaStatus, LocalModel, ModelSettings, ModelInfo, LlamaStatus, LocalModel,
@@ -961,7 +1001,34 @@ async function reset() {
} }
} }
onMounted(load) // 语义分级(sense)灰度
const senseCfg = reactive({ enabled: false, mode: 'collect', obs: 0, labeled: 0, min_labels: 500 })
async function loadSense() {
try {
const [cfg, m] = await Promise.all([getConfig(), getMetrics()])
const s = (cfg as any).sense || {}
senseCfg.enabled = !!s.enabled
senseCfg.mode = s.mode || 'collect'
senseCfg.min_labels = s.policy?.min_labels ?? 500
const sm = (m as any).sense || {}
senseCfg.obs = sm.observations ?? 0
senseCfg.labeled = sm.labeled ?? 0
} catch { /* 静默 */ }
}
async function saveSense() {
try {
await updateConfig({ sense: { enabled: senseCfg.enabled, mode: senseCfg.mode } } as any)
saveMsg.value = { ok: true, msg: `✅ 语义分级已${senseCfg.enabled ? '启用(' + senseCfg.mode + '' : '关闭'}` }
} catch (e: any) {
saveMsg.value = { ok: false, msg: '❌ 保存失败:' + (e?.response?.data?.detail || e?.message || e) }
await loadSense()
}
setTimeout(() => (saveMsg.value = null), 3000)
}
onMounted(() => { load(); loadSense() })
onUnmounted(() => { _dlSSE?.close() }) onUnmounted(() => { _dlSSE?.close() })
</script> </script>
+1 -1
View File
@@ -162,5 +162,5 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T-G4 | 线性头:离线训练脚本 + LinearHead 纯 Python 推理 + 登记 | ✅ 完成 | T-G4 | | T-G4 | 线性头:离线训练脚本 + LinearHead 纯 Python 推理 + 登记 | ✅ 完成 | T-G4 |
| T-G5 | Grader:决策组合(特征门×概率×conformal+ /v1/route + 三态 mode | ✅ 完成 | T-G5 | | T-G5 | Grader:决策组合(特征门×概率×conformal+ /v1/route + 三态 mode | ✅ 完成 | T-G5 |
| T-G6 | live 分流:pipeline tier_fn 三档钩子 + proxy 档位映射 + T2 档升级阶梯 | ✅ 完成 | T-G6 | | T-G6 | live 分流:pipeline tier_fn 三档钩子 + proxy 档位映射 + T2 档升级阶梯 | ✅ 完成 | T-G6 |
| T-G7 | 审计+前端:ReviewQueue 抽样 + tier 指标卡 + 客户端来源显示/一键升级 | ⬜ 待办 | | | T-G7 | 审计+前端:ReviewQueue 抽样 + tier 指标卡 + 客户端来源显示/一键升级 | ✅ 完成 | T-G7 |
| T-G8 | 实验:E-G1/E-G3 报告;(可选)LoraRemote + E-G2 线性 vs LoRA | ⬜ 待办 | | | T-G8 | 实验:E-G1/E-G3 报告;(可选)LoraRemote + E-G2 线性 vs LoRA | ⬜ 待办 | |