"""PlantAI-rag-new mock server —— 前端开发与 E2E 场景的固定数据源。 监听 127.0.0.1:8099。实现 API.md 的全部接口,返回 fixtures/ 中的固定数据。 场景切换:查询参数 ?scenario= 或请求头 X-Mock-Scenario: normal(默认)| empty(空结果)| slow(慢响应 5s)| error(上游故障)| degraded(QA 不可用) SSE 流按真实时序回放,让流式 UI 在没有上游时也能开发。 """ from __future__ import annotations import asyncio import json import time from pathlib import Path from fastapi import FastAPI, Header, Query, Request from fastapi.responses import JSONResponse, StreamingResponse FIXTURES = Path(__file__).parent / "fixtures" app = FastAPI(title="PlantAI-rag-new mock") def load(name: str): return json.loads((FIXTURES / name).read_text(encoding="utf-8")) def scenario_of(request: Request, q: str | None = None) -> str: s = q or request.headers.get("X-Mock-Scenario") or "normal" return s if s in {"normal", "empty", "slow", "error", "degraded"} else "normal" def envelope(code: str, message: str, degraded: bool, status: int): return JSONResponse( status_code=status, content={"ok": False, "error": {"code": code, "message": message, "degraded": degraded}}, ) # ---------- 健康与元信息 ---------- @app.get("/api/health") async def health(): return load("health.json") @app.get("/api/qa/info") async def qa_info(): return load("qa_info.json") # ---------- QA ---------- @app.post("/api/qa/chat") async def qa_chat(request: Request): sc = scenario_of(request) if sc == "degraded": return envelope("UPSTREAM_DOWN", "知识问答服务当前不可用,请稍后重试", True, 503) if sc == "error": return envelope("UPSTREAM_ERROR", "上游问答服务返回错误", True, 502) if sc == "slow": await asyncio.sleep(5) if sc == "empty": return load("qa_chat_empty.json") return load("qa_chat.json") @app.post("/api/qa/search") async def qa_search(request: Request): sc = scenario_of(request) if sc in ("degraded",): return envelope("UPSTREAM_DOWN", "知识问答服务当前不可用,请稍后重试", True, 503) return load("qa_search.json") @app.post("/api/qa/chat/stream") async def qa_chat_stream(request: Request): sc = scenario_of(request) if sc == "degraded": return envelope("UPSTREAM_DOWN", "知识问答服务当前不可用,请稍后重试", True, 503) frames = load("sse_frames.json") # [{stage, phase, label, details, delay_ms}, ..., "__complete__"] async def gen(): for f in frames: if f == "__complete__": yield "event: complete\n" yield f"data: {json.dumps(load('qa_chat.json'), ensure_ascii=False)}\n\n" break await asyncio.sleep(f.get("delay_ms", 600) / 1000) payload = { "stage": f["stage"], "phase": f["phase"], "label": f["label"], "details": f.get("details", {}), "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } yield f"event: progress\n" yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" if int(f.get("delay_ms", 600)) > 3000: # 慢帧之间补 keepalive yield ": ping\n\n" return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) # ---------- 会话与消息 ---------- _sessions = [] _messages = {} _seq = 0 def _now(): return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @app.get("/api/sessions") async def sessions(): return load("sessions.json") @app.post("/api/sessions") async def create_session(request: Request): body = await request.json() return {**load("session_created.json"), "title": body.get("title") or "新会话"} @app.get("/api/sessions/{sid}") async def session_detail(sid: str): return load("session_detail.json") @app.patch("/api/sessions/{sid}") async def session_patch(sid: str, request: Request): body = await request.json() return {**load("session_created.json"), **body} @app.delete("/api/sessions/{sid}") async def session_delete(sid: str): return JSONResponse(status_code=204, content=None) @app.post("/api/sessions/{sid}/messages") async def create_message(sid: str, request: Request): body = await request.json() return {"id": "msg-mock-001", "role": body.get("role", "user"), "kind": body.get("kind", "qa"), "status": body.get("status", "done"), "blocks": body.get("blocks", []), "raw": None, "created_at": _now(), "updated_at": _now()} @app.patch("/api/sessions/{sid}/messages/{mid}") async def patch_message(sid: str, mid: str, request: Request): body = await request.json() return {"id": mid, "role": "assistant", "kind": "qa", "status": body.get("status", "done"), "blocks": body.get("blocks", []), "raw": body.get("raw"), "created_at": _now(), "updated_at": _now()} @app.get("/api/sessions/{sid}/sources") async def session_sources(sid: str): return load("sources_aggregate.json") # ---------- 知识图谱 ---------- @app.get("/api/kg/status") async def kg_status(): return load("kg_status.json") @app.get("/api/kg/overview") async def kg_overview(): return load("kg_overview.json") @app.get("/api/kg/entities") async def kg_entities(q: str = "", limit: int = 20): return load("kg_entities.json") @app.get("/api/kg/entities/{name}") async def kg_entity(name: str): return load("kg_entity.json") @app.get("/api/kg/entities/{name}/neighborhood") async def kg_neighborhood(name: str, depth: int = 2, limit: int = 50): return load("kg_neighborhood.json") # ---------- 抽取任务 ---------- @app.get("/api/extraction/jobs") async def extraction_jobs(): return load("extraction_jobs.json") @app.get("/api/extraction/jobs/{job_id}") async def extraction_job(job_id: str): return load("extraction_job.json") @app.get("/api/extraction/processing-status") async def extraction_processing_status(): return load("extraction_processing_status.json") @app.get("/api/extraction/run-log") async def extraction_run_log(): return {"text": "2026-08-25 10:00:01 INFO MinerU 解析完成,11 sections\n2026-08-25 10:03:44 INFO 实体识别 712 entities\n2026-08-25 10:12:09 INFO 关系抽取 751 relations\n"} @app.get("/api/extraction/summary") async def extraction_summary(): return load("extraction_summary.json") @app.post("/api/extraction/upload") async def extraction_upload(request: Request): return load("extraction_upload.json") if __name__ == "__main__": import uvicorn uvicorn.run(app, host="127.0.0.1", port=8099, log_level="info")