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:
lhl
2026-08-26 22:13:19 +08:00
parent a1fefd43eb
commit 215c605650
14 changed files with 1474 additions and 1 deletions
+239
View File
@@ -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}/filesmultipart
- 解析:POST start-parse、GET parse-result、POST confirm-parse
- 影响:POST start-impact、GET impact-result、POST confirm-impact
- 生成/QAPOST 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
# 模块级 appuvicorn app.main:app 兼容)
app = create_app()