"""Sense 路由(T-G2 补观察初始化):/sense 前缀组 + /v1 无前缀组。""" from __future__ import annotations from typing import List from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from gateway.sense.config import SenseConfig from gateway.sense.embedder import embed from gateway.sense.errors import EmbedderDown from gateway.sense.observer import get_observer from gateway.sense.store import SenseStore def build_sense_routers(cfg: SenseConfig) -> List[APIRouter]: """组装 sense 路由组:[/sense 前缀组, /v1 无前缀组]。""" main = APIRouter(prefix="/sense") v1 = APIRouter() store = SenseStore.init_db(cfg.db_path) @main.on_event("startup") async def _start_observer(): get_observer(store) obs = get_observer() await obs.start() @main.on_event("shutdown") async def _stop_observer(): obs = get_observer() await obs.flush_once() await obs.stop() @main.get("/health", tags=["sense"]) async def health(): """sense 面健康检查(含灰度状态)。""" 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/shadow:tier 为决策值,执行仍走消费方现状; 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(客户端/代理共用)。""" try: body = await request.json() except Exception: body = {} text = str((body or {}).get("input") or "").strip() if not text: return JSONResponse({"error": {"message": "input 必填"}}, status_code=400) model = str((body or {}).get("model") or cfg.embedder.model) try: vec = await embed(text, cfg.embedder) except EmbedderDown as e: return JSONResponse( {"error": {"message": str(e), "type": "embedder_down"}}, status_code=503) return {"object": "list", "model": model, "data": [{"object": "embedding", "index": 0, "embedding": vec}]} # /v1/route(T-G5 grader)与 /sense/admin/*(T-G3+)后续追加。 return [main, v1]