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("| {html.escape(c.text)} | " for c in row.cells) + "