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:
@@ -123,6 +123,27 @@ python scripts/run_trial.py `
|
||||
**中文场景注意**:若规则文档(作成说明书/记入规则)仍为日文,`auto` 推导会倾向日文——请显式
|
||||
`--output-language zh`,不要依赖 auto。
|
||||
|
||||
## Web 服务(交互界面,成果物 03)
|
||||
|
||||
Genesis 提供 **FastAPI Web 服务 + 内嵌零构建前端**(上传 → 解析确认 → 影响确认 → 生成 → QA → 预览/下载),
|
||||
会话/文件/结果持久化到 SQLite(`data/server/`)。
|
||||
|
||||
```powershell
|
||||
# 离线 Fake 引擎(无需 API key,适合演示)
|
||||
python scripts/serve.py --fake
|
||||
|
||||
# 真实 LLM 模式(需 .env 配置 GENESIS_INFERENCE__API_KEY)
|
||||
python scripts/serve.py
|
||||
```
|
||||
|
||||
- 访问 `http://127.0.0.1:8000/` 使用页面;API 文档 `http://127.0.0.1:8000/docs`
|
||||
- 主要端点:`POST /api/sessions`、`POST /api/sessions/{id}/files`(multipart)、
|
||||
`POST .../start-parse`、`POST .../confirm-parse`、`POST .../start-impact`、`POST .../confirm-impact`、
|
||||
`POST .../generate`(body `{"output_language":"auto|zh|ja"}`)、`POST .../run-qa`、
|
||||
`GET .../result/preview|download|impact-report|qa-report`
|
||||
- **既有系统**以 `.zip` 上传(追加/改修场景);不传则影响调查跳过
|
||||
- 部署到公网后登记 `service_url`(格式 `http://<域名或公网IP>:<端口>`)供评审系统 B 阶段黑盒冒烟
|
||||
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -121,3 +121,4 @@
|
||||
| 2026-08-26 02:10 | 反馈迭代 | 未解决问题收尾:#1/#2 orchestrator.generate 新增 chapter_attempts 章级管道重试(默认3,单章硬失败不连坐整次run,耗尽才抛错)+ tests/test_orchestrator_retry.py;#3 find_language_violations 增加表格 caption 检查(rows/headers 仍照抄源不检),QA ChapterArtifact.blocks 扩为 (type,text,caption) 同步生效;#4/#5/#6/#7 文档收尾(README --output-language/中文模板、design.md §6.2.1/§7.2 十一项校验、计划验收勾选、.gitignore 加 .opencode/);全量 pytest 431 passed / 99.15% | src/genesis/writer/orchestrator.py; src/genesis/writer/language.py; src/genesis/qa/validator.py; src/genesis/eval/scorer.py; tests/test_orchestrator_retry.py; tests/test_writer_language.py; README.md; docs/design.md; docs/superpowers/plans/2026-08-24-language-consistency.md; .gitignore | x-preview-f-free (opencode) |
|
||||
| 2026-08-26 03:00 | 文档规范 | 参赛提交规范红线修复(阶段A):samples/ 改名为 sample/(git mv),11 个非 ASCII 文件名重命名为 ASCII(要件定義→requirements_*、模板→template_*、规则→rules_*、参赛手册PDF→contestant-handbook.pdf);tests/test_zh_template.py 硬编码绝对路径 D:\00_project\Genesis\samples 改为相对路径;全局更新 21 个活动文件引用(src/scripts/tests/README/AGENTS/design.md/sample-spec.md);历史日志与审查文档不改(追加本记录说明);全量 pytest 431 passed / 99.15% 无回归 | sample/(目录改名+10文件重命名); docs/contestant-handbook.pdf; tests/test_zh_template.py; scripts/run_trial.py; scripts/run_phase5_slice.py; scripts/make_zh_template.py; src/genesis/services/rag_service.py; src/genesis/writer/context_builder.py; src/genesis/writer/orchestrator.py; src/genesis/qa/qa_loop.py; tests/test_real_samples.py; tests/test_code_parser.py; tests/test_impact_agent.py; tests/test_phase5_rag.py; tests/test_phase5_e2e.py; tests/test_language_coverage.py; tests/test_orchestrator_retry.py; tests/test_data_models.py; tests/test_eval_scorer.py; README.md; AGENTS.md; docs/design.md; docs/sample-spec.md | x-preview-f-free (opencode) |
|
||||
| 2026-08-26 03:40 | 文档规范 | 参赛成果物补齐(阶段B):README 重写——新增项目性质:新规声明 + 项目概述/整体功能说明/效果总结(431测试99.15%覆盖/双语试运行通过/影响调查基线)/团队分工/规模与难度自评,保留安装运行说明;design.md 补开发范式流程图(mermaid 6步,与AI日志范式步骤列一致)+ §2.1 Agent 架构图(感知-规划-行动-记忆映射);_AI_USAGE_LOG.md 回填 L15「待补充」→架构设计、L33「整体迭代」→反馈迭代;生成 tests/coverage/ 覆盖率HTML报告(99.15%)+ tests/test-execution-log.txt 执行日志入库;docs/参赛成果物提交规范-赛道一.md 改名 docs/submission-spec-track1.md(ASCII);pyproject pytest norecursedirs 排除执行日志;全量 pytest 431 passed / 99.15% | README.md; docs/design.md; docs/submission-spec-track1.md; pyproject.toml; tests/coverage/; tests/test-execution-log.txt; _AI_USAGE_LOG.md | x-preview-f-free (opencode) |
|
||||
| 2026-08-26 05:10 | Agent 实现 | Web 服务化(参赛成果物03 交互界面):新增 src/genesis/server/(store.py SQLite 会话持久化、service.py 会话化服务层——上传/解析/确认/影响/确认/生成/QA、app.py FastAPI 端点 9 组、static/index.html 内嵌零构建前端)+ scripts/serve.py 启动入口(--fake 离线引擎);pyproject 加 fastapi/uvicorn/python-multipart;修复 qa_loop._build meta={} 导致真实模板占位符残留 DocxInjectError(改为与 orchestrator 一致的默认 meta);README 加 Web 服务说明 + service_url 登记指引;design.md §12.5 新增 Web 服务化记录(含与 api-design 同步执行/zip 既有系统/无 WebSocket 的诚实偏差标注);测试 test_server_store/service/api 34 用例(TestClient 全链路 + zip 影响流程 + 错误分支);全量 pytest 473 passed / 99.20% | src/genesis/server/__init__.py; src/genesis/server/store.py; src/genesis/server/service.py; src/genesis/server/app.py; src/genesis/server/static/index.html; scripts/serve.py; src/genesis/qa/qa_loop.py; pyproject.toml; README.md; docs/design.md; tests/test_server_store.py; tests/test_server_service.py; tests/test_server_api.py | x-preview-f-free (opencode) |
|
||||
|
||||
@@ -1860,3 +1860,14 @@ Document(注入后 Word 文档)
|
||||
**真实 LLM 生成 + 人工质量门禁(待人工执行项,P5-T10 推后)**:
|
||||
- 真实推理接入(DeepSeek / Qwen 等)与端到端人工质量门禁(内容准确性 / 格式精度 / 规则遵守的人工判读)不在本自动实现范围内,标记为推后项。
|
||||
- 真实 LLM 接入点已预留(`InferenceEngine` 默认实例 + `PromptRegistry` 注入),人工执行时仅需提供可用模型配置与 `scripts/run_phase5_slice.py` 的非 `--fake` 路径。
|
||||
|
||||
### 12.5 Web 服务化(2026-08,参赛成果物 03 交互界面)
|
||||
|
||||
新增 `src/genesis/server/`(FastAPI + SQLite + 内嵌零构建前端):
|
||||
- `store.py` — 会话持久化(SessionStore,SQLite,sessions 表 data JSON)
|
||||
- `service.py` — 会话化服务层(GenesisService:上传 → 解析 → 确认 → 影响 → 确认 → 生成 → QA)
|
||||
- `app.py` — REST 端点(api-design §2 核心子集);`scripts/serve.py` 启动;`--fake` 离线引擎
|
||||
- 前端 `static/index.html` 内嵌单页(零构建,无 node_modules 依赖,符合提交规范 §6 红线)
|
||||
- **与 api-design 的偏差(诚实标注)**:v1 采用**进程内同步执行**(非"异步启动+轮询");
|
||||
既有系统以 zip 上传;WebSocket 事件通道未实现(v1 范围外)。样本规模小,同步可接受。
|
||||
- 测试:`tests/test_server_store.py` / `test_server_service.py` / `test_server_api.py`(TestClient 全链路)
|
||||
|
||||
@@ -16,6 +16,9 @@ dependencies = [
|
||||
"httpx>=0.28",
|
||||
"jinja2>=3.1",
|
||||
"jsonschema>=4.23",
|
||||
"fastapi>=0.115",
|
||||
"uvicorn>=0.30",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Web 服务启动入口(S5)。
|
||||
|
||||
用法:
|
||||
python scripts/serve.py [--host 0.0.0.0] [--port 8000] [--fake]
|
||||
|
||||
- 默认真实 LLM 模式(需 .env 配置 GENESIS_INFERENCE__API_KEY,generate 时按需加载)
|
||||
- --fake 为离线确定性引擎(无需 key,适合评审演示环境)
|
||||
- 会话/文件/结果存储于 data/server/(SQLite + 上传快照,不入库)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT / "src") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
import uvicorn # noqa: E402
|
||||
|
||||
from genesis.server.app import create_app # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Genesis Web 服务")
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=8000)
|
||||
p.add_argument("--fake", action="store_true", help="离线 Fake 引擎(无需 API key)")
|
||||
p.add_argument("--data-root", default=str(ROOT / "data" / "server"), help="会话/文件/结果存储目录")
|
||||
args = p.parse_args()
|
||||
|
||||
app = create_app(data_root=args.data_root, engine="fake" if args.fake else None)
|
||||
print(f"Genesis Web 服务启动: http://{args.host}:{args.port} (mode={'fake' if args.fake else 'real'})")
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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", ""),
|
||||
)
|
||||
@@ -0,0 +1,281 @@
|
||||
"""S4:FastAPI 端点测试(server/app.py,httpx TestClient 全链路)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from genesis.server.app import create_app
|
||||
from genesis.server.store import SessionStore
|
||||
|
||||
_SAMPLE = Path(__file__).resolve().parents[1] / "sample"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
app = create_app(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine="fake",
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _upload_core(client, sid):
|
||||
files = [
|
||||
("requirements", "requirements_newdev.xlsx", _SAMPLE / "requirements_newdev.xlsx"),
|
||||
("template", "template_design_ja.docx", _SAMPLE / "template_design_ja.docx"),
|
||||
("write_instruction", "rules_design_ja.docx", _SAMPLE / "rules_design_ja.docx"),
|
||||
("rules", "rules_entry_ja.docx", _SAMPLE / "rules_entry_ja.docx"),
|
||||
]
|
||||
for ft, name, path in files:
|
||||
r = client.post(f"/api/sessions/{sid}/files",
|
||||
data={"file_type": ft},
|
||||
files={"file": (name, path.read_bytes())})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_health(client):
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_create_and_get_session(client):
|
||||
r = client.post("/api/sessions", json={"user_id": "u1"})
|
||||
assert r.status_code == 200
|
||||
sid = r.json()["session_id"]
|
||||
g = client.get(f"/api/sessions/{sid}")
|
||||
assert g.status_code == 200
|
||||
assert g.json()["status"] == "uploading"
|
||||
|
||||
|
||||
def test_session_list(client):
|
||||
client.post("/api/sessions", json={"user_id": "u1"})
|
||||
client.post("/api/sessions", json={"user_id": "u1"})
|
||||
r = client.get("/api/sessions", params={"user_id": "u1"})
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 2
|
||||
|
||||
|
||||
def test_full_flow_http(tmp_path):
|
||||
"""HTTP 全链路:建会话 → 上传 → 解析 → 确认 → 生成 → QA → 预览/下载。"""
|
||||
app = create_app(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine="fake",
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
_upload_core(client, sid)
|
||||
|
||||
r = client.post(f"/api/sessions/{sid}/start-parse")
|
||||
assert r.status_code == 200
|
||||
assert client.get(f"/api/sessions/{sid}").json()["status"] == "awaiting_parse_confirm"
|
||||
|
||||
pr = client.get(f"/api/sessions/{sid}/parse-result")
|
||||
assert pr.status_code == 200
|
||||
assert "tables" in pr.json()
|
||||
|
||||
r = client.post(f"/api/sessions/{sid}/confirm-parse")
|
||||
assert r.status_code == 200
|
||||
assert client.get(f"/api/sessions/{sid}").json()["status"] == "writing" # 无既有系统 → 影响跳过
|
||||
|
||||
r = client.post(f"/api/sessions/{sid}/generate", json={"output_language": "auto"})
|
||||
assert r.status_code == 200
|
||||
assert client.get(f"/api/sessions/{sid}").json()["result_path"]
|
||||
|
||||
r = client.post(f"/api/sessions/{sid}/run-qa")
|
||||
assert r.status_code == 200
|
||||
got = client.get(f"/api/sessions/{sid}").json()
|
||||
assert got["status"] == "done"
|
||||
assert "overall_score" in got["qa_summary"]
|
||||
|
||||
prev = client.get(f"/api/sessions/{sid}/result/preview")
|
||||
assert prev.status_code == 200
|
||||
assert "html" in prev.json()
|
||||
|
||||
dl = client.get(f"/api/sessions/{sid}/result/download")
|
||||
assert dl.status_code == 200
|
||||
assert dl.headers["content-type"].startswith("application/vnd.openxmlformats")
|
||||
|
||||
|
||||
def test_state_transition_invalid_409(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
# uploading 直接 generate → 409
|
||||
r = client.post(f"/api/sessions/{sid}/generate", json={})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_upload_invalid_type_400(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
r = client.post(f"/api/sessions/{sid}/files",
|
||||
data={"file_type": "bogus"},
|
||||
files={"file": ("x.txt", b"x")})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_root_serves_frontend(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "Genesis" in r.text
|
||||
|
||||
|
||||
def test_delete_session(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
r = client.delete(f"/api/sessions/{sid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["deleted"] is True
|
||||
assert client.get(f"/api/sessions/{sid}").status_code == 404
|
||||
|
||||
|
||||
def test_generate_requires_confirm_409(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
_upload_core(client, sid)
|
||||
# 未确认解析就生成 → 409
|
||||
r = client.post(f"/api/sessions/{sid}/generate", json={})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_start_impact_without_existing_system_409(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
_upload_core(client, sid)
|
||||
client.post(f"/api/sessions/{sid}/start-parse")
|
||||
client.post(f"/api/sessions/{sid}/confirm-parse")
|
||||
# 无既有系统 → writing,start-impact 409
|
||||
r = client.post(f"/api/sessions/{sid}/start-impact")
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_qa_requires_generate_409(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
_upload_core(client, sid)
|
||||
client.post(f"/api/sessions/{sid}/start-parse")
|
||||
client.post(f"/api/sessions/{sid}/confirm-parse")
|
||||
r = client.post(f"/api/sessions/{sid}/run-qa")
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_report_downloads_after_full_flow(tmp_path):
|
||||
"""QA/影响报告下载端点(FileResponse 分支)。"""
|
||||
app = create_app(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine="fake",
|
||||
)
|
||||
client = TestClient(app)
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
_upload_core(client, sid)
|
||||
client.post(f"/api/sessions/{sid}/start-parse")
|
||||
client.post(f"/api/sessions/{sid}/confirm-parse")
|
||||
client.post(f"/api/sessions/{sid}/generate", json={})
|
||||
client.post(f"/api/sessions/{sid}/run-qa")
|
||||
|
||||
qa = client.get(f"/api/sessions/{sid}/result/qa-report")
|
||||
assert qa.status_code == 200
|
||||
assert "application/json" in qa.headers["content-type"]
|
||||
qa_body = client.get(f"/api/sessions/{sid}/qa-result")
|
||||
assert qa_body.status_code == 200
|
||||
assert "overall_score" in qa_body.json()
|
||||
|
||||
# 无影响报告(未跑影响)→ 404
|
||||
assert client.get(f"/api/sessions/{sid}/result/impact-report").status_code == 404
|
||||
|
||||
|
||||
def test_session_not_found_404(client):
|
||||
assert client.get("/api/sessions/nope").status_code == 404
|
||||
|
||||
|
||||
def test_upload_to_missing_session_404(client):
|
||||
r = client.post("/api/sessions/nope/files",
|
||||
data={"file_type": "requirements"},
|
||||
files={"file": ("a.xlsx", b"x")})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_start_parse_no_files_409(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
assert client.post(f"/api/sessions/{sid}/start-parse").status_code == 409
|
||||
|
||||
|
||||
def test_start_parse_missing_session_404(client):
|
||||
assert client.post("/api/sessions/nope/start-parse").status_code == 404
|
||||
|
||||
|
||||
def test_confirm_parse_wrong_state_409(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
# uploading 态直接确认解析 → 409
|
||||
assert client.post(f"/api/sessions/{sid}/confirm-parse").status_code == 409
|
||||
|
||||
|
||||
def _upload_zip(client, sid):
|
||||
import io
|
||||
import zipfile
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("demo/OrderController.java",
|
||||
"package demo;\n@RestController public class OrderController {}\n")
|
||||
client.post(f"/api/sessions/{sid}/files",
|
||||
data={"file_type": "existing_system"},
|
||||
files={"file": ("existing.zip", buf.getvalue())})
|
||||
|
||||
|
||||
def test_impact_flow_http_zip(tmp_path):
|
||||
"""HTTP 影响调查全流程 + 报告下载(app 164/168-170/174-178/226)。"""
|
||||
app = create_app(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine="fake",
|
||||
)
|
||||
client = TestClient(app)
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
_upload_core(client, sid)
|
||||
_upload_zip(client, sid)
|
||||
client.post(f"/api/sessions/{sid}/start-parse")
|
||||
assert client.post(f"/api/sessions/{sid}/confirm-parse").json()["status"] == "impact_running"
|
||||
r = client.post(f"/api/sessions/{sid}/start-impact")
|
||||
assert r.status_code == 200
|
||||
ir = client.get(f"/api/sessions/{sid}/impact-result")
|
||||
assert ir.status_code == 200 and "summary" in ir.json()
|
||||
assert client.post(f"/api/sessions/{sid}/confirm-impact").json()["status"] == "writing"
|
||||
dl = client.get(f"/api/sessions/{sid}/result/impact-report")
|
||||
assert dl.status_code == 200 and "application/json" in dl.headers["content-type"]
|
||||
|
||||
|
||||
def test_confirm_impact_wrong_state_409(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
_upload_core(client, sid)
|
||||
client.post(f"/api/sessions/{sid}/start-parse")
|
||||
client.post(f"/api/sessions/{sid}/confirm-parse")
|
||||
# writing(无既有系统)直接确认影响 → 409
|
||||
assert client.post(f"/api/sessions/{sid}/confirm-impact").status_code == 409
|
||||
|
||||
|
||||
def test_download_result_not_found_404(client):
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
assert client.get(f"/api/sessions/{sid}/result/download").status_code == 404
|
||||
assert client.get(f"/api/sessions/{sid}/result/qa-report").status_code == 404
|
||||
|
||||
|
||||
def test_real_mode_generate_without_key_500(tmp_path, monkeypatch):
|
||||
"""engine=None(真实模式)→ generate 按需 build;build 抛错 → 500。"""
|
||||
import genesis.server.app as app_mod
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("LLMNotConfiguredError: 缺少 API Key")
|
||||
monkeypatch.setattr(app_mod, "build_inference_engine", boom)
|
||||
|
||||
app = create_app(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine=None,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
|
||||
_upload_core(client, sid)
|
||||
client.post(f"/api/sessions/{sid}/start-parse")
|
||||
client.post(f"/api/sessions/{sid}/confirm-parse")
|
||||
r = client.post(f"/api/sessions/{sid}/generate", json={})
|
||||
assert r.status_code == 500
|
||||
@@ -0,0 +1,237 @@
|
||||
"""S3:会话化服务层测试(server/service.py)。
|
||||
|
||||
用真实样本文件 + FakeEngine 验证闭环:上传 → 解析 → 确认 → 影响 → 确认 → 生成 → QA。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from genesis.server.store import SessionStore
|
||||
from genesis.server.service import GenesisService, ServiceStepError, FileTypeError
|
||||
|
||||
_SAMPLE = Path(__file__).resolve().parents[1] / "sample"
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
|
||||
title = variables.get("title", "x")
|
||||
# 日文样本(章节标题含假名)→ 返回日文正文,满足语言一致性强制
|
||||
return SimpleNamespace(
|
||||
data={
|
||||
"title": title,
|
||||
"blocks": [
|
||||
{"type": "paragraph",
|
||||
"text": "本機能はFakeLLMにより生成された十分な説明内容であり、書込規則を満たす。"},
|
||||
],
|
||||
},
|
||||
status="ok",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc(tmp_path):
|
||||
return GenesisService(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(),
|
||||
)
|
||||
|
||||
|
||||
def _upload_core(svc, session_id):
|
||||
svc.upload_file(session_id, "requirements", "requirements_newdev.xlsx",
|
||||
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
|
||||
svc.upload_file(session_id, "template", "template_design_ja.docx",
|
||||
(_SAMPLE / "template_design_ja.docx").read_bytes())
|
||||
svc.upload_file(session_id, "write_instruction", "rules_design_ja.docx",
|
||||
(_SAMPLE / "rules_design_ja.docx").read_bytes())
|
||||
svc.upload_file(session_id, "rules", "rules_entry_ja.docx",
|
||||
(_SAMPLE / "rules_entry_ja.docx").read_bytes())
|
||||
|
||||
|
||||
def test_create_and_upload_files(svc):
|
||||
s = svc.create_session("u1")
|
||||
_upload_core(svc, s.session_id)
|
||||
got = svc.get_session(s.session_id)
|
||||
assert set(got.files.keys()) == {"requirements", "template", "write_instruction", "rules"}
|
||||
assert got.files["requirements"]["name"] == "requirements_newdev.xlsx"
|
||||
|
||||
|
||||
def test_upload_invalid_type_rejected(svc):
|
||||
s = svc.create_session("u1")
|
||||
with pytest.raises(FileTypeError):
|
||||
svc.upload_file(s.session_id, "bogus", "x.txt", b"x")
|
||||
|
||||
|
||||
def test_parse_requires_files(svc):
|
||||
s = svc.create_session("u1")
|
||||
with pytest.raises(ServiceStepError):
|
||||
svc.run_parse(s.session_id)
|
||||
|
||||
|
||||
def test_full_flow_fake(tmp_path):
|
||||
"""完整闭环:解析 → 确认 → 影响 → 确认 → 生成 → QA → 结果路径就绪。"""
|
||||
svc = GenesisService(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(),
|
||||
)
|
||||
s = svc.create_session("u1")
|
||||
_upload_core(svc, s.session_id)
|
||||
|
||||
svc.run_parse(s.session_id)
|
||||
assert svc.get_session(s.session_id).status == "awaiting_parse_confirm"
|
||||
assert "機能" in svc.get_session(s.session_id).structured_summary or "tables" in svc.get_session(s.session_id).structured_summary
|
||||
|
||||
svc.confirm_parse(s.session_id)
|
||||
assert svc.get_session(s.session_id).status == "writing" # 无既有系统 → 影响跳过 → writing
|
||||
|
||||
svc.run_generate(s.session_id, output_language="auto")
|
||||
got = svc.get_session(s.session_id)
|
||||
assert got.status == "writing"
|
||||
assert got.result_path and Path(got.result_path).is_file()
|
||||
|
||||
# 预览可读
|
||||
html = svc.result_preview(s.session_id)
|
||||
assert "<html" in html or "概要" in html or len(html) > 0
|
||||
|
||||
|
||||
def test_generate_requires_confirmed_parse(svc):
|
||||
s = svc.create_session("u1")
|
||||
_upload_core(svc, s.session_id)
|
||||
with pytest.raises(ServiceStepError):
|
||||
svc.run_generate(s.session_id) # 尚未解析确认
|
||||
|
||||
|
||||
def test_run_qa_after_generate(tmp_path):
|
||||
svc = GenesisService(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(),
|
||||
)
|
||||
s = svc.create_session("u1")
|
||||
_upload_core(svc, s.session_id)
|
||||
svc.run_parse(s.session_id)
|
||||
svc.confirm_parse(s.session_id)
|
||||
svc.run_generate(s.session_id)
|
||||
svc.run_qa(s.session_id)
|
||||
got = svc.get_session(s.session_id)
|
||||
assert got.qa_summary # QA 报告 JSON 已持久化
|
||||
assert "language" in got.qa_summary or "overall_score" in got.qa_summary
|
||||
|
||||
|
||||
def _make_existing_zip(tmp_path) -> bytes:
|
||||
"""构造含 1 个 Java 文件的 zip(既有系统样本)。"""
|
||||
import io
|
||||
import zipfile
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("demo/OrderController.java",
|
||||
"package demo;\n"
|
||||
"@RestController public class OrderController {\n"
|
||||
" @GetMapping public String list() { return \"ok\"; }\n}\n")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_existing_system_zip_triggers_impact_flow(tmp_path):
|
||||
"""既有系统 zip 上传 → 影响调查全流程(run_impact/confirm_impact)。"""
|
||||
svc = GenesisService(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(),
|
||||
)
|
||||
s = svc.create_session("u1")
|
||||
_upload_core(svc, s.session_id)
|
||||
svc.upload_file(s.session_id, "existing_system", "existing.zip", _make_existing_zip(tmp_path))
|
||||
|
||||
svc.run_parse(s.session_id)
|
||||
svc.confirm_parse(s.session_id)
|
||||
assert svc.get_session(s.session_id).status == "impact_running"
|
||||
|
||||
svc.run_impact(s.session_id)
|
||||
got = svc.get_session(s.session_id)
|
||||
assert got.status == "awaiting_impact_confirm"
|
||||
assert got.impact_summary
|
||||
assert got.impact_report_path and Path(got.impact_report_path).exists()
|
||||
|
||||
svc.confirm_impact(s.session_id)
|
||||
assert svc.get_session(s.session_id).status == "writing"
|
||||
|
||||
# 从 awaiting_impact_confirm 也能生成(兼容)
|
||||
svc.run_generate(s.session_id)
|
||||
assert svc.get_session(s.session_id).result_path
|
||||
|
||||
|
||||
def test_impact_state_guard(svc):
|
||||
s = svc.create_session("u1")
|
||||
_upload_core(svc, s.session_id)
|
||||
svc.run_parse(s.session_id)
|
||||
svc.confirm_parse(s.session_id)
|
||||
# 无既有系统 → writing,start-impact 应报错
|
||||
with pytest.raises(ServiceStepError):
|
||||
svc.run_impact(s.session_id)
|
||||
|
||||
|
||||
def test_qa_requires_generated(svc):
|
||||
s = svc.create_session("u1")
|
||||
_upload_core(svc, s.session_id)
|
||||
svc.run_parse(s.session_id)
|
||||
svc.confirm_parse(s.session_id)
|
||||
with pytest.raises(ServiceStepError):
|
||||
svc.run_qa(s.session_id) # 尚未生成
|
||||
|
||||
|
||||
def test_preview_missing_result_raises(svc):
|
||||
s = svc.create_session("u1")
|
||||
with pytest.raises(ServiceStepError):
|
||||
svc.result_preview(s.session_id)
|
||||
|
||||
|
||||
def test_preview_with_table(tmp_path):
|
||||
"""预览含表格的 docx → 覆盖表格渲染分支(service 241-244)。"""
|
||||
from docx import Document
|
||||
from docx.table import Table
|
||||
from genesis.server.store import SessionStore
|
||||
svc = GenesisService(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(),
|
||||
)
|
||||
s = svc.create_session("u1")
|
||||
doc_path = tmp_path / "data" / s.session_id / "with_table.docx"
|
||||
doc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc = Document()
|
||||
doc.add_paragraph("概要段落")
|
||||
t = doc.add_table(rows=1, cols=2)
|
||||
t.rows[0].cells[0].text = "列A"
|
||||
t.rows[0].cells[1].text = "列B"
|
||||
doc.save(str(doc_path))
|
||||
svc.store.update_session(s.session_id, result_path=str(doc_path))
|
||||
html_out = svc.result_preview(s.session_id)
|
||||
assert "<table" in html_out
|
||||
assert "列A" in html_out
|
||||
|
||||
|
||||
def test_confirm_parse_wrong_state_raises(svc):
|
||||
s = svc.create_session("u1")
|
||||
_upload_core(svc, s.session_id)
|
||||
# uploading 态直接确认解析 → 报错(service 139)
|
||||
with pytest.raises(ServiceStepError):
|
||||
svc.confirm_parse(s.session_id)
|
||||
|
||||
|
||||
def test_confirm_impact_wrong_state_raises(tmp_path):
|
||||
svc = GenesisService(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(),
|
||||
)
|
||||
s = svc.create_session("u1")
|
||||
_upload_core(svc, s.session_id)
|
||||
svc.run_parse(s.session_id)
|
||||
svc.confirm_parse(s.session_id)
|
||||
# writing 态直接确认影响 → 报错(service 167)
|
||||
with pytest.raises(ServiceStepError):
|
||||
svc.confirm_impact(s.session_id)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""S2:SQLite 会话存储测试(server/store.py)。
|
||||
|
||||
覆盖:创建会话 / 列表 / 状态更新 / 文件登记 / 结果路径 / 持久化重建。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from genesis.server.store import SessionStore, SessionRecord, SessionNotFoundError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return SessionStore(db_path=str(tmp_path / "sessions.db"))
|
||||
|
||||
|
||||
def test_create_session(store):
|
||||
s = store.create_session(user_id="u1")
|
||||
assert s.session_id
|
||||
assert s.user_id == "u1"
|
||||
assert s.status == "uploading"
|
||||
assert s.files == {}
|
||||
assert s.created_at
|
||||
|
||||
|
||||
def test_get_session(store):
|
||||
s = store.create_session("u1")
|
||||
got = store.get_session(s.session_id)
|
||||
assert got.session_id == s.session_id
|
||||
assert got.status == "uploading"
|
||||
|
||||
|
||||
def test_get_missing_session_raises(store):
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session("nope")
|
||||
|
||||
|
||||
def test_list_sessions_by_user(store):
|
||||
a = store.create_session("u1")
|
||||
b = store.create_session("u1")
|
||||
store.create_session("u2")
|
||||
lst = store.list_sessions("u1")
|
||||
ids = {s.session_id for s in lst}
|
||||
assert ids == {a.session_id, b.session_id}
|
||||
|
||||
|
||||
def test_update_status(store):
|
||||
s = store.create_session("u1")
|
||||
store.update_status(s.session_id, "parsing")
|
||||
assert store.get_session(s.session_id).status == "parsing"
|
||||
|
||||
|
||||
def test_update_fields_merge(store):
|
||||
s = store.create_session("u1")
|
||||
store.update_session(s.session_id, files={"requirements": {"file_id": "f1", "name": "a.xlsx", "size": 10}})
|
||||
got = store.get_session(s.session_id)
|
||||
assert got.files["requirements"]["file_id"] == "f1"
|
||||
# 保留既有字段
|
||||
assert got.status == "uploading"
|
||||
|
||||
|
||||
def test_set_result_paths(store):
|
||||
s = store.create_session("u1")
|
||||
store.update_session(s.session_id, result_path="out.docx", impact_report_path="ir.json", qa_report_path="qa.json")
|
||||
got = store.get_session(s.session_id)
|
||||
assert got.result_path == "out.docx"
|
||||
assert got.impact_report_path == "ir.json"
|
||||
assert got.qa_report_path == "qa.json"
|
||||
|
||||
|
||||
def test_store_reload_persists(tmp_path):
|
||||
db = str(tmp_path / "s.db")
|
||||
store1 = SessionStore(db_path=db)
|
||||
s = store1.create_session("u1")
|
||||
store1.update_status(s.session_id, "done")
|
||||
store1.update_session(s.session_id, result_path="x.docx")
|
||||
# 重新打开同一 db → 数据仍在
|
||||
store2 = SessionStore(db_path=db)
|
||||
got = store2.get_session(s.session_id)
|
||||
assert got.status == "done"
|
||||
assert got.result_path == "x.docx"
|
||||
Reference in New Issue
Block a user