diff --git a/README.md b/README.md index 794865e..6ae0430 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ pnpm build - 前端依赖统一使用 pnpm 安装,不要混用 npm 或 yarn。 - `backend/.venv`、`frontend/node_modules`、`frontend/dist` 均为本地生成目录,不提交到 Git。 - API 默认监听 `127.0.0.1:8000`,前端默认监听 `127.0.0.1:5173`。 +- 后端附件目录默认是 `backend/data/attachments`,可通过 `APP_ATTACHMENTS_PATH` 覆盖;该目录由桌面 Host 管理。 - 跨模块接口发生变化时,需要同步更新前后端类型和 `docs` 中的接口说明。 - 当前前后端接口清单见 `docs/后端接口契约-开发版.md`,OpenAPI 以 `/openapi.json` 为准。 - 前端页面、交互、状态管理和第一阶段验收要求见 `docs/前端页面需求说明-开发版.md`。 diff --git a/docs/AI-Core与Agent-Core开发说明.md b/docs/AI-Core与Agent-Core开发说明.md index aebcc89..4eea0cd 100644 --- a/docs/AI-Core与Agent-Core开发说明.md +++ b/docs/AI-Core与Agent-Core开发说明.md @@ -93,7 +93,7 @@ streaming } ``` -`POST /api/chat` 返回 ModelEvent SSE。 +`POST /api/chat` 返回 ModelEvent SSE。OpenAI-Compatible Adapter 直接消费上游 SSE,Ollama Adapter 直接消费 JSONL,`TextDelta` 是真实增量内容,不再等待整段回答完成。 另外已经实现以下可配置 Adapter: @@ -166,7 +166,7 @@ GET /api/agent/runs/{run_id}/events POST /api/agent/runs/{run_id}/cancel ``` -当前 Run 与 Trace 保存在内存中,AI Core 重启后清空。后续数据库层接入时替换 Repository,不改变 API Contract。 +当前 Run 与 Trace 保存在内存中,AI Core 重启后清空。Runtime 最多保留 200 个 Run,每个 Run 最多保留 2000 个事件,并限制单轮 Tool Call 数量,避免长时间运行时无界增长。后续数据库层接入时替换 Repository,不改变 API Contract。 ## Tool Calling @@ -181,6 +181,12 @@ notes.read notes.create notes.update notes.list +notes.move +tasks.create +tasks.update +tasks.list +attachments.read +audio.transcribe ``` Mock Provider 使用下面的开发语法产生 Tool Call: @@ -229,7 +235,7 @@ POST /api/agent/runs/{run_id}/permissions/{request_id} {"decision":"deny"} ``` -默认需要确认的高影响权限包括 `notes.write`、`notes.delete`、`network.request` 和 `secrets.use`。 +默认需要确认的高影响权限包括 `notes.write`、`notes.delete`、`tasks.write`、`network.request` 和 `secrets.use`。权限命名空间采用白名单,未知权限默认拒绝。 ## Knowledge / Retrieval 接入 @@ -296,9 +302,12 @@ POST /api/plugins/install GET /api/plugins/{plugin_id} POST /api/plugins/{plugin_id}/enable POST /api/plugins/{plugin_id}/disable +PUT /api/plugins/{plugin_id}/permissions DELETE /api/plugins/{plugin_id} ``` +Plugin Manifest 中的权限只是声明,不代表已经授权。带权限的 Plugin 安装后进入 `permission_required`,Host 必须通过权限接口记录用户授权,之后才能启用。JSON Schema 在安装阶段校验,Tool 调用时再次校验实际参数。 + 内置示例 `text-tools` 注册 `text.uppercase`。内置 `knowledge-assistant` Skill 同时声明 `notes.search` 和 `text.uppercase`,用于验证完整链路: ```text @@ -313,9 +322,10 @@ Skill Manifest ## 当前限制与下一步 - 已实现 Mock、OpenAI-Compatible Chat Completions 与 Ollama Adapter;OpenAI Responses 和 Anthropic Messages 尚未实现。 -- Provider 配置暂存内存,后续通过 Repository 接入 SQLite。 +- Provider 配置暂存内存,后续通过 Repository 接入 SQLite;PATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。 - Run/Trace 暂存内存;下一步抽象 Repository 并接入 SQLite。 - Permission 已有核心等待/恢复机制,前端确认 UI 尚未联调。 -- Task、Attachment、Audio Tool 尚未接入。 +- Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。 +- `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍按技术基线在第二阶段接入。 - Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。 - 当前 Plugin Host 只支持内置声明式白名单 handler;MCP Bridge、独立进程健康检查与 UI Contribution 在第二阶段实现。 diff --git a/docs/Knowledge与Retrieval-Core开发说明.md b/docs/Knowledge与Retrieval-Core开发说明.md index edb51cd..ebb7648 100644 --- a/docs/Knowledge与Retrieval-Core开发说明.md +++ b/docs/Knowledge与Retrieval-Core开发说明.md @@ -196,7 +196,7 @@ cd backend uv run pytest -q ``` -当前 26 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把 +当前后端完整测试共 62 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把 数据目录/DB/Vault 重定向到临时目录,不读写真实 `backend/data`,任何本机状态下结果确定。 ## 配置 @@ -205,6 +205,7 @@ uv run pytest -q APP_DATA_DIR 默认 backend/data APP_DB_PATH 默认 backend/data/app.db APP_VAULT_PATH 默认 backend/data/vault +APP_ATTACHMENTS_PATH 默认 backend/data/attachments ``` 运行期生成的 `backend/data/*.db*` 已被 `.gitignore` 忽略,vault 下的 Markdown 测试数据会提交。 @@ -223,7 +224,9 @@ rag.search ## 当前限制与下一步 -- `move` 接口未实现(需确认移动后 `note_id` 是否保持稳定)。 +- `move` 接口已实现,移动文件后保持原 `note_id`,同时原子更新 Block、FTS 和向量索引。 +- Citation 的 `start_offset` / `end_offset` 使用 UTF-16 code unit,直接兼容浏览器编辑器。 +- Markdown 分块会识别 fenced code block,不会把代码中的 `#` 注释误判为标题。 - Embedding / Reranker 为轻量实现,后续替换为真实模型(接口不变)。 - 小语料下 hybrid 检索召回偏宽(向量 Top-K 覆盖全部 block),可加相关性阈值收紧。 - 重建为同步 + 全量,后续接入增量索引与异步任务队列。 diff --git a/docs/后端全面审阅问题与修复复盘.md b/docs/后端全面审阅问题与修复复盘.md new file mode 100644 index 0000000..740c3d6 --- /dev/null +++ b/docs/后端全面审阅问题与修复复盘.md @@ -0,0 +1,334 @@ +# 后端全面审阅问题与修复复盘 + +> 审阅日期:2026-08-28 +> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。 +> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。 + +## 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-03:Citation 偏移单位不一致 + +### 原因 + +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-04:Permission 与 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 路由保留为 501;Agent 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-06:Provider 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-08:Provider 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-09:Vault 扫描越过根目录 + +### 原因 + +旧重建逻辑直接读取 `rglob("*.md")` 的结果,只使用词法相对路径,没有验证符号链接解析后的目标是否仍位于 Vault。 + +### 后果 + +在支持符号链接的平台上,Vault 内链接可以指向外部 Markdown。外部内容随后进入 FTS、向量索引和 RAG 上下文,并可能发送给外部模型 Provider。 + +### 解决思路 + +扫描和 Note API 应使用同一条路径安全原则:先 `resolve()`,再验证真实目标仍位于解析后的 Vault 根目录。 + +### 解决方案 + +- 扫描开始时解析 Vault 根目录; +- 每个 Markdown 路径执行 `resolve()`; +- 不在 Vault 内的真实路径直接跳过; +- 文件状态和正文均从验证后的真实路径读取。 + +## 11. R-10:Agent 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 +62 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 增量事件。 + +## 14. 后续工作 + +本轮解决的是第一阶段后端正确性和契约问题。以下内容仍按技术基线留在后续阶段: + +- Run、Trace、Provider 和 Extension Registry 的完整 SQLite 持久化; +- faster-whisper、pyannote.audio 和真实音频任务队列; +- MCP Plugin Host 与独立进程健康检查; +- OpenAI Responses 与 Anthropic Messages Adapter; +- 真实 Embedding / Reranker 模型; +- 增量索引、文件监听和 RAG / Agent Benchmark。 diff --git a/docs/后端接口契约-开发版.md b/docs/后端接口契约-开发版.md index 9b05463..10e69df 100644 --- a/docs/后端接口契约-开发版.md +++ b/docs/后端接口契约-开发版.md @@ -60,6 +60,7 @@ | GET | `/api/plugins/{plugin_id}` | 获取 Plugin Manifest 与状态 | | POST | `/api/plugins/{plugin_id}/enable` | 启用 Plugin | | POST | `/api/plugins/{plugin_id}/disable` | 停用 Plugin | +| PUT | `/api/plugins/{plugin_id}/permissions` | 设置 Plugin 已授权权限 | | DELETE | `/api/plugins/{plugin_id}` | 卸载 Plugin | ### Provider @@ -105,11 +106,12 @@ Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`, } ``` -当前接口壳子的写操作主要返回: +当前接口主要返回: -- `501 NOT_IMPLEMENTED`:契约已经建立,业务服务尚未接入; - `422 VALIDATION_ERROR`:请求字段不符合 Pydantic Contract; - `404 RESOURCE_NOT_FOUND`:路由或资源不存在。 +- `409`:资源冲突、依赖缺失或扩展尚未获得权限; +- `429 AGENT_CAPACITY_EXCEEDED`:活动 Agent Run 达到上限。 前端只根据 `error.code` 判断业务错误,不解析第三方 SDK 的原始异常文本。 @@ -154,8 +156,9 @@ RunCancelled ## 当前实现状态 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。 -- Provider Adapter 当前包含 Mock、OpenAI-Compatible Chat Completions 和 Ollama。 -- 默认提供 `mock/mock-1` 离线 Provider,以及 `system.echo`、`math.add` 开发 Tool。 -- Notes、Search、Skills、Plugins、Tasks、Media、Index 等尚未接入业务服务的接口继续返回空结果、`idle` 或 `501`。 -- 需要尚未接入的数据库、文件或扩展 Runtime 的操作统一返回 `501`。 +- Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。 +- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。 +- Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。 +- Plugin 启用前必须通过权限接口记录授权,未知权限默认拒绝。 +- Attachment Tool 读取 Host 管理的 `attachments` 目录;音频接口读取 Host 生成的转写文本,真实本地语音模型在第二阶段接入。 - 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。