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:
lhl
2026-08-27 12:13:28 +08:00
parent 838a93720e
commit 916c5beed7
18 changed files with 1174 additions and 65 deletions
+7 -2
View File
@@ -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`multiparttype=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`multiparttype=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 阶段黑盒冒烟
## 测试
+1
View File
@@ -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.mdASCII);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 |
+29
View File
@@ -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 调用,保持影响调查零幻觉目标。
+123
View File
@@ -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` → upsertbodydisplay_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`
+19 -5
View File
@@ -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")
+10
View File
@@ -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)
+39
View File
@@ -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(
+8
View File
@@ -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,
)
+57 -5
View File
@@ -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)
]
+73 -13
View File
@@ -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 = {
+204 -34
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
function esc(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[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
View File
@@ -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", ""),
)
+81
View File
@@ -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
+39 -2
View File
@@ -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", "")] # 无正文 → continueline 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 正常执行
+68
View File
@@ -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
+99 -1
View File
@@ -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
View File
@@ -1,12 +1,15 @@
"""S2SQLite 会话存储测试(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_dirrules 为空
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="")
+8
View File
@@ -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):