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:
@@ -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]
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Web 服务化包(S2-S4)。"""
|
||||
@@ -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()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Web 服务化:会话化服务层(S3)。
|
||||
|
||||
把 CLI 管线(SourceParser / ImpactAgent / WriteOrchestrator / QALoop)包装为
|
||||
会话化的服务:文件落盘 → 解析 → 确认 → 影响 → 确认 → 生成 → QA,状态与结果
|
||||
持久化到 SessionStore(SQLite)。
|
||||
|
||||
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
|
||||
@@ -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>
|
||||
@@ -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", ""),
|
||||
)
|
||||
Reference in New Issue
Block a user