feat(v2): T7 网关扩展 + T9 token 计量与账单(/chat v2,/chat/legacy,/runs,/review,/metrics)
This commit is contained in:
+204
-37
@@ -1,14 +1,18 @@
|
|||||||
"""FastAPI 网关:对外提供 /chat /health /metrics 接口。
|
"""FastAPI 网关:对外提供 v2(端云协同)与 v1(legacy 路由)两套接口。
|
||||||
|
|
||||||
启动:
|
v2 端点(《实现方案_v2》5.3):
|
||||||
uvicorn gateway.api:app --host 0.0.0.0 --port 8000
|
POST /chat 端云协同协作管线
|
||||||
或:
|
POST /chat/legacy 原 v1 L0 行为(离线降级,测试封闭)
|
||||||
python -m gateway.api
|
GET /runs/{request_id}/workspace 交流文本
|
||||||
|
GET /runs/{request_id}/artifacts/{name} 工件下载
|
||||||
|
GET /review/queue、POST /review/{id} 人工检验
|
||||||
|
GET /metrics 含 v2 token/快路径统计
|
||||||
|
|
||||||
依赖:fastapi, uvicorn, pydantic(见 requirements.txt)
|
启动:uvicorn gateway.api:app --host 0.0.0.0 --port 8000
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -16,9 +20,26 @@ from pydantic import BaseModel, Field
|
|||||||
from router_system.config import load_config
|
from router_system.config import load_config
|
||||||
from router_system.router import Router, build_router
|
from router_system.router import Router, build_router
|
||||||
|
|
||||||
# ---- 全局单例 ----
|
# ---- v2 依赖(惰性导入,缺依赖时降级提示) ----
|
||||||
|
try:
|
||||||
|
from router_system.architect import build_architect
|
||||||
|
from router_system.knowledge import KnowledgeBase
|
||||||
|
from router_system.pipeline import CollaborativePipeline, build_pipeline
|
||||||
|
from router_system.review import ReviewQueue
|
||||||
|
from router_system.v2stats import V2Stats
|
||||||
|
from router_system.worker import build_worker
|
||||||
|
_V2_OK = True
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
_V2_OK = False
|
||||||
|
|
||||||
|
# ---- v1 全局单例 ----
|
||||||
_router: Optional[Router] = None
|
_router: Optional[Router] = None
|
||||||
|
|
||||||
|
# ---- v2 全局单例 ----
|
||||||
|
_pipeline: Optional["CollaborativePipeline"] = None
|
||||||
|
_v2stats = V2Stats() if _V2_OK else None
|
||||||
|
_review = None
|
||||||
|
|
||||||
|
|
||||||
def get_router() -> Router:
|
def get_router() -> Router:
|
||||||
global _router
|
global _router
|
||||||
@@ -27,25 +48,52 @@ def get_router() -> Router:
|
|||||||
return _router
|
return _router
|
||||||
|
|
||||||
|
|
||||||
|
def get_review() -> "ReviewQueue":
|
||||||
|
global _review
|
||||||
|
if _review is None and _V2_OK:
|
||||||
|
cfg = load_config().get("review", {})
|
||||||
|
_review = ReviewQueue(db_path=cfg.get("queue_db", "data/review.sqlite3"))
|
||||||
|
return _review
|
||||||
|
|
||||||
|
|
||||||
|
def build_v2_pipeline(worker_cfg_override: Optional[dict] = None):
|
||||||
|
"""从配置构建 v2 协作管线(architect + worker + pipeline)。
|
||||||
|
|
||||||
|
worker_cfg_override 可注入(测试/演示用 mock)。无 API key 时 /chat 会走
|
||||||
|
本地降级路径(不崩溃)。
|
||||||
|
"""
|
||||||
|
global _pipeline
|
||||||
|
if _pipeline is None:
|
||||||
|
cfg = load_config()
|
||||||
|
kb = KnowledgeBase()
|
||||||
|
architect = build_architect(cfg.get("architect", {}))
|
||||||
|
wcfg = dict(cfg.get("worker", {}))
|
||||||
|
if worker_cfg_override:
|
||||||
|
wcfg.update(worker_cfg_override)
|
||||||
|
worker = build_worker(wcfg, kb=kb)
|
||||||
|
_pipeline = build_pipeline(cfg, architect, worker)
|
||||||
|
return _pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def set_pipeline(pipe) -> None:
|
||||||
|
"""测试注入替身管线。"""
|
||||||
|
global _pipeline
|
||||||
|
_pipeline = pipe
|
||||||
|
|
||||||
|
|
||||||
|
def get_pipeline():
|
||||||
|
global _pipeline
|
||||||
|
if _pipeline is None:
|
||||||
|
return build_v2_pipeline()
|
||||||
|
return _pipeline
|
||||||
|
|
||||||
|
|
||||||
# ---- 请求/响应模型 ----
|
# ---- 请求/响应模型 ----
|
||||||
class QueryRequest(BaseModel):
|
class QueryRequest(BaseModel):
|
||||||
query: str = Field(..., min_length=1, max_length=8000, description="用户查询")
|
query: str = Field(..., min_length=1, max_length=8000, description="用户查询")
|
||||||
|
domain_group: Optional[str] = Field(
|
||||||
|
None, description="大领域组(两级路由第一级):tech | professional | lifestyle | general;不指定则自动检测"
|
||||||
class QueryResponse(BaseModel):
|
)
|
||||||
response: str
|
|
||||||
domain: str
|
|
||||||
difficulty: str
|
|
||||||
confidence: float
|
|
||||||
upgraded: bool
|
|
||||||
quality_score: float
|
|
||||||
model_used: str
|
|
||||||
route: List[str]
|
|
||||||
latency_ms: float
|
|
||||||
cache_hit: bool
|
|
||||||
cache_level: Optional[str]
|
|
||||||
cost_est: float
|
|
||||||
error: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
@@ -58,37 +106,156 @@ class HealthResponse(BaseModel):
|
|||||||
|
|
||||||
# ---- FastAPI 应用 ----
|
# ---- FastAPI 应用 ----
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Multi-Expert Router API",
|
title="端云协同 LLM 协作系统",
|
||||||
description="多专业小模型 + 路由模型系统(MVP)",
|
description="大模型(Architect) + 本地小模型(Worker) 通过交流文本协作;v1 保留为 legacy 路由",
|
||||||
version="0.1.0",
|
version="2.0.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthResponse, tags=["system"])
|
@app.get("/health", response_model=HealthResponse, tags=["system"])
|
||||||
async def health():
|
async def health():
|
||||||
return get_router().health()
|
return get_router().health()
|
||||||
|
|
||||||
@app.post("/chat", response_model=QueryResponse, tags=["chat"])
|
# ---------------- v2:/chat ----------------
|
||||||
|
@app.post("/chat", tags=["chat"])
|
||||||
async def chat(req: QueryRequest):
|
async def chat(req: QueryRequest):
|
||||||
result = await get_router().route(req.query)
|
try:
|
||||||
return QueryResponse(**result.to_dict())
|
result = await get_pipeline().run(req.query)
|
||||||
|
except ValueError as e:
|
||||||
@app.get("/metrics", tags=["system"])
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
async def metrics():
|
# 记账 + 入队
|
||||||
r = get_router()
|
if _v2stats is not None:
|
||||||
|
_v2stats.record(result)
|
||||||
|
_maybe_enqueue(result)
|
||||||
return {
|
return {
|
||||||
"router": r.stats.summary(),
|
"response": result.response,
|
||||||
"cache": r.cache.stats(),
|
"request_id": result.request_id,
|
||||||
|
"status": result.status,
|
||||||
|
"fast_path": result.fast_path,
|
||||||
|
"rounds_used": result.rounds_used,
|
||||||
|
"api_input_tokens": result.api_input_tokens,
|
||||||
|
"api_output_tokens": result.api_output_tokens,
|
||||||
|
"cost_est": result.cost_est,
|
||||||
|
"model_used": result.model_used,
|
||||||
|
"latency_ms": round(result.latency_ms, 2),
|
||||||
|
"route": result.route,
|
||||||
|
"workspace_path": result.workspace_path,
|
||||||
|
"error": result.error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------- v1:/chat/legacy ----------------
|
||||||
|
@app.post("/chat/legacy", tags=["chat"])
|
||||||
|
async def chat_legacy(req: QueryRequest):
|
||||||
|
"""原 v1 L0 专家系统行为(离线可跑,测试封闭)。"""
|
||||||
|
try:
|
||||||
|
result = await get_router().route(req.query, domain_group=req.domain_group)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
return {
|
||||||
|
"response": result.response,
|
||||||
|
"domain": result.domain,
|
||||||
|
"difficulty": result.difficulty,
|
||||||
|
"confidence": round(result.confidence, 4),
|
||||||
|
"upgraded": result.upgraded,
|
||||||
|
"quality_score": round(result.quality_score, 4),
|
||||||
|
"model_used": result.model_used,
|
||||||
|
"route": result.route,
|
||||||
|
"latency_ms": round(result.latency_ms, 2),
|
||||||
|
"cache_hit": result.cache_hit,
|
||||||
|
"cache_level": result.cache_level,
|
||||||
|
"cost_est": round(result.cost_est, 6),
|
||||||
|
"error": result.error,
|
||||||
|
"subdomain": result.subdomain,
|
||||||
|
"subdomain2": result.subdomain2,
|
||||||
|
"domain_group": result.domain_group,
|
||||||
|
"request_id": result.request_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/traces/{request_id}", tags=["system"])
|
||||||
|
async def get_trace(request_id: str):
|
||||||
|
trace = get_router().trace_store.get(request_id)
|
||||||
|
if trace is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到请求 {request_id} 的推理链")
|
||||||
|
return trace
|
||||||
|
|
||||||
|
# ---------------- v2:workspace / artifacts ----------------
|
||||||
|
@app.get("/runs/{request_id}/workspace", tags=["v2"])
|
||||||
|
async def get_workspace(request_id: str):
|
||||||
|
p = Path("runs") / request_id / "workspace.json"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到运行 {request_id}")
|
||||||
|
import json
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
@app.get("/runs/{request_id}/artifacts/{name}", tags=["v2"])
|
||||||
|
async def get_artifact(request_id: str, name: str):
|
||||||
|
p = Path("runs") / request_id / "artifacts" / name
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到工件 {name}")
|
||||||
|
return {"name": name, "content": p.read_text(encoding="utf-8")}
|
||||||
|
|
||||||
|
# ---------------- v2:人工检验 ----------------
|
||||||
|
@app.get("/review/queue", tags=["v2"])
|
||||||
|
async def review_queue(status: Optional[str] = None, limit: int = 50):
|
||||||
|
return get_review().list(status=status, limit=limit)
|
||||||
|
|
||||||
|
@app.post("/review/{review_id}", tags=["v2"])
|
||||||
|
async def review_submit(review_id: int, verdict: str, correction: Optional[str] = None):
|
||||||
|
try:
|
||||||
|
ok = get_review().submit(review_id, verdict, correction=correction)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail=f"审核记录不存在或已审核: {review_id}")
|
||||||
|
return {"ok": True, "review_id": review_id, "verdict": verdict}
|
||||||
|
|
||||||
|
# ---------------- metrics ----------------
|
||||||
|
@app.get("/metrics", tags=["system"])
|
||||||
|
async def metrics():
|
||||||
|
out = {
|
||||||
|
"router": get_router().stats.summary(),
|
||||||
|
"cache": get_router().cache.stats(),
|
||||||
|
}
|
||||||
|
if _V2_OK and _v2stats is not None:
|
||||||
|
out["v2"] = _v2stats.summary()
|
||||||
|
out["review"] = {
|
||||||
|
"pending": get_review().count(status="pending"),
|
||||||
|
"total": get_review().count(),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# fastapi 未安装时,提供 CLI 入口提示
|
|
||||||
app = None
|
app = None
|
||||||
print("[gateway] 未安装 fastapi,请执行: pip install -r requirements.txt")
|
print("[gateway] 未安装 fastapi,请执行: pip install -r requirements.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_enqueue(result) -> None:
|
||||||
|
"""按 review 配置抽样/强制入队(异步场景用同步快速调用)。"""
|
||||||
|
if not _V2_OK or get_review() is None:
|
||||||
|
return
|
||||||
|
if result.status not in ("done", "escalated", "fast_path"):
|
||||||
|
return
|
||||||
|
cfg = load_config().get("review", {})
|
||||||
|
sample_rate = float(cfg.get("sample_rate", 0.10))
|
||||||
|
force_tags = cfg.get("force_tags", ["safety"])
|
||||||
|
# 从交流文本读取 tags(若可)
|
||||||
|
tags: List[str] = []
|
||||||
|
if result.workspace_path:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
ws = json.loads(Path(result.workspace_path).read_text(encoding="utf-8"))
|
||||||
|
tags = (ws.get("brief") or {}).get("tags") or []
|
||||||
|
except Exception:
|
||||||
|
tags = []
|
||||||
|
if ReviewQueue.should_enqueue(tags, sample_rate=sample_rate, force_tags=force_tags):
|
||||||
|
get_review().enqueue(
|
||||||
|
request_id=result.request_id, query=result.query, response=result.response,
|
||||||
|
tags=tags, reason="auto", workspace_path=result.workspace_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run("gateway.api:app", host="0.0.0.0", port=8000, reload=False)
|
uvicorn.run("gateway.api:app", host="0.0.0.0", port=8000, reload=False)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""v2 统计聚合器(V2Stats)—— token 计量与账单(纯标准库)。
|
||||||
|
|
||||||
|
T9:每请求 API token 记账;聚合快路径命中率、回合数分布、熔断次数、累计 token/成本。
|
||||||
|
配合 /metrics 对外透出(论文 E1 token 经济学数据来源之一)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class V2Stats:
|
||||||
|
"""线程安全的 v2 运行统计。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._total = 0
|
||||||
|
self._fast_path = 0
|
||||||
|
self._breach = 0
|
||||||
|
self._by_status: Dict[str, int] = {}
|
||||||
|
self._rounds: List[int] = []
|
||||||
|
self._api_input_tokens = 0
|
||||||
|
self._api_output_tokens = 0
|
||||||
|
self._api_cost_usd = 0.0
|
||||||
|
self._recent: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def record(self, result) -> None:
|
||||||
|
"""记录一次 PipelineResult。"""
|
||||||
|
with self._lock:
|
||||||
|
self._total += 1
|
||||||
|
if getattr(result, "fast_path", False):
|
||||||
|
self._fast_path += 1
|
||||||
|
status = getattr(result, "status", "?")
|
||||||
|
self._by_status[status] = self._by_status.get(status, 0) + 1
|
||||||
|
self._rounds.append(getattr(result, "rounds_used", 0))
|
||||||
|
if "breach" in " ".join(getattr(result, "route", [])):
|
||||||
|
self._breach += 1
|
||||||
|
self._api_input_tokens += getattr(result, "api_input_tokens", 0)
|
||||||
|
self._api_output_tokens += getattr(result, "api_output_tokens", 0)
|
||||||
|
self._api_cost_usd += getattr(result, "cost_est", 0.0)
|
||||||
|
self._recent.append({
|
||||||
|
"request_id": getattr(result, "request_id", ""),
|
||||||
|
"status": status,
|
||||||
|
"fast_path": getattr(result, "fast_path", False),
|
||||||
|
"api_input_tokens": getattr(result, "api_input_tokens", 0),
|
||||||
|
"api_output_tokens": getattr(result, "api_output_tokens", 0),
|
||||||
|
"rounds_used": getattr(result, "rounds_used", 0),
|
||||||
|
})
|
||||||
|
if len(self._recent) > 200:
|
||||||
|
self._recent = self._recent[-200:]
|
||||||
|
|
||||||
|
def summary(self) -> Dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
total = self._total
|
||||||
|
rounds = self._rounds
|
||||||
|
return {
|
||||||
|
"total_requests": total,
|
||||||
|
"fast_path_rate": round(self._fast_path / total, 4) if total else 0.0,
|
||||||
|
"status_distribution": dict(self._by_status),
|
||||||
|
"breach_count": self._breach,
|
||||||
|
"rounds_used": {
|
||||||
|
"avg": round(sum(rounds) / len(rounds), 2) if rounds else 0.0,
|
||||||
|
"max": max(rounds) if rounds else 0,
|
||||||
|
"distribution": _histogram(rounds),
|
||||||
|
},
|
||||||
|
"api_tokens": {
|
||||||
|
"input": self._api_input_tokens,
|
||||||
|
"output": self._api_output_tokens,
|
||||||
|
"total": self._api_input_tokens + self._api_output_tokens,
|
||||||
|
"cost_est_usd": round(self._api_cost_usd, 6),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _histogram(values: List[int], max_bucket: int = 10) -> Dict[str, int]:
|
||||||
|
out: Dict[str, int] = {}
|
||||||
|
for v in values:
|
||||||
|
key = str(v) if v <= max_bucket else f">{max_bucket}"
|
||||||
|
out[key] = out.get(key, 0) + 1
|
||||||
|
return out
|
||||||
+16
-1
@@ -154,8 +154,13 @@ class WorkerLoop:
|
|||||||
|
|
||||||
def build_worker(cfg: Dict[str, Any], kb: Any = None,
|
def build_worker(cfg: Dict[str, Any], kb: Any = None,
|
||||||
generate: Optional[Callable[[str], Awaitable[str]]] = None) -> WorkerLoop:
|
generate: Optional[Callable[[str], Awaitable[str]]] = None) -> WorkerLoop:
|
||||||
"""cfg 为 config.worker 段。generate 缺省时用 llama-server 端点客户端(惰性)。"""
|
"""cfg 为 config.worker 段。generate 缺省时按 backend 选择:
|
||||||
|
mock(零运行时演示)| llama_server(真实本地模型,惰性连接)。"""
|
||||||
|
backend = cfg.get("backend", "llama_server")
|
||||||
if generate is None:
|
if generate is None:
|
||||||
|
if backend == "mock":
|
||||||
|
generate = _mock_generate()
|
||||||
|
else:
|
||||||
generate = _make_llama_generate(cfg)
|
generate = _make_llama_generate(cfg)
|
||||||
verifier = Verifier(code_timeout_s=float(cfg.get("code_timeout_s", 10)))
|
verifier = Verifier(code_timeout_s=float(cfg.get("code_timeout_s", 10)))
|
||||||
return WorkerLoop(
|
return WorkerLoop(
|
||||||
@@ -167,6 +172,16 @@ def build_worker(cfg: Dict[str, Any], kb: Any = None,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_generate() -> Callable[[str], Awaitable[str]]:
|
||||||
|
"""零运行时 mock 生成器:返回一段确定性文本(演示/测试,不连真实模型)。"""
|
||||||
|
|
||||||
|
async def _gen(prompt: str) -> str:
|
||||||
|
return ("(mock worker)以下是对当前步骤的实现说明:"
|
||||||
|
"步骤已完成,内容足够长且非占位,可供接地验证通过。")
|
||||||
|
|
||||||
|
return _gen
|
||||||
|
|
||||||
|
|
||||||
def _make_llama_generate(cfg: Dict[str, Any]) -> Callable[[str], Awaitable[str]]:
|
def _make_llama_generate(cfg: Dict[str, Any]) -> Callable[[str], Awaitable[str]]:
|
||||||
"""返回调用本地 llama-server(OpenAI 兼容 /v1/chat/completions)的生成器。"""
|
"""返回调用本地 llama-server(OpenAI 兼容 /v1/chat/completions)的生成器。"""
|
||||||
base_url = cfg.get("base_url", f"http://127.0.0.1:{cfg.get('port', 8901)}/v1")
|
base_url = cfg.get("base_url", f"http://127.0.0.1:{cfg.get('port', 8901)}/v1")
|
||||||
|
|||||||
+41
-3
@@ -1,4 +1,4 @@
|
|||||||
"""???????? fastapi + httpx??"""
|
"""FastAPI 网关测试:v1 legacy 端点保持 + v2 端点(封闭,注入 mock 管线)。"""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytest.importorskip("fastapi")
|
pytest.importorskip("fastapi")
|
||||||
@@ -6,6 +6,7 @@ pytest.importorskip("httpx")
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import gateway.api as ga
|
||||||
from gateway.api import app
|
from gateway.api import app
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ def client():
|
|||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def v2_client(client):
|
||||||
|
# 用 mock worker 构建真实 v2 管线并注入(无需 API key / 真实模型)
|
||||||
|
pipe = ga.build_v2_pipeline(worker_cfg_override={"backend": "mock"})
|
||||||
|
ga.set_pipeline(pipe)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
def test_health(client):
|
def test_health(client):
|
||||||
resp = client.get("/health")
|
resp = client.get("/health")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -22,8 +31,8 @@ def test_health(client):
|
|||||||
assert "code" in data["domains"]
|
assert "code" in data["domains"]
|
||||||
|
|
||||||
|
|
||||||
def test_chat(client):
|
def test_chat_legacy(client):
|
||||||
resp = client.post("/chat", json={"query": "? Python ???????"})
|
resp = client.post("/chat/legacy", json={"query": "用 Python 写一个快速排序函数"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["response"]
|
assert data["response"]
|
||||||
@@ -31,6 +40,17 @@ def test_chat(client):
|
|||||||
assert "route" in data
|
assert "route" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_v2(v2_client):
|
||||||
|
resp = v2_client.post("/chat", json={"query": "请介绍快速排序算法"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["response"]
|
||||||
|
assert data["request_id"]
|
||||||
|
assert "fast_path" in data
|
||||||
|
assert "route" in data
|
||||||
|
assert data["status"] in ("fast_path", "done", "escalated", "failed")
|
||||||
|
|
||||||
|
|
||||||
def test_chat_empty_query(client):
|
def test_chat_empty_query(client):
|
||||||
resp = client.post("/chat", json={"query": ""})
|
resp = client.post("/chat", json={"query": ""})
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
@@ -42,3 +62,21 @@ def test_metrics(client):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "router" in data
|
assert "router" in data
|
||||||
assert "cache" in data
|
assert "cache" in data
|
||||||
|
assert "v2" in data
|
||||||
|
assert "review" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_not_found(client):
|
||||||
|
resp = client.get("/runs/nonexistent/workspace")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_flow(client):
|
||||||
|
q = ga.get_review()
|
||||||
|
rid = q.enqueue("req-x", "q", "ans", tags=["safety"], reason="test")
|
||||||
|
assert q.count() >= 1
|
||||||
|
resp = client.get("/review/queue")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp2 = client.post(f"/review/{rid}", params={"verdict": "approve"})
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["ok"] is True
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""推理链轨迹存储 / 查询测试(T3:整体项目部分拆解·先行实现)。"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from router_system.router import build_router
|
||||||
|
from router_system.trace import TraceStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_store_put_get():
|
||||||
|
ts = TraceStore()
|
||||||
|
ts.put("abc", {"query": "q1", "route": ["a", "b"]})
|
||||||
|
t = ts.get("abc")
|
||||||
|
assert t["query"] == "q1"
|
||||||
|
assert ts.get("not-exist") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_store_ring_eviction():
|
||||||
|
ts = TraceStore(max_entries=3)
|
||||||
|
for i in range(5):
|
||||||
|
ts.put(f"id{i}", {"i": i})
|
||||||
|
assert ts.size() == 3
|
||||||
|
assert ts.get("id0") is None # 最旧被淘汰
|
||||||
|
assert ts.get("id4") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_store_overwrite():
|
||||||
|
ts = TraceStore()
|
||||||
|
ts.put("a", {"v": 1})
|
||||||
|
ts.put("a", {"v": 2})
|
||||||
|
assert ts.get("a")["v"] == 2
|
||||||
|
assert ts.size() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_router_records_trace():
|
||||||
|
r = build_router()
|
||||||
|
res = await r.route("求解方程 x^2 - 5x + 6 = 0")
|
||||||
|
assert res.request_id
|
||||||
|
trace = r.trace_store.get(res.request_id)
|
||||||
|
assert trace is not None
|
||||||
|
assert trace["domain"] == "math"
|
||||||
|
assert trace["domain_group"] == "tech"
|
||||||
|
assert "plan:multi" in " ".join(trace["route"])
|
||||||
|
assert trace["quality_score"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trace_cache_hit_roundtrip():
|
||||||
|
r = build_router()
|
||||||
|
q = "加班费怎么计算"
|
||||||
|
r1 = await r.route(q)
|
||||||
|
r2 = await r.route(q) # 缓存命中
|
||||||
|
assert r2.cache_hit is True
|
||||||
|
trace = r.trace_store.get(r2.request_id)
|
||||||
|
assert trace["cache_hit"] is True
|
||||||
|
assert trace["cache_level"] == "exact"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trace_subdomain_fields():
|
||||||
|
r = build_router()
|
||||||
|
res = await r.route("基金定投的收益率怎么计算")
|
||||||
|
trace = r.trace_store.get(res.request_id)
|
||||||
|
assert trace["subdomain"] == "investing"
|
||||||
|
assert trace["subdomain2"] == "investing"
|
||||||
|
assert trace["domain_group"] == "professional"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_trace_endpoint():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from gateway.api import app
|
||||||
|
c = TestClient(app, raise_server_exceptions=False)
|
||||||
|
chat = c.post("/chat/legacy", json={"query": "请用 Python 实现快速排序的迭代版本,并分析其时间与空间复杂度"})
|
||||||
|
rid = chat.json().get("request_id")
|
||||||
|
assert rid
|
||||||
|
t = c.get(f"/traces/{rid}")
|
||||||
|
assert t.status_code == 200
|
||||||
|
body = t.json()
|
||||||
|
assert body["domain"] == "code"
|
||||||
|
assert "subdomain:algorithm" in " ".join(body["route"])
|
||||||
|
miss = c.get("/traces/不存在的id")
|
||||||
|
assert miss.status_code == 404
|
||||||
+2
-2
@@ -73,9 +73,9 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
|||||||
| T4 | Workspace(交流文本 schema/校验/渲染/rollup) | ✅ 完成 | T4 |
|
| T4 | Workspace(交流文本 schema/校验/渲染/rollup) | ✅ 完成 | T4 |
|
||||||
| T5 | WorkerLoop + 接地验证 | ✅ 完成 | T5 |
|
| T5 | WorkerLoop + 接地验证 | ✅ 完成 | T5 |
|
||||||
| T6 | CollaborativePipeline 编排 | ✅ 完成 | T6 |
|
| T6 | CollaborativePipeline 编排 | ✅ 完成 | T6 |
|
||||||
| T7 | 网关扩展(/chat 切 v2,/chat/legacy) | ⬜ | |
|
| T7 | 网关扩展(/chat 切 v2,/chat/legacy) | ✅ 完成 | T7 |
|
||||||
| T8 | 人工检验队列 ReviewQueue | ✅ 完成 | T8 |
|
| T8 | 人工检验队列 ReviewQueue | ✅ 完成 | T8 |
|
||||||
| T9 | token 计量与账单 | ⬜ | |
|
| T9 | token 计量与账单 | ✅ 完成 | T9 |
|
||||||
| T10 | rollup + prefix cache 调优 | ⬜ | |
|
| T10 | rollup + prefix cache 调优 | ⬜ | |
|
||||||
| T11 | 打包分发 setup_runtime.py | ⬜ | |
|
| T11 | 打包分发 setup_runtime.py | ⬜ | |
|
||||||
| T12 | 实验脚本 bench_tokens.py + 数据集 | ⬜ | |
|
| T12 | 实验脚本 bench_tokens.py + 数据集 | ⬜ | |
|
||||||
|
|||||||
Reference in New Issue
Block a user