From 916c5beed78d82188aef9c1df034bc94ae3dddf4 Mon Sep 17 00:00:00 2001 From: lhl Date: Thu, 27 Aug 2026 12:13:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=A1=B9=E7=9B=AE=E7=BA=A7?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20+=20=E4=BC=9A=E8=AF=9D=E5=91=BD=E5=90=8D/?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=20+=20=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=E5=BD=B1=E5=93=8D=E8=B0=83=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会话支持 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% --- README.md | 9 +- _AI_USAGE_LOG.md | 1 + docs/design.md | 29 +++ docs/plan-web-chat-project-config.md | 123 ++++++++++++ src/genesis/chat/agent.py | 24 ++- src/genesis/data_models.py | 10 + src/genesis/impact/impact_agent.py | 39 ++++ src/genesis/parsers/source_aggregator.py | 8 + src/genesis/server/app.py | 62 +++++- src/genesis/server/service.py | 86 ++++++-- src/genesis/server/static/chat.html | 238 +++++++++++++++++++---- src/genesis/server/store.py | 174 ++++++++++++++++- tests/test_chat_agent.py | 81 ++++++++ tests/test_impact_agent.py | 41 +++- tests/test_server_api.py | 68 +++++++ tests/test_server_service.py | 100 +++++++++- tests/test_server_store.py | 138 ++++++++++++- tests/test_source_aggregator.py | 8 + 18 files changed, 1174 insertions(+), 65 deletions(-) create mode 100644 docs/plan-web-chat-project-config.md diff --git a/README.md b/README.md index a848393..5a4bc1c 100644 --- a/README.md +++ b/README.md @@ -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 阶段黑盒冒烟 ## 测试 diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 88a0517..1fe1010 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -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 | diff --git a/docs/design.md b/docs/design.md index e67228a..b32e33e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 调用,保持影响调查零幻觉目标。 diff --git a/docs/plan-web-chat-project-config.md b/docs/plan-web-chat-project-config.md new file mode 100644 index 0000000..dc15e09 --- /dev/null +++ b/docs/plan-web-chat-project-config.md @@ -0,0 +1,123 @@ +# Web 聊天前端改造 + 项目级配置 + 设计文档纳入影响(实施计划) + +> 状态:已评审定稿(2026-08-26) +> 决策记录: +> - 会话名称:自动取要件定义文件名(未上传前显示「新会话」),header 不显示会话 ID +> - 历史:左侧会话列表侧边栏,可切换历史会话并加载聊天记录 +> - 配置单位:**以项目为单位**(服务端持久化,复用 SQLite),项目下多个会话共享配置 +> - 上传区:选定项目后只显示「要件定义 xlsx(必需)」,其余来自项目配置 +> - 设计文档入影响:**A 确定性交叉引用**(代码变更点标识符 × 设计文档文本命中 → design_references) + +--- + +## 一、总体设计 + +| 维度 | 方案 | +|---|---| +| 会话名称 | `name` 自动取要件定义文件名;默认「新会话」;header 显示 `会话: ` | +| 历史 | 左侧会话列表侧边栏;点击切换并 `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 显示 `会话: `(不含 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`。 diff --git a/src/genesis/chat/agent.py b/src/genesis/chat/agent.py index f914ae2..327eb68 100644 --- a/src/genesis/chat/agent.py +++ b/src/genesis/chat/agent.py @@ -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") diff --git a/src/genesis/data_models.py b/src/genesis/data_models.py index 123c549..9ee2fe2 100644 --- a/src/genesis/data_models.py +++ b/src/genesis/data_models.py @@ -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) diff --git a/src/genesis/impact/impact_agent.py b/src/genesis/impact/impact_agent.py index 5f42eca..bf5e509 100644 --- a/src/genesis/impact/impact_agent.py +++ b/src/genesis/impact/impact_agent.py @@ -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( diff --git a/src/genesis/parsers/source_aggregator.py b/src/genesis/parsers/source_aggregator.py index 4792745..f161462 100644 --- a/src/genesis/parsers/source_aggregator.py +++ b/src/genesis/parsers/source_aggregator.py @@ -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, ) diff --git a/src/genesis/server/app.py b/src/genesis/server/app.py index d6c4161..c9d7c5f 100644 --- a/src/genesis/server/app.py +++ b/src/genesis/server/app.py @@ -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) ] diff --git a/src/genesis/server/service.py b/src/genesis/server/service.py index e32bd2a..437ddd0 100644 --- a/src/genesis/server/service.py +++ b/src/genesis/server/service.py @@ -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 = { diff --git a/src/genesis/server/static/chat.html b/src/genesis/server/static/chat.html index 3eaccba..2aec0ca 100644 --- a/src/genesis/server/static/chat.html +++ b/src/genesis/server/static/chat.html @@ -5,11 +5,32 @@ Genesis — 概要设计书自动生成(对话) -
-

Genesis 概要设计书 Agent

- 未创建会话 - -
- -
- -
-
- 📎 上传资料: - - - - + -
-
- - +
+
+

Genesis 概要设计书 Agent

+ 未创建会话 +
+ +
+ +
+
+ + 📎 上传资料: + + + + +
+
+ +
+
+ + +
diff --git a/src/genesis/server/store.py b/src/genesis/server/store.py index 7455bc9..dc56870 100644 --- a/src/genesis/server/store.py +++ b/src/genesis/server/store.py @@ -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", ""), + ) diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py index 29180a1..122af3f 100644 --- a/tests/test_chat_agent.py +++ b/tests/test_chat_agent.py @@ -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 diff --git a/tests/test_impact_agent.py b/tests/test_impact_agent.py index 9742249..c833126 100644 --- a/tests/test_impact_agent.py +++ b/tests/test_impact_agent.py @@ -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 正常执行 diff --git a/tests/test_server_api.py b/tests/test_server_api.py index c775061..32177db 100644 --- a/tests/test_server_api.py +++ b/tests/test_server_api.py @@ -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 diff --git a/tests/test_server_service.py b/tests/test_server_service.py index f793403..e9215a1 100644 --- a/tests/test_server_service.py +++ b/tests/test_server_service.py @@ -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): diff --git a/tests/test_server_store.py b/tests/test_server_store.py index 8633cca..aef09c2 100644 --- a/tests/test_server_store.py +++ b/tests/test_server_store.py @@ -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="") diff --git a/tests/test_source_aggregator.py b/tests/test_source_aggregator.py index 4702dfe..48c281d 100644 --- a/tests/test_source_aggregator.py +++ b/tests/test_source_aggregator.py @@ -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):