diff --git a/AGENTS.md b/AGENTS.md index d2fb3e27..f148b1e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,13 @@ additional hardening beyond the actual internal-network threat model: repository mechanisms instead of inventing extra gates, ceremonies, or disabled states. - Add or update focused tests for behavioral changes and regressions. +- Read [`docs/quality/recurring-defects.md`](./docs/quality/recurring-defects.md) + before changing overlays, store lookups, retrieval entry points, cancellation + behavior, or render-time derived values. It records defect types that have + already recurred in this repository and the shared mechanism that covers each + one. When fixing an instance of a listed type, extend that shared mechanism + instead of patching the one reported case, and add an entry when a new defect + type recurs. - After completing any functional change, inspect the affected product, architecture, design, feature, setup, and operational documentation and update every relevant document to match the implemented behavior. Treat the diff --git a/docs/features/knowledge-base/external-knowledge-technical-design.md b/docs/features/knowledge-base/external-knowledge-technical-design.md index 8e4a5f8c..219cf38b 100644 --- a/docs/features/knowledge-base/external-knowledge-technical-design.md +++ b/docs/features/knowledge-base/external-knowledge-technical-design.md @@ -118,13 +118,15 @@ Preload 暴露实例 `List/Save/Test/SetEnabled/Delete`、目录 `List/Get`、 Handler 调用 `assertTrustedSender` 并解析共享输入;检索按实例 ID 在 Main 读取地址与凭据。 实例先保存为 `untested`,列表上的连接测试调用目录请求并更新摘要,不测试未保存草稿。 -绑定检索测试是单独操作,不会把实例探测状态自动升级为 `healthy`。 +绑定检索测试是单独操作,不会改变实例探测状态。探测状态取值为 `untested`、 +`catalog-ready`、`list-restricted`、`auth-failed`、`unreachable`、`failed`。 服务按实例记录 AbortController。保存实例、停用、删除以及服务关闭会取消相应请求; -调用方取消信号与总超时合并。异步测试或保存完成前比较实例配置,变化时返回 -`EXTERNAL_KB_CONFIG_CHANGED`,绑定更新还检查保存期间绑定是否改变。共享 IPC 没有 -`requestId` 或取消方法;Renderer 用局部 generation/active 标记丢弃旧响应,这与中止 -Main 请求是两种行为。 +调用方取消信号与总超时合并。取消即是配置变更的唯一处理方式:被取消的连接测试直接抛出 +`EXTERNAL_KB_CANCELLED`,不写回探测结果,因此不需要额外的前后配置快照比较。绑定保存在 +验证请求前后各检查一次同一远端目标是否已被占用,最终以 `UNIQUE(instance_id, remote_id)` +索引为准。共享 IPC 没有 `requestId` 或取消方法;Renderer 用局部 generation/active 标记 +丢弃旧响应,这与中止 Main 请求是两种行为。 删除仍被引用的实例返回 `EXTERNAL_KB_INSTANCE_IN_USE`。UI 确认后逐个调用本地 `deleteLibrary`,再删除实例;这是顺序操作,发生中途失败会刷新已移除的绑定,不是跨调用事务。 @@ -143,16 +145,22 @@ Main 请求是两种行为。 ## 7. 检索编排 -`KnowledgeService.retrieve` 查询本地绑定,存在绑定时调用 -`ExternalKnowledgeService.testRetrieval`,否则执行现有本地路径。外部结果附加 +`KnowledgeService.retrieve` 按知识库 ID 单行查询本地绑定,存在绑定时调用 +`ExternalKnowledgeService.retrieve`,否则执行现有本地路径。外部结果附加 `diagnostics.external`,不运行本地向量、全文或图谱检索。 +`ExternalKnowledgeService` 区分两个入口:`retrieve` 是生产检索路径,只发出一次检索请求; +`testRetrieval` 仅用于保存绑定前的验证,额外在同一超时预算内确认 RAGFlow 图谱与知识编排 +能力可用,能力缺失时返回 `EXTERNAL_KB_CAPABILITY_UNAVAILABLE`。生产检索不执行该能力预检, +因此正常对话不会多一次 detail 请求,也不会出现仅属于保存校验的错误码。 + `retrieveMany` 对传入知识库 ID 去重并读取知识库,使用 `Promise.all` 逐库检索;只有选中 需要向量的本地库时才准备本地查询 Embedding。当前没有额外的 Provider 并发队列。单库 失败写入 `response.diagnostics.failure` 并保留空结果,成功零命中没有 failure;调用方 取消仍向上传播。当前返回 `{knowledgeBaseId, response}[]`,没有独立的 Outcome 状态联合。 -服务测试先按绑定的结果数与单片段限制裁剪,并限制该次片段总量为 48,000 字符。 +服务检索与绑定验证共用同一裁剪逻辑:先按绑定的结果数与单片段限制裁剪,并限制该次片段 +总量为 48,000 字符。 `retrieveMany` 展平各库结果并按库内 rank 稳定排序,再轮转分配 48,000 字符的片段预算: 先处理各库第 1 条,再处理各库第 2 条,同 rank 保持输入库顺序。不会先耗完整个库再轮到 下一个库;仍没有固定的每库最低字符配额。关联的本地上下文 groups 随相应结果使用另一份 diff --git a/docs/features/knowledge-base/progress.md b/docs/features/knowledge-base/progress.md index e28dcdec..5cb7e560 100644 --- a/docs/features/knowledge-base/progress.md +++ b/docs/features/knowledge-base/progress.md @@ -16,6 +16,36 @@ ## 验证证据 +### 2026-09-13 检索路径与绑定查询简化 + +对 `3b560c9..ca4a58c` 的复查发现三处与本功能相关的实现问题,已按 KISS 与性能优先修正: + +| 修正项 | 修正前行为 | 修正后行为 | +| --- | --- | --- | +| 生产检索入口 | 对话检索复用 `testRetrieval`,把用户问题传入 `testQuery`,并对 RAGFlow 额外发一次 detail 请求 | 新增 `ExternalKnowledgeService.retrieve` 作为生产路径,只发一次检索请求;能力预检仅保留在 `testRetrieval` | +| 配置变更检测 | 5 处 `JSON.stringify` 前后快照对比,抛出 `EXTERNAL_KB_CONFIG_CHANGED` | 全部删除,改由既有 `AbortController` 取消覆盖;被取消的探测抛 `EXTERNAL_KB_CANCELLED` 且不写回探测结果 | +| 绑定查询 | `requireLibrary`(30+ 调用点,其中一处在循环内)与 `getKnowledgeSnapshot` 每次全表 `listBindings()` | 新增 `getBinding`/`hasBinding`/`getBindingsForInstance` 主键查询;快照改为单次 `Map` 构建 | + +修正过程中被测试拦下两次行为回退,已记录为当前不变量: + +- 被取消的连接测试必须抛出且不写回 `probeStatus`,不能记为 `failed`;用户停用实例不等于探测失败。 +- `testRetrieval` 的 RAGFlow 能力预检必须与检索共用绑定配置的 `requestTimeoutMs`,不能各自计时。 + +同时删除确认无生产引用的死代码:`probeStatus` 的 `'healthy'` 取值、`external.transportConfirm` +与 `external.states` 的 `remote-missing`/`config-invalid` 文案、`KnowledgeService.searchHybridMany`、 +`ExternalKnowledgeRemoteResult.sourceUrl`。`externalKnowledgeLocatorSchema` 的 `sourceUrl` +保留,因为它解析已持久化的引用数据。 + +| 验证 | 结果 | +| --- | --- | +| `npx vitest run src/main/knowledge src/main/agent/knowledge-mcp-gateway.test.ts` | 21 个文件通过、1 个跳过;288 项通过、1 项跳过 | +| `npm test` | 348 个文件通过、9 个跳过;4,088 项通过、66 项跳过 | +| `npm run typecheck`、`npm run lint` | 通过 | + +`npm test` 的唯一失败为 `src/main/agent/opencode-runtime-lifecycle.test.ts:120` +("Matcher did not succeed in time")。已用 `git stash` 在同一 `HEAD` 上复现同样失败, +确认早于本次修正且与知识库无关,未计入上表。 + ### 2026-09-13 当前工作区实现与分组验证 三家 Provider 已通过生产服务、本机 HTTP MCP 与生产 IPC 的短答案生成检查,Dify 另已 diff --git a/docs/quality/README.md b/docs/quality/README.md index 82f5e9fa..8acc2bb4 100644 --- a/docs/quality/README.md +++ b/docs/quality/README.md @@ -5,6 +5,7 @@ | 文档 | 职责 | | --- | --- | | [Electron UI 自动化测试规范](./electron-ui-automation.md) | 测试分层、当前源码启动、视觉与原生交互取证、真实调用计数、脱敏报告和清理 | +| [复发缺陷与防复发规则](./recurring-defects.md) | 已重复出现的缺陷类型、对应的共享机制和自动发现测试 | | [开发与构建](../../BUILD.md#质量验证) | 依赖、测试、类型检查、Lint 和构建命令 | | [UI 设计系统](../../UI-DESIGN.md#155-验收) | 全局视觉、交互和响应式验收要求 | | [知识检索评估](../features/knowledge-base/retrieval-evaluation.md) | 检索数据集、指标及评估方法 | diff --git a/docs/quality/recurring-defects.md b/docs/quality/recurring-defects.md new file mode 100644 index 00000000..f6c04361 --- /dev/null +++ b/docs/quality/recurring-defects.md @@ -0,0 +1,96 @@ +# 复发缺陷与防复发规则 + +本文档收录已经在本仓库重复出现过的缺陷类型。每条规则都来自至少一次真实修正,不是预设的 +通用最佳实践。新增条目前,先确认该问题在提交历史中确实出现过一次以上,或一次修正中同时 +出现在多处。 + +修正复发缺陷时,优先建立一条能覆盖整类问题的机制(共享选择器、共享函数、能发现全部实例 +的测试),而不是逐个点名修补。逐个修补正是这些条目反复出现的原因。 + +## 整窗遮罩与原生拖动区域 + +**症状**:弹窗覆盖标题栏区域时,其关闭按钮无法悬停和点击。 + +**原因**:`.brand` 与 `.topbar` 声明了 `-webkit-app-region: drag`。整窗遮罩绘制在其上方 +时,原生标题栏仍然截获指针。 + +**规则**:整窗遮罩必须 `inset: 0` 铺满窗口,并由 `styles.css` 中统一的遮罩选择器声明 +`-webkit-app-region: no-drag`。新增遮罩时扩展该共享选择器;不要在各自规则里复制 +`no-drag`,也不要用 `inset: 38px 0 0` 之类的偏移绕开标题栏。完整要求见 +[UI 设计系统](../../UI-DESIGN.md)。 + +**防复发**:`WorkspacePrimitives.test.tsx` 的 +"releases the native drag region for every full-window overlay" 从 `styles.css` 自动发现 +全部 `position: fixed` 遮罩并逐个校验,新增遗漏会直接失败。 + +**历史**:`e9679fe`、`ca4a58c` 各修了一部分被人工点到的弹窗,同一批改动又新增了两个未覆盖 +的遮罩;后续复查改为共享规则加自动发现测试。 + +## 逐行查询代替主键查询 + +**症状**:列表渲染或校验函数随数据量线性变慢,且慢在单次操作内重复发生。 + +**原因**:用 `listAll().some(...)` 或 `listAll().find(...)` 判断单行是否存在。表有主键或 +唯一索引时,这是把 O(1) 查询写成了全表扫描加 `JSON.parse`。 + +**规则**:判断单行存在或取单行数据时,在 store 上提供按键查询方法(例如 `hasBinding`、 +`getBinding`),不要在调用方过滤全量列表。确实需要按分组统计时,循环外取一次并构建 +`Map`,不要在循环内反复查询。 + +**历史**:外部知识库绑定同时出现三处:`requireLibrary`(30+ 调用点,一处在循环内)、 +`getKnowledgeSnapshot`(每个知识库两次全表扫描)、`listInstances`(每个实例过滤一次)。 + +## 生产路径复用“测试”函数 + +**症状**:正常用户操作触发了只应在配置校验时出现的错误码,或多发了一次网络请求。 + +**原因**:让生产路径直接调用为“测试连接”“保存前校验”准备的函数。这类函数通常带有额外的 +能力预检和更严格的失败条件。 + +**规则**:生产路径与校验路径各自提供入口。校验入口可以在生产入口之上叠加预检,反向复用 +不允许。共用的裁剪、归一化逻辑抽成独立函数由两者调用。 + +**历史**:对话检索曾调用 `ExternalKnowledgeService.testRetrieval` 并把用户问题放进 +`testQuery`,导致每次提问多一次 detail 请求,且 `EXTERNAL_KB_CAPABILITY_UNAVAILABLE` +会出现在正常对话中。 + +## 快照比对代替取消 + +**症状**:为检测“操作期间配置被改动”而反复读回状态并 `JSON.stringify` 比较。 + +**原因**:把取消语义当成配置漂移检测来实现。桌面单用户场景下,改动配置的那次操作本身就能 +取消在飞请求。 + +**规则**:用 `AbortController` 在配置变更点取消在飞请求,并让被取消的操作不写回任何结果。 +不要引入前后快照比较、版本化内部凭据或多阶段状态机。唯一性约束交给数据库索引。 + +**历史**:外部知识库服务曾有 5 处快照比对与专用错误码 `EXTERNAL_KB_CONFIG_CHANGED`;全部 +删除后行为反而更正确,因为旧实现会把“用户停用实例”记成 `probeStatus: 'failed'`。 + +## 删除疑似死代码时的验证顺序 + +**症状**:删除后类型检查通过,但运行时行为改变,或测试断言的是已不存在的行为。 + +**规则**:删除枚举取值、文案键、导出函数前,按此顺序确认: + +1. `rg` 全仓搜索引用,区分生产代码与测试 fixture。 +2. 确认该取值是否可能由生产代码写入。仅测试 fixture 使用即为死代码。 +3. 检查是否属于已持久化数据的解析契约。持久化契约即使当前不写入也必须保留。 +4. 删除后跑受影响的定向测试,再跑 `npm test`。 + +**历史**:`probeStatus` 的 `'healthy'` 仅存在于测试 fixture,可以删除;而 +`externalKnowledgeLocatorSchema` 的 `sourceUrl` 虽然同样从不写入,但它解析已持久化的引用 +数据,必须保留。两者表面症状相同,处置相反。 + +## 渲染期派生值未记忆化 + +**症状**:流式响应期间界面卡顿,或大列表操作延迟。 + +**原因**:在组件体内直接构建 `Map`/`Set` 或链式 `filter`/`flatMap`,而该组件在流式增量期间 +每个 token 都会重渲染。 + +**规则**:组件体内的派生集合必须包在 `useMemo` 中,依赖收窄到真正影响结果的值。紧邻的 +`useMemo` 之后又裸建集合,会让记忆化收益减半。 + +**注意**:React 允许在渲染期用守卫调用 `setState` 来响应 props 变化,这是官方推荐做法,不要 +把它改写成 `useEffect`。仓库 lint 规则 `react-hooks/set-state-in-effect` 会拒绝后者。