diff --git a/README.md b/README.md index b527bef..7a91eaf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 8dc8aa1..b648613 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -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) | diff --git a/docs/design.md b/docs/design.md index 8486914..d647aee 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 全链路) diff --git a/pyproject.toml b/pyproject.toml index f7372bd..5e86658 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/scripts/serve.py b/scripts/serve.py new file mode 100644 index 0000000..18ace7a --- /dev/null +++ b/scripts/serve.py @@ -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() diff --git a/src/genesis/qa/qa_loop.py b/src/genesis/qa/qa_loop.py index c87e257..373a540 100644 --- a/src/genesis/qa/qa_loop.py +++ b/src/genesis/qa/qa_loop.py @@ -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] diff --git a/src/genesis/server/__init__.py b/src/genesis/server/__init__.py new file mode 100644 index 0000000..5592750 --- /dev/null +++ b/src/genesis/server/__init__.py @@ -0,0 +1 @@ +"""Web 服务化包(S2-S4)。""" diff --git a/src/genesis/server/app.py b/src/genesis/server/app.py new file mode 100644 index 0000000..1da4fb0 --- /dev/null +++ b/src/genesis/server/app.py @@ -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 "Genesis Web UI" + + 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() diff --git a/src/genesis/server/service.py b/src/genesis/server/service.py new file mode 100644 index 0000000..e32bd2a --- /dev/null +++ b/src/genesis/server/service.py @@ -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 = [""] + for p in doc.paragraphs: + if p.text.strip(): + parts.append(f"

{html.escape(p.text)}

") + for tbl in doc.tables: + parts.append("") + for row in tbl.rows: + parts.append("" + "".join(f"" for c in row.cells) + "") + parts.append("
{html.escape(c.text)}
") + parts.append("") + 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 diff --git a/src/genesis/server/static/index.html b/src/genesis/server/static/index.html new file mode 100644 index 0000000..b2c470b --- /dev/null +++ b/src/genesis/server/static/index.html @@ -0,0 +1,139 @@ + + + + +Genesis — 概要设计书自动生成 + + + +

Genesis — 概要设计书自动生成 Agent

+
+ + +
+ +
+

1. 上传输入资料

+
+
+
+
+
+
+
+ +
+
+ +
+

2. 解析与确认

+ + + + + + +
+ +
+

3. 生成与 QA

+ + + +
+ +
+

4. 结果

+ + + + +
+
+ +
+
未创建会话
+
+
+ + + + diff --git a/src/genesis/server/store.py b/src/genesis/server/store.py new file mode 100644 index 0000000..ec69a7a --- /dev/null +++ b/src/genesis/server/store.py @@ -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", ""), + ) diff --git a/tests/test_server_api.py b/tests/test_server_api.py new file mode 100644 index 0000000..c775061 --- /dev/null +++ b/tests/test_server_api.py @@ -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 diff --git a/tests/test_server_service.py b/tests/test_server_service.py new file mode 100644 index 0000000..f793403 --- /dev/null +++ b/tests/test_server_service.py @@ -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 " 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 "