From 838a93720e305c7ab1678cbbea51195560782698 Mon Sep 17 00:00:00 2001 From: lhl Date: Thu, 27 Aug 2026 05:11:40 +0800 Subject: [PATCH] =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=BC=8F=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E6=94=B9=E9=80=A0=EF=BC=88Web=20UI=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端由分步表单页升级为 DeepSeek 式聊天页(static/chat.html) - 新增 chat 包:意图识别(intent.py)+ ChatAgent(agent.py)自动驱动 解析→影响→生成→QA 整条工作流,影响确认节点反问、错误分支兜底 - store.py 扩展 pending_intent 字段与 chat_messages 表 - app.py 新增 POST/GET /api/chat/{sid}/messages,GET / 返回聊天页 - README/design.md 同步聊天模式说明;测试 4 文件覆盖 - 全量 pytest 522 passed / 99.03%,覆盖率红线达标 --- README.md | 22 +- _AI_USAGE_LOG.md | 1 + docs/design.md | 17 + src/genesis/chat/__init__.py | 1 + src/genesis/chat/agent.py | 254 +++++++++++++++ src/genesis/chat/intent.py | 102 ++++++ src/genesis/server/app.py | 30 +- src/genesis/server/static/chat.html | 168 ++++++++++ src/genesis/server/static/index.html | 139 -------- src/genesis/server/store.py | 36 +++ tests/test_chat_agent.py | 454 +++++++++++++++++++++++++++ tests/test_chat_intent.py | 66 ++++ tests/test_server_chat_api.py | 97 ++++++ tests/test_server_chat_store.py | 38 +++ 14 files changed, 1275 insertions(+), 150 deletions(-) create mode 100644 src/genesis/chat/__init__.py create mode 100644 src/genesis/chat/agent.py create mode 100644 src/genesis/chat/intent.py create mode 100644 src/genesis/server/static/chat.html delete mode 100644 src/genesis/server/static/index.html create mode 100644 tests/test_chat_agent.py create mode 100644 tests/test_chat_intent.py create mode 100644 tests/test_server_chat_api.py create mode 100644 tests/test_server_chat_store.py diff --git a/README.md b/README.md index 7a91eaf..a848393 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Genesis 是一款 **Agent 开发实战赛赛道一作品**:以多 Agent 协作 ## 效果总结(核心指标摘要) -- **测试**:431 个单元/集成测试全绿,代码覆盖率 **99.15%**(红线 ≥99%) +- **测试**:522 个单元/集成测试全绿,代码覆盖率 **99.0%+**(红线 ≥99%) - **端到端**:真实 LLM 双语试运行通过(中文模板 + `--output-language zh` → 7 章;日文模板 → 7 章),程序化扫描确认正文无中日混杂 - **真实样本**:7 个脱敏样本(新规/追加改修/混合/自由记述等)驱动解析与生成验证 - **影响调查基线**:追加改修样本 total=16(new=5 / modified=8 / deleted=3 / unchanged=50 / warnings=0) @@ -123,10 +123,10 @@ python scripts/run_trial.py ` **中文场景注意**:若规则文档(作成说明书/记入规则)仍为日文,`auto` 推导会倾向日文——请显式 `--output-language zh`,不要依赖 auto。 -## Web 服务(交互界面,成果物 03) +## Web 服务(聊天式交互,成果物 03) -Genesis 提供 **FastAPI Web 服务 + 内嵌零构建前端**(上传 → 解析确认 → 影响确认 → 生成 → QA → 预览/下载), -会话/文件/结果持久化到 SQLite(`data/server/`)。 +Genesis 提供 **FastAPI Web 服务 + 内嵌零构建聊天前端**(DeepSeek 式对话页面):用户用自然语言下达指令, +后台自动驱动「解析 →(影响调查)→ 生成 → QA」整条工作流;影响调查完成时会先反问确认,确认后继续生成。 ```powershell # 离线 Fake 引擎(无需 API key,适合演示) @@ -136,11 +136,15 @@ python scripts/serve.py --fake 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` +- 访问 `http://127.0.0.1:8000/` 进入聊天页面;API 文档 `http://127.0.0.1:8000/docs` +- 聊天页面支持:📎 上传要件定义/模板/规则/既有系统 zip、自然语言指令(`生成概要设计书` / `用中文生成` / + `现在什么状态?` / `做影响调查` / `运行QA校验`)、影响确认反问(`确认,继续` / `打回`)、结果下载与预览 +- 主要 API 端点: + - 会话/文件:`POST /api/sessions`、`POST /api/sessions/{id}/files`(multipart,type=requirements/template/write_instruction/rules/existing_system) + - 聊天驱动:`POST /api/chat/{id}/messages`(body `{"content":"..."}`)、`GET /api/chat/{id}/messages`(历史) + - 分步端点(聊天底层复用):`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 阶段黑盒冒烟 diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index b648613..88a0517 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -122,3 +122,4 @@ | 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) | +| 2026-08-26 09:30 | Agent 实现 | 聊天式交互改造(Web UI 升级):前端由分步表单页 static/index.html 升级为 DeepSeek 式聊天页 static/chat.html;新增 src/genesis/chat/(intent.py 意图识别 INTENT_SCHEMA + parse_intent_fake/llm、agent.py ChatAgent.handle_message 自动驱动 解析→影响→生成→QA、确认节点处理、错误分支兜底)+ store.py 扩展 pending_intent 字段与 chat_messages 表 + app.py 新增 POST/GET /api/chat/{sid}/messages 且 GET / 返回聊天页;README 更新 Web 服务为聊天式说明 + 端点列表含聊天端点;design.md §12.6 补聊天改造记录;全量 pytest 522 passed / 99.03%,fail_under=99 达标 | src/genesis/chat/__init__.py; src/genesis/chat/intent.py; src/genesis/chat/agent.py; src/genesis/server/store.py; src/genesis/server/app.py; src/genesis/server/static/chat.html; src/genesis/server/static/index.html; scripts/serve.py; README.md; docs/design.md; tests/test_server_chat_store.py; tests/test_chat_intent.py; tests/test_chat_agent.py; tests/test_server_chat_api.py | x-preview-f-free (opencode) | diff --git a/docs/design.md b/docs/design.md index d647aee..e67228a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1871,3 +1871,20 @@ Document(注入后 Word 文档) - **与 api-design 的偏差(诚实标注)**:v1 采用**进程内同步执行**(非"异步启动+轮询"); 既有系统以 zip 上传;WebSocket 事件通道未实现(v1 范围外)。样本规模小,同步可接受。 - 测试:`tests/test_server_store.py` / `test_server_service.py` / `test_server_api.py`(TestClient 全链路) + +### 12.6 聊天式交互改造(2026-08,Web UI 升级) + +将 Web 前端由分步表单页(`static/index.html`)升级为 **DeepSeek 式聊天页**(`static/chat.html`): +用户用自然语言下达指令,后台 `ChatAgent` 自动驱动「解析 →(影响调查)→ 生成 → QA」整条工作流。 + +新增模块: +- `src/genesis/chat/intent.py` — 意图识别:`INTENT_SCHEMA`(动作 = parse/impact/generate/qa/status/confirm/reject/unknown) + 与 `parse_intent_fake`(规则兜底)/ `parse_intent_llm`(真实模式,引擎 `chat_structured` 结构化抽取) +- `src/genesis/chat/agent.py` — `ChatAgent.handle_message`: + - 处于 `awaiting_impact_confirm` 时,将用户回复作为**确认节点**(确认/打回)处理 + - 否则解析意图并分发;`generate` 自动推进前置步骤(解析→确认→影响→反问),影响完成先反问、记住 pending 意图 + - 错误分支(解析/生成/QA/影响确认失败)均以友好回复兜底,绝不抛出未捕获异常 +- `store.py` 扩展:会话增 `pending_intent` 字段;新增 `chat_messages` 表与 `add_message`/`list_messages` +- `app.py` 新增 `POST /api/chat/{sid}/messages`、`GET /api/chat/{sid}/messages`;`GET /` 改为返回聊天页 +- 测试:`tests/test_chat_intent.py` / `tests/test_chat_agent.py` / `tests/test_server_chat_api.py`(TestClient 全链路) +- 真实黑盒冒烟建议:用 `python scripts/serve.py` 部署后,从聊天页用中文下达「上传了文件,生成概要设计书」并确认影响即可走通全程。 diff --git a/src/genesis/chat/__init__.py b/src/genesis/chat/__init__.py new file mode 100644 index 0000000..ede4e08 --- /dev/null +++ b/src/genesis/chat/__init__.py @@ -0,0 +1 @@ +"""聊天 Agent 包(C2-C3)。""" diff --git a/src/genesis/chat/agent.py b/src/genesis/chat/agent.py new file mode 100644 index 0000000..f914ae2 --- /dev/null +++ b/src/genesis/chat/agent.py @@ -0,0 +1,254 @@ +"""聊天 Agent(C3)。 + +自然语言驱动后台工作流:用户消息 → 意图解析 → 执行/推进 GenesisService 流程 → +返回 {reply, progress, status}。关键确认节点(影响调查结果)在聊天中反问,确认后继续。 +""" +from __future__ import annotations + +import json + +from genesis.chat.intent import parse_intent_fake, parse_intent_llm +from genesis.server.service import GenesisService + + +class ChatAgent: + def __init__(self, service: GenesisService, fake: bool = False, engine=None) -> None: + self.service = service + self.store = service.store + self.fake = fake + self.engine = engine or service.engine + + # ---------- 主入口 ---------- + + def handle_message(self, session_id: str, content: str) -> dict: + self.store.add_message(session_id, "user", content) + rec = self.store.get_session(session_id) + progress: list[dict] = [] + + # 影响调查确认节点:先处理确认/打回 + if rec.status == "awaiting_impact_confirm": + return self._handle_confirmation(session_id, content, progress) + + intent = self._parse_intent(session_id, content, rec) + result = self._dispatch(session_id, intent, progress) + reply, status = result["reply"], result["status"] + self.store.add_message(session_id, "assistant", reply, action=intent.action) + return {"reply": reply, "progress": result["progress"], "status": status} + + # ---------- 意图解析 ---------- + + def _parse_intent(self, session_id, content, rec): + if self.fake: + return parse_intent_fake(content) + context = f"当前会话状态: {rec.status}; 已上传文件: {list(rec.files.keys())}" + return parse_intent_llm(self.engine, session_id, content, context) + + # ---------- 确认节点 ---------- + + def _handle_confirmation(self, session_id, content, progress) -> dict: + intent = self._parse_intent(session_id, content, self.store.get_session(session_id)) + if intent.action == "reject": + # 打回:回到影响调查中(简单实现:状态回 impact_running,可重新 start-impact) + self.store.update_status(session_id, "impact_running") + self.store.update_session(session_id, pending_intent="") + reply = "已打回影响调查。可以回复「重新影响调查」或「开始解析」重来。" + self.store.add_message(session_id, "assistant", reply, action="reject") + return {"reply": reply, "progress": progress, "status": "impact_running"} + if intent.action == "confirm": + try: + self.service.confirm_impact(session_id) + progress.append({"step": "impact", "status": "ok", "detail": "影响调查已确认"}) + except Exception as e: # noqa: BLE001 + reply = f"影响确认失败:{e}" + self.store.add_message(session_id, "assistant", reply, action="confirm") + return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"} + pending = self.store.get_session(session_id).pending_intent + if pending == "generate": + self.store.update_session(session_id, pending_intent="") + return self._run_generate(session_id, self.store.get_session(session_id), progress) + reply = "已确认影响调查。可以回复「生成概要设计书」继续。" + self.store.add_message(session_id, "assistant", reply, action="confirm") + return {"reply": reply, "progress": progress, "status": "writing"} + reply = "影响调查结果待确认,请回复「确认,继续」或「打回」。" + self.store.add_message(session_id, "assistant", reply, action="unknown") + return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"} + + # ---------- 分发 ---------- + + def _dispatch(self, session_id, intent, progress): + rec = self.store.get_session(session_id) + action = intent.action + if action == "generate": + return self._auto_generate(session_id, intent.params.get("output_language", "auto"), progress) + if action == "parse": + return self._run_parse(session_id, progress) + if action == "impact": + return self._run_impact(session_id, progress) + if action == "qa": + return self._run_qa(session_id, progress) + if action == "status": + return self._status_reply(rec, progress) + if action == "confirm": + reply = "当前没有待确认的事项。可以回复「生成概要设计书」开始。" + self.store.add_message(session_id, "assistant", reply, action="confirm") + return {"reply": reply, "progress": progress, "status": rec.status} + return self._help_reply(rec, progress) + + # ---------- 流程步骤 ---------- + + def _auto_generate(self, session_id, output_language, progress): + """生成 = 自动推进 解析→确认→(影响→反问)→生成→QA。""" + rec = self.store.get_session(session_id) + if "requirements" not in rec.files or "template" not in rec.files: + reply = "请先上传要件定义 Excel 与概要设计模板 docx(聊天框上方附件按钮)。" + self.store.add_message(session_id, "assistant", reply, action="generate") + return {"reply": reply, "progress": progress, "status": rec.status} + if rec.status == "uploading": + try: + rec = self._parse_and_confirm(session_id, progress) + except Exception as e: # noqa: BLE001 + reply = f"解析失败:{e}" + self.store.add_message(session_id, "assistant", reply, action="generate") + return {"reply": reply, "progress": progress, "status": rec.status} + if rec.status == "writing": + # 无既有系统 → 直接生成 + return self._run_generate(session_id, rec, progress, output_language=output_language) + if rec.status == "awaiting_impact_confirm": + # 有既有系统 → 影响完成,反问确认,记住 pending generate + self.store.update_session(session_id, pending_intent="generate") + reply = f"影响调查已完成:{self._impact_brief(rec)}。是否确认后继续生成概要设计书?(回复「确认,继续」)" + self.store.add_message(session_id, "assistant", reply, action="impact") + return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"} + return self._status_reply(rec, progress) + + def _parse_and_confirm(self, session_id, progress): + """解析并推进:run_parse → confirm_parse;有既有系统则 run_impact → 反问。""" + rec = self.service.run_parse(session_id) + summary = json.loads(rec.structured_summary) if rec.structured_summary else {} + progress.append({"step": "parse", "status": "ok", + "detail": f"解析完成:{summary.get('tables', 0)} 张表"}) + rec = self.service.confirm_parse(session_id) + if rec.status == "impact_running": + rec = self.service.run_impact(session_id) + progress.append({"step": "impact", "status": "ok", + "detail": f"影响调查完成:{self._impact_brief(rec)}"}) + return self.store.get_session(session_id) # awaiting_impact_confirm + return rec # writing + + def _run_parse(self, session_id, progress): + rec = self.store.get_session(session_id) + if "requirements" not in rec.files or "template" not in rec.files: + reply = "请先上传要件定义 Excel 与模板 docx。" + self.store.add_message(session_id, "assistant", reply, action="parse") + return {"reply": reply, "progress": progress, "status": rec.status} + try: + rec = self._parse_and_confirm(session_id, progress) + except Exception as e: # noqa: BLE001 + reply = f"解析失败:{e}" + self.store.add_message(session_id, "assistant", reply, action="parse") + return {"reply": reply, "progress": progress, "status": rec.status} + if rec.status == "awaiting_impact_confirm": + reply = f"解析与影响调查已完成:{self._impact_brief(rec)}。是否确认?(回复「确认,继续」)" + self.store.add_message(session_id, "assistant", reply, action="impact") + return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"} + reply = "解析完成。可以回复「生成概要设计书」继续,或查看结果。" + self.store.add_message(session_id, "assistant", reply, action="parse") + return {"reply": reply, "progress": progress, "status": rec.status} + + def _run_impact(self, session_id, progress): + rec = self.store.get_session(session_id) + if "existing_system" not in rec.files: + reply = "未提供既有系统(zip),影响调查跳过。可直接回复「生成概要设计书」。" + self.store.add_message(session_id, "assistant", reply, action="impact") + return {"reply": reply, "progress": progress, "status": rec.status} + # 未解析则先解析并确认(自然语言触发应自动推进前置步骤) + if rec.status == "uploading": + try: + rec = self._parse_and_confirm(session_id, progress) + except Exception as e: # noqa: BLE001 + reply = f"解析失败:{e}" + self.store.add_message(session_id, "assistant", reply, action="impact") + return {"reply": reply, "progress": progress, "status": rec.status} + # _parse_and_confirm 已自动跑影响 → 直接返回反问 + if rec.status == "awaiting_impact_confirm": + reply = f"影响调查完成:{self._impact_brief(rec)}。是否确认?(回复「确认,继续」)" + self.store.add_message(session_id, "assistant", reply, action="impact") + return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"} + if rec.status == "awaiting_parse_confirm": + rec = self.service.confirm_parse(session_id) + if rec.status != "impact_running": + reply = "当前状态无法进行影响调查。" + self.store.add_message(session_id, "assistant", reply, action="impact") + return {"reply": reply, "progress": progress, "status": rec.status} + rec = self.service.run_impact(session_id) + progress.append({"step": "impact", "status": "ok", + "detail": f"影响调查完成:{self._impact_brief(rec)}"}) + reply = f"影响调查完成:{self._impact_brief(rec)}。是否确认?(回复「确认,继续」)" + self.store.add_message(session_id, "assistant", reply, action="impact") + return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"} + + def _run_generate(self, session_id, rec, progress, output_language: str = "auto") -> dict: + try: + if rec.status == "awaiting_impact_confirm": + self.service.confirm_impact(session_id) + rec = self.service.run_generate(session_id, output_language=output_language) + except Exception as e: # noqa: BLE001 + reply = f"生成失败:{e}" + self.store.add_message(session_id, "assistant", reply, action="generate") + return {"reply": reply, "progress": progress, "status": rec.status} + progress.append({"step": "generate", "status": "ok", "detail": "概要设计书已生成"}) + try: + rec = self.service.run_qa(session_id) + progress.append({"step": "qa", "status": "ok", + "detail": f"QA: 通过={rec.qa_summary}"}) + except Exception as e: # noqa: BLE001 + rec = self.store.get_session(session_id) + progress.append({"step": "qa", "status": "warn", "detail": f"QA 未执行:{e}"}) + reply = "概要设计书生成完成,QA 已执行。点击下方「下载 docx」获取结果。" + self.store.add_message(session_id, "assistant", reply, action="generate") + return {"reply": reply, "progress": progress, "status": rec.status} + + def _run_qa(self, session_id, progress): + rec = self.store.get_session(session_id) + if rec.status not in ("writing", "done") or not rec.result_path: + reply = "尚未生成结果,无法运行 QA。先回复「生成概要设计书」。" + self.store.add_message(session_id, "assistant", reply, action="qa") + return {"reply": reply, "progress": progress, "status": rec.status} + if rec.status == "done": + # 允许对已完成结果重新执行 QA 校验 + self.store.update_status(session_id, "writing") + try: + rec = self.service.run_qa(session_id) + progress.append({"step": "qa", "status": "ok", "detail": "QA 完成"}) + reply = f"QA 完成:{rec.qa_summary}" + self.store.add_message(session_id, "assistant", reply, action="qa") + return {"reply": reply, "progress": progress, "status": rec.status} + except Exception as e: # noqa: BLE001 + rec = self.store.get_session(session_id) + reply = f"QA 未执行:{e}" + self.store.add_message(session_id, "assistant", reply, action="qa") + return {"reply": reply, "progress": progress, "status": rec.status} + + def _status_reply(self, rec, progress): + reply = f"当前状态:{rec.status}。已上传:{list(rec.files.keys()) or '无'}。" + if rec.result_path: + reply += " 结果已生成,可下载。" + self.store.add_message(session_id := rec.session_id, "assistant", reply, action="status") + return {"reply": reply, "progress": progress, "status": rec.status} + + def _help_reply(self, rec, progress): + reply = ("我可以帮你完成概要设计书生成。试试说:\n" + "· 「生成概要设计书」(自动解析→影响→生成→QA)\n" + "· 「开始解析」「做影响调查」「运行QA校验」\n" + "· 「现在什么状态」「用中文生成」") + self.store.add_message(rec.session_id, "assistant", reply, action="unknown") + return {"reply": reply, "progress": progress, "status": rec.status} + + @staticmethod + def _impact_brief(rec) -> str: + try: + d = json.loads(rec.impact_summary) if rec.impact_summary else {} + s = d.get("summary", {}) + return f"新增{s.get('new', 0)}/变更{s.get('modified', 0)}/删除{s.get('deleted', 0)}/未变化{s.get('unchanged', 0)}" + except Exception: # noqa: BLE001 + return "已完成" diff --git a/src/genesis/chat/intent.py b/src/genesis/chat/intent.py new file mode 100644 index 0000000..fbcccb7 --- /dev/null +++ b/src/genesis/chat/intent.py @@ -0,0 +1,102 @@ +"""聊天意图解析(C2)。 + +真实模式:LLM chat_structured 按 INTENT_SCHEMA 输出 {action, params}。 +--fake 模式:parse_intent_fake 关键词规则解析(离线确定性)。 + +动作集: + parse / impact / generate / qa / status / confirm / reject / unknown +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +INTENT_SCHEMA: dict = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["parse", "impact", "generate", "qa", "status", "confirm", "reject", "unknown"], + }, + "params": { + "type": "object", + "properties": { + "output_language": {"type": "string", "enum": ["auto", "zh", "ja"]}, + "chapter_id": {"type": "string"}, + }, + }, + }, + "required": ["action"], +} + +INTENT_PROMPT = ( + "你是概要设计书生成 Agent 的对话意图识别器。" + "根据用户最新一条消息,判断用户想执行的操作,只输出 JSON:\n" + " parse=开始解析要件定义;impact=影响调查;generate=生成概要设计书;\n" + " qa=运行QA校验;status=查询当前状态;confirm=确认(影响调查等确认节点);\n" + " reject=打回/拒绝;unknown=与上述无关。\n" + "params.output_language:消息提到中文→zh,日文→ja,否则 auto。" +) + + +@dataclass +class Intent: + action: str + params: dict = field(default_factory=dict) + + +# ---------- --fake 规则解析 ---------- + +_CONFIRM_RE = re.compile(r"确认|可以|继续|没问题|好的|OK|はい|同意") +_REJECT_RE = re.compile(r"打回|拒绝|不对|重来|重新|不要|取消") +_LANG_ZH_RE = re.compile(r"中文|汉语|zh|简体") +_LANG_JA_RE = re.compile(r"日本語|日文|ja|日本语") +_GENERATE_RE = re.compile(r"生成|开始写|出设计书|写设计书|产出") +_PARSE_RE = re.compile(r"解析|读取资料|分析资料") +_IMPACT_RE = re.compile(r"影响|调查|关联分析|变更影响") +_QA_RE = re.compile(r"QA|校验|质检|检查质量|质量验证") +_STATUS_RE = re.compile(r"状态|进度|现在到哪|当前情况") + + +def parse_intent_fake(message: str) -> Intent: + """离线规则意图解析(--fake 模式,确定性)。""" + text = message.strip() + params: dict = {} + if _LANG_ZH_RE.search(text): + params["output_language"] = "zh" + elif _LANG_JA_RE.search(text): + params["output_language"] = "ja" + + if _CONFIRM_RE.search(text) and not _REJECT_RE.search(text): + return Intent("confirm", params) + if _REJECT_RE.search(text): + return Intent("reject", params) + if _GENERATE_RE.search(text): + return Intent("generate", params) + if _IMPACT_RE.search(text) and not _PARSE_RE.search(text): + return Intent("impact", params) + if _PARSE_RE.search(text): + return Intent("parse", params) + if _QA_RE.search(text): + return Intent("qa", params) + if _STATUS_RE.search(text): + return Intent("status", params) + return Intent("unknown", params) + + +def parse_intent_llm(engine, session_id: str, user_message: str, context: str) -> Intent: + """真实模式:LLM 输出动作 JSON。""" + from genesis.inference.types import Prompt + + prompt = Prompt(name="chat.intent", version="1", template=INTENT_PROMPT) + result = engine.chat_structured( + session_id=session_id, + prompt=prompt, + variables={"user_message": user_message, "context": context}, + schema=INTENT_SCHEMA, + retry_count=1, + ) + if result.status in ("ok", "fallback"): + data = result.data or {} + return Intent(action=data.get("action", "unknown"), params=data.get("params", {})) + return Intent("unknown", {}) diff --git a/src/genesis/server/app.py b/src/genesis/server/app.py index 1da4fb0..d6c4161 100644 --- a/src/genesis/server/app.py +++ b/src/genesis/server/app.py @@ -41,6 +41,10 @@ class _FakeEngine: ) +class ChatMessageReq(BaseModel): + content: str + + class SessionCreate(BaseModel): user_id: str = "default" @@ -65,6 +69,7 @@ def create_app( engine: Any = None, ) -> FastAPI: store = store or SessionStore() + is_fake = engine == "fake" if engine == "fake": engine = _FakeEngine() elif engine is None: @@ -72,8 +77,11 @@ def create_app( service = GenesisService(store=store, data_root=data_root, engine=engine) + from genesis.chat.agent import ChatAgent + chat_agent = ChatAgent(service=service, fake=is_fake, 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" + chat_html = (static_dir / "chat.html").read_text(encoding="utf-8") if (static_dir / "chat.html").exists() else "Genesis Chat" app = FastAPI(title="Genesis API", version=VERSION) @@ -85,7 +93,7 @@ def create_app( @app.get("/", response_class=HTMLResponse) def index(): - return index_html + return chat_html # ---------- 会话 ---------- @@ -232,6 +240,24 @@ def create_app( raise _error(404, "RESULT_NOT_FOUND", "QA 报告不存在") return FileResponse(rec.qa_report_path, media_type="application/json", filename="qa-report.json") + # ---------- 聊天 ---------- + + @app.post("/api/chat/{sid}/messages") + def chat_message(sid: str, body: ChatMessageReq): + try: + result = chat_agent.handle_message(sid, body.content) + except SessionNotFoundError: + raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}") + return result + + @app.get("/api/chat/{sid}/messages") + def chat_history(sid: str): + try: + service.get_session(sid) + except SessionNotFoundError: + raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}") + return service.store.list_messages(sid) + return app diff --git a/src/genesis/server/static/chat.html b/src/genesis/server/static/chat.html new file mode 100644 index 0000000..3eaccba --- /dev/null +++ b/src/genesis/server/static/chat.html @@ -0,0 +1,168 @@ + + + + +Genesis — 概要设计书自动生成(对话) + + + +
+

