Files
NotesAgentic/docs/retrospectives/后端全面审阅问题与修复复盘.md
T

339 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 后端全面审阅问题与修复复盘
> 审阅日期:2026-08-28
> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储和 Agent Trace 持久化,当前完整后端回归基线为 80 项测试通过。
## 1. 审阅结论
审阅前的主链路已经能够运行,原有 50 项测试全部通过,但测试没有覆盖首次失败、畸形扩展包、代码型 Markdown、浏览器字符偏移和真实 Provider Streaming 等边界。
本轮共处理 10 类问题:
| 编号 | 问题 | 原级别 | 处理结果 |
| --- | --- | --- | --- |
| R-01 | 首次索引重建失败留下半成品 | P1 | 已修复并补充失败注入测试 |
| R-02 | 代码围栏中的 `#` 被误判为标题 | P1 | 已增加 fenced code block 状态解析 |
| R-03 | Citation 偏移与浏览器 UTF-16 不一致 | P1 | 已统一为 UTF-16 code unit |
| R-04 | 未知权限默认放行,Plugin 缺少授权记录 | P1 | 已改为白名单和默认拒绝,并增加授权接口 |
| R-05 | 第一阶段 Tool 与接口不完整 | P1 | 已接入 Note Move、Task、Attachment 和转写适配链路 |
| R-06 | Provider SSE 不是真实增量流 | P1 | 已接入 OpenAI SSE 与 Ollama JSONL |
| R-07 | 畸形 Plugin Schema 导致 500 | P2 | 已在安装和调用阶段执行 JSON Schema 校验 |
| R-08 | Provider PATCH 无法清空可空字段 | P2 | 已按 `model_fields_set` 实现正确 PATCH 语义 |
| R-09 | Vault 扫描可能跟随链接读取外部文件 | P2 | 已增加真实路径范围校验 |
| R-10 | Agent Run 与 Trace 无界保留 | P2 | 已增加 Run、事件和单轮 Tool Call 上限 |
修复后后端共有 62 项自动化测试通过。
## 2. R-01:首次索引重建失败留下半成品
### 原因
旧实现只在正式数据库已经存在时创建 `.bak`。首次启动时没有旧库,重建过程会直接创建正式数据库并逐篇写入;如果第二篇或后续笔记解析、Embedding 或向量写入失败,异常分支没有可恢复的备份,也没有删除新建数据库。
### 后果
- 接口报告重建失败,但搜索仍能看到部分笔记;
- Metadata、FTS 和向量索引可能只覆盖 Vault 的一部分;
- 用户无法区分旧索引、半成品索引和完整索引;
- 再次重建前,Agent 可能基于不完整知识回答。
审阅时通过失败注入实际复现:初始数据库不存在,第二篇笔记抛错后,数据库残留 1 篇 Note 和 2 个 Block。
### 解决思路
失败后的状态必须与重建前一致:有旧库时恢复旧库,没有旧库时删除本次创建的数据库。同时限制并发重建,避免多个任务覆盖同一备份。
### 解决方案
- 使用共享 Vault Mutation Lock 串行化重建与 Note 创建、更新、移动、删除,避免文件和索引写操作交错;
- 备份文件名带 `job_id`,不再共用固定 `.db.bak`
- 首次重建失败时删除本次创建的数据库;
- 记录 running、failed、last completed 和 error 状态;
- Index Job 最多保留 100 条;
- 新增“首次重建第二篇失败”的回归测试。
## 3. R-02:代码围栏中的 `#` 被误判为标题
### 原因
旧解析器逐行使用标题正则,没有维护 Markdown fenced code block 状态。Python、Shell、YAML 等代码中的注释行符合 Markdown 标题正则。
### 后果
- 编程笔记产生不存在的标题层级;
- 后续正文被挂到错误的 `heading_path`
- Block 切分、RAG 上下文和 Citation 章节定位错误;
- 相同正文在重新编辑后可能生成不同 Block ID。
### 解决思路
标题和空行分块只应在普通 Markdown 上下文执行。进入反引号或波浪线围栏后,整段代码应作为普通正文收集,直到合法闭合围栏出现。
### 解决方案
- 识别 ````` 和 `~~~` 围栏;
- 围栏内部不执行标题识别和空行分块;
- 代码块作为独立 Block 保留原始内容;
- 增加 Python `# code comment` 回归测试。
## 4. R-03Citation 偏移单位不一致
### 原因
Python `len()` 返回 Unicode code point 数量,而 JavaScript 编辑器通常按 UTF-16 code unit 定位。emoji 和部分扩展汉字占一个 Python 字符,但占两个 UTF-16 单元。
### 后果
只要 Citation 前出现非 BMP 字符,前端按 `start_offset` / `end_offset` 跳转时就会错位,字符越多偏移越大。
### 解决思路
偏移是跨语言 Contract,必须明确单位。项目主要消费者是 Vue 和浏览器编辑器,因此后端直接输出 UTF-16 code unit,避免每个前端调用点重复转换。
### 解决方案
- `_split_lines()` 按 UTF-16 长度累加;
- Block 结束偏移和 frontmatter 正文起点使用相同单位;
- 接口文档明确 Citation 偏移为 UTF-16 code unit
- 增加 emoji 位于正文之前的回归测试。
## 5. R-04Permission 与 Plugin 授权 fail-open
### 原因
`PermissionPolicy` 对未知权限返回 `allow`。Plugin Runtime 只验证 Tool 使用的权限是否写进 Manifest,没有判断该权限是否属于项目命名空间,也没有区分“声明权限”和“用户已经授权”。
### 后果
- `notes.wirte` 一类拼写错误会静默放行;
- 新增高风险权限但忘记更新 Policy 时默认无确认执行;
- Plugin Enable 无法向用户展示和记录权限决策;
- Manifest 声明被错误地当成用户授权。
### 解决思路
权限边界应 fail-closed。声明、授权和单次运行确认是三个不同状态,不能共用一个布尔判断。
### 解决方案
- 建立 `KNOWN_PERMISSIONS` 白名单;
- 未知权限默认 `deny`
- Skill 和 Plugin 安装时拒绝未知权限;
- `Plugin` Contract 增加 `granted_permissions`
- 新增 `PUT /api/plugins/{plugin_id}/permissions`
- 带权限 Plugin 在授权完成前保持 `permission_required`
- 撤销必要权限时自动停用 Plugin;
- 增加未知权限、授权前启用和授权后启用测试。
## 6. R-05:第一阶段 Tool 与业务接口缺失
### 原因
早期先建立了 API 壳子,Note Move、Task 和 Media 路由保留为 501Agent Tool Registry 也只接入了 Note 基础读写与搜索。这与技术栈文档列出的第一阶段 Tool 集合不一致。
### 后果
- 前端能够看到接口,但一调用就得到 501;
- Agent 无法完成移动笔记和任务管理;
- Attachment 与音频链路没有统一 Tool Contract
- “第一阶段完成”的说法无法按技术基线验收。
### 解决思路
补齐能够在当前架构安全落地的能力。真实语音模型仍属于第二阶段,因此第一阶段提供 Host transcript 适配,不伪装成已经集成 faster-whisper。
### 解决方案
- 实现 `notes.move`,移动后保持原 `note_id`,失败时恢复文件;
- 新增 SQLite v2 migration 和 Task CRUD Service
- 接入 `tasks.create``tasks.update``tasks.list`
- 新增 Host 管理的 `attachments` 目录和 `attachments.read`
- 接入 `audio.transcribe`,读取 Host 预生成 transcript
- Transcript Job 最多保留 100 条;
- 实现原 Task、Media 和 Move HTTP 路由;
- 增加 Note Move、Task 生命周期和 Attachment/Transcript Tool 测试。
### 当前边界
`audio.transcribe` 当前只负责统一调用链和读取 Host 生成的文本。faster-whisper、pyannote.audio 与真实音频推理仍按照技术栈说明在第二阶段实现。
## 7. R-06Provider Streaming 只是 SSE 外壳
### 原因
`TurnStreamingMixin` 先调用非流式 `complete()`,等待完整回答后只发送一个 `TextDelta`。OpenAI-Compatible 和 Ollama 请求都明确设置 `stream=false`
### 后果
- 首字等待时间等于完整回答生成时间;
- 长回答无法边生成边展示;
- SSE 连接存在,但不具备真实 Streaming 的用户体验;
- 上游生成期间无法及时反馈 Tool Call 或 Usage。
### 解决思路
Adapter 应直接消费各 Provider 的原生流协议,再映射成统一 `ModelEvent`
### 解决方案
- OpenAI-Compatible 使用 `httpx.AsyncClient.stream()` 消费 SSE
- 解析 `TextDelta``ThinkingDelta`、Tool Call 分片、Usage 和 Done
- Ollama 使用相同连接消费 JSONL
- 统一递增 Event sequence
- Provider HTTP 错误继续映射为统一错误码;
- 增加两个 Adapter 的增量分片测试。
## 8. R-07:畸形 Plugin JSON Schema 返回 500
### 原因
旧实现假定 `parameters.properties` 一定是对象,启用阶段直接调用 `.items()`。扩展包可以提交 `properties: []`,安装成功后在启用时触发未捕获 `AttributeError`
### 后果
- 客户端输入错误被当成服务端故障;
- 统一错误 Contract 被破坏;
- 已安装记录进入 error,但用户拿不到可处理的 Schema 错误;
- JSON Schema 中的 enum、长度和嵌套约束没有在 Tool 调用时落实。
### 解决思路
Schema 是不可信扩展输入,应在安装阶段检查结构,并在每次 Tool 调用时校验实际参数。
### 解决方案
- 引入 `jsonschema`
- 安装时执行 Draft 2020-12 Schema Check
- 要求 Tool 参数根节点和 `properties` 为对象;
- Tool Registry 在 Pydantic 校验前执行 JSON Schema 校验;
- 所有格式错误转换为 `PLUGIN_TOOL_SCHEMA_INVALID`
- 增加畸形 `properties` 回归测试。
## 9. R-08Provider PATCH 无法清空字段
### 原因
旧路由使用 `model_dump(exclude_none=True)`。该调用无法区分“字段没有发送”和“客户端明确发送 null”。
### 后果
用户无法清空 `credential_id``base_url``default_model`,只能删除 Provider 后重建;同时直接 `model_copy(update=...)` 不会重新验证完整模型。
### 解决思路
PATCH 必须根据 Pydantic 的 `model_fields_set` 判断客户端实际发送了哪些字段,并在合并后重新验证 ProviderConfig。
### 解决方案
- 使用 `model_fields_set` 构建更新字典;
- 允许可空字段显式设置为 null
- 禁止 `name``enabled` 显式设置为 null
- 合并后通过 `ProviderConfig.model_validate()` 重新验证;
- 增加同时清空三个可空字段的测试。
## 10. R-09Vault 扫描越过根目录
### 原因
旧重建逻辑直接读取 `rglob("*.md")` 的结果,只使用词法相对路径,没有验证符号链接解析后的目标是否仍位于 Vault。
### 后果
在支持符号链接的平台上,Vault 内链接可以指向外部 Markdown。外部内容随后进入 FTS、向量索引和 RAG 上下文,并可能发送给外部模型 Provider。
### 解决思路
扫描和 Note API 应使用同一条路径安全原则:先 `resolve()`,再验证真实目标仍位于解析后的 Vault 根目录。
### 解决方案
- 扫描开始时解析 Vault 根目录;
- 每个 Markdown 路径执行 `resolve()`
- 不在 Vault 内的真实路径直接跳过;
- 文件状态和正文均从验证后的真实路径读取。
## 11. R-10Agent Run 与 Trace 无界增长
### 原因
旧 Runtime 将所有 Run、Agent Event、Tool Result 和 Citation 永久保存在进程字典中,没有 TTL、数量限制或持久化后的裁剪。
### 后果
桌面应用运行时间越长,内存占用越高。`notes.read` 等 Tool Result 还可能携带整篇 Markdown,使单个 Run 的体积明显增加。
### 解决思路
在 SQLite Trace Repository 接入前,先给内存实现设置明确上限,并在容量不足时返回可处理错误,不能让进程无限增长。
### 解决方案
- 最多保留 200 个 Run
- 创建新 Run 时优先淘汰最旧的终态 Run;
- 全部是活动 Run 且达到上限时返回 `429 AGENT_CAPACITY_EXCEEDED`
- 每个 Run 最多保留 2000 个 Event
- 单轮最多接受 50 个 Tool Call
- Index Job 和 Transcript Job 同样设置 100 条保留上限。
## 12. 文档同步
本轮同时修正以下文档漂移:
- 后端接口契约不再把 Notes、Search、Skills、Plugins、Tasks 和 Index 标为未实现;
- 补充 Plugin 权限设置接口;
- 补充真实 Provider Streaming 说明;
- 明确 Citation 偏移单位;
- 更新 Tool 列表和 Attachment / Transcript 边界;
- 删除 Knowledge 文档中的 Note Move 未实现说明;
- 测试数量从旧的 26 更新为当前完整数量。
## 13. 验证方法
执行:
```powershell
cd backend
uv lock --check
uv run python -m compileall -q app
uv run pytest -q -p no:cacheprovider
git diff --check
```
验证结果:
```text
71 passed
compileall passed
uv lock --check passed
git diff --check passed
```
测试覆盖新增了:
- 首次重建失败回滚;
- fenced code block 标题隔离;
- UTF-16 Citation 偏移;
- Note Move ID 稳定;
- Plugin 权限授权和未知权限拒绝;
- 畸形 JSON Schema 拒绝;
- Task CRUD
- Attachment 与 transcript Tool
- Provider PATCH 显式 null
- OpenAI SSE 与 Ollama JSONL 增量事件。
- Provider 预设、模型发现、凭据缺失/鉴权错误映射;
- 凭据密文落盘、API 不回显明文及 Provider 解密读取。
## 14. 后续工作
本轮解决的是第一阶段后端正确性和契约问题。以下内容仍按技术基线留在后续阶段:
- Run、Trace、Provider 和 Extension Registry 的完整 SQLite 持久化;
- faster-whisper、pyannote.audio 和真实音频任务队列;
- MCP Plugin Host 与独立进程健康检查;
- OpenAI Responses 与 Anthropic Messages Adapter
- 真实 Embedding / Reranker 模型;
- 增量索引、文件监听和 RAG / Agent Benchmark。