docs(rag): 补录 RAG 迭代各任务评审报告

This commit is contained in:
lhl
2026-08-30 00:57:36 +08:00
parent b2277ebf05
commit cc8850cffe
3 changed files with 263 additions and 0 deletions
@@ -0,0 +1,63 @@
# RAG 迭代 Task 3 实现报告(含评审修订 D1 的 index_dir
## 状态
**GREEN — 已完成并通过测试**
- 先写失败测试(RED:因 `impact_rag.py` 缺失触发 `ModuleNotFoundError`
- 实现 `ImpactRAG` 及 D1 修订项 `index_dir`
- 重新运行测试:`2 passed`
## Commit
`feat(rag): 新增 ImpactRAG 索引/检索服务(含 index_dir`
涉及文件:
- `src/genesis/rag/impact_rag.py`(新增)
- `tests/test_impact_rag.py`(新增)
## 测试输出摘要
```
python -m pytest tests/test_impact_rag.py -q
2 passed in 2.86s
```
测试用例:
1. `test_retrieve_returns_relevant_chunk`:验证 `index` + `retrieve` 能做到 chunk 级语义召回(FakeEmbedder 词袋向量 + cosine),返回结果含 `OrderController`
2. `test_index_dir_reads_text_files`:验证 `index_dir` 仅索引文本源文件(白名单扩展名),跳过 `binary.bin` 与非文本/空文件,返回索引片段数为 1,且可检索到内容。
## 自我审查
- `index`:将 `(文件名, 文本)` 切分为 chunk(默认 800 字,按段落聚合),前缀 `[文件名]`,批量 embed 后 `store.add`
- `index_dir`D1):`Path(root).rglob("*")` 遍历,按扩展名白名单过滤,UTF-8 `errors="ignore"` 读取,跳过空文本与读取异常;返回实际索引文件数(即 sources 数,非 chunk 数 —— 与 brief 文档及测试断言 `n == 1` 一致)。
- `retrieve`:空查询直接返回 `[]`;否则 embed 查询向量后调用 `store.search`
- 测试中对 `RagStore(":memory:")` 采用 `try/finally` 包裹 `close()`,避免 ResourceWarning。
## 一句话摘要
实现 `ImpactRAG``index`/`retrieve` 与 D1 修订项 `index_dir`,2 项测试全绿,可为上传即索引(Task 5 接线)提供目录级源码索引能力。
## 顾虑
- 运行指定测试文件时,全局 pytest 覆盖率配置(要求 99%)会报 `Required test coverage of 99.0% not reached`,但本任务验证仅需 `2 passed`,不影响功能正确性;若 CI 以全量套件口径执行,需单独豁免或补充本模块覆盖率。
- `index_dir` 返回的是"索引文件数"而非"片段数"brief 文档两处措辞略不一致(接口签名注释写"返回索引的片段数",测试与示例暗示为文件数),当前实现以测试断言为准(文件数)。
## 修复后验证
### 状态
**GREEN — 分支测试补全,覆盖率 100%**
针对评审发现(空 query 行为未测 + fail_under=99 硬要求),补全 `index`/`index_dir`/`retrieve` 全部分支测试与防御性过滤,并更新 `index_dir` docstring 为"返回索引的文件数"。
### 修改摘要
涉及文件:
- `src/genesis/rag/impact_rag.py`
- `index`:对 `_split` 结果过滤空片段(`if piece.strip():`),避免空向量噪声 chunk。
- `index_dir`docstring 由"片段数"更正为"文件数";读取文本后叠加内容级跳过(空文本 `if not text.strip(): continue`、含 NUL 字节二进制误带扩展名 `\x00` 跳过)。
- `tests/test_impact_rag.py`:新增 10 个用例,覆盖空 query、空文本源、非目录 root、空文件、读取异常、大写扩展名、空目录、NUL 二进制、单段超长与多段拆行。
### 测试输出摘要
```
python -m pytest tests/test_impact_rag.py -q --cov=genesis.rag.impact_rag --cov-report=term-missing
src\genesis\rag\impact_rag.py 53 0 26 0 100%
12 passed in 0.28s
```
(注:若沿用全局 `addopts``--cov=genesis`,整体覆盖率会因只跑单文件而低于 99% 告警,但 `impact_rag.py` 本文件覆盖率为 100%、12 例全绿,符合本次验收口径。)
### 一句话摘要
补全 `ImpactRAG` 分支测试与防御后,`impact_rag.py` 达到语句/分支 100% 覆盖,原 2 例与新 10 例共 12 例全绿。
+148
View File
@@ -0,0 +1,148 @@
# RAG 迭代 Task 4ImpactAgent 接入可选 RAG 上下文
## 状态
- 状态:**DONEGREEN**
- 新增/修改文件:
- `src/genesis/impact/impact_agent.py`(新增 `__init__``run_impact``_build_impact_prompt`、模块常量 `_RAG_CONTEXT_TITLE`
- `tests/test_impact_agent_rag.py`(新增,3 个测试)
- 提交:见文末 commit
## 真实代码要点(先读真实代码后实现)
实现前已实际读取以下文件,确认与 Task Brief 的初始假设**存在重大出入**,据此调整落地方式:
1. **`src/genesis/impact/impact_agent.py`(原文件)**
- `ImpactAgent` 原**没有 `__init__` 方法**(无位置参数、无 engine 成员)。
- 原**没有 `run_impact` 方法、没有 `engine` 成员、没有任何 LLM 调用**;它是**确定性规则实现**,唯一对外方法是
`run(self, structured_source: StructuredSource, session_id: str = "impact", scope: dict | None = None) -> ImpactReport`
内部通过 `_classify_table` / `_build_lookup` / `_match_tokens` 等纯规则产出影响调查书。
- prompt 文本在原文件中**不存在**(无 LLM),故不存在"既有 prompt 拼装位置"。
- 其他被调用方(`impact_report_to_dict``_element_to_dict``_header_index` 等)保持原样未改动,向后兼容。
2. **`src/genesis/rag/impact_rag.py`(真实 RAG 接口)**
- `ImpactRAG.__init__(self, store: RagStore, embedder: Embedder)`
- `index(self, scope: str, sources: List[Tuple[str, str]]) -> None`
- `retrieve(self, scope: str, query: str, k: int = 5) -> List[str]`
- 命中片段格式为 `[{name}]\n{piece}`,因此片段文本天然含文件名(如 `TradeApplication.java`)。
3. **`src/genesis/inference/engine.py`(真实 LLM engine 接口)**
- `InferenceEngine.chat_structured(self, *, session_id, prompt, variables, dict, schema, retry_count=2)`
- 注意:真实方法是 **async** 协程;但本仓库各测试中的 `FakeEngine.chat_structured` 普遍为**同步**签名
`def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2)`
- 为与测试契约(同步调用、捕获 `prompt`)一致,`run_impact` 以**同步**方式调用
`self.engine.chat_structured(...)`,形参名与真实接口完全一致(`session_id/prompt/variables/schema`)。
4. **`src/genesis/rag/embeddings.py`**
- `FakeEmbedder().embed(texts) -> List[List[float]]`,测试可直接用。
### 据此落地的真实签名(实现后)
- `ImpactAgent.__init__(self, engine=None, use_rag: bool = False, rag: "ImpactRAG | None" = None)`
- 新增末尾参数,保留无参 `ImpactAgent()` 兼容(既有 18 个 `run` 测试仍通过)。
- engine 成员名:`self.engine`RAG 成员:`self.rag`;默认开关:`self.use_rag`
- `ImpactAgent.run_impact(self, session_id: str, requirements_text: str, use_rag: bool | None = None, k: int = 5)`
- LLM 调用方法名:`self.engine.chat_structured(...)`(与真实 engine 形参对齐)。
- prompt 拼装位置:`_build_impact_prompt(requirements_text)` 产出基础 prompt
RAG 注入在 `run_impact` 内:启用时 `prompt = prompt + f"\n\n{_RAG_CONTEXT_TITLE}\n{rag_context}"`
- RAG 检索查询:`"影响调查:" + requirements_text[:200]``self.rag.retrieve(session_id, query, k)`
## 行为说明
- `use_rag=True`(显参或 `self.use_rag`)且 `self.rag` 存在:检索命中片段以明确小节标题
`# 既有系统关联上下文(RAG 检索,辅助判断影响范围)` 追加进实际发送给 LLM 的 prompt。
- `use_rag=False`(默认)或 `self.rag` 为 None:prompt 与原版完全一致,**不含**该小节(向后兼容已验证)。
- `run_impact` 在无 `engine` 时抛出 `RuntimeError`,避免静默空跑。
- 既有确定性 `run` 路径未改动,影响调查书生成逻辑不受影响。
## 测试输出摘要
新增 `tests/test_impact_agent_rag.py`
```
python -m pytest tests/test_impact_agent_rag.py -q --no-cov
... [100%]
3 passed in 0.10s
```
- `test_run_impact_with_rag_injects_context`:构造 `ImpactAgent(engine=FakeEngine(), rag=rag, use_rag=True)`
`rag.index(session_id, [("TradeApplication.java","订单创建调用 MyBatis")])`,调用
`run_impact(session_id, requirements_text="创建订单的影响", k=5)`;断言 FakeEngine 收到的 prompt
**包含** `"TradeApplication"` 与 RAG 小节标题。PASS。
- `test_run_impact_without_rag_no_context`:同上但 `use_rag=False`;断言 prompt **不含** RAG 小节标题与片段。PASS。
- `test_run_impact_default_no_rag_no_context`:不传 `use_rag`(默认 False);同向后兼容断言。PASS。
回归(既有 impact 测试,确认无破坏):
```
python -m pytest tests/ -q -k impact --no-cov
71 passed, 533 deselected, 1 warning in 32.40s
```
> 说明:仓库 `pytest` 配置含 99% 覆盖率门禁;单独跑子集会因覆盖率不足而返回非 0,
> 故验证阶段加 `--no-cov` 仅校验测试本身。全量 `pytest tests/` 不受影响(本次未改动既有逻辑)。
> 新增 `run_impact` 经由 `tests/test_impact_agent_rag.py` 覆盖,确定性 `run` 路径由既有 18 个用例覆盖。
## 自我审查
- ✅ 先读真实代码:确认原 `ImpactAgent` 无 LLM、无 `run_impact`,已据实调整而非照搬 Brief 假设。
- ✅ TDD:先写失败测试(RED3 failed)→ 实现 → GREEN3 passed)。
- ✅ 向后兼容:默认 `use_rag=False` 时 prompt 内容不变;既有 `run` 与 18 个旧测试全部通过。
- ✅ RAG 注入正确:命中片段含文件名 `TradeApplication.java` 被拼入 prompt 并加明确小节标题。
- ✅ 不新增重依赖:`ImpactRAG`/`RagStore`/`FakeEmbedder` 均为既有模块;类型注解用字符串前向引用避免新增 import。
- ⚠ 顾虑:`run_impact` 为同步调用 `chat_structured`,而真实 `InferenceEngine.chat_structured` 是 async。
Task 5 服务层接线时需注入**同步包装**或使 `run_impact` 支持 await(当前按 Brief 测试契约保持同步)。
- ⚠ 顾虑:新增 `engine`/`rag` 成员在确定性 `run` 路径中未被使用,仅为 LLM 路径 `run_impact` 服务;
若评审期望保持 `ImpactAgent` 纯确定性,可考虑将 `run_impact` 拆分为独立子类,但 Brief 明确要求加在 `ImpactAgent` 上,故未拆分。
## Commit
```
<待提交> feat(rag): ImpactAgent 接入可选 RAG 上下文(use_rag
```
## 修复后验证(RAG 迭代 Task 4 严重正确性缺陷修复)
### 缺陷说明
`run_impact``self.engine.chat_structured(...)` 当作**同步**调用并直接返回其结果。
而真实 `InferenceEngine.chat_structured`src/genesis/inference/engine.py:153)是 **`async def`**
返回 **`StructuredResult`**。原实现运行时会得到一个**未 await 的协程**,LLM 实际从未执行。
旧测试通过只是因为 `FakeEngine` 也是同步的(mock 镜像了错误的 sync 形态)——属"测试断言了错误形态"陷阱。
### 修复要点
- `run_impact` 改为 **`async def`**,引擎调用加 **`await`**`return await self.engine.chat_structured(...)`
返回底层引擎的 `StructuredResult`(含 `data`/`raw_text`)。
- `tests/test_impact_agent_rag.py` 改写为**异步**形态:`FakeEngine.chat_structured` 改为
`async def`,测试内用 `asyncio.run(agent.run_impact(...))` 驱动(不依赖 pytest-asyncio 配置)。
- 扩展为 6 例,覆盖全部分支:`engine is None` / `use_rag is None` 回退实例默认 /
`use_rag``rag` 存在且 `chunks` 非空 / `chunks` 空(无命中)/ 显式 `use_rag=False`
### 验证输出
`python -m pytest tests/test_impact_agent_rag.py -q`(新增 6 例):
```
6 passed in 4.92s
```
`run_impact` 分支覆盖率(仅统计 124-155 行,独立 `--cov-branch` JSON 复核):
**lines 124-155 无 missingbranches 无 missing100%**
(文件其余 missing 行/分支均在确定性 `run()` 路径,不在本次修复范围内。)
回归(既有 impact 测试,确认无破坏):
```
python -m pytest tests/ -q -k impact
74 passed, 533 deselected, 1 warning in 50.06s
```
> 说明:仓库 `pytest` 配置含 99% 覆盖率门禁,单独跑子集会因覆盖率不足而返回非 0,
> 故修复验证加 `--no-cov`/独立 JSON 仅校验测试本身。
### Commit
```
fix(rag): run_impact 改为 async 并 await 引擎(StructuredResult
```
@@ -0,0 +1,52 @@
# RAG 影响调查接入(Task 5)实现报告
日期:2026-08-29
范式步骤:Agent 实现(RAG 迭代 Task 5,含评审修订 D1/D3
## 真实代码要点(先读后改)
- `src/genesis/server/service.py`
- `GenesisService.__init__`:新增 `rag: ImpactRAG | None = None``use_rag: bool = False``rag_db_path: str | None = None``embedder=None`(默认 `get_embedder("fake")`)。保存 `self.rag`/`self.use_rag``rag=None` 为不启用(向后兼容默认关闭)。
- `upload_file`:当 `file_type == "existing_system"``self.rag is not None`,解压后 `self.rag.index_dir(session_id, path)`;异常 `try/except` + `_LOGGER.warning` 不阻断上传(D1 上传即索引,scope=session_id)。
- `run_impact` 改为 `async def run_impact(self, session_id, use_rag=None)``eff = self.use_rag if use_rag is None else use_rag``eff 且 self.rag is not None` 时走 RAG 路径——`engine` 为空则 `build_inference_engine()`;取 `requirements_text = rec.files["requirements"]["name"] or "要件定義"``await ImpactAgent(engine=..., rag=..., use_rag=True).run_impact(...)`;写 `status=awaiting_impact_confirm` + `impact_summary={"rag_enabled": True, "impact_llm": ...}`,并尽量保留 `impact_report_path`。否则走原确定性 `ImpactAgent().run(...)` 路径(行为不变)。
- `src/genesis/server/app.py`
- `create_app` 新增 `rag=None`;为 None 时内部构造 `RagStore(data_root/rag.db) + ImpactRAG(..., get_embedder("fake"))`(先 `mkdir(parents=True)` 防止 `unable to open database file`),以 `rag=rag, use_rag=False` 注入 `GenesisService`
- **Critical 修复**`_FakeEngine.chat_structured` 改为 `async def`(与真实 `InferenceEngine` 一致)。
- `start_impact` 改为 `async def start_impact(sid, use_rag=False)``await service.run_impact(sid, use_rag=use_rag)`
- `src/genesis/chat/agent.py``_parse_and_confirm``_run_impact` 中两处 `self.service.run_impact(...)``asyncio.run(...)` 包裹(兼容同步消息处理,避免协程未执行)。
- `tests/test_server_service.py`:两处 `svc.run_impact(...)` 改为 `asyncio.run(svc.run_impact(...))`,保持全绿。
- `tests/test_impact_rag_e2e.py`(新增,D3 强验证):真实 `GenesisService` + 临时 `SessionStore` + 临时 `data_root` + 异步 `FakeEngine`(捕获 prompt+ `ImpactRAG(RagStore(tmp/rag.db), FakeEmbedder())``use_rag=True`
- 辅助:`zipfile` 构造含 `src/KnownOrder.java`(内容含「订单创建调用 MyBatis」)的 existing_system ziprequirements/template 用 `glob` 定位 `sample/` 下真实文件。
- 流程:create_session → upload requirements → upload template → upload existing_system → run_parse → confirm_parse(→ impact_running)→ `asyncio.run(svc.run_impact(sid, use_rag=True))`
- 核心断言:`FakeEngine.captured` 含「KnownOrder」(上传源码被 RAG 检索并注入 LLM prompt);上传后 `rag.retrieve(sid, "订单创建", k=1)` 命中非空(D1 验证);另起会话 `rag=None` 且默认 `use_rag` 时走确定性路径、`captured is None` 且不含 RAG 小节标题(向后兼容)。
## 验证状态
- `python -m pytest tests/test_impact_rag_e2e.py -q`**2 passed**
- `python -m pytest tests/test_server_service.py -q`**20 passed**
- `python -m pytest tests/ -k "server or impact or chat" -q`**228 passed**(无回归;最初 `test_server_api``rag.db` 目录不存在报 `OperationalError`,已通过 `Path(rag_db).parent.mkdir(...)` 修复)
## 自我审查(顾虑)
- `create_app` 现在总会创建 `data_root/rag.db`(即使 `use_rag=False`);默认 `app = create_app()` 会在工作区生成 `data/server/rag.db` 文件,属轻微副作用,可接受(向后兼容且关闭时不参与影响调查)。
- `chat/agent.py``asyncio.run` 包裹 `run_impact` 是最小侵入方案;若未来聊天层整体异步化,可改为直接 `await`
- 覆盖配置 `fail_under=99%`,单文件运行 e2e 会因采样不足触发覆盖率告警(非测试失败);以 `-o addopts=""` 运行可获干净结果。
- RAG 路径不依赖 `_rebuild_source`(仅用 requirements 文件名 + RAG 检索),与 Brief 一致;确定性路径仍保留 `_rebuild_source`
## 修复后验证(复审 Minor 3 项最小化修复)
日期:2026-08-30
范式步骤:反馈迭代(RAG 迭代 Task 5 复审 Minor 修复)
### 修复项
1. **死存储(service.py**:移除 `GenesisService.__init__``rag_db_path``embedder` 两个未使用参数及其赋值(`rag`/`use_rag` 保留)。已确认全仓无调用方传入这两个参数(仅 `app.create_app``rag=`)。
2. **引擎构建竞态(service.py**`run_impact` 中引擎懒构建改为双重检查锁:`__init__` 新增 `self._engine_lock = threading.Lock()`,构建处 `with self._engine_lock: if self.engine is None: self.engine = build_inference_engine()`(新增 `import threading`)。
3. **文档措辞(docs/design.md**:将"端点同步改为 `async def`"改为"端点改为 `async def`"(原端点本就是同步 def,去掉"同步"歧义)。
### 验证命令
`python -m pytest tests/test_impact_rag_e2e.py tests/test_server_service.py -q`
### 结果摘要
- **22 passed**test_impact_rag_e2e 2 + test_server_service 20),无回归。
- 注:覆盖率门禁 `fail_under=99%` 在仅运行这两个文件时会触发(项目级全量门禁,与本次修复无关,非测试失败)。按报告既有建议以 `-o addopts=""` 运行可获干净结果。
- 默认路径语义与 `use_rag=False` 行为保持不变。