Genesis 概要设计书 Agent

+ 未创建会话 + +
+ +
+ +
+
+ 📎 上传资料: + + + + +
+
+ +
+
+ + +
+
+ + + + diff --git a/src/genesis/server/static/index.html b/src/genesis/server/static/index.html deleted file mode 100644 index b2c470b..0000000 --- a/src/genesis/server/static/index.html +++ /dev/null @@ -1,139 +0,0 @@ - - - - -Genesis — 概要设计书自动生成 - - - -

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

-
- - -
- -
-

1. 上传输入资料

-
-
-
-
-
-
-
- -
-
- -
-

2. 解析与确认

- - - - - - -
- -
-

3. 生成与 QA

- - - -
- -
-

4. 结果

- - - - -
-
- -
-
未创建会话
-
-
- - - - diff --git a/src/genesis/server/store.py b/src/genesis/server/store.py index ec69a7a..7455bc9 100644 --- a/src/genesis/server/store.py +++ b/src/genesis/server/store.py @@ -34,6 +34,7 @@ class SessionRecord: impact_report_path: str = "" qa_report_path: str = "" output_language: str = "auto" + pending_intent: str = "" # 等待确认后继续的意图(如 "generate") created_at: str = "" updated_at: str = "" @@ -51,6 +52,7 @@ class SessionRecord: "impact_report_path": self.impact_report_path, "qa_report_path": self.qa_report_path, "output_language": self.output_language, + "pending_intent": self.pending_intent, "created_at": self.created_at, "updated_at": self.updated_at, } @@ -81,6 +83,39 @@ class SessionStore: " user_id TEXT NOT NULL," " data TEXT NOT NULL)" ) + c.execute( + "CREATE TABLE IF NOT EXISTS chat_messages (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " session_id TEXT NOT NULL," + " role TEXT NOT NULL," + " content TEXT NOT NULL," + " action TEXT," + " created_at TEXT NOT NULL)" + ) + c.execute( + "CREATE INDEX IF NOT EXISTS idx_messages_session ON chat_messages(session_id)" + ) + + # ---------- 聊天消息 ---------- + + def add_message(self, session_id: str, role: str, content: str, action: str | None = None) -> dict: + msg = {"role": role, "content": content, "action": action, "created_at": _now()} + with self._conn() as c: + c.execute( + "INSERT INTO chat_messages (session_id, role, content, action, created_at)" + " VALUES (?, ?, ?, ?, ?)", + (session_id, role, content, action, msg["created_at"]), + ) + return msg + + def list_messages(self, session_id: str) -> list[dict]: + with self._conn() as c: + rows = c.execute( + "SELECT role, content, action, created_at FROM chat_messages" + " WHERE session_id = ? ORDER BY id", + (session_id,), + ).fetchall() + return [dict(r) for r in rows] def create_session(self, user_id: str) -> SessionRecord: rec = SessionRecord( @@ -160,6 +195,7 @@ class SessionStore: impact_report_path=d.get("impact_report_path", ""), qa_report_path=d.get("qa_report_path", ""), output_language=d.get("output_language", "auto"), + pending_intent=d.get("pending_intent", ""), created_at=d.get("created_at", ""), updated_at=d.get("updated_at", ""), ) diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py new file mode 100644 index 0000000..29180a1 --- /dev/null +++ b/tests/test_chat_agent.py @@ -0,0 +1,454 @@ +"""C3:聊天 Agent 测试(chat/agent.py)——确认反问/自动推进。""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from genesis.chat.agent import ChatAgent +from genesis.server.service import GenesisService +from genesis.server.store import SessionStore + +_SAMPLE = Path(__file__).resolve().parents[1] / "sample" + + +class FakeEngine: + 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", + ) + + +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(agent, sid): + agent.service.upload_file(sid, "requirements", "requirements_newdev.xlsx", + (_SAMPLE / "requirements_newdev.xlsx").read_bytes()) + agent.service.upload_file(sid, "template", "template_design_ja.docx", + (_SAMPLE / "template_design_ja.docx").read_bytes()) + agent.service.upload_file(sid, "write_instruction", "rules_design_ja.docx", + (_SAMPLE / "rules_design_ja.docx").read_bytes()) + agent.service.upload_file(sid, "rules", "rules_entry_ja.docx", + (_SAMPLE / "rules_entry_ja.docx").read_bytes()) + + +def test_generate_full_flow_without_existing_system(tmp_path): + """无既有系统:一条"生成"消息 → 全流程完成(无确认打断)。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + result = agent.handle_message(sid, "请生成概要设计书") + assert result["status"] == "done" + assert any(p["step"] == "generate" and p["status"] == "ok" for p in result["progress"]) + assert any(p["step"] == "qa" for p in result["progress"]) + + +def test_generate_with_existing_system_asks_confirmation(tmp_path): + """有既有系统:影响调查完成后反问确认(awaiting_impact_confirm),确认后继续。""" + 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") + + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "existing.zip", buf.getvalue()) + + r1 = agent.handle_message(sid, "请生成概要设计书") + assert r1["status"] == "awaiting_impact_confirm" + assert any(p["step"] == "impact" for p in r1["progress"]) + assert "确认" in r1["reply"] + + r2 = agent.handle_message(sid, "确认,继续") + assert r2["status"] == "done" + steps = [p["step"] for p in r2["progress"]] + assert "generate" in steps and "qa" in steps + + +def test_confirm_without_pending_says_ok(tmp_path): + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + r = agent.handle_message(sid, "确认") + assert r["status"] == "uploading" + assert "确认" in r["reply"] + + +def test_status_reports_current_state(tmp_path): + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + r = agent.handle_message(sid, "现在什么状态?") + assert r["status"] == "uploading" + assert "uploading" in r["reply"] + + +def test_generate_without_files_hints_upload(tmp_path): + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + r = agent.handle_message(sid, "生成概要设计书") + assert r["status"] == "uploading" + assert "上传" in r["reply"] + + +def test_messages_persisted(tmp_path): + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + agent.handle_message(sid, "现在什么状态?") + msgs = agent.store.list_messages(sid) + assert msgs[0]["role"] == "user" + assert msgs[-1]["role"] == "assistant" + + +def test_reject_impact_returns_to_impact_running(tmp_path): + """影响确认节点:回复「打回」→ 回到 impact_running。""" + 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") + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", buf.getvalue()) + r1 = agent.handle_message(sid, "生成概要设计书") + assert r1["status"] == "awaiting_impact_confirm" + r2 = agent.handle_message(sid, "打回,重新影响调查") + assert r2["status"] == "impact_running" + + +def test_confirm_at_impact_node_continues_generate(tmp_path): + """影响节点单独确认(无 pending)→ 状态 writing。""" + 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") + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", buf.getvalue()) + r1 = agent.handle_message(sid, "做影响调查") + assert r1["status"] == "awaiting_impact_confirm" + r2 = agent.handle_message(sid, "确认") + assert r2["status"] == "writing" + + +def test_impact_action_without_zip_hints_generate(tmp_path): + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + r = agent.handle_message(sid, "做影响调查") + assert "跳过" in r["reply"] + + +def test_qa_without_result_hints_generate(tmp_path): + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + r = agent.handle_message(sid, "运行QA校验") + assert "尚未生成" in r["reply"] + + +def test_help_for_unknown_message(tmp_path): + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + r = agent.handle_message(sid, "今天天气怎么样") + assert r["status"] == "uploading" + assert "生成概要设计书" in r["reply"] + + +class IntentLLMEngine: + """模拟真实 LLM 意图解析:返回指定 action。""" + + def __init__(self, action, params=None): + self.action = action + self.params = params or {} + self.calls = 0 + + def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2): + self.calls += 1 + from types import SimpleNamespace + return SimpleNamespace(data={"action": self.action, "params": self.params}, + status="ok") + + +def test_llm_intent_mode_generate(tmp_path): + """真实模式(非 fake):LLM 判定 generate → 自动推进完成。""" + eng = IntentLLMEngine("generate") + agent = ChatAgent(_svc(tmp_path), fake=False, engine=eng) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + r = agent.handle_message(sid, "帮我生成设计书") + assert r["status"] == "done" + assert eng.calls >= 1 + + +def test_llm_intent_mode_unknown_falls_back(tmp_path): + eng = IntentLLMEngine("unknown") + agent = ChatAgent(_svc(tmp_path), fake=False, engine=eng) + sid = agent.service.create_session("u1").session_id + r = agent.handle_message(sid, "随便聊聊") + assert r["status"] == "uploading" + + +def test_generate_failure_reported(tmp_path): + """生成失败 → 回复失败信息(不崩溃)。""" + from genesis.server.service import ServiceStepError + + svc = _svc(tmp_path) + # 直接注入一个会在 run_generate 抛错的子类 + class BadGenerateService(type(svc)): + def run_generate(self, session_id, output_language="auto"): + raise ServiceStepError("生成内部错误") + + agent = ChatAgent(BadGenerateService(svc.store, data_root=str(svc.data_root), + engine=FakeEngine()), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + # 先正常解析到 writing + agent.service.run_parse(sid) + agent.service.confirm_parse(sid) + r = agent.handle_message(sid, "生成概要设计书") + assert "生成失败" in r["reply"] + + +def test_explicit_parse_action(tmp_path): + """自然语言「开始解析」→ 解析完成到 awaiting。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + r = agent.handle_message(sid, "开始解析") + assert r["status"] in ("awaiting_impact_confirm", "uploading", "writing", "awaiting_parse_confirm") + + +def test_parse_without_files_hints_upload(tmp_path): + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + r = agent.handle_message(sid, "开始解析") + assert "要件定义" in r["reply"] or "上传" in r["reply"] + + +def test_parse_failure_reported(tmp_path): + """解析失败 → 回复失败信息。""" + from genesis.server.service import ServiceStepError + svc = _svc(tmp_path) + + class BadParseService(type(svc)): + def run_parse(self, session_id): + raise ServiceStepError("解析内部错误") + + agent = ChatAgent(BadParseService(svc.store, data_root=str(svc.data_root), + engine=FakeEngine()), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + r = agent.handle_message(sid, "生成概要设计书") + assert "解析失败" in r["reply"] + + +def test_impact_confirm_node_unknown_reply(tmp_path): + """影响确认节点:非确认/打回的消息 → 提示再次确认。""" + 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") + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", buf.getvalue()) + agent.handle_message(sid, "生成概要设计书") # -> awaiting_impact_confirm + r = agent.handle_message(sid, "这个影响范围对吗") + assert "确认" in r["reply"] + + +def test_qa_runs_after_generate(tmp_path): + """生成后运行 QA → qa 完成。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.handle_message(sid, "生成概要设计书") + r = agent.handle_message(sid, "运行QA校验") + assert r["status"] == "done" + assert "QA 完成" in r["reply"] + + +def test_qa_failure_reported(tmp_path): + """QA 失败 → warn 提示但不崩溃。""" + from genesis.server.service import ServiceStepError + svc = _svc(tmp_path) + + class BadQaService(type(svc)): + def run_qa(self, session_id): + raise ServiceStepError("QA 内部错误") + + agent = ChatAgent(BadQaService(svc.store, data_root=str(svc.data_root), + engine=FakeEngine()), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.handle_message(sid, "生成概要设计书") + r = agent.handle_message(sid, "运行QA校验") + assert "QA 未执行" in r["reply"] + + +def test_status_reply_done_mentions_download(tmp_path): + """done 状态回复包含下载提示。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.handle_message(sid, "生成概要设计书") + r = agent.handle_message(sid, "现在什么状态?") + assert "下载" in r["reply"] + + +def _zip(): + import io + import zipfile + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("demo/X.java", "package demo; public class X {}") + return buf.getvalue() + + +def test_explicit_parse_when_already_done(tmp_path): + """已完成后「开始解析」→ 返回状态回复。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.handle_message(sid, "生成概要设计书") + r = agent.handle_message(sid, "开始解析") + assert r["status"] in ("writing", "done", "awaiting_parse_confirm") + + +def test_explicit_parse_failure_reported(tmp_path): + """显式「开始解析」时解析异常 → 回复解析失败。""" + from genesis.server.service import ServiceStepError + svc = _svc(tmp_path) + + class BadParse(type(svc)): + def run_parse(self, session_id): + raise ServiceStepError("解析错误") + + agent = ChatAgent(BadParse(svc.store, data_root=str(svc.data_root), + engine=FakeEngine()), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + r = agent.handle_message(sid, "开始解析") + assert "解析失败" in r["reply"] + + +def test_explicit_parse_with_zip_asks_confirm(tmp_path): + """解析(带 zip)完成 → 影响确认反问。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", _zip()) + r = agent.handle_message(sid, "开始解析") + assert r["status"] == "awaiting_impact_confirm" + + +def test_impact_with_parse_failure(tmp_path): + """「做影响调查」触发解析异常 → 回复解析失败。""" + from genesis.server.service import ServiceStepError + svc = _svc(tmp_path) + + class BadParse(type(svc)): + def run_parse(self, session_id): + raise ServiceStepError("解析错误") + + agent = ChatAgent(BadParse(svc.store, data_root=str(svc.data_root), + engine=FakeEngine()), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", _zip()) + r = agent.handle_message(sid, "做影响调查") + assert "解析失败" in r["reply"] + + +def test_impact_after_awaiting_parse_confirm(tmp_path): + """已解析待确认状态「做影响调查」→ 确认解析后进入影响确认。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", _zip()) + agent.handle_message(sid, "开始解析") # -> awaiting_parse_confirm + r = agent.handle_message(sid, "做影响调查") + assert r["status"] == "awaiting_impact_confirm" + + +def test_impact_when_already_writing_refused(tmp_path): + """已生成(writing)状态再「做影响调查」→ 提示无法。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", _zip()) + # 手动将状态置于 writing(已生成、不再处于影响环节) + agent.store.update_status(sid, "writing") + r = agent.handle_message(sid, "做影响调查") + assert "无法" in r["reply"] + + +def test_run_impact_from_impact_running_state(tmp_path): + """状态置于 impact_running 时「做影响调查」→ 直接执行影响调查。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", _zip()) + # 模拟已进入影响环节但尚未执行 + agent.store.update_status(sid, "impact_running") + r = agent.handle_message(sid, "做影响调查") + assert r["status"] == "awaiting_impact_confirm" + + +def test_confirm_impact_then_generate(tmp_path): + """影响确认节点「确认,继续」→ 自动生成完成。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", _zip()) + agent.handle_message(sid, "生成概要设计书") # -> awaiting_impact_confirm + r = agent.handle_message(sid, "确认,继续") + assert r["status"] == "done" + + +def test_confirm_impact_failure_reported(tmp_path): + """影响确认异常 → 回复失败信息。""" + from genesis.server.service import ServiceStepError + svc = _svc(tmp_path) + + class BadImpact(type(svc)): + def confirm_impact(self, session_id): + raise ServiceStepError("影响确认错误") + + agent = ChatAgent(BadImpact(svc.store, data_root=str(svc.data_root), + engine=FakeEngine()), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.service.upload_file(sid, "existing_system", "e.zip", _zip()) + agent.handle_message(sid, "生成概要设计书") # awaiting_impact_confirm + r = agent.handle_message(sid, "确认,继续") + assert "影响确认失败" in r["reply"] or "失败" in r["reply"] + + +def test_status_reply_result_without_impact(tmp_path): + """结果存在但无影响摘要 → 状态回复不崩溃。""" + agent = ChatAgent(_svc(tmp_path), fake=True) + sid = agent.service.create_session("u1").session_id + _upload_core(agent, sid) + agent.handle_message(sid, "生成概要设计书") + agent.store.update_session(sid, impact_summary=None) + r = agent.handle_message(sid, "现在什么状态?") + assert "下载" in r["reply"] diff --git a/tests/test_chat_intent.py b/tests/test_chat_intent.py new file mode 100644 index 0000000..2136d9b --- /dev/null +++ b/tests/test_chat_intent.py @@ -0,0 +1,66 @@ +"""C2:意图解析测试(chat/intent.py)。""" +from __future__ import annotations + +from genesis.chat.intent import INTENT_SCHEMA, parse_intent_fake, Intent + + +def test_schema_defines_actions(): + enums = INTENT_SCHEMA["properties"]["action"]["enum"] + assert {"parse", "impact", "generate", "qa", "status", "confirm", "reject", "unknown"} <= set(enums) + + +def test_parse_generate_keywords(): + assert parse_intent_fake("请生成概要设计书").action == "generate" + assert parse_intent_fake("开始生成吧").action == "generate" + assert parse_intent_fake("生成中文设计书").params.get("output_language") == "zh" + + +def test_parse_parse_and_impact(): + assert parse_intent_fake("开始解析").action == "parse" + assert parse_intent_fake("做影响调查").action == "impact" + + +def test_parse_qa_status(): + assert parse_intent_fake("运行QA校验").action == "qa" + assert parse_intent_fake("现在什么状态").action == "status" + + +def test_parse_confirm_reject(): + assert parse_intent_fake("确认").action == "confirm" + assert parse_intent_fake("可以,继续").action == "confirm" + assert parse_intent_fake("打回,重新影响调查").action == "reject" + + +def test_parse_unknown(): + it = parse_intent_fake("今天天气怎么样") + assert it.action == "unknown" + + +def test_intent_language_detection_zh_ja(): + assert parse_intent_fake("用中文生成").params.get("output_language") == "zh" + assert parse_intent_fake("日本語で生成して").params.get("output_language") == "ja" + + +class _Engine: + def __init__(self, data, status="ok"): + self.data = data + self.status = status + + def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2): + from types import SimpleNamespace + return SimpleNamespace(data=self.data, status=self.status) + + +def test_parse_intent_llm_ok(): + from genesis.chat.intent import parse_intent_llm + eng = _Engine({"action": "generate", "params": {"output_language": "zh"}}) + it = parse_intent_llm(eng, "s1", "生成", "context") + assert it.action == "generate" + assert it.params.get("output_language") == "zh" + + +def test_parse_intent_llm_failed_falls_to_unknown(): + from genesis.chat.intent import parse_intent_llm + eng = _Engine({}, status="failed") + it = parse_intent_llm(eng, "s1", "生成", "context") + assert it.action == "unknown" diff --git a/tests/test_server_chat_api.py b/tests/test_server_chat_api.py new file mode 100644 index 0000000..dd71938 --- /dev/null +++ b/tests/test_server_chat_api.py @@ -0,0 +1,97 @@ +"""C4:聊天 API 端点测试(app.py chat 端点 + GET / 指向聊天页)。""" +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_root_serves_chat_page(client): + r = client.get("/") + assert r.status_code == 200 + assert "chat" in r.text.lower() or "message" in r.text.lower() + + +def test_chat_message_flow_generate(client): + """聊天消息:上传后「生成概要设计书」→ 全流程完成。""" + sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"] + _upload_core(client, sid) + r = client.post(f"/api/chat/{sid}/messages", json={"content": "请生成概要设计书"}) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "done" + assert any(p["step"] == "generate" for p in body["progress"]) + + +def test_chat_message_asks_confirmation_with_zip(tmp_path): + """有既有系统 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") + + 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}/files", data={"file_type": "existing_system"}, + files={"file": ("e.zip", buf.getvalue())}) + + r1 = client.post(f"/api/chat/{sid}/messages", json={"content": "生成概要设计书"}) + assert r1.json()["status"] == "awaiting_impact_confirm" + + r2 = client.post(f"/api/chat/{sid}/messages", json={"content": "确认,继续"}) + assert r2.json()["status"] == "done" + + +def test_chat_history_endpoint(client): + sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"] + client.post(f"/api/chat/{sid}/messages", json={"content": "现在什么状态?"}) + r = client.get(f"/api/chat/{sid}/messages") + assert r.status_code == 200 + msgs = r.json() + assert len(msgs) >= 2 + assert msgs[0]["role"] == "user" + + +def test_chat_message_unknown_session_404(client): + r = client.post("/api/chat/nope/messages", json={"content": "hi"}) + assert r.status_code == 404 + + +def test_chat_history_unknown_session_404(client): + r = client.get("/api/chat/nope/messages") + assert r.status_code == 404 diff --git a/tests/test_server_chat_store.py b/tests/test_server_chat_store.py new file mode 100644 index 0000000..aa7c560 --- /dev/null +++ b/tests/test_server_chat_store.py @@ -0,0 +1,38 @@ +"""C1:聊天消息存储 + pending_intent 测试(store.py 扩展)。""" +from __future__ import annotations + +from genesis.server.store import SessionStore + + +def test_add_and_list_messages(tmp_path): + store = SessionStore(db_path=str(tmp_path / "s.db")) + sid = store.create_session("u1").session_id + store.add_message(sid, "user", "生成概要设计书", action="generate") + store.add_message(sid, "assistant", "影响调查完成,请确认", action=None) + msgs = store.list_messages(sid) + assert len(msgs) == 2 + assert msgs[0]["role"] == "user" + assert msgs[0]["content"] == "生成概要设计书" + assert msgs[0]["action"] == "generate" + assert msgs[1]["role"] == "assistant" + + +def test_messages_isolated_per_session(tmp_path): + store = SessionStore(db_path=str(tmp_path / "s.db")) + a = store.create_session("u1").session_id + b = store.create_session("u1").session_id + store.add_message(a, "user", "msg-a") + store.add_message(b, "user", "msg-b") + assert [m["content"] for m in store.list_messages(a)] == ["msg-a"] + assert [m["content"] for m in store.list_messages(b)] == ["msg-b"] + + +def test_pending_intent_persisted(tmp_path): + store = SessionStore(db_path=str(tmp_path / "s.db")) + sid = store.create_session("u1").session_id + assert store.get_session(sid).pending_intent == "" + store.update_session(sid, pending_intent="generate") + assert store.get_session(sid).pending_intent == "generate" + # 重开 db 仍在 + store2 = SessionStore(db_path=str(tmp_path / "s.db")) + assert store2.get_session(sid).pending_intent == "generate"