feat(server): Web 服务化(FastAPI + SQLite + 内嵌零构建前端)
参赛成果物 03「交互界面 + 数据存储」落地:
- src/genesis/server/: store.py(SQLite 会话持久化)、service.py(会话化服务层:
上传→解析→确认→影响→确认→生成→QA)、app.py(api-design §2 核心端点 9 组)、
static/index.html(内嵌单页,零构建无 node_modules 依赖)
- scripts/serve.py 启动入口(--fake 离线引擎 / 默认真实 LLM)
- pyproject 加 fastapi/uvicorn/python-multipart
- 修复 qa_loop._build meta={} 导致真实模板 {{doc_title}} 等占位符残留 DocxInjectError
- README Web 服务说明 + service_url 登记指引;design.md §12.5 记录(含同步执行/
zip 既有系统/无 WebSocket 的诚实偏差标注)
- 测试:test_server_store/service/api 共 34 用例(TestClient 全链路 + zip 影响流程 + 错误分支)
全量 pytest 473 passed / 99.20%
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"""Web 服务化:FastAPI 端点(S4)。
|
||||
|
||||
实现 api-design.md §2 的核心端点(v1 同步执行 + 内嵌零构建前端)。
|
||||
- 会话:POST/GET /api/sessions、GET /api/sessions/{id}、DELETE
|
||||
- 文件:POST /api/sessions/{id}/files(multipart)
|
||||
- 解析:POST start-parse、GET parse-result、POST confirm-parse
|
||||
- 影响:POST start-impact、GET impact-result、POST confirm-impact
|
||||
- 生成/QA:POST generate、POST run-qa、GET qa-result
|
||||
- 结果:GET result/preview、result/download、result/impact-report、result/qa-report
|
||||
- 前端:GET / 返回内嵌单页
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from genesis.inference.factory import build_inference_engine
|
||||
from genesis.server.service import FileTypeError, GenesisService, ServiceStepError
|
||||
from genesis.server.store import SessionNotFoundError, SessionStore
|
||||
|
||||
VERSION = "0.1.0"
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
"""离线确定性引擎(--engine fake):用于无 API key 的 Web 演示/测试。"""
|
||||
|
||||
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
|
||||
from types import SimpleNamespace
|
||||
title = variables.get("title", "x")
|
||||
return SimpleNamespace(
|
||||
data={
|
||||
"title": title,
|
||||
"blocks": [{"type": "paragraph",
|
||||
"text": "本機能はFakeLLMにより生成された十分な説明内容であり、書込規則を満たす。"}],
|
||||
},
|
||||
status="ok",
|
||||
)
|
||||
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
user_id: str = "default"
|
||||
|
||||
|
||||
class FileUploadResp(BaseModel):
|
||||
file_id: str
|
||||
file_name: str
|
||||
size: int
|
||||
|
||||
|
||||
class GenerateReq(BaseModel):
|
||||
output_language: str = "auto"
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str) -> HTTPException:
|
||||
return HTTPException(status_code=status, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
def create_app(
|
||||
store: SessionStore | None = None,
|
||||
data_root: str = "data/server",
|
||||
engine: Any = None,
|
||||
) -> FastAPI:
|
||||
store = store or SessionStore()
|
||||
if engine == "fake":
|
||||
engine = _FakeEngine()
|
||||
elif engine is None:
|
||||
engine = None # 真实模式:generate/qa 时按需 build(避免未配置 key 直接 503)
|
||||
|
||||
service = GenesisService(store=store, data_root=data_root, engine=engine)
|
||||
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
index_html = (static_dir / "index.html").read_text(encoding="utf-8") if (static_dir / "index.html").exists() else "<html><body>Genesis Web UI</body></html>"
|
||||
|
||||
app = FastAPI(title="Genesis API", version=VERSION)
|
||||
|
||||
# ---------- 健康/前端 ----------
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok", "version": VERSION}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index():
|
||||
return index_html
|
||||
|
||||
# ---------- 会话 ----------
|
||||
|
||||
@app.post("/api/sessions")
|
||||
def create_session(body: SessionCreate):
|
||||
rec = service.create_session(body.user_id)
|
||||
return {"session_id": rec.session_id, "status": rec.status}
|
||||
|
||||
@app.get("/api/sessions")
|
||||
def list_sessions(user_id: str = "default"):
|
||||
return [
|
||||
{"session_id": r.session_id, "status": r.status, "updated_at": r.updated_at}
|
||||
for r in service.store.list_sessions(user_id)
|
||||
]
|
||||
|
||||
@app.get("/api/sessions/{sid}")
|
||||
def get_session(sid: str):
|
||||
try:
|
||||
rec = service.get_session(sid)
|
||||
except SessionNotFoundError:
|
||||
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
|
||||
return rec.to_dict
|
||||
|
||||
@app.delete("/api/sessions/{sid}")
|
||||
def delete_session(sid: str):
|
||||
ok = service.store.delete_session(sid)
|
||||
return {"deleted": ok}
|
||||
|
||||
# ---------- 文件上传 ----------
|
||||
|
||||
@app.post("/api/sessions/{sid}/files", response_model=FileUploadResp)
|
||||
async def upload_file(sid: str, file_type: str = Form(...), file: UploadFile = File(...)):
|
||||
content = await file.read()
|
||||
try:
|
||||
entry = service.upload_file(sid, file_type, file.filename or "upload", content)
|
||||
except FileTypeError as e:
|
||||
raise _error(400, "FILE_TYPE_INVALID", str(e))
|
||||
except SessionNotFoundError:
|
||||
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
|
||||
return FileUploadResp(file_id=entry["file_id"], file_name=entry["name"], size=entry["size"])
|
||||
|
||||
# ---------- 解析 ----------
|
||||
|
||||
@app.post("/api/sessions/{sid}/start-parse")
|
||||
def start_parse(sid: str):
|
||||
try:
|
||||
rec = service.run_parse(sid)
|
||||
except ServiceStepError as e:
|
||||
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
|
||||
except SessionNotFoundError:
|
||||
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
|
||||
return {"ok": True, "status": rec.status}
|
||||
|
||||
@app.get("/api/sessions/{sid}/parse-result")
|
||||
def parse_result(sid: str):
|
||||
rec = service.get_session(sid)
|
||||
import json
|
||||
return json.loads(rec.structured_summary) if rec.structured_summary else {}
|
||||
|
||||
@app.post("/api/sessions/{sid}/confirm-parse")
|
||||
def confirm_parse(sid: str):
|
||||
try:
|
||||
rec = service.confirm_parse(sid)
|
||||
except ServiceStepError as e:
|
||||
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
|
||||
return {"ok": True, "status": rec.status}
|
||||
|
||||
# ---------- 影响调查 ----------
|
||||
|
||||
@app.post("/api/sessions/{sid}/start-impact")
|
||||
def start_impact(sid: str):
|
||||
try:
|
||||
rec = service.run_impact(sid)
|
||||
except ServiceStepError as e:
|
||||
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
|
||||
return {"ok": True, "status": rec.status}
|
||||
|
||||
@app.get("/api/sessions/{sid}/impact-result")
|
||||
def impact_result(sid: str):
|
||||
rec = service.get_session(sid)
|
||||
import json
|
||||
return json.loads(rec.impact_summary) if rec.impact_summary else {}
|
||||
|
||||
@app.post("/api/sessions/{sid}/confirm-impact")
|
||||
def confirm_impact(sid: str):
|
||||
try:
|
||||
rec = service.confirm_impact(sid)
|
||||
except ServiceStepError as e:
|
||||
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
|
||||
return {"ok": True, "status": rec.status}
|
||||
|
||||
# ---------- 生成 / QA ----------
|
||||
|
||||
@app.post("/api/sessions/{sid}/generate")
|
||||
def generate(sid: str, body: GenerateReq | None = None):
|
||||
lang = (body.output_language if body else "auto") or "auto"
|
||||
try:
|
||||
if service.engine is None:
|
||||
service.engine = build_inference_engine()
|
||||
rec = service.run_generate(sid, output_language=lang)
|
||||
except ServiceStepError as e:
|
||||
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
|
||||
return {"ok": True, "status": rec.status, "result_path": rec.result_path}
|
||||
|
||||
@app.post("/api/sessions/{sid}/run-qa")
|
||||
def run_qa(sid: str):
|
||||
try:
|
||||
rec = service.run_qa(sid)
|
||||
except ServiceStepError as e:
|
||||
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
|
||||
return {"ok": True, "status": rec.status}
|
||||
|
||||
@app.get("/api/sessions/{sid}/qa-result")
|
||||
def qa_result(sid: str):
|
||||
rec = service.get_session(sid)
|
||||
import json
|
||||
return json.loads(rec.qa_summary) if rec.qa_summary else {}
|
||||
|
||||
# ---------- 结果 ----------
|
||||
|
||||
@app.get("/api/sessions/{sid}/result/preview")
|
||||
def result_preview(sid: str):
|
||||
html = service.result_preview(sid)
|
||||
return {"html": html}
|
||||
|
||||
@app.get("/api/sessions/{sid}/result/download")
|
||||
def result_download(sid: str):
|
||||
rec = service.get_session(sid)
|
||||
if not rec.result_path or not Path(rec.result_path).exists():
|
||||
raise _error(404, "RESULT_NOT_FOUND", "结果文档不存在")
|
||||
return FileResponse(rec.result_path, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", filename="output.docx")
|
||||
|
||||
@app.get("/api/sessions/{sid}/result/impact-report")
|
||||
def result_impact(sid: str):
|
||||
rec = service.get_session(sid)
|
||||
if not rec.impact_report_path or not Path(rec.impact_report_path).exists():
|
||||
raise _error(404, "RESULT_NOT_FOUND", "影响调查书不存在")
|
||||
return FileResponse(rec.impact_report_path, media_type="application/json", filename="impact-report.json")
|
||||
|
||||
@app.get("/api/sessions/{sid}/result/qa-report")
|
||||
def result_qa(sid: str):
|
||||
rec = service.get_session(sid)
|
||||
if not rec.qa_report_path or not Path(rec.qa_report_path).exists():
|
||||
raise _error(404, "RESULT_NOT_FOUND", "QA 报告不存在")
|
||||
return FileResponse(rec.qa_report_path, media_type="application/json", filename="qa-report.json")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# 模块级 app(uvicorn app.main:app 兼容)
|
||||
app = create_app()
|
||||
Reference in New Issue
Block a user