聊天式交互改造(Web UI 升级)
- 前端由分步表单页升级为 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%,覆盖率红线达标
This commit is contained in:
@@ -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 阶段黑盒冒烟
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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` 部署后,从聊天页用中文下达「上传了文件,生成概要设计书」并确认影响即可走通全程。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""聊天 Agent 包(C2-C3)。"""
|
||||
@@ -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 "已完成"
|
||||
@@ -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", {})
|
||||
@@ -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 "<html><body>Genesis Web UI</body></html>"
|
||||
chat_html = (static_dir / "chat.html").read_text(encoding="utf-8") if (static_dir / "chat.html").exists() else "<html><body>Genesis Chat</body></html>"
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Genesis — 概要设计书自动生成(对话)</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: "Microsoft YaHei", sans-serif; margin: 0; height: 100vh; display: flex; flex-direction: column; background: #f5f6f8; }
|
||||
header { background: #1a5276; color: #fff; padding: 10px 20px; display: flex; align-items: center; gap: 12px; }
|
||||
header h1 { font-size: 17px; margin: 0; }
|
||||
#sid-badge { background: rgba(255,255,255,.18); border-radius: 12px; padding: 2px 10px; font-size: 12px; }
|
||||
#new-chat { margin-left: auto; background: #fff; color: #1a5276; border: none; padding: 6px 14px; border-radius: 14px; cursor: pointer; }
|
||||
#chat { flex: 1; overflow-y: auto; padding: 20px; max-width: 860px; width: 100%; margin: 0 auto; }
|
||||
.msg { display: flex; margin: 10px 0; }
|
||||
.msg .bubble { max-width: 72%; padding: 10px 14px; border-radius: 14px; white-space: pre-wrap; line-height: 1.55; font-size: 14px; }
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .bubble { background: #1a5276; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.msg.assistant .bubble { background: #fff; border: 1px solid #e0e0e0; border-bottom-left-radius: 4px; }
|
||||
.msg.progress .bubble { background: #eaf2f8; border: 1px dashed #7fb3d5; font-size: 13px; color: #2c3e50; }
|
||||
.msg.error .bubble { background: #fdecea; border: 1px solid #e6b4b0; color: #922b21; }
|
||||
.progress-item { display: block; }
|
||||
.progress-item.ok::before { content: "✔ "; color: #1e8449; }
|
||||
.progress-item.warn::before { content: "⚠ "; color: #b7950b; }
|
||||
.actions a { display: inline-block; margin: 4px 6px 0 0; background: #1a5276; color: #fff; padding: 5px 12px; border-radius: 12px; text-decoration: none; font-size: 13px; }
|
||||
#composer { border-top: 1px solid #ddd; background: #fff; padding: 12px 20px; }
|
||||
#composer-inner { max-width: 860px; margin: 0 auto; display: flex; gap: 8px; align-items: center; }
|
||||
#input { flex: 1; border: 1px solid #ccc; border-radius: 18px; padding: 10px 16px; font-size: 14px; outline: none; }
|
||||
#send { background: #1a5276; color: #fff; border: none; border-radius: 18px; padding: 10px 20px; cursor: pointer; }
|
||||
#send:disabled { opacity: .5; cursor: not-allowed; }
|
||||
#upload-bar { background: #fff; border-top: 1px solid #eee; padding: 8px 20px; font-size: 13px; }
|
||||
#upload-bar-inner { max-width: 860px; margin: 0 auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
#upload-bar select, #upload-bar input[type=file] { font-size: 13px; }
|
||||
#upload-btn { background: #f0f3f5; border: 1px solid #ccc; border-radius: 12px; padding: 5px 12px; cursor: pointer; }
|
||||
.typing { color: #888; font-size: 13px; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Genesis 概要设计书 Agent</h1>
|
||||
<span id="sid-badge">未创建会话</span>
|
||||
<button id="new-chat">新会话</button>
|
||||
</header>
|
||||
|
||||
<div id="chat"></div>
|
||||
|
||||
<div id="upload-bar">
|
||||
<div id="upload-bar-inner">
|
||||
<span>📎 上传资料:</span>
|
||||
<select id="file-type">
|
||||
<option value="requirements">要件定义 xlsx(必需)</option>
|
||||
<option value="template">概要设计模板 docx(必需)</option>
|
||||
<option value="write_instruction">做成说明书 docx</option>
|
||||
<option value="rules">记入/图表规则 docx/xlsx</option>
|
||||
<option value="existing_system">既有系统 zip(追加改修)</option>
|
||||
</select>
|
||||
<input type="file" id="file-input">
|
||||
<button id="upload-btn">上传</button>
|
||||
<span id="upload-status" style="color:#888"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="composer">
|
||||
<div id="composer-inner">
|
||||
<input id="input" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off">
|
||||
<button id="send">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sid = null;
|
||||
const chatEl = document.getElementById('chat');
|
||||
const inputEl = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('send');
|
||||
|
||||
function addMsg(role, html) {
|
||||
const m = document.createElement('div');
|
||||
m.className = 'msg ' + role;
|
||||
m.innerHTML = '<div class="bubble">' + html + '</div>';
|
||||
chatEl.appendChild(m);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
return m;
|
||||
}
|
||||
function esc(s) { return s.replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
||||
|
||||
function showTyping() { return addMsg('assistant', '<span class="typing">思考中…</span>'); }
|
||||
|
||||
async function api(method, url, body) {
|
||||
const opt = { method, headers: {} };
|
||||
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||||
const r = await fetch(url, opt);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.detail?.message || r.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function newSession() {
|
||||
const d = await api('POST', '/api/sessions', { user_id: 'default' });
|
||||
sid = d.session_id;
|
||||
document.getElementById('sid-badge').textContent = '会话: ' + sid;
|
||||
chatEl.innerHTML = '';
|
||||
addMsg('assistant', '你好!我是 Genesis 概要设计书生成 Agent。<br>请先在上方📎上传<strong>要件定义 xlsx</strong>与<strong>模板 docx</strong>,然后直接告诉我:<br>· 「生成概要设计书」(自动解析→影响→生成→QA)<br>· 「用中文生成」 / 「现在什么状态?」');
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text || !sid) return;
|
||||
inputEl.value = '';
|
||||
addMsg('user', esc(text));
|
||||
const typing = showTyping();
|
||||
sendBtn.disabled = true;
|
||||
try {
|
||||
const res = await api('POST', '/api/chat/' + sid + '/messages', { content: text });
|
||||
typing.remove();
|
||||
if (res.progress && res.progress.length) {
|
||||
const items = res.progress.map(p => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
|
||||
addMsg('progress', items);
|
||||
}
|
||||
let replyHtml = esc(res.reply || '');
|
||||
const s = res.status;
|
||||
if (s === 'done' || s === 'writing') {
|
||||
replyHtml += '<div class="actions">'
|
||||
+ '<a href="/api/sessions/' + sid + '/result/download">下载 docx</a>'
|
||||
+ '<a href="/api/sessions/' + sid + '/result/preview">预览</a>'
|
||||
+ '<a href="/api/sessions/' + sid + '/result/impact-report">影响调查书</a>'
|
||||
+ '<a href="/api/sessions/' + sid + '/result/qa-report">QA 报告</a></div>';
|
||||
}
|
||||
addMsg('assistant', replyHtml);
|
||||
} catch (e) {
|
||||
typing.remove();
|
||||
addMsg('error', '请求失败:' + esc(String(e)));
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
inputEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('send').addEventListener('click', send);
|
||||
inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
|
||||
document.getElementById('new-chat').addEventListener('click', () => newSession().catch(e => addMsg('error', esc(String(e)))));
|
||||
|
||||
document.getElementById('upload-btn').addEventListener('click', async () => {
|
||||
const ft = document.getElementById('file-type').value;
|
||||
const file = document.getElementById('file-input').files[0];
|
||||
if (!sid) { addMsg('error', '请先创建会话'); return; }
|
||||
if (!file) { addMsg('error', '请选择文件'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('file_type', ft);
|
||||
fd.append('file', file);
|
||||
const statusEl = document.getElementById('upload-status');
|
||||
statusEl.textContent = '上传中…';
|
||||
try {
|
||||
const r = await fetch('/api/sessions/' + sid + '/files', { method: 'POST', body: fd });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail?.message || r.status);
|
||||
statusEl.textContent = '';
|
||||
addMsg('user', '[上传] ' + ft + ':' + d.file_name);
|
||||
addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '(' + d.size + 'B)</span>');
|
||||
document.getElementById('file-input').value = '';
|
||||
} catch (e) {
|
||||
statusEl.textContent = '';
|
||||
addMsg('error', '上传失败:' + esc(String(e)));
|
||||
}
|
||||
});
|
||||
|
||||
newSession().catch(e => addMsg('error', esc(String(e))));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,139 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Genesis — 概要设计书自动生成</title>
|
||||
<style>
|
||||
body { font-family: "Microsoft YaHei", sans-serif; max-width: 900px; margin: 0 auto; padding: 24px; color: #222; }
|
||||
h1 { color: #1a5276; }
|
||||
.card { border: 1px solid #ccc; border-radius: 8px; padding: 16px; margin: 12px 0; }
|
||||
button { padding: 8px 16px; margin: 4px; cursor: pointer; border-radius: 4px; border: 1px solid #999; }
|
||||
button.primary { background: #1a5276; color: #fff; border-color: #1a5276; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
pre { background: #f6f6f6; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 13px; }
|
||||
#status { font-weight: bold; margin: 8px 0; }
|
||||
.msg { margin: 4px 0; font-size: 14px; }
|
||||
input[type=file] { margin: 4px 0; }
|
||||
table { border-collapse: collapse; } th, td { border: 1px solid #999; padding: 4px 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Genesis — 概要设计书自动生成 Agent</h1>
|
||||
<div class="card">
|
||||
<button class="primary" onclick="createSession()">创建会话</button>
|
||||
<span id="session-info"></span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>1. 上传输入资料</h3>
|
||||
<div>
|
||||
<label>要件定义 Excel(必需): <input type="file" id="f-requirements" accept=".xlsx"></label><br>
|
||||
<label>概要设计模板 docx(必需): <input type="file" id="f-template" accept=".docx"></label><br>
|
||||
<label>做成说明书 docx: <input type="file" id="f-write_instruction" accept=".docx"></label><br>
|
||||
<label>记入/图表规则 docx/xlsx: <input type="file" id="f-rules" accept=".docx,.xlsx"></label><br>
|
||||
<label>既有系统 zip(追加改修场景,可选): <input type="file" id="f-existing_system" accept=".zip"></label><br>
|
||||
</div>
|
||||
<button onclick="uploadAll()">上传全部</button>
|
||||
<div id="upload-result"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>2. 解析与确认</h3>
|
||||
<button onclick="post('/api/sessions/'+sid+'/start-parse', {})">开始解析</button>
|
||||
<button onclick="get('/api/sessions/'+sid+'/parse-result')">解析结果</button>
|
||||
<button onclick="post('/api/sessions/'+sid+'/confirm-parse', {})">确认解析</button>
|
||||
<button onclick="post('/api/sessions/'+sid+'/start-impact', {})">开始影响调查</button>
|
||||
<button onclick="get('/api/sessions/'+sid+'/impact-result')">影响结果</button>
|
||||
<button onclick="post('/api/sessions/'+sid+'/confirm-impact', {})">确认影响</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>3. 生成与 QA</h3>
|
||||
<label>输出语言:
|
||||
<select id="lang">
|
||||
<option value="auto">auto(跟随标题)</option>
|
||||
<option value="zh">zh(简体中文)</option>
|
||||
<option value="ja">ja(日文)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onclick="generate()">开始生成</button>
|
||||
<button onclick="post('/api/sessions/'+sid+'/run-qa', {})">运行 QA</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>4. 结果</h3>
|
||||
<button onclick="preview()">预览</button>
|
||||
<a id="dl" download><button>下载 docx</button></a>
|
||||
<a id="dl-impact" download><button>下载影响调查书</button></a>
|
||||
<a id="dl-qa" download><button>下载 QA 报告</button></a>
|
||||
<div id="preview-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div id="status">未创建会话</div>
|
||||
<div id="out"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sid = null;
|
||||
const out = document.getElementById('out');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
function log(html) { out.innerHTML += '<div class="msg">' + html + '</div>'; }
|
||||
function setStatus(s) { statusEl.textContent = s; }
|
||||
|
||||
async function api(method, url, body, isForm) {
|
||||
const opt = { method, headers: {} };
|
||||
if (body instanceof FormData) { opt.body = body; }
|
||||
else if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||||
const r = await fetch(url, opt);
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
const data = ct.includes('json') ? await r.json() : await r.text();
|
||||
if (!r.ok) throw new Error(JSON.stringify(data));
|
||||
return data;
|
||||
}
|
||||
function post(u, b) { return api('POST', u, b).then(d => { log('✔ ' + u + ' → ' + JSON.stringify(d)); return d; }).catch(e => log('✘ ' + u + ' → ' + e.message)); }
|
||||
function get(u) { return api('GET', u).then(d => { log('✔ ' + u + ' → ' + JSON.stringify(d).slice(0, 300)); return d; }).catch(e => log('✘ ' + u + ' → ' + e.message)); }
|
||||
|
||||
async function createSession() {
|
||||
try {
|
||||
const d = await api('POST', '/api/sessions', { user_id: 'default' });
|
||||
sid = d.session_id;
|
||||
document.getElementById('session-info').textContent = '会话: ' + sid;
|
||||
setStatus('会话已创建: ' + sid + '(状态 uploading)');
|
||||
} catch (e) { setStatus('创建失败: ' + e.message); }
|
||||
}
|
||||
|
||||
async function uploadAll() {
|
||||
if (!sid) return setStatus('请先创建会话');
|
||||
const map = { requirements: 'f-requirements', template: 'f-template', write_instruction: 'f-write_instruction', rules: 'f-rules', existing_system: 'f-existing_system' };
|
||||
for (const [ft, id] of Object.entries(map)) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el.files.length) continue;
|
||||
const fd = new FormData();
|
||||
fd.append('file_type', ft);
|
||||
fd.append('file', el.files[0]);
|
||||
try {
|
||||
const d = await api('POST', '/api/sessions/' + sid + '/files', fd);
|
||||
document.getElementById('upload-result').innerHTML += '<div>✔ ' + ft + ': ' + d.file_name + '(' + d.size + 'B)</div>';
|
||||
} catch (e) { document.getElementById('upload-result').innerHTML += '<div>✘ ' + ft + ' → ' + e.message + '</div>'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const lang = document.getElementById('lang').value;
|
||||
await post('/api/sessions/' + sid + '/generate', { output_language: lang });
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
try {
|
||||
const d = await api('GET', '/api/sessions/' + sid + '/result/preview');
|
||||
document.getElementById('preview-box').innerHTML = d.html;
|
||||
document.getElementById('dl').href = '/api/sessions/' + sid + '/result/download';
|
||||
document.getElementById('dl-impact').href = '/api/sessions/' + sid + '/result/impact-report';
|
||||
document.getElementById('dl-qa').href = '/api/sessions/' + sid + '/result/qa-report';
|
||||
} catch (e) { log('✘ 预览失败: ' + e.message); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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", ""),
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user