feat(server): Web 服务化(FastAPI + SQLite + 内嵌零构建前端)

参赛成果物 03「交互界面 + 数据存储」落地:
- src/genesis/server/: store.py(SQLite 会话持久化)、service.py(会话化服务层:
  上传→解析→确认→影响→确认→生成→QA)、app.py(api-design §2 核心端点 9 组)、
  static/index.html(内嵌单页,零构建无 node_modules 依赖)
- scripts/serve.py 启动入口(--fake 离线引擎 / 默认真实 LLM)
- pyproject 加 fastapi/uvicorn/python-multipart
- 修复 qa_loop._build meta={} 导致真实模板 {{doc_title}} 等占位符残留 DocxInjectError
- README Web 服务说明 + service_url 登记指引;design.md §12.5 记录(含同步执行/
  zip 既有系统/无 WebSocket 的诚实偏差标注)
- 测试:test_server_store/service/api 共 34 用例(TestClient 全链路 + zip 影响流程 + 错误分支)
全量 pytest 473 passed / 99.20%
This commit is contained in:
lhl
2026-08-26 22:13:19 +08:00
parent a1fefd43eb
commit 215c605650
14 changed files with 1474 additions and 1 deletions
+21
View File
@@ -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
+1
View File
@@ -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.mdASCII);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) |
+11
View File
@@ -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` — 会话持久化(SessionStoreSQLitesessions 表 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 全链路)
+3
View File
@@ -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]
+39
View File
@@ -0,0 +1,39 @@
"""Web 服务启动入口(S5)。
用法:
python scripts/serve.py [--host 0.0.0.0] [--port 8000] [--fake]
- 默认真实 LLM 模式(需 .env 配置 GENESIS_INFERENCE__API_KEYgenerate 时按需加载)
- --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()
+5 -1
View File
@@ -43,7 +43,11 @@ class QALoop:
if not tpl:
raise ValueError("template_path 必须提供")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
doc = DocxInjector(tpl).inject(sections, meta={})
# 与 orchestrator.generate 一致的默认 meta:避免 {{doc_title}}/{{version}}/{{created_at}}
# 占位符残留导致 DocxInjectError(真实模板含封面字段;slice 模板无则无影响)
from datetime import date
meta = {"doc_title": Path(tpl).stem, "version": "v1", "created_at": date.today().isoformat()}
doc = DocxInjector(tpl).inject(sections, meta=meta)
doc.save(output_path)
return [contents_map[cid] for cid in order]
+1
View File
@@ -0,0 +1 @@
"""Web 服务化包(S2-S4)。"""
+239
View File
@@ -0,0 +1,239 @@
"""Web 服务化:FastAPI 端点(S4)。
实现 api-design.md §2 的核心端点(v1 同步执行 + 内嵌零构建前端)。
- 会话:POST/GET /api/sessions、GET /api/sessions/{id}、DELETE
- 文件:POST /api/sessions/{id}/filesmultipart
- 解析:POST start-parse、GET parse-result、POST confirm-parse
- 影响:POST start-impact、GET impact-result、POST confirm-impact
- 生成/QAPOST generate、POST run-qa、GET qa-result
- 结果:GET result/preview、result/download、result/impact-report、result/qa-report
- 前端:GET / 返回内嵌单页
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
from genesis.inference.factory import build_inference_engine
from genesis.server.service import FileTypeError, GenesisService, ServiceStepError
from genesis.server.store import SessionNotFoundError, SessionStore
VERSION = "0.1.0"
class _FakeEngine:
"""离线确定性引擎(--engine fake):用于无 API key 的 Web 演示/测试。"""
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
from types import SimpleNamespace
title = variables.get("title", "x")
return SimpleNamespace(
data={
"title": title,
"blocks": [{"type": "paragraph",
"text": "本機能はFakeLLMにより生成された十分な説明内容であり、書込規則を満たす。"}],
},
status="ok",
)
class SessionCreate(BaseModel):
user_id: str = "default"
class FileUploadResp(BaseModel):
file_id: str
file_name: str
size: int
class GenerateReq(BaseModel):
output_language: str = "auto"
def _error(status: int, code: str, message: str) -> HTTPException:
return HTTPException(status_code=status, detail={"code": code, "message": message})
def create_app(
store: SessionStore | None = None,
data_root: str = "data/server",
engine: Any = None,
) -> FastAPI:
store = store or SessionStore()
if engine == "fake":
engine = _FakeEngine()
elif engine is None:
engine = None # 真实模式:generate/qa 时按需 build(避免未配置 key 直接 503)
service = GenesisService(store=store, data_root=data_root, engine=engine)
static_dir = Path(__file__).parent / "static"
index_html = (static_dir / "index.html").read_text(encoding="utf-8") if (static_dir / "index.html").exists() else "<html><body>Genesis Web UI</body></html>"
app = FastAPI(title="Genesis API", version=VERSION)
# ---------- 健康/前端 ----------
@app.get("/api/health")
def health():
return {"status": "ok", "version": VERSION}
@app.get("/", response_class=HTMLResponse)
def index():
return index_html
# ---------- 会话 ----------
@app.post("/api/sessions")
def create_session(body: SessionCreate):
rec = service.create_session(body.user_id)
return {"session_id": rec.session_id, "status": rec.status}
@app.get("/api/sessions")
def list_sessions(user_id: str = "default"):
return [
{"session_id": r.session_id, "status": r.status, "updated_at": r.updated_at}
for r in service.store.list_sessions(user_id)
]
@app.get("/api/sessions/{sid}")
def get_session(sid: str):
try:
rec = service.get_session(sid)
except SessionNotFoundError:
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
return rec.to_dict
@app.delete("/api/sessions/{sid}")
def delete_session(sid: str):
ok = service.store.delete_session(sid)
return {"deleted": ok}
# ---------- 文件上传 ----------
@app.post("/api/sessions/{sid}/files", response_model=FileUploadResp)
async def upload_file(sid: str, file_type: str = Form(...), file: UploadFile = File(...)):
content = await file.read()
try:
entry = service.upload_file(sid, file_type, file.filename or "upload", content)
except FileTypeError as e:
raise _error(400, "FILE_TYPE_INVALID", str(e))
except SessionNotFoundError:
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
return FileUploadResp(file_id=entry["file_id"], file_name=entry["name"], size=entry["size"])
# ---------- 解析 ----------
@app.post("/api/sessions/{sid}/start-parse")
def start_parse(sid: str):
try:
rec = service.run_parse(sid)
except ServiceStepError as e:
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
except SessionNotFoundError:
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
return {"ok": True, "status": rec.status}
@app.get("/api/sessions/{sid}/parse-result")
def parse_result(sid: str):
rec = service.get_session(sid)
import json
return json.loads(rec.structured_summary) if rec.structured_summary else {}
@app.post("/api/sessions/{sid}/confirm-parse")
def confirm_parse(sid: str):
try:
rec = service.confirm_parse(sid)
except ServiceStepError as e:
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
return {"ok": True, "status": rec.status}
# ---------- 影响调查 ----------
@app.post("/api/sessions/{sid}/start-impact")
def start_impact(sid: str):
try:
rec = service.run_impact(sid)
except ServiceStepError as e:
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
return {"ok": True, "status": rec.status}
@app.get("/api/sessions/{sid}/impact-result")
def impact_result(sid: str):
rec = service.get_session(sid)
import json
return json.loads(rec.impact_summary) if rec.impact_summary else {}
@app.post("/api/sessions/{sid}/confirm-impact")
def confirm_impact(sid: str):
try:
rec = service.confirm_impact(sid)
except ServiceStepError as e:
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
return {"ok": True, "status": rec.status}
# ---------- 生成 / QA ----------
@app.post("/api/sessions/{sid}/generate")
def generate(sid: str, body: GenerateReq | None = None):
lang = (body.output_language if body else "auto") or "auto"
try:
if service.engine is None:
service.engine = build_inference_engine()
rec = service.run_generate(sid, output_language=lang)
except ServiceStepError as e:
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
return {"ok": True, "status": rec.status, "result_path": rec.result_path}
@app.post("/api/sessions/{sid}/run-qa")
def run_qa(sid: str):
try:
rec = service.run_qa(sid)
except ServiceStepError as e:
raise _error(409, "STATE_TRANSITION_INVALID", str(e))
return {"ok": True, "status": rec.status}
@app.get("/api/sessions/{sid}/qa-result")
def qa_result(sid: str):
rec = service.get_session(sid)
import json
return json.loads(rec.qa_summary) if rec.qa_summary else {}
# ---------- 结果 ----------
@app.get("/api/sessions/{sid}/result/preview")
def result_preview(sid: str):
html = service.result_preview(sid)
return {"html": html}
@app.get("/api/sessions/{sid}/result/download")
def result_download(sid: str):
rec = service.get_session(sid)
if not rec.result_path or not Path(rec.result_path).exists():
raise _error(404, "RESULT_NOT_FOUND", "结果文档不存在")
return FileResponse(rec.result_path, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", filename="output.docx")
@app.get("/api/sessions/{sid}/result/impact-report")
def result_impact(sid: str):
rec = service.get_session(sid)
if not rec.impact_report_path or not Path(rec.impact_report_path).exists():
raise _error(404, "RESULT_NOT_FOUND", "影响调查书不存在")
return FileResponse(rec.impact_report_path, media_type="application/json", filename="impact-report.json")
@app.get("/api/sessions/{sid}/result/qa-report")
def result_qa(sid: str):
rec = service.get_session(sid)
if not rec.qa_report_path or not Path(rec.qa_report_path).exists():
raise _error(404, "RESULT_NOT_FOUND", "QA 报告不存在")
return FileResponse(rec.qa_report_path, media_type="application/json", filename="qa-report.json")
return app
# 模块级 appuvicorn app.main:app 兼容)
app = create_app()
+251
View File
@@ -0,0 +1,251 @@
"""Web 服务化:会话化服务层(S3)。
把 CLI 管线(SourceParser / ImpactAgent / WriteOrchestrator / QALoop)包装为
会话化的服务:文件落盘 → 解析 → 确认 → 影响 → 确认 → 生成 → QA,状态与结果
持久化到 SessionStoreSQLite)。
v1 范围(诚实标注):
- 文件类型:requirements / template / write_instruction / rules / existing_system(zip)
- existing_system 以 .zip 上传 → 解压为目录;未提供时影响调查跳过(门控,同 CLI)
- 执行方式:进程内同步执行(v1 简化,非 api-design 的异步+轮询;样本规模小可接受)
"""
from __future__ import annotations
import html
import json
import shutil
import zipfile
from pathlib import Path
from docx import Document
from genesis.parsers.source_aggregator import SourceParser
from genesis.server.store import SessionStore, SessionRecord
ALLOWED_FILE_TYPES = {
"requirements", "template", "write_instruction", "rules", "existing_system",
}
# 各类型建议扩展名(宽松校验:仅拒绝明显非法的空文件)
EXPECTED_SUFFIX = {
"requirements": (".xlsx",),
"template": (".docx",),
"write_instruction": (".docx",),
"rules": (".docx", ".xlsx"),
"existing_system": (".zip",),
}
class ServiceError(Exception):
"""服务层通用错误。"""
class FileTypeError(ServiceError):
"""非法文件类型。"""
class ServiceStepError(ServiceError):
"""状态/步骤非法(对应 api-design STATE_TRANSITION_INVALID 409)。"""
class GenesisService:
def __init__(
self,
store: SessionStore,
data_root: str = "data/server",
engine=None,
prompt_registry=None,
samples_dir: str = "sample",
) -> None:
self.store = store
self.data_root = Path(data_root)
self.engine = engine
self.prompt_registry = prompt_registry
self.samples_dir = samples_dir
# ---------- 会话与文件 ----------
def create_session(self, user_id: str) -> SessionRecord:
rec = self.store.create_session(user_id)
(self.data_root / rec.session_id).mkdir(parents=True, exist_ok=True)
return rec
def get_session(self, session_id: str) -> SessionRecord:
return self.store.get_session(session_id)
def upload_file(self, session_id: str, file_type: str, filename: str, content: bytes) -> dict:
if file_type not in ALLOWED_FILE_TYPES:
raise FileTypeError(f"不支持的 file_type: {file_type}")
sdir = self.data_root / session_id / "uploads"
sdir.mkdir(parents=True, exist_ok=True)
if file_type == "existing_system":
# zip → 解压到 existing_system/
dst = sdir / "existing_system"
dst.mkdir(parents=True, exist_ok=True)
zpath = sdir / filename
zpath.write_bytes(content)
with zipfile.ZipFile(zpath) as zf:
zf.extractall(dst)
zpath.unlink(missing_ok=True)
path = str(dst)
else:
path = str(sdir / filename)
Path(path).write_bytes(content)
entry = {"file_id": f"{session_id}-{file_type}", "name": filename, "size": len(content), "path": path}
files = dict(self.get_session(session_id).files)
files[file_type] = entry
self.store.update_session(session_id, files=files)
return entry
# ---------- 内部:重建 StructuredSource ----------
def _rebuild_source(self, rec: SessionRecord):
files = rec.files
parser = SourceParser()
return parser.parse(
requirement_paths=[files["requirements"]["path"]] if "requirements" in files else None,
template_path=files.get("template", {}).get("path"),
write_instruction_paths=[files["write_instruction"]["path"]] if "write_instruction" in files else None,
rule_paths=[files["rules"]["path"]] if "rules" in files else None,
existing_system_path=files.get("existing_system", {}).get("path"),
existing_system_language=None,
)
# ---------- 解析 ----------
def run_parse(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if "requirements" not in rec.files or "template" not in rec.files:
raise ServiceStepError("缺少 requirements / template 文件,无法解析")
ss = self._rebuild_source(rec)
summary = {
"tables": len(getattr(ss, "tables", []) or []),
"template_sections": [
{"type": m.type, "name": m.name, "level": m.level}
for m in (getattr(getattr(ss, "template", None), "sections", None) or [])
],
"rule_docs": len(getattr(ss, "rule_docs", []) or []),
}
self.store.update_session(
session_id,
status="awaiting_parse_confirm",
structured_summary=json.dumps(summary, ensure_ascii=False),
)
return self.get_session(session_id)
def confirm_parse(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if rec.status != "awaiting_parse_confirm":
raise ServiceStepError(f"当前状态 {rec.status} 不可确认解析")
# 有既有系统 → impact_running;否则门控跳过影响 → writing
if "existing_system" in rec.files:
return self.store.update_status(session_id, "impact_running")
return self.store.update_status(session_id, "writing")
# ---------- 影响调查 ----------
def run_impact(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if rec.status != "impact_running":
raise ServiceStepError(f"当前状态 {rec.status} 不可启动影响调查")
from genesis.impact.impact_agent import ImpactAgent, impact_report_to_dict
ss = self._rebuild_source(rec)
report = ImpactAgent().run(ss, session_id=session_id)
self.store.update_session(
session_id,
status="awaiting_impact_confirm",
impact_summary=json.dumps(
{"summary": dict(getattr(report, "summary", {})),
"report": impact_report_to_dict(report)}, ensure_ascii=False),
impact_report_path=str(self._save_json(session_id, "impact-report.json", impact_report_to_dict(report))),
)
return self.get_session(session_id)
def confirm_impact(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if rec.status != "awaiting_impact_confirm":
raise ServiceStepError(f"当前状态 {rec.status} 不可确认影响调查")
return self.store.update_status(session_id, "writing")
# ---------- 生成与 QA ----------
def run_generate(self, session_id: str, output_language: str = "auto") -> SessionRecord:
rec = self.get_session(session_id)
if rec.status not in ("writing", "awaiting_impact_confirm"):
raise ServiceStepError(f"当前状态 {rec.status} 不可启动生成")
from genesis.writer.orchestrator import WriteOrchestrator
ss = self._rebuild_source(rec)
out_path = self.data_root / session_id / "output.docx"
out_path.parent.mkdir(parents=True, exist_ok=True)
WriteOrchestrator().generate(
ss, str(out_path),
session_id=session_id,
samples_dir=self.samples_dir,
engine=self.engine,
prompt_registry=self.prompt_registry,
template_path=rec.files.get("template", {}).get("path"),
output_language=output_language,
)
self.store.update_session(
session_id,
status="writing",
result_path=str(out_path),
output_language=output_language,
)
return self.get_session(session_id)
def run_qa(self, session_id: str) -> SessionRecord:
rec = self.get_session(session_id)
if rec.status != "writing" or not rec.result_path:
raise ServiceStepError(f"当前状态 {rec.status} 不可运行 QA(需先生成)")
from genesis.qa.qa_loop import QALoop
from genesis.qa.report import QAReport
ss = self._rebuild_source(rec)
loop = QALoop(max_rounds=1)
report: QAReport = loop.run(
ss, rec.result_path,
session_id=session_id,
samples_dir=self.samples_dir,
engine=self.engine,
prompt_registry=self.prompt_registry,
template_path=rec.files.get("template", {}).get("path"),
output_language=rec.output_language,
)
payload = {
"passed": report.passed,
"overall_score": report.overall_score,
"failed_chapters": report.failed_chapters,
"summary": report.summary,
}
qa_path = self._save_json(session_id, "qa-report.json", payload)
self.store.update_session(
session_id,
status="done",
qa_summary=json.dumps(payload, ensure_ascii=False),
qa_report_path=str(qa_path),
)
return self.get_session(session_id)
# ---------- 结果 ----------
def result_preview(self, session_id: str) -> str:
rec = self.get_session(session_id)
if not rec.result_path or not Path(rec.result_path).exists():
raise ServiceStepError("结果文档不存在")
doc = Document(rec.result_path)
parts = ["<!DOCTYPE html><html><head><meta charset='utf-8'></head><body>"]
for p in doc.paragraphs:
if p.text.strip():
parts.append(f"<p>{html.escape(p.text)}</p>")
for tbl in doc.tables:
parts.append("<table border='1' cellpadding='4'>")
for row in tbl.rows:
parts.append("<tr>" + "".join(f"<td>{html.escape(c.text)}</td>" for c in row.cells) + "</tr>")
parts.append("</table>")
parts.append("</body></html>")
return "".join(parts)
def _save_json(self, session_id: str, name: str, payload: dict) -> Path:
path = self.data_root / session_id / name
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return path
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Genesis — 概要设计书自动生成</title>
<style>
body { font-family: "Microsoft YaHei", sans-serif; max-width: 900px; margin: 0 auto; padding: 24px; color: #222; }
h1 { color: #1a5276; }
.card { border: 1px solid #ccc; border-radius: 8px; padding: 16px; margin: 12px 0; }
button { padding: 8px 16px; margin: 4px; cursor: pointer; border-radius: 4px; border: 1px solid #999; }
button.primary { background: #1a5276; color: #fff; border-color: #1a5276; }
button:disabled { opacity: .5; cursor: not-allowed; }
pre { background: #f6f6f6; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 13px; }
#status { font-weight: bold; margin: 8px 0; }
.msg { margin: 4px 0; font-size: 14px; }
input[type=file] { margin: 4px 0; }
table { border-collapse: collapse; } th, td { border: 1px solid #999; padding: 4px 8px; }
</style>
</head>
<body>
<h1>Genesis — 概要设计书自动生成 Agent</h1>
<div class="card">
<button class="primary" onclick="createSession()">创建会话</button>
<span id="session-info"></span>
</div>
<div class="card">
<h3>1. 上传输入资料</h3>
<div>
<label>要件定义 Excel(必需): <input type="file" id="f-requirements" accept=".xlsx"></label><br>
<label>概要设计模板 docx(必需): <input type="file" id="f-template" accept=".docx"></label><br>
<label>做成说明书 docx: <input type="file" id="f-write_instruction" accept=".docx"></label><br>
<label>记入/图表规则 docx/xlsx: <input type="file" id="f-rules" accept=".docx,.xlsx"></label><br>
<label>既有系统 zip(追加改修场景,可选): <input type="file" id="f-existing_system" accept=".zip"></label><br>
</div>
<button onclick="uploadAll()">上传全部</button>
<div id="upload-result"></div>
</div>
<div class="card">
<h3>2. 解析与确认</h3>
<button onclick="post('/api/sessions/'+sid+'/start-parse', {})">开始解析</button>
<button onclick="get('/api/sessions/'+sid+'/parse-result')">解析结果</button>
<button onclick="post('/api/sessions/'+sid+'/confirm-parse', {})">确认解析</button>
<button onclick="post('/api/sessions/'+sid+'/start-impact', {})">开始影响调查</button>
<button onclick="get('/api/sessions/'+sid+'/impact-result')">影响结果</button>
<button onclick="post('/api/sessions/'+sid+'/confirm-impact', {})">确认影响</button>
</div>
<div class="card">
<h3>3. 生成与 QA</h3>
<label>输出语言:
<select id="lang">
<option value="auto">auto(跟随标题)</option>
<option value="zh">zh(简体中文)</option>
<option value="ja">ja(日文)</option>
</select>
</label>
<button onclick="generate()">开始生成</button>
<button onclick="post('/api/sessions/'+sid+'/run-qa', {})">运行 QA</button>
</div>
<div class="card">
<h3>4. 结果</h3>
<button onclick="preview()">预览</button>
<a id="dl" download><button>下载 docx</button></a>
<a id="dl-impact" download><button>下载影响调查书</button></a>
<a id="dl-qa" download><button>下载 QA 报告</button></a>
<div id="preview-box"></div>
</div>
<div class="card">
<div id="status">未创建会话</div>
<div id="out"></div>
</div>
<script>
let sid = null;
const out = document.getElementById('out');
const statusEl = document.getElementById('status');
function log(html) { out.innerHTML += '<div class="msg">' + html + '</div>'; }
function setStatus(s) { statusEl.textContent = s; }
async function api(method, url, body, isForm) {
const opt = { method, headers: {} };
if (body instanceof FormData) { opt.body = body; }
else if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
const r = await fetch(url, opt);
const ct = r.headers.get('content-type') || '';
const data = ct.includes('json') ? await r.json() : await r.text();
if (!r.ok) throw new Error(JSON.stringify(data));
return data;
}
function post(u, b) { return api('POST', u, b).then(d => { log('✔ ' + u + ' → ' + JSON.stringify(d)); return d; }).catch(e => log('✘ ' + u + ' → ' + e.message)); }
function get(u) { return api('GET', u).then(d => { log('✔ ' + u + ' → ' + JSON.stringify(d).slice(0, 300)); return d; }).catch(e => log('✘ ' + u + ' → ' + e.message)); }
async function createSession() {
try {
const d = await api('POST', '/api/sessions', { user_id: 'default' });
sid = d.session_id;
document.getElementById('session-info').textContent = '会话: ' + sid;
setStatus('会话已创建: ' + sid + '(状态 uploading');
} catch (e) { setStatus('创建失败: ' + e.message); }
}
async function uploadAll() {
if (!sid) return setStatus('请先创建会话');
const map = { requirements: 'f-requirements', template: 'f-template', write_instruction: 'f-write_instruction', rules: 'f-rules', existing_system: 'f-existing_system' };
for (const [ft, id] of Object.entries(map)) {
const el = document.getElementById(id);
if (!el.files.length) continue;
const fd = new FormData();
fd.append('file_type', ft);
fd.append('file', el.files[0]);
try {
const d = await api('POST', '/api/sessions/' + sid + '/files', fd);
document.getElementById('upload-result').innerHTML += '<div>✔ ' + ft + ': ' + d.file_name + '' + d.size + 'B</div>';
} catch (e) { document.getElementById('upload-result').innerHTML += '<div>✘ ' + ft + ' → ' + e.message + '</div>'; }
}
}
async function generate() {
const lang = document.getElementById('lang').value;
await post('/api/sessions/' + sid + '/generate', { output_language: lang });
}
async function preview() {
try {
const d = await api('GET', '/api/sessions/' + sid + '/result/preview');
document.getElementById('preview-box').innerHTML = d.html;
document.getElementById('dl').href = '/api/sessions/' + sid + '/result/download';
document.getElementById('dl-impact').href = '/api/sessions/' + sid + '/result/impact-report';
document.getElementById('dl-qa').href = '/api/sessions/' + sid + '/result/qa-report';
} catch (e) { log('✘ 预览失败: ' + e.message); }
}
</script>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
"""Web 服务化:SQLite 会话存储(S2)。
满足参赛成果物 03「数据存储」:会话 / 状态 / 文件登记 / 结果路径持久化到 SQLite。
零外部依赖(标准库 sqlite3),db 路径可注入(测试用 tmp_path)。
"""
from __future__ import annotations
import json
import sqlite3
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
class SessionNotFoundError(Exception):
"""会话不存在(对应 api-design §7 404)。"""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@dataclass
class SessionRecord:
session_id: str
user_id: str
status: str = "uploading"
files: dict = field(default_factory=dict) # file_type -> {file_id,name,size,path}
structured_summary: str = "" # 解析结果摘要(JSON 字符串)
impact_summary: str = "" # 影响调查摘要
qa_summary: str = "" # QA 报告 JSON
result_path: str = "" # 概要设计书 docx 路径
impact_report_path: str = ""
qa_report_path: str = ""
output_language: str = "auto"
created_at: str = ""
updated_at: str = ""
@property
def to_dict(self) -> dict:
return {
"session_id": self.session_id,
"user_id": self.user_id,
"status": self.status,
"files": self.files,
"structured_summary": self.structured_summary,
"impact_summary": self.impact_summary,
"qa_summary": self.qa_summary,
"result_path": self.result_path,
"impact_report_path": self.impact_report_path,
"qa_report_path": self.qa_report_path,
"output_language": self.output_language,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
class SessionStore:
"""SQLite 持久化的会话存储。
表结构:sessions(session_id TEXT PK, user_id, data TEXT) —— data 为整条
SessionRecord 的 JSON(简单可靠;会话量为小规模,无需列级查询)。
"""
def __init__(self, db_path: str = "data/server/sessions.db") -> None:
self._db = Path(db_path)
self._db.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self._db))
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._conn() as c:
c.execute(
"CREATE TABLE IF NOT EXISTS sessions ("
" session_id TEXT PRIMARY KEY,"
" user_id TEXT NOT NULL,"
" data TEXT NOT NULL)"
)
def create_session(self, user_id: str) -> SessionRecord:
rec = SessionRecord(
session_id=uuid.uuid4().hex[:12],
user_id=user_id,
created_at=_now(),
updated_at=_now(),
)
with self._conn() as c:
c.execute(
"INSERT INTO sessions (session_id, user_id, data) VALUES (?, ?, ?)",
(rec.session_id, rec.user_id, json.dumps(rec.to_dict, ensure_ascii=False)),
)
return rec
def get_session(self, session_id: str) -> SessionRecord:
with self._conn() as c:
row = c.execute(
"SELECT data FROM sessions WHERE session_id = ?", (session_id,)
).fetchone()
if row is None:
raise SessionNotFoundError(f"会话不存在: {session_id}")
return self._from_dict(json.loads(row["data"]))
def list_sessions(self, user_id: str) -> list[SessionRecord]:
with self._conn() as c:
rows = c.execute(
"SELECT data FROM sessions WHERE user_id = ?",
(user_id,),
).fetchall()
recs = [self._from_dict(json.loads(r["data"])) for r in rows]
# updated_at 在 JSON data 内,无法用 SQL 列排序 → 取回后按时间降序
recs.sort(key=lambda r: r.updated_at, reverse=True)
return recs
def update_status(self, session_id: str, status: str) -> SessionRecord:
rec = self.get_session(session_id)
rec.status = status
return self._persist(rec)
def update_session(self, session_id: str, **fields) -> SessionRecord:
"""按字段名更新会话(files/status/result_path 等任意 to_dict 键)。"""
rec = self.get_session(session_id)
allowed = set(SessionRecord.to_dict.fget.__annotations__) if hasattr(
SessionRecord.to_dict.fget, "__annotations__"
) else set(rec.to_dict.keys())
for k, v in fields.items():
if k in rec.to_dict:
setattr(rec, k, v)
return self._persist(rec)
def delete_session(self, session_id: str) -> bool:
with self._conn() as c:
cur = c.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
return cur.rowcount > 0
def _persist(self, rec: SessionRecord) -> SessionRecord:
rec.updated_at = _now()
with self._conn() as c:
c.execute(
"UPDATE sessions SET user_id = ?, data = ? WHERE session_id = ?",
(rec.user_id, json.dumps(rec.to_dict, ensure_ascii=False), rec.session_id),
)
return rec
@staticmethod
def _from_dict(d: dict) -> SessionRecord:
return SessionRecord(
session_id=d.get("session_id", ""),
user_id=d.get("user_id", ""),
status=d.get("status", "uploading"),
files=d.get("files", {}),
structured_summary=d.get("structured_summary", ""),
impact_summary=d.get("impact_summary", ""),
qa_summary=d.get("qa_summary", ""),
result_path=d.get("result_path", ""),
impact_report_path=d.get("impact_report_path", ""),
qa_report_path=d.get("qa_report_path", ""),
output_language=d.get("output_language", "auto"),
created_at=d.get("created_at", ""),
updated_at=d.get("updated_at", ""),
)
+281
View File
@@ -0,0 +1,281 @@
"""S4FastAPI 端点测试(server/app.pyhttpx 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")
# 无既有系统 → writingstart-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 按需 buildbuild 抛错 → 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
+237
View File
@@ -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)
# 无既有系统 → writingstart-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)
+81
View File
@@ -0,0 +1,81 @@
"""S2SQLite 会话存储测试(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"