327 lines
12 KiB
Python
327 lines
12 KiB
Python
"""FastAPI 网关:对外提供 v2(端云协同)与 v1(legacy 路由)两套接口。
|
||
|
||
v2 端点(《实现方案_v2》5.3):
|
||
POST /chat 端云协同协作管线
|
||
POST /chat/legacy 原 v1 L0 行为(离线降级,测试封闭)
|
||
GET /runs/{request_id}/workspace 交流文本
|
||
GET /runs/{request_id}/artifacts/{name} 工件下载
|
||
GET /review/queue、POST /review/{id} 人工检验
|
||
GET /metrics 含 v2 token/快路径统计
|
||
|
||
启动:uvicorn gateway.api:app --host 0.0.0.0 --port 8000
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from typing import List, Optional
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
from router_system.config import load_config
|
||
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
|
||
|
||
# ---- v2 全局单例 ----
|
||
_pipeline: Optional["CollaborativePipeline"] = None
|
||
_v2stats = V2Stats() if _V2_OK else None
|
||
_review = None
|
||
_settings = None
|
||
|
||
|
||
def get_router() -> Router:
|
||
global _router
|
||
if _router is None:
|
||
_router = build_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 settings_store():
|
||
"""用户可调整设置(懒加载单例)。"""
|
||
global _settings
|
||
if _settings is None:
|
||
from gateway.settings import load_settings
|
||
_settings = load_settings()
|
||
return _settings
|
||
|
||
|
||
def rebuild_pipeline() -> None:
|
||
"""清除管线单例,下次调用重建(配置改动后生效)。"""
|
||
global _pipeline
|
||
_pipeline = None
|
||
|
||
|
||
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()
|
||
s = settings_store().to_dict() if _V2_OK else {}
|
||
kb = KnowledgeBase()
|
||
|
||
# architect(合并用户设置)
|
||
acfg = dict(cfg.get("architect", {}))
|
||
acfg.update(s.get("architect", {}))
|
||
architect = build_architect(acfg)
|
||
|
||
# worker(合并用户设置;backend 可 mock/openai/llama_server)
|
||
wcfg = dict(cfg.get("worker", {}))
|
||
wcfg.update(s.get("worker", {}))
|
||
if worker_cfg_override:
|
||
wcfg.update(worker_cfg_override)
|
||
worker = build_worker(wcfg, kb=kb)
|
||
|
||
# pipeline(合并用户设置)
|
||
cfg2 = dict(cfg)
|
||
pcfg = dict(cfg.get("pipeline", {}))
|
||
pcfg.update(s.get("pipeline", {}))
|
||
cfg2["pipeline"] = pcfg
|
||
_pipeline = build_pipeline(cfg2, 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):
|
||
query: str = Field(..., min_length=1, max_length=8000, description="用户查询")
|
||
domain_group: Optional[str] = Field(
|
||
None, description="大领域组(两级路由第一级):tech | professional | lifestyle | general;不指定则自动检测"
|
||
)
|
||
|
||
|
||
class HealthResponse(BaseModel):
|
||
status: str
|
||
domains: List[str]
|
||
classifier: str
|
||
judge: str
|
||
fallback: str
|
||
|
||
|
||
# ---- FastAPI 应用 ----
|
||
try:
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.responses import HTMLResponse
|
||
|
||
_INDEX_PATH = Path(__file__).resolve().parent / "static" / "index.html"
|
||
|
||
app = FastAPI(
|
||
title="端云协同 LLM 协作系统",
|
||
description="大模型(Architect) + 本地小模型(Worker) 通过交流文本协作;v1 保留为 legacy 路由",
|
||
version="2.0.0",
|
||
)
|
||
|
||
@app.get("/health", response_model=HealthResponse, tags=["system"])
|
||
async def health():
|
||
return get_router().health()
|
||
|
||
@app.get("/", response_class=HTMLResponse, tags=["ui"])
|
||
async def index():
|
||
"""端云协同 Web 界面(单文件前端,无需构建)。"""
|
||
if _INDEX_PATH.exists():
|
||
return HTMLResponse(_INDEX_PATH.read_text(encoding="utf-8"))
|
||
return HTMLResponse("<h1>Web 界面未生成</h1><p>缺少 gateway/static/index.html</p>")
|
||
|
||
# ---------------- v2:/chat ----------------
|
||
@app.post("/chat", tags=["chat"])
|
||
async def chat(req: QueryRequest):
|
||
try:
|
||
result = await get_pipeline().run(req.query)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
# 记账 + 入队
|
||
if _v2stats is not None:
|
||
_v2stats.record(result)
|
||
_maybe_enqueue(result)
|
||
return {
|
||
"response": result.response,
|
||
"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}
|
||
|
||
# ---------------- 模型设置(用户可调整) ----------------
|
||
@app.get("/config", tags=["settings"])
|
||
async def get_config():
|
||
"""读取当前可调整设置(小模型 / 大模型 / 管线)。"""
|
||
return settings_store().to_dict()
|
||
|
||
@app.put("/config", tags=["settings"])
|
||
async def put_config(patch: dict):
|
||
"""部分更新设置并重建管线。示例:
|
||
{"worker": {"backend": "openai", "base_url": "http://127.0.0.1:11434/v1", "temperature": 0.4}}
|
||
"""
|
||
try:
|
||
merged = settings_store().update(patch)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"设置非法: {e}")
|
||
rebuild_pipeline()
|
||
return merged
|
||
|
||
@app.post("/config/reset", tags=["settings"])
|
||
async def reset_config():
|
||
"""恢复默认设置并重建管线。"""
|
||
merged = settings_store().reset()
|
||
rebuild_pipeline()
|
||
return merged
|
||
|
||
# ---------------- 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:
|
||
app = None
|
||
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__":
|
||
import uvicorn
|
||
uvicorn.run("gateway.api:app", host="0.0.0.0", port=8000, reload=False)
|