feat(web): 项目级配置 + 会话命名/历史 + 设计文档纳入影响调查
- 会话支持 name/project 字段,上传要件定义后自动命名;前端侧边栏会话历史 + localStorage 恢复,顶部只显示会话名 - 新增 ProjectsStore(SQLite)与 /api/projects CRUD;绑定项目后 _rebuild_source 合并模板/规则/代码库/设计文档,上传区仅要件定义 - StructuredSource.design_docs 与 ImpactReport.design_references;影响调查新增既有设计文档确定性交叉引用(无 LLM) - 同步更新 docs/design.md §12.7、README、_AI_USAGE_LOG.md;全量测试 558 通过,覆盖率 99.10%
This commit is contained in:
@@ -139,13 +139,18 @@ python scripts/serve.py
|
||||
- 访问 `http://127.0.0.1:8000/` 进入聊天页面;API 文档 `http://127.0.0.1:8000/docs`
|
||||
- 聊天页面支持:📎 上传要件定义/模板/规则/既有系统 zip、自然语言指令(`生成概要设计书` / `用中文生成` /
|
||||
`现在什么状态?` / `做影响调查` / `运行QA校验`)、影响确认反问(`确认,继续` / `打回`)、结果下载与预览
|
||||
- 左侧**会话历史侧边栏**:按会话名/项目列出历史会话,点击即可恢复消息;顶部仅显示会话名(自动取要件定义文件名)
|
||||
- **项目级配置**:侧边栏「项目配置」面板可登记模板/做成说明书/规则/既有系统代码库/设计文档目录,绑定项目的会话
|
||||
只需上传要件定义,其余复用项目配置(`POST /api/projects` 等);`design_docs_dir` 中的设计文档会作为**影响调查的
|
||||
确定性交叉引用证据**(无 LLM、纯字符串匹配)写入影响调查书
|
||||
- 主要 API 端点:
|
||||
- 会话/文件:`POST /api/sessions`、`POST /api/sessions/{id}/files`(multipart,type=requirements/template/write_instruction/rules/existing_system)
|
||||
- 项目配置:`POST /api/projects`、`GET /api/projects`、`GET /api/projects/{name}`、`DELETE /api/projects/{name}`
|
||||
- 会话/文件:`POST /api/sessions`(body 可含 `name`/`project`)、`GET /api/sessions`(含 name/project)、`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` 上传(追加/改修场景);不传则影响调查跳过
|
||||
- **既有系统**以代码库目录(项目配置)或 `.zip`(会话上传)提供(追加/改修场景);不传则影响调查跳过
|
||||
- 部署到公网后登记 `service_url`(格式 `http://<域名或公网IP>:<端口>`)供评审系统 B 阶段黑盒冒烟
|
||||
|
||||
## 测试
|
||||
|
||||
@@ -123,3 +123,4 @@
|
||||
| 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) |
|
||||
| 2026-08-27 12:12 | Agent 实现 | 聊天前端升级二:会话命名(自动取要件定义文件名)+历史侧边栏;以项目为单位的模板/规则/代码库/设计文档目录配置(ProjectsStore+API),绑定项目后上传区仅要件定义;既有设计文档确定性交叉引用纳入影响调查(DesignReference) | src/genesis/server/store.py, src/genesis/server/service.py, src/genesis/server/app.py, src/genesis/parsers/source_aggregator.py, src/genesis/impact/impact_agent.py, src/genesis/data_models.py, src/genesis/chat/agent.py, src/genesis/server/static/chat.html, tests/*, docs/design.md, README.md | hy3-free |
|
||||
|
||||
@@ -1888,3 +1888,32 @@ Document(注入后 Word 文档)
|
||||
- `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` 部署后,从聊天页用中文下达「上传了文件,生成概要设计书」并确认影响即可走通全程。
|
||||
|
||||
### 12.7 项目级配置与既有设计文档纳入影响调查(2026-08,Web UI 升级二)
|
||||
|
||||
在 12.6 聊天页基础上,进一步降低每次生成的配置负担,并把既有设计文档作为影响调查的辅助证据来源。
|
||||
|
||||
#### 12.7.1 会话命名与历史
|
||||
|
||||
- 会话 `SessionRecord` 新增 `name` / `project` 字段(默认 `name="新会话"`);`store.create_session(user_id, name, project)` 支持传入。
|
||||
- 上传**要件定义 xlsx** 后,若会话名仍为默认「新会话」,自动取文件名(去扩展名)作为会话名,便于在历史列表中区分。
|
||||
- 前端 `chat.html` 左侧新增**会话历史侧边栏**:`GET /api/sessions` 返回 `name`/`project`,点击可加载历史会话(`GET /api/chat/{sid}/messages`)并恢复消息;当前会话 ID 存入 `localStorage`,刷新后自动恢复。
|
||||
- 顶部只显示 **会话名**(不显示会话 ID)。
|
||||
|
||||
#### 12.7.2 以项目为单位的配置(用户只传要件定义)
|
||||
|
||||
- 新增 `ProjectsStore`(复用 `sessions.db`):`projects` 表,字段 `name`(主键)/ `display_name` / `template` / `write_instruction` / `rules[]` / `existing_system_code_dir` / `design_docs_dir`。
|
||||
- `ProjectConfigError`:路径不存在 / 非 `.docx` / 非目录 时抛出(对应 `api-design` 400 `PROJECT_CONFIG_INVALID`)。
|
||||
- 校验规则:`_validate_project_paths` 对模板/做成说明书/规则/代码库目录/设计文档目录做存在性与类型校验;`rules` 与 `design_docs_dir` 为目录时枚举其中的 `.docx`。
|
||||
- 配置 CRUD 端点:`POST /api/projects`(创建/更新,同名覆盖)、`GET /api/projects`、`GET /api/projects/{name}`、`DELETE /api/projects/{name}`。
|
||||
- 会话绑定项目:`POST /api/sessions` 收 `project` 字段;`service.has_file(rec, ftype)` 与 `service._eff_path(rec, ftype)` 在用户未上传时回退到项目配置(同类型用户文件优先)。
|
||||
- `_rebuild_source` 合并:模板/做成说明书/规则 = 用户上传优先,否则取项目配置(rules 两者追加);既有系统代码库与设计文档目录取项目配置。
|
||||
- 前端交互:侧边栏「项目配置」面板可新建/选择项目;选中项目后新建会话即绑定,**上传区仅显示「要件定义 xlsx(必需)」**(其余由项目提供),并给出提示。
|
||||
|
||||
#### 12.7.3 既有设计文档纳入影响调查(确定性交叉引用)
|
||||
|
||||
- `StructuredSource` 新增 `design_docs: list[RuleDocument]`(`category="design"`,区别于写入规则);`SourceParser.parse` 新增 `design_doc_paths`,解析为 `RuleDocument`。
|
||||
- `ImpactReport` 新增 `design_references: list[DesignReference]`(`doc_name` / `identifier` / `snippet`)。
|
||||
- `ImpactAgent._cross_ref_design_docs`:以既有系统解析出的标识符(类/方法/模块名,小写键)为锚,在 `design_docs` 的 `markdown_content` 中做**大小写不敏感**子串检索;命中则记录原始大小写 token 与前后文片段。**无 LLM 参与**,纯字符串匹配。
|
||||
- 序列化:`impact_report_to_dict` 输出包含 `design_references`;影响调查书下载 JSON 同步包含。
|
||||
- 说明:设计文档作为 Type A 辅助证据,**不进入写入规则**,不引入额外 LLM 调用,保持影响调查零幻觉目标。
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Web 聊天前端改造 + 项目级配置 + 设计文档纳入影响(实施计划)
|
||||
|
||||
> 状态:已评审定稿(2026-08-26)
|
||||
> 决策记录:
|
||||
> - 会话名称:自动取要件定义文件名(未上传前显示「新会话」),header 不显示会话 ID
|
||||
> - 历史:左侧会话列表侧边栏,可切换历史会话并加载聊天记录
|
||||
> - 配置单位:**以项目为单位**(服务端持久化,复用 SQLite),项目下多个会话共享配置
|
||||
> - 上传区:选定项目后只显示「要件定义 xlsx(必需)」,其余来自项目配置
|
||||
> - 设计文档入影响:**A 确定性交叉引用**(代码变更点标识符 × 设计文档文本命中 → design_references)
|
||||
|
||||
---
|
||||
|
||||
## 一、总体设计
|
||||
|
||||
| 维度 | 方案 |
|
||||
|---|---|
|
||||
| 会话名称 | `name` 自动取要件定义文件名;默认「新会话」;header 显示 `会话: <name>` |
|
||||
| 历史 | 左侧会话列表侧边栏;点击切换并 `GET /api/chat/{sid}/messages` 加载;localStorage 记当前会话 |
|
||||
| 配置单位 | 项目级(服务端 SQLite `projects` 表);会话绑定 `project` 名,共享配置 |
|
||||
| 上传区 | 选定项目后仅「要件定义 xlsx(必需)」;模板/规则/代码库/设计文档来自项目配置 |
|
||||
| 设计文档入影响 | 做法 A:解析设计文档为文本,与代码变更点标识符交叉引用,命中写 `design_references` |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型与存储(`src/genesis/server/store.py`)
|
||||
|
||||
- `SessionRecord` 新增字段:
|
||||
- `name: str = ""`(会话显示名)
|
||||
- `project: str = ""`(绑定的项目名;为空表示无项目)
|
||||
- `to_dict()` / `_from_dict()` 同步。
|
||||
- 新增 `ProjectsStore`(复用同一 SQLite 文件,新增 `projects` 表):
|
||||
- 字段:`name`(PK)、`display_name`、`template`、`write_instruction`、`rules`(JSON list of paths)、`existing_system_code_dir`、`design_docs_dir`、`created_at`、`updated_at`。
|
||||
- 方法:
|
||||
- `upsert(name, display_name, template, write_instruction, rules, existing_system_code_dir, design_docs_dir)`
|
||||
- `get(name) -> ProjectConfig | None`
|
||||
- `list() -> list[ProjectConfig]`
|
||||
- `delete(name) -> bool`
|
||||
- 路径校验(`_validate_project_paths`):
|
||||
- `template` / `write_instruction`:文件须存在且为 `.docx`。
|
||||
- `rules` / `design_docs_dir`:目录须存在,枚举其中 `.docx`(为空目录 → 空列表,不报错)。
|
||||
- `existing_system_code_dir`:目录须存在(代码库目录,直接交给 CodeParser)。
|
||||
- 越界/不存在 → 抛 `ProjectConfigError`(映射 API 400)。
|
||||
- 异常:`ProjectConfigError`(新增,与 `SessionNotFoundError` 并列)。
|
||||
|
||||
---
|
||||
|
||||
## 三、服务层(`src/genesis/server/service.py`)
|
||||
|
||||
- `GenesisService.__init__` 增加 `projects: ProjectsStore | None = None`。
|
||||
- `create_session(user_id, name=None, project=None)`:落 `name`(默认「新会话」)/`project`。
|
||||
- `upload_file`:上传 `requirements` 且当前 `name` 仍为默认「新会话」时,自动将 `name` 设为文件名(去扩展名)。
|
||||
- **`_rebuild_source(rec)` 合并逻辑(核心)**:
|
||||
1. 若 `rec.project` 非空 → `cfg = self.projects.get(rec.project)`;从 cfg 取 template / write_instruction / rules(目录枚举)/ existing_system_code_dir / design_docs(目录枚举)。
|
||||
2. 用户 `rec.files`(要件定义等)**覆盖**同名类型(用户上传优先)。
|
||||
3. 汇总传入 `SourceParser.parse`:
|
||||
- `requirement_paths` ← 用户上传 requirements
|
||||
- `template_path` ← template(用户覆盖优先)
|
||||
- `write_instruction_paths` ← write_instruction
|
||||
- `rule_paths` ← rules 目录枚举的 `.docx`
|
||||
- `existing_system_path` ← existing_system_code_dir(用户上传 zip 覆盖优先)
|
||||
- `design_doc_paths` ← design_docs 目录枚举的 `.docx`
|
||||
- 新增 `has_file(rec, ftype)` 同时查 `rec.files` 与项目配置,供 `run_parse` / `ChatAgent` 判断缺件。
|
||||
- `run_impact`:`_rebuild_source` 已含 `design_doc_paths` → `ImpactAgent` 产出 `design_references`;影响摘要增加「设计文档关联 N 处」。
|
||||
|
||||
---
|
||||
|
||||
## 四、影响调查扩展(做法 A)
|
||||
|
||||
- `src/genesis/data_models.py`:
|
||||
- `StructuredSource` 增加 `design_docs: list`(设计文档解析文本块,元素含 `name` + `text`)。
|
||||
- `ImpactReport` 增加 `design_references: list[dict]`(每项为 `{element_id, identifier, doc_name, snippet}`)。
|
||||
- `src/genesis/parsers/source_aggregator.py`:`parse` 增加 `design_doc_paths` 参数 → 用 `RuleDocParser`(category="design") 解析 Word 为文本块,挂到 `StructuredSource.design_docs`。
|
||||
- `src/genesis/impact/impact_agent.py`:`run(ss, ...)` 在代码变更点分析后,对每个变更标识符(类名/方法名)在 `ss.design_docs` 文本中检索出现处,命中即记入 `design_references`(确定性,无 LLM)。
|
||||
- 影响调查书 JSON 与聊天反问摘要体现 `design_references` 计数。
|
||||
|
||||
---
|
||||
|
||||
## 五、API(`src/genesis/server/app.py`)
|
||||
|
||||
- `GET /api/projects` → 列表(name, display_name)。
|
||||
- `POST /api/projects` → upsert(body:display_name, template, write_instruction, rules[], existing_system_code_dir, design_docs_dir);校验失败 400。
|
||||
- `DELETE /api/projects/{name}`(可选)。
|
||||
- `POST /api/sessions` 增 `project`、`name`;返回含 `name`、`project`。
|
||||
- `GET /api/sessions` 增可选 `project` 过滤;返回含 `name`、`project`。
|
||||
- `GET /api/sessions/{sid}` 已含 to_dict(含 name/project)。
|
||||
- 聊天端点不变。
|
||||
|
||||
---
|
||||
|
||||
## 六、前端(`src/genesis/server/static/chat.html`)
|
||||
|
||||
- **项目配置面板**(可折叠):display_name、name、模板路径、做成说明书路径、记入·图表规则**目录**、既有系统代码库**目录**、既有设计文档**目录**;「保存项目」→ `POST /api/projects`;顶部「项目」下拉(`GET /api/projects`)。
|
||||
- 主页:选活动项目 → 「新会话」在该项目下建会话并自动套用配置;上传区**仅要件定义**。
|
||||
- **左侧会话侧边栏**:`GET /api/sessions` 列(名称 + 项目名 + 状态),点击切换 → `GET /api/chat/{sid}/messages` 加载;「新会话」按钮。
|
||||
- header 显示 `会话: <name>`(不含 ID)。
|
||||
- 页面加载:localStorage 读上次 sid,有效则恢复历史,否则新建。
|
||||
|
||||
---
|
||||
|
||||
## 七、测试(保持 `fail_under=99`)
|
||||
|
||||
- `tests/test_server_store.py`:ProjectsStore CRUD + SessionRecord 新字段。
|
||||
- `tests/test_server_service.py`:项目 upsert / 路径校验拒绝 / 目录枚举 / `has_file` / 会话绑定项目 / `_rebuild_source` 合并 / design_references 进影响。
|
||||
- `tests/test_server_api.py`:projects 端点 / sessions 含 name+project / chat 历史。
|
||||
- `tests/test_impact_agent.py` + `tests/test_source_aggregator.py`:design_doc_paths 与 design_references 命中。
|
||||
- `tests/test_chat_agent.py`:项目绑定流程(仅传要件定义即可生成)。
|
||||
|
||||
---
|
||||
|
||||
## 八、文档与日志
|
||||
|
||||
- `docs/design.md` §12.6 补:会话命名 / 历史侧边栏 / 项目级配置 / 设计文档纳入影响。
|
||||
- `README.md` Web 服务段更新(项目配置用法)。
|
||||
- `_AI_USAGE_LOG.md` 追加(范式步骤=Agent 实现)。
|
||||
|
||||
---
|
||||
|
||||
## 九、提交规范红线自检
|
||||
|
||||
- 新增代码/表全 ASCII 文件名;无新前端文件(仅改 `chat.html`)。
|
||||
- 配置路径运行时解析 + 越界拒绝,**无硬编码绝对路径**。
|
||||
- `.env` 不入库;无 >50MB 二进制。
|
||||
- 全量 pytest 保持 `fail_under=99`。
|
||||
@@ -99,7 +99,7 @@ class ChatAgent:
|
||||
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:
|
||||
if not (self.service.has_file(rec, "requirements") and self.service.has_file(rec, "template")):
|
||||
reply = "请先上传要件定义 Excel 与概要设计模板 docx(聊天框上方附件按钮)。"
|
||||
self.store.add_message(session_id, "assistant", reply, action="generate")
|
||||
return {"reply": reply, "progress": progress, "status": rec.status}
|
||||
@@ -137,7 +137,7 @@ class ChatAgent:
|
||||
|
||||
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:
|
||||
if not (self.service.has_file(rec, "requirements") and self.service.has_file(rec, "template")):
|
||||
reply = "请先上传要件定义 Excel 与模板 docx。"
|
||||
self.store.add_message(session_id, "assistant", reply, action="parse")
|
||||
return {"reply": reply, "progress": progress, "status": rec.status}
|
||||
@@ -157,8 +157,8 @@ class ChatAgent:
|
||||
|
||||
def _run_impact(self, session_id, progress):
|
||||
rec = self.store.get_session(session_id)
|
||||
if "existing_system" not in rec.files:
|
||||
reply = "未提供既有系统(zip),影响调查跳过。可直接回复「生成概要设计书」。"
|
||||
if not self.service.has_file(rec, "existing_system"):
|
||||
reply = "未提供既有系统,影响调查跳过。可直接回复「生成概要设计书」。"
|
||||
self.store.add_message(session_id, "assistant", reply, action="impact")
|
||||
return {"reply": reply, "progress": progress, "status": rec.status}
|
||||
# 未解析则先解析并确认(自然语言触发应自动推进前置步骤)
|
||||
@@ -230,7 +230,21 @@ class ChatAgent:
|
||||
return {"reply": reply, "progress": progress, "status": rec.status}
|
||||
|
||||
def _status_reply(self, rec, progress):
|
||||
reply = f"当前状态:{rec.status}。已上传:{list(rec.files.keys()) or '无'}。"
|
||||
proj = f"(项目:{rec.project})" if rec.project else ""
|
||||
files = list(rec.files.keys())
|
||||
cfg_files = []
|
||||
if self.service.has_file(rec, "template"):
|
||||
cfg_files.append("template")
|
||||
if self.service.has_file(rec, "write_instruction"):
|
||||
cfg_files.append("write_instruction")
|
||||
if self.service.has_file(rec, "rules"):
|
||||
cfg_files.append("rules")
|
||||
if self.service.has_file(rec, "existing_system"):
|
||||
cfg_files.append("existing_system")
|
||||
if self.service.has_file(rec, "design_docs"):
|
||||
cfg_files.append("design_docs")
|
||||
avail = list(dict.fromkeys(files + cfg_files))
|
||||
reply = f"当前状态:{rec.status}{proj}。可用文件:{avail or '无'}。"
|
||||
if rec.result_path:
|
||||
reply += " 结果已生成,可下载。"
|
||||
self.store.add_message(session_id := rec.session_id, "assistant", reply, action="status")
|
||||
|
||||
@@ -214,12 +214,21 @@ class ChangeAnalysis:
|
||||
warnings: list[ImpactWarning]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DesignReference:
|
||||
"""既有设计文档命中引用(Type A 确定性交叉引用)"""
|
||||
doc_name: str # 设计文档文件名
|
||||
identifier: str # 命中的代码/表标识符
|
||||
snippet: str # 摘录(前后文片段)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImpactReport:
|
||||
"""影响调查书(MVP 子集,供 Writer 生成 + 独立下载)"""
|
||||
metadata: dict
|
||||
change_analysis: ChangeAnalysis | None = None
|
||||
summary: dict = field(default_factory=dict)
|
||||
design_references: list[DesignReference] = field(default_factory=list) # 设计文档命中
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -270,6 +279,7 @@ class StructuredSource:
|
||||
image_analyses: list[ImageAnalysis]
|
||||
existing_system: ExistingSystemInfo | None
|
||||
comments: list[CellComment]
|
||||
design_docs: list[RuleDocument] = field(default_factory=list) # 既有系统设计文档(Type A,仅参考不做写入规则)
|
||||
impact_report: "ImpactReport | None" = None # 影响调查书(生成后回填,门控未提供时为 None)
|
||||
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from genesis.data_models import (
|
||||
ChangeAnalysis,
|
||||
ChangeElement,
|
||||
ChangeType,
|
||||
DesignReference,
|
||||
ExcelTable,
|
||||
ImpactReport,
|
||||
ImpactWarning,
|
||||
@@ -66,6 +68,10 @@ def impact_report_to_dict(report: ImpactReport) -> dict:
|
||||
"warnings": [{"element_id": w.element_id, "issue": w.issue} for w in ca.warnings],
|
||||
},
|
||||
"summary": dict(report.summary),
|
||||
"design_references": [
|
||||
{"doc_name": r.doc_name, "identifier": r.identifier, "snippet": r.snippet}
|
||||
for r in report.design_references
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +130,7 @@ class ImpactAgent:
|
||||
"unchanged": unchanged,
|
||||
"warnings": len(warnings),
|
||||
}
|
||||
design_references = self._cross_ref_design_docs(structured_source, lookup)
|
||||
return ImpactReport(
|
||||
metadata={
|
||||
"version": "v1",
|
||||
@@ -134,8 +141,40 @@ class ImpactAgent:
|
||||
},
|
||||
change_analysis=change_analysis,
|
||||
summary=summary,
|
||||
design_references=design_references,
|
||||
)
|
||||
|
||||
def _cross_ref_design_docs(
|
||||
self, structured_source: StructuredSource, lookup: dict[str, list[dict]]
|
||||
) -> list[DesignReference]:
|
||||
"""既有设计文档确定性交叉引用(Type A 辅助证据)。
|
||||
|
||||
以既有系统(existing_system)解析出的标识符(类/方法/模块名)为锚,
|
||||
在 design_docs 的 markdown 文本中检索命中,输出 DesignReference 列表。
|
||||
无 LLM 参与,纯字符串匹配。
|
||||
"""
|
||||
design_docs = getattr(structured_source, "design_docs", []) or []
|
||||
if not design_docs:
|
||||
return []
|
||||
identifiers = [k for k in lookup.keys() if len(k) >= 3]
|
||||
refs: list[DesignReference] = []
|
||||
for doc in design_docs:
|
||||
text = getattr(doc, "markdown_content", "") or ""
|
||||
if not text:
|
||||
continue
|
||||
doc_name = Path(getattr(doc, "source_path", "design.docx")).name
|
||||
text_lower = text.lower()
|
||||
for ident in identifiers:
|
||||
il = ident.lower()
|
||||
idx = text_lower.find(il)
|
||||
if idx >= 0:
|
||||
token = text[idx:idx + len(il)] # 设计文档中的原始大小写
|
||||
start = max(0, idx - 30)
|
||||
end = min(len(text), idx + len(il) + 30)
|
||||
snippet = text[start:end].replace("\n", " ").strip()
|
||||
refs.append(DesignReference(doc_name=doc_name, identifier=token, snippet=snippet))
|
||||
return refs
|
||||
|
||||
# ---------- 内部 ----------
|
||||
|
||||
def _classify_table(
|
||||
|
||||
@@ -46,6 +46,7 @@ class SourceParser:
|
||||
rule_paths: list[str | Path] | None = None,
|
||||
existing_system_path: str | Path | None = None,
|
||||
existing_system_language: str | None = None,
|
||||
design_doc_paths: list[str | Path] | None = None,
|
||||
) -> StructuredSource:
|
||||
"""扩展名校验先于存在性校验(不存在的文件若扩展名未知将抛出 ValueError 而非 FileNotFoundError)。
|
||||
|
||||
@@ -83,6 +84,12 @@ class SourceParser:
|
||||
code = CodeParser().parse(existing_system_path, language=existing_system_language)
|
||||
existing_system = ExistingSystemExplorer().explore(code)
|
||||
|
||||
design_docs = []
|
||||
for p in (design_doc_paths or []):
|
||||
dpath = _validate_path(p, (DOCX_EXT,))
|
||||
# 设计文档为 Type A 参考材料(category="design",区别于写入规则)
|
||||
design_docs.append(RuleDocParser().parse(dpath, category="design"))
|
||||
|
||||
return StructuredSource(
|
||||
tables=tables,
|
||||
template=template,
|
||||
@@ -90,4 +97,5 @@ class SourceParser:
|
||||
image_analyses=[],
|
||||
existing_system=existing_system,
|
||||
comments=comments,
|
||||
design_docs=design_docs,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,9 @@ from pydantic import BaseModel
|
||||
|
||||
from genesis.inference.factory import build_inference_engine
|
||||
from genesis.server.service import FileTypeError, GenesisService, ServiceStepError
|
||||
from genesis.server.store import SessionNotFoundError, SessionStore
|
||||
from genesis.server.store import (
|
||||
ProjectConfigError, ProjectsStore, SessionNotFoundError, SessionStore,
|
||||
)
|
||||
|
||||
VERSION = "0.1.0"
|
||||
|
||||
@@ -47,6 +49,18 @@ class ChatMessageReq(BaseModel):
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
user_id: str = "default"
|
||||
name: str | None = None
|
||||
project: str | None = None
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
display_name: str = ""
|
||||
template: str = ""
|
||||
write_instruction: str = ""
|
||||
rules: list[str] = []
|
||||
existing_system_code_dir: str = ""
|
||||
design_docs_dir: str = ""
|
||||
|
||||
|
||||
class FileUploadResp(BaseModel):
|
||||
@@ -75,7 +89,8 @@ def create_app(
|
||||
elif engine is None:
|
||||
engine = None # 真实模式:generate/qa 时按需 build(避免未配置 key 直接 503)
|
||||
|
||||
service = GenesisService(store=store, data_root=data_root, engine=engine)
|
||||
projects = ProjectsStore(db_path=str(store._db)) if store else None
|
||||
service = GenesisService(store=store, data_root=data_root, engine=engine, projects=projects)
|
||||
|
||||
from genesis.chat.agent import ChatAgent
|
||||
chat_agent = ChatAgent(service=service, fake=is_fake, engine=engine)
|
||||
@@ -95,17 +110,54 @@ def create_app(
|
||||
def index():
|
||||
return chat_html
|
||||
|
||||
# ---------- 项目配置 ----------
|
||||
|
||||
@app.post("/api/projects")
|
||||
def create_project(body: ProjectCreate):
|
||||
try:
|
||||
cfg = service.projects.upsert(
|
||||
name=body.name, display_name=body.display_name, template=body.template,
|
||||
write_instruction=body.write_instruction, rules=body.rules,
|
||||
existing_system_code_dir=body.existing_system_code_dir,
|
||||
design_docs_dir=body.design_docs_dir,
|
||||
)
|
||||
except ProjectConfigError as e:
|
||||
raise _error(400, "PROJECT_CONFIG_INVALID", str(e))
|
||||
return cfg.to_dict
|
||||
|
||||
@app.get("/api/projects")
|
||||
def list_projects():
|
||||
if not service.projects:
|
||||
return []
|
||||
return [c.to_dict for c in service.projects.list()]
|
||||
|
||||
@app.get("/api/projects/{name}")
|
||||
def get_project(name: str):
|
||||
if not service.projects:
|
||||
raise _error(404, "PROJECT_NOT_FOUND", f"项目不存在: {name}")
|
||||
cfg = service.projects.get(name)
|
||||
if cfg is None:
|
||||
raise _error(404, "PROJECT_NOT_FOUND", f"项目不存在: {name}")
|
||||
return cfg.to_dict
|
||||
|
||||
@app.delete("/api/projects/{name}")
|
||||
def delete_project(name: str):
|
||||
if not service.projects:
|
||||
return {"deleted": False}
|
||||
return {"deleted": service.projects.delete(name)}
|
||||
|
||||
# ---------- 会话 ----------
|
||||
|
||||
@app.post("/api/sessions")
|
||||
def create_session(body: SessionCreate):
|
||||
rec = service.create_session(body.user_id)
|
||||
return {"session_id": rec.session_id, "status": rec.status}
|
||||
rec = service.create_session(body.user_id, name=body.name, project=body.project)
|
||||
return {"session_id": rec.session_id, "status": rec.status, "name": rec.name}
|
||||
|
||||
@app.get("/api/sessions")
|
||||
def list_sessions(user_id: str = "default"):
|
||||
return [
|
||||
{"session_id": r.session_id, "status": r.status, "updated_at": r.updated_at}
|
||||
{"session_id": r.session_id, "name": r.name, "project": r.project,
|
||||
"status": r.status, "updated_at": r.updated_at}
|
||||
for r in service.store.list_sessions(user_id)
|
||||
]
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
from docx import Document
|
||||
|
||||
from genesis.parsers.source_aggregator import SourceParser
|
||||
from genesis.server.store import SessionStore, SessionRecord
|
||||
from genesis.server.store import SessionStore, SessionRecord, ProjectsStore, ProjectConfigError
|
||||
|
||||
ALLOWED_FILE_TYPES = {
|
||||
"requirements", "template", "write_instruction", "rules", "existing_system",
|
||||
@@ -56,20 +56,58 @@ class GenesisService:
|
||||
engine=None,
|
||||
prompt_registry=None,
|
||||
samples_dir: str = "sample",
|
||||
projects: "ProjectsStore | None" = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.data_root = Path(data_root)
|
||||
self.engine = engine
|
||||
self.prompt_registry = prompt_registry
|
||||
self.samples_dir = samples_dir
|
||||
self.projects = projects
|
||||
|
||||
# ---------- 会话与文件 ----------
|
||||
|
||||
def create_session(self, user_id: str) -> SessionRecord:
|
||||
rec = self.store.create_session(user_id)
|
||||
def create_session(self, user_id: str, name: str | None = None,
|
||||
project: str | None = None) -> SessionRecord:
|
||||
rec = self.store.create_session(user_id, name=name, project=project)
|
||||
(self.data_root / rec.session_id).mkdir(parents=True, exist_ok=True)
|
||||
return rec
|
||||
|
||||
def has_file(self, rec: SessionRecord, ftype: str) -> bool:
|
||||
"""某类文件是否可用(用户上传优先,否则取项目配置)。
|
||||
|
||||
requirements 只能由用户上传,不来自项目配置。
|
||||
"""
|
||||
if ftype in rec.files:
|
||||
return True
|
||||
if self.projects and rec.project:
|
||||
cfg = self.projects.get(rec.project)
|
||||
if cfg:
|
||||
return {
|
||||
"requirements": False,
|
||||
"template": bool(cfg.template),
|
||||
"write_instruction": bool(cfg.write_instruction),
|
||||
"rules": bool(cfg.rules),
|
||||
"existing_system": bool(cfg.existing_system_code_dir),
|
||||
"design_docs": bool(cfg.design_docs_dir),
|
||||
}.get(ftype, False)
|
||||
return False
|
||||
|
||||
def _eff_path(self, rec: SessionRecord, ftype: str):
|
||||
"""某类文件的有效单路径(用户上传优先,否则项目配置)。"""
|
||||
if ftype in rec.files:
|
||||
return rec.files[ftype]["path"]
|
||||
if self.projects and rec.project:
|
||||
cfg = self.projects.get(rec.project)
|
||||
if cfg:
|
||||
return {
|
||||
"template": cfg.template or None,
|
||||
"write_instruction": cfg.write_instruction or None,
|
||||
"existing_system": cfg.existing_system_code_dir or None,
|
||||
"design_docs": cfg.design_docs_dir or None,
|
||||
}.get(ftype)
|
||||
return None
|
||||
|
||||
def get_session(self, session_id: str) -> SessionRecord:
|
||||
return self.store.get_session(session_id)
|
||||
|
||||
@@ -94,7 +132,13 @@ class GenesisService:
|
||||
entry = {"file_id": f"{session_id}-{file_type}", "name": filename, "size": len(content), "path": path}
|
||||
files = dict(self.get_session(session_id).files)
|
||||
files[file_type] = entry
|
||||
self.store.update_session(session_id, files=files)
|
||||
update = {"files": files}
|
||||
# 上传要件定义且会话名仍为默认 → 自动取文件名(不含扩展名)
|
||||
if file_type == "requirements":
|
||||
rec = self.get_session(session_id)
|
||||
if rec.name == "新会话":
|
||||
update["name"] = Path(filename).stem
|
||||
self.store.update_session(session_id, **update)
|
||||
return entry
|
||||
|
||||
# ---------- 内部:重建 StructuredSource ----------
|
||||
@@ -102,12 +146,28 @@ class GenesisService:
|
||||
def _rebuild_source(self, rec: SessionRecord):
|
||||
files = rec.files
|
||||
parser = SourceParser()
|
||||
# 用户上传优先,否则取项目配置(同类型用户文件覆盖项目配置)
|
||||
cfg = (self.projects.get(rec.project) if (self.projects and rec.project) else None)
|
||||
req = [files["requirements"]["path"]] if "requirements" in files else None
|
||||
tpl = files.get("template", {}).get("path") or (cfg.template or None if cfg else None)
|
||||
wi = [files["write_instruction"]["path"]] if "write_instruction" in files else (
|
||||
[cfg.write_instruction] if (cfg and cfg.write_instruction) else None)
|
||||
rules = [files["rules"]["path"]] if "rules" in files else []
|
||||
if cfg:
|
||||
rules = rules + list(cfg.rules)
|
||||
rules = rules or None
|
||||
existing = files.get("existing_system", {}).get("path") or (
|
||||
cfg.existing_system_code_dir or None if cfg else None)
|
||||
design = None
|
||||
if cfg and cfg.design_docs_dir:
|
||||
design = [str(p) for p in sorted(Path(cfg.design_docs_dir).rglob("*.docx"))] or None
|
||||
return parser.parse(
|
||||
requirement_paths=[files["requirements"]["path"]] if "requirements" in files else None,
|
||||
template_path=files.get("template", {}).get("path"),
|
||||
write_instruction_paths=[files["write_instruction"]["path"]] if "write_instruction" in files else None,
|
||||
rule_paths=[files["rules"]["path"]] if "rules" in files else None,
|
||||
existing_system_path=files.get("existing_system", {}).get("path"),
|
||||
requirement_paths=req,
|
||||
template_path=tpl,
|
||||
write_instruction_paths=wi,
|
||||
rule_paths=rules,
|
||||
existing_system_path=existing,
|
||||
design_doc_paths=design,
|
||||
existing_system_language=None,
|
||||
)
|
||||
|
||||
@@ -115,7 +175,7 @@ class GenesisService:
|
||||
|
||||
def run_parse(self, session_id: str) -> SessionRecord:
|
||||
rec = self.get_session(session_id)
|
||||
if "requirements" not in rec.files or "template" not in rec.files:
|
||||
if not (self.has_file(rec, "requirements") and self.has_file(rec, "template")):
|
||||
raise ServiceStepError("缺少 requirements / template 文件,无法解析")
|
||||
ss = self._rebuild_source(rec)
|
||||
summary = {
|
||||
@@ -138,7 +198,7 @@ class GenesisService:
|
||||
if rec.status != "awaiting_parse_confirm":
|
||||
raise ServiceStepError(f"当前状态 {rec.status} 不可确认解析")
|
||||
# 有既有系统 → impact_running;否则门控跳过影响 → writing
|
||||
if "existing_system" in rec.files:
|
||||
if self.has_file(rec, "existing_system"):
|
||||
return self.store.update_status(session_id, "impact_running")
|
||||
return self.store.update_status(session_id, "writing")
|
||||
|
||||
@@ -183,7 +243,7 @@ class GenesisService:
|
||||
samples_dir=self.samples_dir,
|
||||
engine=self.engine,
|
||||
prompt_registry=self.prompt_registry,
|
||||
template_path=rec.files.get("template", {}).get("path"),
|
||||
template_path=self._eff_path(rec, "template"),
|
||||
output_language=output_language,
|
||||
)
|
||||
self.store.update_session(
|
||||
@@ -208,7 +268,7 @@ class GenesisService:
|
||||
samples_dir=self.samples_dir,
|
||||
engine=self.engine,
|
||||
prompt_registry=self.prompt_registry,
|
||||
template_path=rec.files.get("template", {}).get("path"),
|
||||
template_path=self._eff_path(rec, "template"),
|
||||
output_language=rec.output_language,
|
||||
)
|
||||
payload = {
|
||||
|
||||
@@ -5,11 +5,32 @@
|
||||
<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; }
|
||||
body { font-family: "Microsoft YaHei", sans-serif; margin: 0; height: 100vh; display: flex; flex-direction: row; background: #f5f6f8; }
|
||||
/* 左侧边栏 */
|
||||
#sidebar { width: 250px; background: #16384d; color: #e6eef4; display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
#sidebar h2 { font-size: 13px; margin: 12px 14px 6px; color: #9fc2d6; letter-spacing: 1px; }
|
||||
#new-chat { margin: 12px 14px 6px; background: #2e86c1; color: #fff; border: none; padding: 8px; border-radius: 8px; cursor: pointer; font-size: 14px; }
|
||||
#new-chat:hover { background: #3498db; }
|
||||
#session-list { flex: 1; overflow-y: auto; padding: 0 8px; }
|
||||
.sess { padding: 8px 10px; border-radius: 8px; margin: 4px 0; cursor: pointer; border: 1px solid transparent; }
|
||||
.sess:hover { background: #1f4d68; }
|
||||
.sess.active { background: #2e86c1; }
|
||||
.sess .name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sess .meta { font-size: 11px; color: #9fc2d6; }
|
||||
#proj-panel { border-top: 1px solid #2c5872; padding: 8px 12px; max-height: 42%; overflow-y: auto; }
|
||||
#proj-panel summary { cursor: pointer; font-size: 13px; color: #9fc2d6; outline: none; }
|
||||
#proj-list { list-style: none; padding: 0; margin: 8px 0; }
|
||||
#proj-list li { font-size: 12px; padding: 4px 6px; border-radius: 6px; cursor: pointer; }
|
||||
#proj-list li:hover, #proj-list li.active { background: #1f4d68; }
|
||||
#proj-form { display: none; margin-top: 8px; }
|
||||
#proj-form.show { display: block; }
|
||||
#proj-form input { width: 100%; margin: 3px 0; padding: 5px; border-radius: 5px; border: 1px solid #2c5872; background: #0f2a3b; color: #e6eef4; font-size: 12px; }
|
||||
#proj-form button { margin-top: 6px; width: 100%; background: #2e86c1; color: #fff; border: none; padding: 6px; border-radius: 6px; cursor: pointer; }
|
||||
/* 主区域 */
|
||||
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
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; }
|
||||
@@ -31,46 +52,76 @@
|
||||
#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; }
|
||||
#proj-hint { color: #b7950b; font-size: 12px; }
|
||||
.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 id="sidebar">
|
||||
<button id="new-chat">+ 新会话</button>
|
||||
<h2>会话历史</h2>
|
||||
<div id="session-list"></div>
|
||||
<div id="proj-panel">
|
||||
<details>
|
||||
<summary>项目配置(模板/规则/代码库/设计文档)</summary>
|
||||
<ul id="proj-list"></ul>
|
||||
<button id="proj-new-btn" style="width:100%;background:#2e86c1;color:#fff;border:none;padding:6px;border-radius:6px;cursor:pointer;font-size:12px;">新建项目</button>
|
||||
<div id="proj-form">
|
||||
<input id="pf-name" placeholder="项目名(英文/唯一)">
|
||||
<input id="pf-display" placeholder="显示名(可选)">
|
||||
<input id="pf-template" placeholder="模板 docx 路径">
|
||||
<input id="pf-write" placeholder="做成说明书 docx 路径">
|
||||
<input id="pf-rules" placeholder="规则 docx 目录(逗号分隔)">
|
||||
<input id="pf-code" placeholder="既有系统代码库目录">
|
||||
<input id="pf-design" placeholder="既有设计文档目录">
|
||||
<button id="pf-save">保存项目</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="composer">
|
||||
<div id="composer-inner">
|
||||
<input id="input" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off">
|
||||
<button id="send">发送</button>
|
||||
<div id="main">
|
||||
<header>
|
||||
<h1>Genesis 概要设计书 Agent</h1>
|
||||
<span id="sid-badge">未创建会话</span>
|
||||
</header>
|
||||
|
||||
<div id="chat"></div>
|
||||
|
||||
<div id="upload-bar">
|
||||
<div id="upload-bar-inner">
|
||||
<span id="proj-hint"></span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sid = null;
|
||||
let currentProject = null; // 新建会话时绑定的项目名
|
||||
let activeProject = null; // 当前会话已绑定的项目名
|
||||
|
||||
const chatEl = document.getElementById('chat');
|
||||
const inputEl = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const badge = document.getElementById('sid-badge');
|
||||
|
||||
function addMsg(role, html) {
|
||||
const m = document.createElement('div');
|
||||
@@ -80,8 +131,7 @@ function addMsg(role, html) {
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
return m;
|
||||
}
|
||||
function esc(s) { return s.replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
||||
|
||||
function esc(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
||||
function showTyping() { return addMsg('assistant', '<span class="typing">思考中…</span>'); }
|
||||
|
||||
async function api(method, url, body) {
|
||||
@@ -93,12 +143,119 @@ async function api(method, url, body) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- 会话列表 ----------
|
||||
async function refreshSessions() {
|
||||
const list = await api('GET', '/api/sessions?user_id=default');
|
||||
const box = document.getElementById('session-list');
|
||||
box.innerHTML = '';
|
||||
list.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
||||
for (const s of list) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sess' + (s.session_id === sid ? ' active' : '');
|
||||
const proj = s.project ? ' · ' + esc(s.project) : '';
|
||||
el.innerHTML = '<div class="name">' + esc(s.name || '新会话') + '</div>'
|
||||
+ '<div class="meta">' + esc(s.status) + proj + '</div>';
|
||||
el.onclick = () => loadSession(s.session_id);
|
||||
box.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 项目配置 ----------
|
||||
async function loadProjects() {
|
||||
const projects = await api('GET', '/api/projects');
|
||||
const ul = document.getElementById('proj-list');
|
||||
ul.innerHTML = '';
|
||||
for (const p of projects) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = p.display_name || p.name;
|
||||
li.title = p.name;
|
||||
if (p.name === currentProject) li.classList.add('active');
|
||||
li.onclick = () => {
|
||||
currentProject = (currentProject === p.name) ? null : p.name;
|
||||
loadProjects();
|
||||
updateUploadBar();
|
||||
addMsg('assistant', currentProject
|
||||
? '已选择项目「' + esc(currentProject) + '」,新建会话将复用其模板/规则/代码库配置,你只需上传要件定义。'
|
||||
: '已取消项目选择,新建会话需自行上传全部资料。');
|
||||
};
|
||||
ul.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('proj-new-btn').onclick = () => {
|
||||
document.getElementById('proj-form').classList.toggle('show');
|
||||
};
|
||||
document.getElementById('pf-save').onclick = async () => {
|
||||
const body = {
|
||||
name: document.getElementById('pf-name').value.trim(),
|
||||
display_name: document.getElementById('pf-display').value.trim(),
|
||||
template: document.getElementById('pf-template').value.trim(),
|
||||
write_instruction: document.getElementById('pf-write').value.trim(),
|
||||
rules: document.getElementById('pf-rules').value.split(',').map(s => s.trim()).filter(Boolean),
|
||||
existing_system_code_dir: document.getElementById('pf-code').value.trim(),
|
||||
design_docs_dir: document.getElementById('pf-design').value.trim(),
|
||||
};
|
||||
if (!body.name) { addMsg('error', '项目名不能为空'); return; }
|
||||
try {
|
||||
const cfg = await api('POST', '/api/projects', body);
|
||||
currentProject = cfg.name;
|
||||
document.getElementById('proj-form').classList.remove('show');
|
||||
await loadProjects();
|
||||
addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。');
|
||||
} catch (e) {
|
||||
addMsg('error', '保存项目失败:' + esc(String(e)));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- 上传区:项目绑定时仅要件定义 ----------
|
||||
function updateUploadBar() {
|
||||
const sel = document.getElementById('file-type');
|
||||
const hint = document.getElementById('proj-hint');
|
||||
if (activeProject || currentProject) {
|
||||
// 已绑定/即将绑定项目:模板/规则/代码库来自项目,用户只传要件定义
|
||||
sel.style.display = 'none';
|
||||
hint.textContent = '(项目「' + esc(activeProject || currentProject) + '」已提供模板/规则/代码库,仅需上传要件定义)';
|
||||
} else {
|
||||
sel.style.display = '';
|
||||
hint.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 会话创建/加载 ----------
|
||||
async function newSession() {
|
||||
const d = await api('POST', '/api/sessions', { user_id: 'default' });
|
||||
const d = await api('POST', '/api/sessions', { user_id: 'default', project: currentProject || null });
|
||||
sid = d.session_id;
|
||||
document.getElementById('sid-badge').textContent = '会话: ' + sid;
|
||||
activeProject = d.project || null;
|
||||
badge.textContent = '会话: ' + (d.name || '新会话');
|
||||
chatEl.innerHTML = '';
|
||||
addMsg('assistant', '你好!我是 Genesis 概要设计书生成 Agent。<br>请先在上方📎上传<strong>要件定义 xlsx</strong>与<strong>模板 docx</strong>,然后直接告诉我:<br>· 「生成概要设计书」(自动解析→影响→生成→QA)<br>· 「用中文生成」 / 「现在什么状态?」');
|
||||
updateUploadBar();
|
||||
await refreshSessions();
|
||||
localStorage.setItem('genesis_session', sid);
|
||||
addMsg('assistant', '你好!我是 Genesis 概要设计书生成 Agent。<br>请先在上方📎上传<strong>要件定义 xlsx</strong>'
|
||||
+ (activeProject ? '(模板/规则/代码库已由项目「' + esc(activeProject) + '」提供)' : '与<strong>模板 docx</strong>')
|
||||
+ ',然后告诉我:<br>· 「生成概要设计书」(自动解析→影响→生成→QA)<br>· 「用中文生成」 / 「现在什么状态?」');
|
||||
}
|
||||
|
||||
async function loadSession(id) {
|
||||
try {
|
||||
const rec = await api('GET', '/api/sessions/' + id);
|
||||
sid = id;
|
||||
activeProject = rec.project || null;
|
||||
badge.textContent = '会话: ' + (rec.name || '新会话');
|
||||
// 载入历史消息
|
||||
const msgs = await api('GET', '/api/chat/' + id + '/messages');
|
||||
chatEl.innerHTML = '';
|
||||
for (const m of msgs) {
|
||||
if (m.role === 'user') addMsg('user', esc(m.content));
|
||||
else if (m.action === 'confirm' || m.action === 'reject') addMsg('assistant', esc(m.content));
|
||||
else addMsg('assistant', esc(m.content));
|
||||
}
|
||||
updateUploadBar();
|
||||
await refreshSessions();
|
||||
localStorage.setItem('genesis_session', sid);
|
||||
} catch (e) {
|
||||
addMsg('error', '加载会话失败:' + esc(String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
@@ -139,7 +296,8 @@ 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;
|
||||
let ft = document.getElementById('file-type').value;
|
||||
if (activeProject || currentProject) ft = 'requirements'; // 项目模式仅要件定义
|
||||
const file = document.getElementById('file-input').files[0];
|
||||
if (!sid) { addMsg('error', '请先创建会话'); return; }
|
||||
if (!file) { addMsg('error', '请选择文件'); return; }
|
||||
@@ -156,13 +314,25 @@ document.getElementById('upload-btn').addEventListener('click', async () => {
|
||||
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 = '';
|
||||
// 名称可能随要件定义自动更新
|
||||
const rec = await api('GET', '/api/sessions/' + sid);
|
||||
badge.textContent = '会话: ' + (rec.name || '新会话');
|
||||
await refreshSessions();
|
||||
} catch (e) {
|
||||
statusEl.textContent = '';
|
||||
addMsg('error', '上传失败:' + esc(String(e)));
|
||||
}
|
||||
});
|
||||
|
||||
newSession().catch(e => addMsg('error', esc(String(e))));
|
||||
// ---------- 启动:恢复上次会话 ----------
|
||||
(async () => {
|
||||
await loadProjects();
|
||||
const saved = localStorage.getItem('genesis_session');
|
||||
if (saved) {
|
||||
try { await loadSession(saved); return; } catch (e) { /* 忽略,新建 */ }
|
||||
}
|
||||
newSession().catch(e => addMsg('error', esc(String(e))));
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+173
-1
@@ -17,6 +17,10 @@ class SessionNotFoundError(Exception):
|
||||
"""会话不存在(对应 api-design §7 404)。"""
|
||||
|
||||
|
||||
class ProjectConfigError(Exception):
|
||||
"""项目配置非法(路径不存在/类型不符/越界)。"""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
@@ -25,6 +29,8 @@ def _now() -> str:
|
||||
class SessionRecord:
|
||||
session_id: str
|
||||
user_id: str
|
||||
name: str = "新会话" # 会话显示名(默认「新会话」,上传要件定义后自动取文件名)
|
||||
project: str = "" # 绑定的项目名(空=无项目)
|
||||
status: str = "uploading"
|
||||
files: dict = field(default_factory=dict) # file_type -> {file_id,name,size,path}
|
||||
structured_summary: str = "" # 解析结果摘要(JSON 字符串)
|
||||
@@ -43,6 +49,8 @@ class SessionRecord:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"name": self.name,
|
||||
"project": self.project,
|
||||
"status": self.status,
|
||||
"files": self.files,
|
||||
"structured_summary": self.structured_summary,
|
||||
@@ -117,10 +125,12 @@ class SessionStore:
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def create_session(self, user_id: str) -> SessionRecord:
|
||||
def create_session(self, user_id: str, name: str | None = None, project: str | None = None) -> SessionRecord:
|
||||
rec = SessionRecord(
|
||||
session_id=uuid.uuid4().hex[:12],
|
||||
user_id=user_id,
|
||||
name=name or "新会话",
|
||||
project=project or "",
|
||||
created_at=_now(),
|
||||
updated_at=_now(),
|
||||
)
|
||||
@@ -186,6 +196,8 @@ class SessionStore:
|
||||
return SessionRecord(
|
||||
session_id=d.get("session_id", ""),
|
||||
user_id=d.get("user_id", ""),
|
||||
name=d.get("name", "新会话"),
|
||||
project=d.get("project", ""),
|
||||
status=d.get("status", "uploading"),
|
||||
files=d.get("files", {}),
|
||||
structured_summary=d.get("structured_summary", ""),
|
||||
@@ -199,3 +211,163 @@ class SessionStore:
|
||||
created_at=d.get("created_at", ""),
|
||||
updated_at=d.get("updated_at", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectConfig:
|
||||
"""项目级配置(前端设置、服务端持久化)。
|
||||
|
||||
模板/做成说明书为单文件;rules/design_docs 为目录(枚举其中 .docx);
|
||||
existing_system_code_dir 为代码库目录(直接交给 CodeParser)。
|
||||
"""
|
||||
|
||||
name: str
|
||||
display_name: str = ""
|
||||
template: str = ""
|
||||
write_instruction: str = ""
|
||||
rules: list = field(default_factory=list)
|
||||
existing_system_code_dir: str = ""
|
||||
design_docs_dir: str = ""
|
||||
|
||||
@property
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"display_name": self.display_name,
|
||||
"template": self.template,
|
||||
"write_instruction": self.write_instruction,
|
||||
"rules": list(self.rules),
|
||||
"existing_system_code_dir": self.existing_system_code_dir,
|
||||
"design_docs_dir": self.design_docs_dir,
|
||||
}
|
||||
|
||||
|
||||
def _validate_project_paths(
|
||||
template: str, write_instruction: str, rules: list, existing_system_code_dir: str, design_docs_dir: str
|
||||
) -> tuple[list, list]:
|
||||
"""校验项目配置路径。
|
||||
|
||||
Returns:
|
||||
(rules_docs, design_docs):枚举后的 .docx 路径列表
|
||||
Raises:
|
||||
ProjectConfigError: 路径不存在或类型不符
|
||||
"""
|
||||
def _docx(p: str, label: str) -> str:
|
||||
pp = Path(p)
|
||||
if not pp.exists():
|
||||
raise ProjectConfigError(f"{label} 路径不存在: {p}")
|
||||
if pp.suffix.lower() != ".docx":
|
||||
raise ProjectConfigError(f"{label} 须为 .docx 文件: {p}")
|
||||
return str(pp)
|
||||
|
||||
def _dir(p: str, label: str) -> str:
|
||||
pp = Path(p)
|
||||
if not pp.exists():
|
||||
raise ProjectConfigError(f"{label} 路径不存在: {p}")
|
||||
if not pp.is_dir():
|
||||
raise ProjectConfigError(f"{label} 须为目录: {p}")
|
||||
return str(pp)
|
||||
|
||||
if template:
|
||||
_docx(template, "模板")
|
||||
if write_instruction:
|
||||
_docx(write_instruction, "做成说明书")
|
||||
if existing_system_code_dir:
|
||||
_dir(existing_system_code_dir, "既有系统代码库")
|
||||
rules_docs = []
|
||||
for p in (rules or []):
|
||||
pp = Path(p)
|
||||
if pp.is_dir():
|
||||
rules_docs.extend(str(x) for x in sorted(pp.rglob("*.docx")))
|
||||
else:
|
||||
rules_docs.append(_docx(p, "记入/图表规则"))
|
||||
design_docs = []
|
||||
if design_docs_dir:
|
||||
ddir = _dir(design_docs_dir, "既有系统设计文档目录")
|
||||
design_docs = [str(p) for p in sorted(Path(ddir).rglob("*.docx"))]
|
||||
return rules_docs, design_docs
|
||||
|
||||
|
||||
class ProjectsStore:
|
||||
"""项目配置存储(复用 SessionStore 同一 SQLite 文件)。"""
|
||||
|
||||
def __init__(self, db_path: str = "data/server/sessions.db") -> None:
|
||||
self._db = Path(db_path)
|
||||
self._db.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self._db))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_db(self) -> None:
|
||||
with self._conn() as c:
|
||||
c.execute(
|
||||
"CREATE TABLE IF NOT EXISTS projects ("
|
||||
" name TEXT PRIMARY KEY,"
|
||||
" display_name TEXT NOT NULL,"
|
||||
" data TEXT NOT NULL)"
|
||||
)
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
name: str,
|
||||
display_name: str,
|
||||
template: str,
|
||||
write_instruction: str,
|
||||
rules: list,
|
||||
existing_system_code_dir: str,
|
||||
design_docs_dir: str,
|
||||
) -> ProjectConfig:
|
||||
if not name:
|
||||
raise ProjectConfigError("项目名称不能为空")
|
||||
# 校验路径(同时枚举 rules / design_docs 目录)
|
||||
rules_docs, design_docs = _validate_project_paths(
|
||||
template, write_instruction, rules, existing_system_code_dir, design_docs_dir
|
||||
)
|
||||
cfg = ProjectConfig(
|
||||
name=name,
|
||||
display_name=display_name or name,
|
||||
template=template,
|
||||
write_instruction=write_instruction,
|
||||
rules=rules_docs,
|
||||
existing_system_code_dir=existing_system_code_dir,
|
||||
design_docs_dir=design_docs_dir,
|
||||
)
|
||||
with self._conn() as c:
|
||||
c.execute(
|
||||
"INSERT INTO projects (name, display_name, data) VALUES (?, ?, ?)"
|
||||
" ON CONFLICT(name) DO UPDATE SET display_name=excluded.display_name, data=excluded.data",
|
||||
(cfg.name, cfg.display_name, json.dumps(cfg.to_dict, ensure_ascii=False)),
|
||||
)
|
||||
return cfg
|
||||
|
||||
def get(self, name: str) -> ProjectConfig | None:
|
||||
with self._conn() as c:
|
||||
row = c.execute("SELECT data FROM projects WHERE name = ?", (name,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._from_dict(json.loads(row["data"]))
|
||||
|
||||
def list(self) -> list[ProjectConfig]:
|
||||
with self._conn() as c:
|
||||
rows = c.execute("SELECT data FROM projects ORDER BY name").fetchall()
|
||||
return [self._from_dict(json.loads(r["data"])) for r in rows]
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
with self._conn() as c:
|
||||
cur = c.execute("DELETE FROM projects WHERE name = ?", (name,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
def _from_dict(d: dict) -> ProjectConfig:
|
||||
return ProjectConfig(
|
||||
name=d.get("name", ""),
|
||||
display_name=d.get("display_name", ""),
|
||||
template=d.get("template", ""),
|
||||
write_instruction=d.get("write_instruction", ""),
|
||||
rules=d.get("rules", []) or [],
|
||||
existing_system_code_dir=d.get("existing_system_code_dir", ""),
|
||||
design_docs_dir=d.get("design_docs_dir", ""),
|
||||
)
|
||||
|
||||
@@ -95,6 +95,87 @@ def test_status_reports_current_state(tmp_path):
|
||||
assert "uploading" in r["reply"]
|
||||
|
||||
|
||||
def test_status_reply_with_project_design_docs(tmp_path):
|
||||
from genesis.server.store import ProjectsStore
|
||||
store = SessionStore(db_path=str(tmp_path / "s.db"))
|
||||
dd = tmp_path / "design"
|
||||
dd.mkdir()
|
||||
(dd / "d.docx").write_bytes(b"PK\x03\x04")
|
||||
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
|
||||
projects.upsert(name="projA", display_name="P", template="", write_instruction="",
|
||||
rules=[], existing_system_code_dir="", design_docs_dir=str(dd))
|
||||
agent = ChatAgent(GenesisService(store=store, data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(), projects=projects), fake=True)
|
||||
sid = agent.service.create_session("u1", project="projA").session_id
|
||||
r = agent.handle_message(sid, "现在什么状态?")
|
||||
assert "design_docs" in r["reply"]
|
||||
|
||||
|
||||
def test_status_reply_lists_existing_system_and_design_docs(tmp_path):
|
||||
from genesis.server.store import ProjectsStore
|
||||
store = SessionStore(db_path=str(tmp_path / "s.db"))
|
||||
dd = tmp_path / "design"; dd.mkdir(); (dd / "d.docx").write_bytes(b"PK\x03\x04")
|
||||
code = tmp_path / "code"; code.mkdir()
|
||||
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
|
||||
projects.upsert(name="projA", display_name="P", template="", write_instruction="",
|
||||
rules=[], existing_system_code_dir=str(code), design_docs_dir=str(dd))
|
||||
agent = ChatAgent(GenesisService(store=store, data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(), projects=projects), fake=True)
|
||||
sid = agent.service.create_session("u1", project="projA").session_id
|
||||
reply = agent._status_reply(agent.service.get_session(sid), [])
|
||||
assert "existing_system" in reply["reply"] and "design_docs" in reply["reply"]
|
||||
|
||||
|
||||
def test_auto_generate_unknown_status_falls_to_status_reply(tmp_path):
|
||||
from genesis.server.store import ProjectsStore
|
||||
store = SessionStore(db_path=str(tmp_path / "s.db"))
|
||||
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
|
||||
projects.upsert(name="projA", display_name="P",
|
||||
template=str(_SAMPLE / "template_design_ja.docx"),
|
||||
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
|
||||
agent = ChatAgent(GenesisService(store=store, data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(), projects=projects), fake=True)
|
||||
sid = agent.service.create_session("u1", project="projA").session_id
|
||||
# 上传要件定义,使 has_file 通过;随后将状态置为 done 触发 _status_reply 分支
|
||||
agent.service.upload_file(sid, "requirements", "requirements_newdev.xlsx",
|
||||
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
|
||||
agent.service.store.update_status(sid, "done")
|
||||
r = agent._auto_generate(sid, "auto", [])
|
||||
assert "done" in r["reply"]
|
||||
|
||||
|
||||
def test_run_generate_from_awaiting_impact_confirm_confirms_first(tmp_path):
|
||||
agent = ChatAgent(_svc(tmp_path), fake=True)
|
||||
sid = agent.service.create_session("u1").session_id
|
||||
agent.service.store.update_status(sid, "awaiting_impact_confirm")
|
||||
rec = agent.service.get_session(sid)
|
||||
r = agent._run_generate(sid, rec, [], output_language="auto")
|
||||
# confirm_impact 先执行(line 194),随后 generate 因缺文件抛错被捕获
|
||||
assert r["status"] in ("writing", "awaiting_impact_confirm")
|
||||
|
||||
|
||||
def test_run_impact_from_awaiting_parse_confirm(tmp_path):
|
||||
from genesis.server.store import ProjectsStore
|
||||
store = SessionStore(db_path=str(tmp_path / "s.db"))
|
||||
code = tmp_path / "code"; code.mkdir(); (code / "A.java").write_text("class A {}")
|
||||
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
|
||||
projects.upsert(name="projA", display_name="P", template="", write_instruction="",
|
||||
rules=[], existing_system_code_dir=str(code), design_docs_dir="")
|
||||
agent = ChatAgent(GenesisService(store=store, data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(), projects=projects), fake=True)
|
||||
sid = agent.service.create_session("u1", project="projA").session_id
|
||||
agent.service.store.update_status(sid, "awaiting_parse_confirm")
|
||||
r = agent._run_impact(sid, [])
|
||||
assert r["status"] == "awaiting_impact_confirm"
|
||||
|
||||
|
||||
def test_impact_brief_malformed_returns_default(tmp_path):
|
||||
agent = ChatAgent(_svc(tmp_path), fake=True)
|
||||
rec = agent.service.create_session("u1")
|
||||
rec.impact_summary = "not-json"
|
||||
assert agent._impact_brief(rec) == "已完成"
|
||||
|
||||
|
||||
def test_generate_without_files_hints_upload(tmp_path):
|
||||
agent = ChatAgent(_svc(tmp_path), fake=True)
|
||||
sid = agent.service.create_session("u1").session_id
|
||||
|
||||
@@ -3,9 +3,9 @@ import pytest
|
||||
|
||||
from genesis.data_models import (
|
||||
CellValue, ChangeType, ControllerInfo, EntityInfo, ExcelTable,
|
||||
ExistingSystemInfo, Provenance, ServiceInfo, SheetType, StructuredSource,
|
||||
ExistingSystemInfo, Provenance, RuleDocument, ServiceInfo, SheetType, StructuredSource,
|
||||
)
|
||||
from genesis.impact.impact_agent import ImpactAgent
|
||||
from genesis.impact.impact_agent import ImpactAgent, impact_report_to_dict
|
||||
|
||||
|
||||
def _cv(value) -> CellValue:
|
||||
@@ -201,3 +201,40 @@ def test_impact_report_to_dict_serializable():
|
||||
assert d["change_analysis"]["new_elements"][0]["change_type"] == "新規"
|
||||
assert d["change_analysis"]["modified_elements"][0]["impacted_existing"] == ["OrderController"]
|
||||
assert d["summary"]["modified"] == 1
|
||||
|
||||
|
||||
def _doc(name: str, content: str) -> RuleDocument:
|
||||
return RuleDocument(file_name=name, category="design", markdown_content=content,
|
||||
source_path=name, file_type="word", hash="h")
|
||||
|
||||
|
||||
def _ss_with_design(existing, tables, docs) -> StructuredSource:
|
||||
return StructuredSource(tables=tables, template=None, rule_docs=[], image_analyses=[],
|
||||
existing_system=existing, comments=[], design_docs=docs)
|
||||
|
||||
|
||||
def test_design_doc_cross_reference_hits_identifier():
|
||||
docs = [_doc("design_a.docx", "本章描述 OrderController 的限价单处理逻辑与风控。")]
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
|
||||
report = ImpactAgent().run(_ss_with_design(_existing(), tables, docs), session_id="d")
|
||||
refs = report.design_references
|
||||
ids = {r.identifier for r in refs}
|
||||
assert "OrderController" in ids
|
||||
assert refs[0].doc_name == "design_a.docx"
|
||||
assert "OrderController" in refs[0].snippet
|
||||
# 序列化也包含
|
||||
assert any(r["identifier"] == "OrderController"
|
||||
for r in impact_report_to_dict(report)["design_references"])
|
||||
|
||||
|
||||
def test_design_doc_cross_reference_empty_when_no_docs():
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
|
||||
report = ImpactAgent().run(_ss_with_design(_existing(), tables, []), session_id="d")
|
||||
assert report.design_references == []
|
||||
|
||||
|
||||
def test_design_doc_with_empty_content_skipped():
|
||||
docs = [_doc("empty.docx", "")] # 无正文 → continue(line 164)
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
|
||||
report = ImpactAgent().run(_ss_with_design(_existing(), tables, docs), session_id="d")
|
||||
assert report.design_references == [] # 无正文设计文档被跳过,line 165 正常执行
|
||||
|
||||
@@ -279,3 +279,71 @@ def test_real_mode_generate_without_key_500(tmp_path, monkeypatch):
|
||||
client.post(f"/api/sessions/{sid}/confirm-parse")
|
||||
r = client.post(f"/api/sessions/{sid}/generate", json={})
|
||||
assert r.status_code == 500
|
||||
|
||||
|
||||
# ---------- 项目配置(S6) ----------
|
||||
|
||||
def test_create_and_list_projects(client):
|
||||
body = {
|
||||
"name": "stock", "display_name": "股票系统",
|
||||
"template": str(_SAMPLE / "template_design_ja.docx"),
|
||||
"write_instruction": "", "rules": [],
|
||||
"existing_system_code_dir": "", "design_docs_dir": "",
|
||||
}
|
||||
r = client.post("/api/projects", json=body)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["name"] == "stock"
|
||||
lst = client.get("/api/projects").json()
|
||||
assert any(p["name"] == "stock" for p in lst)
|
||||
one = client.get("/api/projects/stock").json()
|
||||
assert one["template"] == str(_SAMPLE / "template_design_ja.docx")
|
||||
|
||||
|
||||
def test_project_invalid_template_400(client):
|
||||
r = client.post("/api/projects", json={"name": "p", "template": "/no/such.docx"})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["code"] == "PROJECT_CONFIG_INVALID"
|
||||
|
||||
|
||||
def test_delete_project(client):
|
||||
client.post("/api/projects", json={"name": "p", "template": str(_SAMPLE / "template_design_ja.docx")})
|
||||
assert client.delete("/api/projects/p").json()["deleted"] is True
|
||||
assert client.get("/api/projects/p").status_code == 404
|
||||
|
||||
|
||||
def test_session_accepts_name_and_project(client):
|
||||
r = client.post("/api/sessions", json={"user_id": "u1", "name": "我的会话", "project": "projA"})
|
||||
assert r.status_code == 200
|
||||
sid = r.json()["session_id"]
|
||||
assert r.json()["name"] == "我的会话"
|
||||
g = client.get(f"/api/sessions/{sid}").json()
|
||||
assert g["name"] == "我的会话"
|
||||
assert g["project"] == "projA"
|
||||
|
||||
|
||||
def test_session_list_includes_name_project(client):
|
||||
client.post("/api/sessions", json={"user_id": "u1", "name": "n1", "project": "pA"})
|
||||
r = client.get("/api/sessions", params={"user_id": "u1"}).json()
|
||||
assert any(s["name"] == "n1" and s["project"] == "pA" for s in r)
|
||||
|
||||
|
||||
def test_generate_with_project_config_no_template_upload(client):
|
||||
"""绑定项目(含模板/规则)后,仅上传要件定义即可解析生成。"""
|
||||
client.post("/api/projects", json={
|
||||
"name": "projA", "template": str(_SAMPLE / "template_design_ja.docx"),
|
||||
"write_instruction": str(_SAMPLE / "rules_design_ja.docx"),
|
||||
"rules": [str(_SAMPLE / "rules_entry_ja.docx")],
|
||||
"existing_system_code_dir": "", "design_docs_dir": "",
|
||||
})
|
||||
sid = client.post("/api/sessions", json={"user_id": "u1", "project": "projA"}).json()["session_id"]
|
||||
# 仅上传要件定义
|
||||
r = client.post(f"/api/sessions/{sid}/files", data={"file_type": "requirements"},
|
||||
files={"file": ("requirements_newdev.xlsx", (_SAMPLE / "requirements_newdev.xlsx").read_bytes())})
|
||||
assert r.status_code == 200
|
||||
p = client.post(f"/api/sessions/{sid}/start-parse")
|
||||
assert p.status_code == 200, p.text
|
||||
assert client.post(f"/api/sessions/{sid}/confirm-parse").status_code == 200
|
||||
# 无既有系统 → 直接 writing,可生成
|
||||
gen = client.post(f"/api/sessions/{sid}/generate", json={})
|
||||
assert gen.status_code == 200, gen.text
|
||||
assert client.get(f"/api/sessions/{sid}/result/download").status_code == 200
|
||||
|
||||
@@ -9,7 +9,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from genesis.server.store import SessionStore
|
||||
from genesis.server.store import SessionStore, ProjectsStore
|
||||
from genesis.server.service import GenesisService, ServiceStepError, FileTypeError
|
||||
|
||||
_SAMPLE = Path(__file__).resolve().parents[1] / "sample"
|
||||
@@ -59,6 +59,104 @@ def test_create_and_upload_files(svc):
|
||||
assert got.files["requirements"]["name"] == "requirements_newdev.xlsx"
|
||||
|
||||
|
||||
def test_create_session_name_and_project(tmp_path):
|
||||
svc = GenesisService(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(),
|
||||
)
|
||||
s = svc.create_session("u1", name="我的会话", project="projA")
|
||||
got = svc.get_session(s.session_id)
|
||||
assert got.name == "我的会话"
|
||||
assert got.project == "projA"
|
||||
|
||||
|
||||
def test_upload_requirements_auto_names_session(tmp_path):
|
||||
svc = GenesisService(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"),
|
||||
engine=FakeEngine(),
|
||||
)
|
||||
s = svc.create_session("u1") # 默认「新会话」
|
||||
svc.upload_file(s.session_id, "requirements", "要件定义_v2.xlsx", b"PK\x03\x04")
|
||||
assert svc.get_session(s.session_id).name == "要件定义_v2"
|
||||
|
||||
|
||||
def test_has_file_falls_back_to_project_config(tmp_path):
|
||||
store = SessionStore(db_path=str(tmp_path / "s.db"))
|
||||
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
|
||||
projects.upsert(name="projA", display_name="P", template=str(_SAMPLE / "template_design_ja.docx"),
|
||||
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
|
||||
svc = GenesisService(
|
||||
store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), projects=projects,
|
||||
)
|
||||
s = svc.create_session("u1", project="projA")
|
||||
svc.upload_file(s.session_id, "requirements", "requirements_newdev.xlsx",
|
||||
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
|
||||
rec = svc.get_session(s.session_id)
|
||||
# requirements 仅用户上传;template 来自项目配置
|
||||
assert svc.has_file(rec, "requirements") is True
|
||||
assert svc.has_file(rec, "template") is True
|
||||
assert svc.has_file(rec, "write_instruction") is False
|
||||
assert svc._eff_path(rec, "template") == str(_SAMPLE / "template_design_ja.docx")
|
||||
|
||||
|
||||
def test_rebuild_source_merges_project_config(tmp_path):
|
||||
store = SessionStore(db_path=str(tmp_path / "s.db"))
|
||||
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
|
||||
projects.upsert(name="projA", display_name="P", template=str(_SAMPLE / "template_design_ja.docx"),
|
||||
write_instruction=str(_SAMPLE / "rules_design_ja.docx"),
|
||||
rules=[str(_SAMPLE / "rules_entry_ja.docx")],
|
||||
existing_system_code_dir="", design_docs_dir="")
|
||||
svc = GenesisService(
|
||||
store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), projects=projects,
|
||||
)
|
||||
s = svc.create_session("u1", project="projA")
|
||||
svc.upload_file(s.session_id, "requirements", "requirements_newdev.xlsx",
|
||||
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
|
||||
ss = svc._rebuild_source(svc.get_session(s.session_id))
|
||||
assert ss.template is not None
|
||||
assert len(ss.rule_docs) >= 2 # write_instruction + rules 合并
|
||||
|
||||
|
||||
def test_rebuild_source_enumerates_design_docs_dir(tmp_path):
|
||||
from docx import Document
|
||||
store = SessionStore(db_path=str(tmp_path / "s.db"))
|
||||
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
|
||||
dd = tmp_path / "design"
|
||||
dd.mkdir()
|
||||
d = Document()
|
||||
d.add_paragraph("设计文档:订单管理整体方案")
|
||||
d.save(dd / "d1.docx")
|
||||
projects.upsert(name="projA", display_name="P", template="", write_instruction="",
|
||||
rules=[], existing_system_code_dir="", design_docs_dir=str(dd))
|
||||
svc = GenesisService(
|
||||
store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), projects=projects,
|
||||
)
|
||||
s = svc.create_session("u1", project="projA")
|
||||
svc.upload_file(s.session_id, "requirements", "requirements_newdev.xlsx",
|
||||
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
|
||||
ss = svc._rebuild_source(svc.get_session(s.session_id))
|
||||
assert len(ss.design_docs) == 1
|
||||
|
||||
|
||||
def test_has_file_without_projects_returns_false(svc):
|
||||
s = svc.create_session("u1")
|
||||
rec = svc.get_session(s.session_id)
|
||||
assert svc.has_file(rec, "template") is False
|
||||
assert svc._eff_path(rec, "template") is None
|
||||
|
||||
|
||||
def test_upload_requirements_does_not_override_named_session(tmp_path):
|
||||
svc = GenesisService(
|
||||
store=SessionStore(db_path=str(tmp_path / "s.db")),
|
||||
data_root=str(tmp_path / "data"), engine=FakeEngine(),
|
||||
)
|
||||
s = svc.create_session("u1", name="已命名会话")
|
||||
svc.upload_file(s.session_id, "requirements", "other_name.xlsx", b"PK\x03\x04")
|
||||
assert svc.get_session(s.session_id).name == "已命名会话"
|
||||
|
||||
|
||||
def test_upload_invalid_type_rejected(svc):
|
||||
s = svc.create_session("u1")
|
||||
with pytest.raises(FileTypeError):
|
||||
|
||||
+136
-2
@@ -1,12 +1,15 @@
|
||||
"""S2:SQLite 会话存储测试(server/store.py)。
|
||||
|
||||
覆盖:创建会话 / 列表 / 状态更新 / 文件登记 / 结果路径 / 持久化重建。
|
||||
覆盖:创建会话 / 列表 / 状态更新 / 文件登记 / 结果路径 / 持久化重建 / 会话命名与项目绑定 / 项目配置存储。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from genesis.server.store import SessionStore, SessionRecord, SessionNotFoundError
|
||||
from genesis.server.store import (
|
||||
SessionStore, SessionRecord, SessionNotFoundError, ProjectsStore, ProjectConfigError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -79,3 +82,134 @@ def test_store_reload_persists(tmp_path):
|
||||
got = store2.get_session(s.session_id)
|
||||
assert got.status == "done"
|
||||
assert got.result_path == "x.docx"
|
||||
|
||||
|
||||
# ---------- 会话命名与项目绑定 ----------
|
||||
|
||||
def test_session_record_name_and_project_defaults():
|
||||
rec = SessionRecord(session_id="s1", user_id="u1")
|
||||
assert rec.name == "新会话"
|
||||
assert rec.project == ""
|
||||
assert rec.to_dict["name"] == "新会话"
|
||||
assert rec.to_dict["project"] == ""
|
||||
|
||||
|
||||
def test_create_session_with_name_and_project(store):
|
||||
s = store.create_session("u1", name="我的会话", project="projA")
|
||||
got = store.get_session(s.session_id)
|
||||
assert got.name == "我的会话"
|
||||
assert got.project == "projA"
|
||||
|
||||
|
||||
def test_session_persists_name_project(tmp_path):
|
||||
db = str(tmp_path / "s.db")
|
||||
s = SessionStore(db_path=db).create_session("u1", name="名字", project="p")
|
||||
reopened = SessionStore(db_path=db).get_session(s.session_id)
|
||||
assert reopened.name == "名字"
|
||||
assert reopened.project == "p"
|
||||
|
||||
|
||||
# ---------- 项目配置存储(ProjectsStore) ----------
|
||||
|
||||
@pytest.fixture
|
||||
def projects_store(tmp_path):
|
||||
return ProjectsStore(db_path=str(tmp_path / "projects.db"))
|
||||
|
||||
|
||||
def _make_docx(path: Path) -> str:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"PK\x03\x04") # 最小占位,仅用于存在性/后缀校验
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_projects_upsert_and_get(projects_store, tmp_path):
|
||||
tpl = _make_docx(tmp_path / "t.docx")
|
||||
wi = _make_docx(tmp_path / "wi.docx")
|
||||
rules_dir = tmp_path / "rules"
|
||||
rules_dir.mkdir()
|
||||
_make_docx(rules_dir / "r1.docx")
|
||||
cfg = projects_store.upsert(
|
||||
name="stock", display_name="股票系统", template=tpl, write_instruction=wi,
|
||||
rules=[str(rules_dir)], existing_system_code_dir="", design_docs_dir="",
|
||||
)
|
||||
assert cfg.name == "stock"
|
||||
got = projects_store.get("stock")
|
||||
assert got is not None
|
||||
assert got.display_name == "股票系统"
|
||||
assert got.template == tpl
|
||||
assert got.rules == [str(rules_dir / "r1.docx")]
|
||||
|
||||
|
||||
def test_projects_list_and_delete(projects_store, tmp_path):
|
||||
_make_docx(tmp_path / "t.docx")
|
||||
projects_store.upsert(name="p1", display_name="P1", template=str(tmp_path / "t.docx"),
|
||||
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
|
||||
assert any(p.name == "p1" for p in projects_store.list())
|
||||
assert projects_store.delete("p1") is True
|
||||
assert projects_store.get("p1") is None
|
||||
|
||||
|
||||
def test_projects_upsert_enumerates_design_docs_dir(projects_store, tmp_path):
|
||||
_make_docx(tmp_path / "t.docx")
|
||||
dd = tmp_path / "design"
|
||||
dd.mkdir()
|
||||
_make_docx(dd / "d1.docx")
|
||||
_make_docx(dd / "d2.docx")
|
||||
cfg = projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "t.docx"),
|
||||
write_instruction="", rules=[], existing_system_code_dir="",
|
||||
design_docs_dir=str(dd))
|
||||
assert set(cfg.rules) == set() # 仅 design_docs_dir,rules 为空
|
||||
assert len(cfg.rules) == 0
|
||||
|
||||
|
||||
def test_projects_upsert_rejects_missing_template(projects_store, tmp_path):
|
||||
with pytest.raises(ProjectConfigError):
|
||||
projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "nope.docx"),
|
||||
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
|
||||
|
||||
|
||||
def test_projects_upsert_rejects_non_docx(projects_store, tmp_path):
|
||||
bad = tmp_path / "x.txt"
|
||||
bad.write_text("x")
|
||||
with pytest.raises(ProjectConfigError):
|
||||
projects_store.upsert(name="p", display_name="P", template=str(bad),
|
||||
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
|
||||
|
||||
|
||||
def test_projects_upsert_rejects_missing_code_dir(projects_store, tmp_path):
|
||||
_make_docx(tmp_path / "t.docx")
|
||||
with pytest.raises(ProjectConfigError):
|
||||
projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "t.docx"),
|
||||
write_instruction="", rules=[], existing_system_code_dir=str(tmp_path / "missing"),
|
||||
design_docs_dir="")
|
||||
|
||||
|
||||
def test_projects_reupsert_updates(projects_store, tmp_path):
|
||||
_make_docx(tmp_path / "t.docx")
|
||||
projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "t.docx"),
|
||||
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
|
||||
projects_store.upsert(name="p", display_name="P2", template=str(tmp_path / "t.docx"),
|
||||
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
|
||||
assert projects_store.get("p").display_name == "P2"
|
||||
|
||||
|
||||
def test_projects_code_dir_must_be_directory(projects_store, tmp_path):
|
||||
_make_docx(tmp_path / "t.docx")
|
||||
(tmp_path / "afile.txt").write_text("x")
|
||||
with pytest.raises(ProjectConfigError):
|
||||
projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "t.docx"),
|
||||
write_instruction="", rules=[], existing_system_code_dir=str(tmp_path / "afile.txt"),
|
||||
design_docs_dir="")
|
||||
|
||||
|
||||
def test_projects_empty_template_skips_validation(projects_store, tmp_path):
|
||||
# 模板留空:不应触发文件校验(line 271/273 分支)
|
||||
cfg = projects_store.upsert(name="p", display_name="P", template="",
|
||||
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
|
||||
assert cfg.template == ""
|
||||
|
||||
|
||||
def test_projects_upsert_empty_name_rejected(projects_store, tmp_path):
|
||||
with pytest.raises(ProjectConfigError):
|
||||
projects_store.upsert(name="", display_name="P", template="", write_instruction="",
|
||||
rules=[], existing_system_code_dir="", design_docs_dir="")
|
||||
|
||||
@@ -54,6 +54,14 @@ def test_parse_missing_requirement_file(tmp_path):
|
||||
SourceParser().parse(requirement_paths=[tmp_path / "missing.xlsx"])
|
||||
|
||||
|
||||
def test_parse_design_docs_collected_as_design_category(tmp_path):
|
||||
xlsx = _xlsx(tmp_path)
|
||||
design = make_rule_doc(tmp_path, [("H1", "設計書:注文管理の全体方針")])
|
||||
result = SourceParser().parse(requirement_paths=[xlsx], design_doc_paths=[design])
|
||||
assert len(result.design_docs) == 1
|
||||
assert result.design_docs[0].category == "design"
|
||||
|
||||
|
||||
def test_parse_missing_template_file(tmp_path):
|
||||
xlsx = _xlsx(tmp_path)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
|
||||
Reference in New Issue
Block a user