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
+5 -1
View File
@@ -43,7 +43,11 @@ class QALoop:
if not tpl:
raise ValueError("template_path 必须提供")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
doc = DocxInjector(tpl).inject(sections, meta={})
# 与 orchestrator.generate 一致的默认 meta:避免 {{doc_title}}/{{version}}/{{created_at}}
# 占位符残留导致 DocxInjectError(真实模板含封面字段;slice 模板无则无影响)
from datetime import date
meta = {"doc_title": Path(tpl).stem, "version": "v1", "created_at": date.today().isoformat()}
doc = DocxInjector(tpl).inject(sections, meta=meta)
doc.save(output_path)
return [contents_map[cid] for cid in order]
+1
View File
@@ -0,0 +1 @@
"""Web 服务化包(S2-S4)。"""
+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()
+251
View File
@@ -0,0 +1,251 @@
"""Web 服务化:会话化服务层(S3)。
把 CLI 管线(SourceParser / ImpactAgent / WriteOrchestrator / QALoop)包装为
会话化的服务:文件落盘 → 解析 → 确认 → 影响 → 确认 → 生成 → QA,状态与结果
持久化到 SessionStoreSQLite)。
v1 范围(诚实标注):
- 文件类型:requirements / template / write_instruction / rules / existing_system(zip)
- existing_system 以 .zip 上传 → 解压为目录;未提供时影响调查跳过(门控,同 CLI)
- 执行方式:进程内同步执行(v1 简化,非 api-design 的异步+轮询;样本规模小可接受)
"""
from __future__ import annotations
import html
import json
import shutil
import zipfile
from pathlib import Path
from docx import Document
from genesis.parsers.source_aggregator import SourceParser
from genesis.server.store import SessionStore, SessionRecord
ALLOWED_FILE_TYPES = {
"requirements", "template", "write_instruction", "rules", "existing_system",
}
# 各类型建议扩展名(宽松校验:仅拒绝明显非法的空文件)
EXPECTED_SUFFIX = {
"requirements": (".xlsx",),
"template": (".docx",),
"write_instruction": (".docx",),
"rules": (".docx", ".xlsx"),
"existing_system": (".zip",),
}
class ServiceError(Exception):
"""服务层通用错误。"""
class FileTypeError(ServiceError):
"""非法文件类型。"""
class ServiceStepError(ServiceError):
"""状态/步骤非法(对应 api-design STATE_TRANSITION_INVALID 409)。"""
class GenesisService:
def __init__(
self,
store: SessionStore,
data_root: str = "data/server",
engine=None,
prompt_registry=None,
samples_dir: str = "sample",
) -> None:
self.store = store
self.data_root = Path(data_root)
self.engine = engine
self.prompt_registry = prompt_registry
self.samples_dir = samples_dir
# ---------- 会话与文件 ----------
def create_session(self, user_id: str) -> SessionRecord:
rec = self.store.create_session(user_id)
(self.data_root / rec.session_id).mkdir(parents=True, exist_ok=True)
return rec
def get_session(self, session_id: str) -> SessionRecord:
return self.store.get_session(session_id)
def upload_file(self, session_id: str, file_type: str, filename: str, content: bytes) -> dict:
if file_type not in ALLOWED_FILE_TYPES:
raise FileTypeError(f"不支持的 file_type: {file_type}")
sdir = self.data_root / session_id / "uploads"
sdir.mkdir(parents=True, exist_ok=True)
if file_type == "existing_system":
# zip → 解压到 existing_system/
dst = sdir / "existing_system"
dst.mkdir(parents=True, exist_ok=True)
zpath = sdir / filename
zpath.write_bytes(content)
with zipfile.ZipFile(zpath) as zf:
zf.extractall(dst)
zpath.unlink(missing_ok=True)
path = str(dst)
else:
path = str(sdir / filename)
Path(path).write_bytes(content)
entry = {"file_id": f"{session_id}-{file_type}", "name": filename, "size": len(content), "path": path}
files = dict(self.get_session(session_id).files)
files[file_type] = entry
self.store.update_session(session_id, files=files)
return entry
# ---------- 内部:重建 StructuredSource ----------
def _rebuild_source(self, rec: SessionRecord):
files = rec.files
parser = SourceParser()
return parser.parse(
requirement_paths=[files["requirements"]["path"]] if "requirements" in files else None,
template_path=files.get("template", {}).get("path"),
write_instruction_paths=[files["write_instruction"]["path"]] if "write_instruction" in files else None,
rule_paths=[files["rules"]["path"]] if "rules" in files else None,
existing_system_path=files.get("existing_system", {}).get("path"),
existing_system_language=None,
)
# ---------- 解析 ----------
def run_parse(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if "requirements" not in rec.files or "template" not in rec.files:
raise ServiceStepError("缺少 requirements / template 文件,无法解析")
ss = self._rebuild_source(rec)
summary = {
"tables": len(getattr(ss, "tables", []) or []),
"template_sections": [
{"type": m.type, "name": m.name, "level": m.level}
for m in (getattr(getattr(ss, "template", None), "sections", None) or [])
],
"rule_docs": len(getattr(ss, "rule_docs", []) or []),
}
self.store.update_session(
session_id,
status="awaiting_parse_confirm",
structured_summary=json.dumps(summary, ensure_ascii=False),
)
return self.get_session(session_id)
def confirm_parse(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if rec.status != "awaiting_parse_confirm":
raise ServiceStepError(f"当前状态 {rec.status} 不可确认解析")
# 有既有系统 → impact_running;否则门控跳过影响 → writing
if "existing_system" in rec.files:
return self.store.update_status(session_id, "impact_running")
return self.store.update_status(session_id, "writing")
# ---------- 影响调查 ----------
def run_impact(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if rec.status != "impact_running":
raise ServiceStepError(f"当前状态 {rec.status} 不可启动影响调查")
from genesis.impact.impact_agent import ImpactAgent, impact_report_to_dict
ss = self._rebuild_source(rec)
report = ImpactAgent().run(ss, session_id=session_id)
self.store.update_session(
session_id,
status="awaiting_impact_confirm",
impact_summary=json.dumps(
{"summary": dict(getattr(report, "summary", {})),
"report": impact_report_to_dict(report)}, ensure_ascii=False),
impact_report_path=str(self._save_json(session_id, "impact-report.json", impact_report_to_dict(report))),
)
return self.get_session(session_id)
def confirm_impact(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if rec.status != "awaiting_impact_confirm":
raise ServiceStepError(f"当前状态 {rec.status} 不可确认影响调查")
return self.store.update_status(session_id, "writing")
# ---------- 生成与 QA ----------
def run_generate(self, session_id: str, output_language: str = "auto") -> SessionRecord:
rec = self.get_session(session_id)
if rec.status not in ("writing", "awaiting_impact_confirm"):
raise ServiceStepError(f"当前状态 {rec.status} 不可启动生成")
from genesis.writer.orchestrator import WriteOrchestrator
ss = self._rebuild_source(rec)
out_path = self.data_root / session_id / "output.docx"
out_path.parent.mkdir(parents=True, exist_ok=True)
WriteOrchestrator().generate(
ss, str(out_path),
session_id=session_id,
samples_dir=self.samples_dir,
engine=self.engine,
prompt_registry=self.prompt_registry,
template_path=rec.files.get("template", {}).get("path"),
output_language=output_language,
)
self.store.update_session(
session_id,
status="writing",
result_path=str(out_path),
output_language=output_language,
)
return self.get_session(session_id)
def run_qa(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if rec.status != "writing" or not rec.result_path:
raise ServiceStepError(f"当前状态 {rec.status} 不可运行 QA(需先生成)")
from genesis.qa.qa_loop import QALoop
from genesis.qa.report import QAReport
ss = self._rebuild_source(rec)
loop = QALoop(max_rounds=1)
report: QAReport = loop.run(
ss, rec.result_path,
session_id=session_id,
samples_dir=self.samples_dir,
engine=self.engine,
prompt_registry=self.prompt_registry,
template_path=rec.files.get("template", {}).get("path"),
output_language=rec.output_language,
)
payload = {
"passed": report.passed,
"overall_score": report.overall_score,
"failed_chapters": report.failed_chapters,
"summary": report.summary,
}
qa_path = self._save_json(session_id, "qa-report.json", payload)
self.store.update_session(
session_id,
status="done",
qa_summary=json.dumps(payload, ensure_ascii=False),
qa_report_path=str(qa_path),
)
return self.get_session(session_id)
# ---------- 结果 ----------
def result_preview(self, session_id: str) -> str:
rec = self.get_session(session_id)
if not rec.result_path or not Path(rec.result_path).exists():
raise ServiceStepError("结果文档不存在")
doc = Document(rec.result_path)
parts = ["<!DOCTYPE html><html><head><meta charset='utf-8'></head><body>"]
for p in doc.paragraphs:
if p.text.strip():
parts.append(f"<p>{html.escape(p.text)}</p>")
for tbl in doc.tables:
parts.append("<table border='1' cellpadding='4'>")
for row in tbl.rows:
parts.append("<tr>" + "".join(f"<td>{html.escape(c.text)}</td>" for c in row.cells) + "</tr>")
parts.append("</table>")
parts.append("</body></html>")
return "".join(parts)
def _save_json(self, session_id: str, name: str, payload: dict) -> Path:
path = self.data_root / session_id / name
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return path
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Genesis — 概要设计书自动生成</title>
<style>
body { font-family: "Microsoft YaHei", sans-serif; max-width: 900px; margin: 0 auto; padding: 24px; color: #222; }
h1 { color: #1a5276; }
.card { border: 1px solid #ccc; border-radius: 8px; padding: 16px; margin: 12px 0; }
button { padding: 8px 16px; margin: 4px; cursor: pointer; border-radius: 4px; border: 1px solid #999; }
button.primary { background: #1a5276; color: #fff; border-color: #1a5276; }
button:disabled { opacity: .5; cursor: not-allowed; }
pre { background: #f6f6f6; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 13px; }
#status { font-weight: bold; margin: 8px 0; }
.msg { margin: 4px 0; font-size: 14px; }
input[type=file] { margin: 4px 0; }
table { border-collapse: collapse; } th, td { border: 1px solid #999; padding: 4px 8px; }
</style>
</head>
<body>
<h1>Genesis — 概要设计书自动生成 Agent</h1>
<div class="card">
<button class="primary" onclick="createSession()">创建会话</button>
<span id="session-info"></span>
</div>
<div class="card">
<h3>1. 上传输入资料</h3>
<div>
<label>要件定义 Excel(必需): <input type="file" id="f-requirements" accept=".xlsx"></label><br>
<label>概要设计模板 docx(必需): <input type="file" id="f-template" accept=".docx"></label><br>
<label>做成说明书 docx: <input type="file" id="f-write_instruction" accept=".docx"></label><br>
<label>记入/图表规则 docx/xlsx: <input type="file" id="f-rules" accept=".docx,.xlsx"></label><br>
<label>既有系统 zip(追加改修场景,可选): <input type="file" id="f-existing_system" accept=".zip"></label><br>
</div>
<button onclick="uploadAll()">上传全部</button>
<div id="upload-result"></div>
</div>
<div class="card">
<h3>2. 解析与确认</h3>
<button onclick="post('/api/sessions/'+sid+'/start-parse', {})">开始解析</button>
<button onclick="get('/api/sessions/'+sid+'/parse-result')">解析结果</button>
<button onclick="post('/api/sessions/'+sid+'/confirm-parse', {})">确认解析</button>
<button onclick="post('/api/sessions/'+sid+'/start-impact', {})">开始影响调查</button>
<button onclick="get('/api/sessions/'+sid+'/impact-result')">影响结果</button>
<button onclick="post('/api/sessions/'+sid+'/confirm-impact', {})">确认影响</button>
</div>
<div class="card">
<h3>3. 生成与 QA</h3>
<label>输出语言:
<select id="lang">
<option value="auto">auto(跟随标题)</option>
<option value="zh">zh(简体中文)</option>
<option value="ja">ja(日文)</option>
</select>
</label>
<button onclick="generate()">开始生成</button>
<button onclick="post('/api/sessions/'+sid+'/run-qa', {})">运行 QA</button>
</div>
<div class="card">
<h3>4. 结果</h3>
<button onclick="preview()">预览</button>
<a id="dl" download><button>下载 docx</button></a>
<a id="dl-impact" download><button>下载影响调查书</button></a>
<a id="dl-qa" download><button>下载 QA 报告</button></a>
<div id="preview-box"></div>
</div>
<div class="card">
<div id="status">未创建会话</div>
<div id="out"></div>
</div>
<script>
let sid = null;
const out = document.getElementById('out');
const statusEl = document.getElementById('status');
function log(html) { out.innerHTML += '<div class="msg">' + html + '</div>'; }
function setStatus(s) { statusEl.textContent = s; }
async function api(method, url, body, isForm) {
const opt = { method, headers: {} };
if (body instanceof FormData) { opt.body = body; }
else if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
const r = await fetch(url, opt);
const ct = r.headers.get('content-type') || '';
const data = ct.includes('json') ? await r.json() : await r.text();
if (!r.ok) throw new Error(JSON.stringify(data));
return data;
}
function post(u, b) { return api('POST', u, b).then(d => { log('✔ ' + u + ' → ' + JSON.stringify(d)); return d; }).catch(e => log('✘ ' + u + ' → ' + e.message)); }
function get(u) { return api('GET', u).then(d => { log('✔ ' + u + ' → ' + JSON.stringify(d).slice(0, 300)); return d; }).catch(e => log('✘ ' + u + ' → ' + e.message)); }
async function createSession() {
try {
const d = await api('POST', '/api/sessions', { user_id: 'default' });
sid = d.session_id;
document.getElementById('session-info').textContent = '会话: ' + sid;
setStatus('会话已创建: ' + sid + '(状态 uploading');
} catch (e) { setStatus('创建失败: ' + e.message); }
}
async function uploadAll() {
if (!sid) return setStatus('请先创建会话');
const map = { requirements: 'f-requirements', template: 'f-template', write_instruction: 'f-write_instruction', rules: 'f-rules', existing_system: 'f-existing_system' };
for (const [ft, id] of Object.entries(map)) {
const el = document.getElementById(id);
if (!el.files.length) continue;
const fd = new FormData();
fd.append('file_type', ft);
fd.append('file', el.files[0]);
try {
const d = await api('POST', '/api/sessions/' + sid + '/files', fd);
document.getElementById('upload-result').innerHTML += '<div>✔ ' + ft + ': ' + d.file_name + '' + d.size + 'B</div>';
} catch (e) { document.getElementById('upload-result').innerHTML += '<div>✘ ' + ft + ' → ' + e.message + '</div>'; }
}
}
async function generate() {
const lang = document.getElementById('lang').value;
await post('/api/sessions/' + sid + '/generate', { output_language: lang });
}
async function preview() {
try {
const d = await api('GET', '/api/sessions/' + sid + '/result/preview');
document.getElementById('preview-box').innerHTML = d.html;
document.getElementById('dl').href = '/api/sessions/' + sid + '/result/download';
document.getElementById('dl-impact').href = '/api/sessions/' + sid + '/result/impact-report';
document.getElementById('dl-qa').href = '/api/sessions/' + sid + '/result/qa-report';
} catch (e) { log('✘ 预览失败: ' + e.message); }
}
</script>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
"""Web 服务化:SQLite 会话存储(S2)。
满足参赛成果物 03「数据存储」:会话 / 状态 / 文件登记 / 结果路径持久化到 SQLite。
零外部依赖(标准库 sqlite3),db 路径可注入(测试用 tmp_path)。
"""
from __future__ import annotations
import json
import sqlite3
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
class SessionNotFoundError(Exception):
"""会话不存在(对应 api-design §7 404)。"""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@dataclass
class SessionRecord:
session_id: str
user_id: str
status: str = "uploading"
files: dict = field(default_factory=dict) # file_type -> {file_id,name,size,path}
structured_summary: str = "" # 解析结果摘要(JSON 字符串)
impact_summary: str = "" # 影响调查摘要
qa_summary: str = "" # QA 报告 JSON
result_path: str = "" # 概要设计书 docx 路径
impact_report_path: str = ""
qa_report_path: str = ""
output_language: str = "auto"
created_at: str = ""
updated_at: str = ""
@property
def to_dict(self) -> dict:
return {
"session_id": self.session_id,
"user_id": self.user_id,
"status": self.status,
"files": self.files,
"structured_summary": self.structured_summary,
"impact_summary": self.impact_summary,
"qa_summary": self.qa_summary,
"result_path": self.result_path,
"impact_report_path": self.impact_report_path,
"qa_report_path": self.qa_report_path,
"output_language": self.output_language,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
class SessionStore:
"""SQLite 持久化的会话存储。
表结构:sessions(session_id TEXT PK, user_id, data TEXT) —— data 为整条
SessionRecord 的 JSON(简单可靠;会话量为小规模,无需列级查询)。
"""
def __init__(self, db_path: str = "data/server/sessions.db") -> None:
self._db = Path(db_path)
self._db.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self._db))
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._conn() as c:
c.execute(
"CREATE TABLE IF NOT EXISTS sessions ("
" session_id TEXT PRIMARY KEY,"
" user_id TEXT NOT NULL,"
" data TEXT NOT NULL)"
)
def create_session(self, user_id: str) -> SessionRecord:
rec = SessionRecord(
session_id=uuid.uuid4().hex[:12],
user_id=user_id,
created_at=_now(),
updated_at=_now(),
)
with self._conn() as c:
c.execute(
"INSERT INTO sessions (session_id, user_id, data) VALUES (?, ?, ?)",
(rec.session_id, rec.user_id, json.dumps(rec.to_dict, ensure_ascii=False)),
)
return rec
def get_session(self, session_id: str) -> SessionRecord:
with self._conn() as c:
row = c.execute(
"SELECT data FROM sessions WHERE session_id = ?", (session_id,)
).fetchone()
if row is None:
raise SessionNotFoundError(f"会话不存在: {session_id}")
return self._from_dict(json.loads(row["data"]))
def list_sessions(self, user_id: str) -> list[SessionRecord]:
with self._conn() as c:
rows = c.execute(
"SELECT data FROM sessions WHERE user_id = ?",
(user_id,),
).fetchall()
recs = [self._from_dict(json.loads(r["data"])) for r in rows]
# updated_at 在 JSON data 内,无法用 SQL 列排序 → 取回后按时间降序
recs.sort(key=lambda r: r.updated_at, reverse=True)
return recs
def update_status(self, session_id: str, status: str) -> SessionRecord:
rec = self.get_session(session_id)
rec.status = status
return self._persist(rec)
def update_session(self, session_id: str, **fields) -> SessionRecord:
"""按字段名更新会话(files/status/result_path 等任意 to_dict 键)。"""
rec = self.get_session(session_id)
allowed = set(SessionRecord.to_dict.fget.__annotations__) if hasattr(
SessionRecord.to_dict.fget, "__annotations__"
) else set(rec.to_dict.keys())
for k, v in fields.items():
if k in rec.to_dict:
setattr(rec, k, v)
return self._persist(rec)
def delete_session(self, session_id: str) -> bool:
with self._conn() as c:
cur = c.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
return cur.rowcount > 0
def _persist(self, rec: SessionRecord) -> SessionRecord:
rec.updated_at = _now()
with self._conn() as c:
c.execute(
"UPDATE sessions SET user_id = ?, data = ? WHERE session_id = ?",
(rec.user_id, json.dumps(rec.to_dict, ensure_ascii=False), rec.session_id),
)
return rec
@staticmethod
def _from_dict(d: dict) -> SessionRecord:
return SessionRecord(
session_id=d.get("session_id", ""),
user_id=d.get("user_id", ""),
status=d.get("status", "uploading"),
files=d.get("files", {}),
structured_summary=d.get("structured_summary", ""),
impact_summary=d.get("impact_summary", ""),
qa_summary=d.get("qa_summary", ""),
result_path=d.get("result_path", ""),
impact_report_path=d.get("impact_report_path", ""),
qa_report_path=d.get("qa_report_path", ""),
output_language=d.get("output_language", "auto"),
created_at=d.get("created_at", ""),
updated_at=d.get("updated_at", ""),
)