docs: 重组文档目录并补充CI/CD细则
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
# Agent Core 第二阶段:Trace 持久化与 SSE 恢复问题复盘
|
||||
|
||||
> 审阅与修复日期:2026-09-01
|
||||
> 涉及分支:`feat/agent-trace-persistence`
|
||||
> 功能提交:`3cb197a feat(agent): 持久化Trace并支持SSE恢复`
|
||||
> 文档用途:记录 Agent Run、Trace、SSE 恢复和审计数据安全问题的形成原因、实际后果、解决思路与落地方案,供后续开发文档、比赛材料和技术博客写作使用。
|
||||
|
||||
## 1. 背景与结论
|
||||
|
||||
第一阶段 Agent Runtime 已经能够完成模型调用、Tool Calling、权限确认、取消、Usage 和 Citation,但 Run 与 Event 仍以进程内字典和列表为事实来源。第一阶段审阅加入的 Run/Event 数量上限解决了内存无界增长,却没有解决重启丢失、断线续传、Benchmark 复用和敏感数据审计等第二阶段问题。
|
||||
|
||||
本轮处理了 8 类问题:
|
||||
|
||||
| 编号 | 问题 | 级别 | 处理结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| A-01 | Agent Run 与 Event 只存在于进程内存 | P0 | 增加 SQLite v3 Schema 和 Trace Repository |
|
||||
| A-02 | Event 裁剪后 sequence 可能重复 | P0 | 改为独立单调序号并建立数据库幂等键 |
|
||||
| A-03 | SSE 断线后无法从指定事件恢复 | P1 | 支持 `Last-Event-ID`、`after_sequence` 和 SSE `id` |
|
||||
| A-04 | 缺少可分页 Trace 与 Benchmark 配置快照 | P1 | 增加 Trace API、统计摘要与配置快照 |
|
||||
| A-05 | Trace 缺少模型调用、父子关系和权限结果 | P1 | 增加第二阶段事件及耗时/parent 字段 |
|
||||
| A-06 | Trace 可能保存 Secret 和超大 Tool Result | P0 | Run、Request、Config、Event 统一脱敏与截断 |
|
||||
| A-07 | 进程重启后未终止 Run 永久显示运行中 | P1 | 自动收束为 `AGENT_PROCESS_RESTARTED` |
|
||||
| A-08 | 前端 DTO 与 SSE Client 无法消费恢复协议 | P1 | 同步 TypeScript Contract、Service、标签和 SSE id |
|
||||
|
||||
修复后的验证基线为:后端 80 项测试、前端 27 项测试、TypeScript 类型检查和生产构建通过。
|
||||
|
||||
## 2. A-01:Agent Run 与 Event 只存在于进程内存
|
||||
|
||||
### 原因
|
||||
|
||||
旧 `AgentRuntime` 使用 `_records: dict[str, RunRecord]` 保存全部运行状态。每个 `RunRecord` 内部再保存 `events`、实时订阅队列和异步任务。创建、查询、列表和 SSE 回放都只读取这个字典,没有 Repository 边界。
|
||||
|
||||
第一阶段为控制内存加入了最多 200 个 Run、每个 Run 2000 个 Event 的限制。这是必要的资源保护,但只是减少进程内数据量,不能替代持久化。
|
||||
|
||||
### 后果
|
||||
|
||||
- AI Core 重启后,历史 Run、Tool Result、Citation 和错误全部丢失;
|
||||
- 前端刷新或重新连接后,只能读取当前进程尚未淘汰的数据;
|
||||
- Agent Benchmark 无法使用稳定的历史事实计算指标;
|
||||
- Run 列表随着进程重启清空,界面记录和实际操作脱节;
|
||||
- 内存裁剪后的旧事件无法再恢复。
|
||||
|
||||
### 解决思路
|
||||
|
||||
将 SQLite 中的 Run/Event 作为唯一持久化事实,把内存降级为执行上下文和实时订阅窗口。Runtime 继续负责状态机,Repository 负责落库、分页、恢复和统计,Router 不直接访问数据库。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新增 SQLite v3 migration:
|
||||
|
||||
```text
|
||||
agent_runs
|
||||
├── run_id
|
||||
├── status
|
||||
├── run_json
|
||||
├── request_json
|
||||
├── config_snapshot_json
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
agent_events
|
||||
├── run_id
|
||||
├── sequence
|
||||
├── event
|
||||
├── data_json
|
||||
└── timestamp
|
||||
```
|
||||
|
||||
新增 `AgentTraceRepository`,负责:
|
||||
|
||||
- 创建 Run 及请求、配置快照;
|
||||
- 在同一事务中更新 Run 并追加 Event;
|
||||
- 按创建时间分页列出 Run;
|
||||
- 按 sequence 读取 Event;
|
||||
- 生成 Trace 分页响应和统计摘要;
|
||||
- 恢复进程中断的非终态 Run。
|
||||
|
||||
内存中的 2000 条 Event 上限继续保留,但只用于实时订阅窗口;完整记录由 SQLite 管理。
|
||||
|
||||
## 3. A-02:Event 裁剪后 sequence 可能重复
|
||||
|
||||
### 原因
|
||||
|
||||
旧 `_publish()` 使用下面的方式生成序号:
|
||||
|
||||
```python
|
||||
sequence = len(record.events)
|
||||
```
|
||||
|
||||
当事件数量超过 2000 后,Runtime 会删除列表头部。列表长度重新回到 2000,后续事件仍会得到 2000,形成重复 sequence。
|
||||
|
||||
### 后果
|
||||
|
||||
- `run_id + sequence` 无法作为幂等键;
|
||||
- 前端按 sequence 去重时会错误丢弃新事件;
|
||||
- 时间线排序出现同序号节点;
|
||||
- 持久化后会触发主键冲突,或者在错误的覆盖策略下破坏旧事件;
|
||||
- Benchmark 无法可靠还原 Tool Call 顺序。
|
||||
|
||||
### 解决思路
|
||||
|
||||
sequence 应属于 Run 的逻辑时钟,不能从当前缓存长度推导。内存是否裁剪不得影响序号。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- `RunRecord` 增加独立的 `next_sequence`;
|
||||
- 每次发布读取当前值,再原子递增;
|
||||
- `agent_events` 使用 `(run_id, sequence)` 复合主键;
|
||||
- Repository 对重复写入使用幂等插入,不覆盖已经存在的事件事实;
|
||||
- Trace 和 SSE 均严格按 sequence 升序返回。
|
||||
|
||||
## 4. A-03:SSE 断线后无法恢复
|
||||
|
||||
### 原因
|
||||
|
||||
旧 SSE 接口只能从内存列表头部重新回放全部历史,再切换到实时队列。协议帧只有 `event:` 和 `data:`,没有 SSE 标准的 `id:`。接口也不读取 `Last-Event-ID` 或查询游标。
|
||||
|
||||
前端即使知道自己最后处理到哪个 sequence,也无法把该位置传回服务端。
|
||||
|
||||
### 后果
|
||||
|
||||
- 短暂断网或页面切换后只能从头回放;
|
||||
- 长 Run 重连会重复传输大量事件;
|
||||
- 前端需要依赖本地去重掩盖服务端缺少恢复能力;
|
||||
- AI Core 重启后无法续传,因为历史事件本身也不存在;
|
||||
- 实时与历史交界处容易漏事件或重复事件。
|
||||
|
||||
### 解决思路
|
||||
|
||||
恢复协议以 sequence 为游标。服务端先注册实时订阅,再读取 `sequence > cursor` 的持久化历史,随后消费实时队列;交界处允许重复,但 Runtime 和前端都按 sequence 去重。
|
||||
|
||||
### 解决方案
|
||||
|
||||
接口支持两种游标输入:
|
||||
|
||||
```http
|
||||
GET /api/agent/runs/{run_id}/events?after_sequence=42
|
||||
Last-Event-ID: 42
|
||||
```
|
||||
|
||||
返回帧包含:
|
||||
|
||||
```text
|
||||
id: 43
|
||||
event: ToolResult
|
||||
data: {"run_id":"...","sequence":43,"data":{...}}
|
||||
```
|
||||
|
||||
具体处理:
|
||||
|
||||
- `after_sequence` 优先于 `Last-Event-ID`;
|
||||
- 默认游标为 `-1`,表示从 sequence 0 开始;
|
||||
- 非整数或小于 `-1` 的 Header 返回 `TRACE_CURSOR_INVALID`;
|
||||
- 历史回放读取 SQLite,不依赖内存窗口;
|
||||
- 历史与实时交界处按最后已发送 sequence 跳过重复项;
|
||||
- 终态 Run 回放完终止事件后关闭连接。
|
||||
|
||||
## 5. A-04:缺少可分页 Trace 与 Benchmark 配置快照
|
||||
|
||||
### 原因
|
||||
|
||||
旧接口只有 Run 状态和 SSE。SSE 适合实时消费,不适合报告页随机访问、大 Trace 分页或 Benchmark 批量计算。Run 也没有保存创建时的 Provider、Model、Skill、允许工具和限制参数快照。
|
||||
|
||||
### 后果
|
||||
|
||||
- Trace 页面只能依赖一次长连接重建全部状态;
|
||||
- Benchmark 需要绕过正式接口读取 Runtime 内部对象;
|
||||
- Provider 或 Skill 配置变化后,旧结果失去可解释性;
|
||||
- 大 Trace 无法受控分页,接口响应体会持续增大;
|
||||
- 前端和 Benchmark 容易各自实现一套不一致的统计逻辑。
|
||||
|
||||
### 解决思路
|
||||
|
||||
提供面向读取的 Trace Snapshot API,但只返回平铺事实,不在后端生成前端树形布局。前端按 parent ID 和 sequence 构造时间线,Benchmark 从同一事件计算指标。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新增接口:
|
||||
|
||||
```http
|
||||
GET /api/agent/runs/{run_id}/trace?after_sequence=-1&limit=200
|
||||
```
|
||||
|
||||
响应包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "run_123",
|
||||
"status": "completed",
|
||||
"items": [],
|
||||
"next_sequence": 199,
|
||||
"has_more": true,
|
||||
"summary": {
|
||||
"model_calls": 2,
|
||||
"tool_calls": 3,
|
||||
"duration_ms": 1530,
|
||||
"token_usage": 2048,
|
||||
"errors": 0
|
||||
},
|
||||
"config_snapshot": {}
|
||||
}
|
||||
```
|
||||
|
||||
配置快照保存:
|
||||
|
||||
- Provider ID 和类型;
|
||||
- Model;
|
||||
- Capability;
|
||||
- Skill ID;
|
||||
- 允许的 Tool;
|
||||
- `max_steps`、Token Budget 和网络权限;
|
||||
- 经过脱敏的 Metadata。
|
||||
|
||||
## 6. A-05:Trace 缺少关键执行事实
|
||||
|
||||
### 原因
|
||||
|
||||
第一阶段事件能够表达 Run、Tool、Permission Request、Usage 和 Citation,但没有明确表示一次模型调用的开始、完成或失败。Tool Call 与模型轮次之间也没有 parent ID,Tool Result 缺少统一耗时。权限接口只唤醒 Future,不记录最终决定。
|
||||
|
||||
### 后果
|
||||
|
||||
- 前端无法展示“模型调用 → 多个 Tool → 下一次模型调用”的完整树;
|
||||
- Agent Benchmark 无法计算模型调用次数和平均步骤耗时;
|
||||
- 并发 Tool Call 时难以判断属于哪个模型轮次;
|
||||
- 权限卡片消失后,Trace 中只保留“请求过权限”,不知道用户允许还是拒绝;
|
||||
- Provider 失败只能看到最终 RunFailed,缺少模型调用级上下文。
|
||||
|
||||
### 解决思路
|
||||
|
||||
保持现有 AgentEvent envelope 不变,只增加事件类型和可选数据字段。调用关系使用稳定 ID 表达,不让前端根据相邻位置猜测父子关系。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新增事件:
|
||||
|
||||
```text
|
||||
ModelCallStarted
|
||||
ModelCallCompleted
|
||||
ModelCallFailed
|
||||
PermissionResolved
|
||||
```
|
||||
|
||||
补充字段:
|
||||
|
||||
- Model Call:`model_call_id`、Provider、Model、Step、Finish Reason、Token、Duration;
|
||||
- Tool Call/Result:`parent_model_call_id`、`duration_ms`;
|
||||
- Permission Resolved:`request_id`、Permission、Decision;
|
||||
- Model Call Failed:`error_code` 和耗时,不写入第三方原始敏感异常。
|
||||
|
||||
模型与 Tool 耗时使用单调时钟计算,避免系统时间调整影响 Duration。
|
||||
|
||||
## 7. A-06:Trace 可能保存 Secret 和超大结果
|
||||
|
||||
### 原因
|
||||
|
||||
Tool 参数和输出来自模型、插件或外部服务,属于不可信数据。旧事件直接保存 `ToolCall.model_dump()` 和 `ToolResult.model_dump()`。`notes.read`、附件或第三方 Tool 可以返回大段正文,参数也可能包含 `api_key`、Authorization 或 Password。
|
||||
|
||||
初版持久化修复只净化了 `agent_events.data_json`。提交前审阅发现,`agent_runs.run_json` 中的 `tool_results`、`output` 和 `input` 仍可能保存同一份敏感值,说明“只在事件层脱敏”并不完整。
|
||||
|
||||
### 后果
|
||||
|
||||
- API Key 或 Bearer Token 可能进入 SQLite、备份和测试产物;
|
||||
- Trace API 不返回 Secret,但数据库中的 Run Snapshot 仍可能泄露;
|
||||
- 单个 Tool Result 可以让 Event 和 Run JSON 快速膨胀;
|
||||
- 前端展开节点时可能因为超大 JSON 卡顿;
|
||||
- Benchmark Dataset 或报告导出可能间接携带密钥。
|
||||
|
||||
### 解决思路
|
||||
|
||||
所有进入持久化边界的数据统一经过同一个净化函数,不能分别在 Router、Runtime 和 Repository 中维护不同规则。净化必须同时覆盖键名、常见密钥值模式、递归深度、字符串长度和集合大小。
|
||||
|
||||
### 解决方案
|
||||
|
||||
统一处理以下对象:
|
||||
|
||||
```text
|
||||
AgentRun Snapshot
|
||||
AgentRunCreateRequest Snapshot
|
||||
Config Snapshot
|
||||
AgentEvent Data
|
||||
```
|
||||
|
||||
净化规则:
|
||||
|
||||
- `api_key`、Authorization、Access/Refresh Token、Password、Secret 等键替换为 `[REDACTED]`;
|
||||
- 常见 `sk-...` 和 `Bearer ...` 字符串模式直接替换;
|
||||
- 单字符串最多保留 4096 个字符;
|
||||
- 单集合最多保留 100 项;
|
||||
- 递归深度最多 8 层;
|
||||
- 超限位置使用明确的 `[TRUNCATED]` 或 `[MAX_DEPTH]` 标记;
|
||||
- `credential_id` 等非明文引用保留,不误判为 Secret。
|
||||
|
||||
回归测试直接读取 `agent_runs` 原始 SQLite 字段,确认测试密钥没有落盘,避免只验证 API 响应造成假安全。
|
||||
|
||||
## 8. A-07:重启后未终止 Run 永久显示运行中
|
||||
|
||||
### 原因
|
||||
|
||||
Agent 的异步 Task 和 Permission Future 不能跨进程恢复。持久化 Run 后,如果直接返回数据库状态,重启前处于 `queued`、`running` 或 `waiting_permission` 的记录会一直保持非终态,但新进程中没有对应 Task 可以继续执行。
|
||||
|
||||
### 后果
|
||||
|
||||
- 前端长期显示“运行中”或“等待授权”;
|
||||
- SSE 订阅等待一个永远不会到来的终止事件;
|
||||
- Benchmark Runner 无法判断 Case 已中断;
|
||||
- 用户取消该 Run 时,新进程找不到实际 Task;
|
||||
- 统计中的成功率和耗时被悬挂 Run 污染。
|
||||
|
||||
### 解决思路
|
||||
|
||||
本阶段提供“状态恢复”,不伪装成“执行恢复”。没有可重放状态机、Provider 幂等令牌和 Tool 副作用日志之前,自动继续执行会造成重复写入或重复网络请求。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新 Runtime 首次读取不属于当前进程的非终态 Run 时:
|
||||
|
||||
- 状态改为 `failed`;
|
||||
- 错误码设为 `AGENT_PROCESS_RESTARTED`;
|
||||
- 错误信息说明进程在完成前重启;
|
||||
- 使用数据库最大 sequence 加一,追加唯一 `RunFailed` 事件;
|
||||
- 后续 Run 查询、Trace 和 SSE 都返回同一终态事实。
|
||||
|
||||
已经完成、失败或取消的 Run 不修改,可以在重启后继续查询和回放。
|
||||
|
||||
## 9. A-08:前端无法消费第二阶段恢复协议
|
||||
|
||||
### 原因
|
||||
|
||||
后端增加新事件和 SSE `id` 后,前端 `AgentEventType` 仍只包含第一阶段事件。`SseClient` 只解析 `event` 和 `data`,忽略 `id`,也没有发送 `Last-Event-ID`。因此仅完成后端并不能形成可联调的 Contract。
|
||||
|
||||
### 后果
|
||||
|
||||
- `Record<AgentEventType, string>` 中文标签无法通过类型检查;
|
||||
- 前端不知道 Model Call 和 Permission Resolved 的类型;
|
||||
- 断线后无法把最后事件 ID 传回后端;
|
||||
- Trace 可视化负责人需要自行猜测 Wire DTO;
|
||||
- 后端恢复能力只能通过 Curl 使用,页面调用链没有闭环。
|
||||
|
||||
### 解决思路
|
||||
|
||||
范侧只提供稳定的前端接口适配,不越过分工实现 Trace Visualization。Pydantic Contract、TypeScript Wire DTO 和 Service 必须在同一功能提交中同步。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- TypeScript 增加四类第二阶段 Agent Event;
|
||||
- 增加 `AgentTraceSummary` 和 `AgentTraceResponse`;
|
||||
- `agentService.getAgentTrace()` 封装分页查询;
|
||||
- `streamAgentEvents()` 接受 `afterSequence`;
|
||||
- `SseClient` 发送 `Last-Event-ID` 并解析返回帧的 `id`;
|
||||
- 新事件增加中文标签和详情字段名;
|
||||
- 增加 SSE 请求头和 Event ID 单元测试。
|
||||
|
||||
Trace 时间线、树形布局、筛选、节点展开和 Citation 跳转仍由前端负责人实现。
|
||||
|
||||
## 10. 事务、顺序与恢复不变量
|
||||
|
||||
本轮修复明确了以下不变量:
|
||||
|
||||
1. `run_id + sequence` 唯一标识一条 Agent Event。
|
||||
2. sequence 在一个 Run 内只增不减,不受内存裁剪影响。
|
||||
3. 发布事件时,在同一 SQLite 事务中更新 Run Snapshot 并追加 Event。
|
||||
4. SSE、Trace 页面和 Agent Benchmark 读取同一份 `agent_events`,不建立旁路。
|
||||
5. 终止事件为 `RunCompleted`、`RunFailed` 或 `RunCancelled`;终态 Run 不再产生业务事件。
|
||||
6. 重启后不能安全继续执行的 Run 必须明确失败,不能永久悬挂。
|
||||
7. 所有持久化 Trace 数据先脱敏、再写入。
|
||||
8. 前端按 `run_id + sequence` 去重,不能依赖一次网络读取对应一条 SSE Event。
|
||||
|
||||
## 11. 验证方法
|
||||
|
||||
后端:
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv run python -m compileall -q app
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
pnpm test
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
仓库检查:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
```
|
||||
|
||||
验证结果:
|
||||
|
||||
```text
|
||||
backend pytest 80 passed
|
||||
backend compileall passed
|
||||
frontend vitest 11 files / 27 tests passed
|
||||
frontend type-check passed
|
||||
frontend build passed
|
||||
git diff --check passed
|
||||
```
|
||||
|
||||
本轮新增回归覆盖:
|
||||
|
||||
- 完成 Run 在新 Runtime 中恢复查询;
|
||||
- Trace 多页读取和游标无重复;
|
||||
- SSE `Last-Event-ID`、`after_sequence` 和 `id:` 帧;
|
||||
- Model Call、Permission Resolved 和 Tool parent ID;
|
||||
- 进程中断 Run 自动生成唯一终止事件;
|
||||
- API Key、Authorization 和超长 Tool 参数净化;
|
||||
- 直接检查 SQLite,确认 Secret 未进入 Run/Request/Config Snapshot;
|
||||
- OpenAPI 发布 Trace 路径;
|
||||
- 前端 SSE Client 发送和解析恢复游标。
|
||||
|
||||
测试仍会出现本机 `.pytest_cache` 无写入权限警告,不影响 80 项用例结果,也不涉及产品代码。
|
||||
|
||||
## 12. 当前边界与后续工作
|
||||
|
||||
### 12.1 本阶段明确不做
|
||||
|
||||
- 不在后端生成前端 Trace 树形布局;
|
||||
- 不在进程重启后自动重放未完成 Tool 副作用;
|
||||
- 不把 Secret 明文放入 Trace、日志或 Benchmark;
|
||||
- 不为 Benchmark 建立绕过 Agent Runtime 的专用执行协议。
|
||||
|
||||
### 12.2 后续需要继续处理
|
||||
|
||||
- 增加 Trace 保留、归档和被 Benchmark 引用时的保护策略;
|
||||
- 引入保留窗口后实现 `TRACE_CURSOR_EXPIRED`;
|
||||
- 根据桌面网络策略增加有上限的指数退避自动重连;
|
||||
- 评估高频 Token Event 的批量写入,减少 SQLite 连接与事务开销;
|
||||
- Agent Benchmark 接入正式 Trace 并验证指标字段是否充足;
|
||||
- 前端完成 Trace Timeline/Tree、筛选、节点详情和 Citation 跳转;
|
||||
- 多进程或远程执行出现需求后,再设计带租约和幂等副作用的执行恢复。
|
||||
|
||||
## 13. 可复用经验
|
||||
|
||||
### 13.1 资源上限不等于持久化
|
||||
|
||||
限制内存 Run 和 Event 数量只能防止进程膨胀,不能解决重启、审计和报告复现。临时保护措施应在文档中明确标注,不能被误认为最终架构已经完成。
|
||||
|
||||
### 13.2 游标必须独立于缓存结构
|
||||
|
||||
只要 sequence 来源于 `len(list)`、数组下标或当前页位置,裁剪和分页就可能破坏唯一性。可恢复事件流必须使用独立、单调且可持久化的逻辑序号。
|
||||
|
||||
### 13.3 恢复读取不等于恢复执行
|
||||
|
||||
恢复 Run/Trace 查询相对安全;恢复一个包含 Tool 副作用的执行任务需要额外的幂等、租约和补偿机制。在没有这些机制时,明确失败比重复执行更可靠。
|
||||
|
||||
### 13.4 脱敏要覆盖全部持久化副本
|
||||
|
||||
同一敏感值可能同时出现在 Event、Run Snapshot、Request、Config、日志和报告中。只检查最终 API 响应无法证明数据没有落盘,安全测试应直接验证持久化介质。
|
||||
|
||||
### 13.5 生产者和消费者 Contract 必须同时更新
|
||||
|
||||
后端新增事件类型、字段或 SSE 规则时,至少同步 Pydantic、OpenAPI、TypeScript DTO、Service 和协议测试。可视化页面可以由另一成员开发,但不能让对方从后端实现反推 Contract。
|
||||
@@ -0,0 +1,503 @@
|
||||
# Knowledge Core 与 Retrieval Core:PR 审阅问题与修复复盘
|
||||
|
||||
> 本文记录 `feat/knowledge-retrieval-core` 合并前后的两轮代码审阅、问题复现、修复过程与工程经验。
|
||||
> 它既是团队内部的问题档案,也可作为后续技术文档、课程报告和博客文章的素材底稿。
|
||||
|
||||
> 2026-08-30 状态补充:本文中的 43 项测试是当时该模块的历史基线,不应替换为当前全仓测试数。相关修复仍有效,当前完整后端回归为 71 项通过,Knowledge/Retrieval 已通过 `notes.*` 与 `rag.search` Tool 接入 Agent Runtime。
|
||||
|
||||
## 1. 背景
|
||||
|
||||
Knowledge Core 与 Retrieval Core 建立了项目第一条完整的本地知识检索链路:
|
||||
|
||||
```text
|
||||
Markdown Vault
|
||||
→ Markdown Block 解析
|
||||
→ SQLite 元数据 / FTS5 / sqlite-vec
|
||||
→ FTS 与向量双路召回
|
||||
→ RRF 融合与轻量 Reranker
|
||||
→ Metadata Filter
|
||||
→ Citation 与 Snippet
|
||||
```
|
||||
|
||||
功能分支完成后进行了两轮审阅。第一轮主要检查路径安全、索引一致性、PATCH 语义、检索分页和重建行为;第二轮重点验证第一轮修复是否真正覆盖事务失败和大数据边界。
|
||||
|
||||
审阅不是只看现有测试是否通过,而是主动构造失败条件,例如:
|
||||
|
||||
- 让 embedding、向量删除或索引元信息写入主动抛出异常;
|
||||
- 创建同目录、同标题的重复笔记;
|
||||
- 使用 `../`、Windows 盘符等恶意 folder;
|
||||
- 制造超过候选池以及超过 1000 条的 FTS 命中;
|
||||
- 比较 `blocks` 与 `vec_blocks` 的 ID 集合;
|
||||
- 在接口报错后重新读取 Markdown、SQLite 和向量表,检查是否发生部分提交。
|
||||
|
||||
## 2. 审阅与修复时间线
|
||||
|
||||
| 阶段 | 提交 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| 功能分支首轮修复 | `8771745` | 路径逃逸、更新部分提交、失效向量和基础分页 |
|
||||
| 功能分支二次修复 | `6cf531f` | 跨表事务、Metadata Filter、PATCH tags、rebuild 回滚 |
|
||||
| 合并提交 | `9348225` | 将 Knowledge/Retrieval Core 合并到 `main` |
|
||||
| 合并后第一批修复 | `af8ccb0` | `index_meta` 事务一致性、重复创建冲突 |
|
||||
| 合并后第二批修复 | `32f249c` | 删除一致性、FTS 精确过滤与分页 |
|
||||
|
||||
最终回归测试结果:
|
||||
|
||||
```text
|
||||
43 passed
|
||||
```
|
||||
|
||||
## 3. 问题总览
|
||||
|
||||
| 编号 | 问题 | 主要风险 | 最终处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| P-01 | folder 可逃逸 Vault | 任意位置写入或删除 Markdown | 路径标准化,并校验 resolve 后仍位于 Vault |
|
||||
| P-02 | VectorStore 的 upsert 实际为 INSERT | 正常 PATCH 主键冲突 | delete-then-insert,实现幂等 upsert |
|
||||
| P-03 | 元数据与向量分开提交 | 接口失败但数据库已部分改变 | 共用连接和 SQLite 事务 |
|
||||
| P-04 | 更新正文后遗留旧向量 | Top-K 被无效向量占据 | 比较新旧 Block ID,事务内清理失效向量 |
|
||||
| P-05 | PATCH tags 语义错误 | 省略字段却清空标签,空数组无法清空 | 严格区分 `None` 与 `[]` |
|
||||
| P-06 | 固定候选池破坏过滤与分页 | 合法结果漏召回,total 不准确 | 先扩充候选,最终改为 FTS SQL 侧过滤和分页 |
|
||||
| P-07 | rebuild 忽略 scope/note_ids 且先清空 | 请求语义错误,失败留下半成品 | 拒绝未支持范围,扫描在前,备份与失败恢复 |
|
||||
| P-08 | `index_meta` 在主事务外写入 | 文件回滚但索引已更新 | 将 `index_meta` 纳入同一事务 |
|
||||
| P-09 | POST 同路径静默覆盖 | 原笔记无提示丢失 | 数据库预检 + 文件排他创建 + 409 |
|
||||
| P-10 | 删除由三个独立操作组成 | notes、向量和文件互相不一致 | tombstone + SQLite 同事务 + 失败恢复 |
|
||||
|
||||
## 4. P-01:folder 路径逃逸
|
||||
|
||||
### 4.1 问题原因
|
||||
|
||||
最初的笔记路径由下面的逻辑生成:
|
||||
|
||||
```python
|
||||
folder_part = folder.strip().strip("/")
|
||||
rel_path = f"{folder_part}/{safe_title}.md"
|
||||
absolute_path = vault / rel_path
|
||||
```
|
||||
|
||||
代码只去除了 folder 两端的斜杠,没有处理:
|
||||
|
||||
- `..` 和 `.` 路径段;
|
||||
- Windows 反斜杠;
|
||||
- `C:\...` 等盘符路径;
|
||||
- NUL 字节;
|
||||
- 符号链接或规范化后越过 Vault 的路径。
|
||||
|
||||
因此 `folder="../outside"` 会使最终路径落到 Vault 之外。
|
||||
|
||||
### 4.2 后果
|
||||
|
||||
攻击者或错误的前端输入可以在 Vault 外创建 Markdown。数据库会保存这个越界路径,后续读取和删除接口还会继续访问该文件,风险从“越界写入”扩大为“越界读取和删除”。
|
||||
|
||||
### 4.3 解决思路
|
||||
|
||||
路径安全不能只依赖字符串替换,需要同时完成两层校验:
|
||||
|
||||
1. 输入层拒绝明显非法的路径片段;
|
||||
2. 文件系统层对最终路径执行 `resolve()`,确认它仍属于 Vault。
|
||||
|
||||
### 4.4 最终方案
|
||||
|
||||
`note_service.py` 新增 `_normalize_folder()` 和安全的 `_abs_path()`:
|
||||
|
||||
- 同时按 `/` 和 `\` 拆分目录;
|
||||
- 拒绝 `.`、`..`、盘符和 NUL;
|
||||
- 对 Vault 与目标执行 `resolve()`;
|
||||
- 使用 `Path.is_relative_to()` 验证目标未逃逸;
|
||||
- 所有读、写、删操作统一经过 `_abs_path()`。
|
||||
|
||||
回归测试覆盖 `../../outside`、`..\..\etc`、`C:\Windows` 和 `a/../b` 等输入。
|
||||
|
||||
## 5. P-02 与 P-03:伪 upsert 和索引部分提交
|
||||
|
||||
### 5.1 问题原因
|
||||
|
||||
Block ID 根据 `note_id + heading_path + content` 稳定生成。只修改标题或标签时,正文 Block ID 不会改变。
|
||||
|
||||
最初 `SqliteVecStore.upsert()` 名为 upsert,实际执行的却是普通 INSERT:
|
||||
|
||||
```sql
|
||||
INSERT INTO vec_blocks (block_id, embedding) VALUES (?, ?)
|
||||
```
|
||||
|
||||
同一个 Block 再次写入时会触发 sqlite-vec 主键冲突。同时,元数据和 FTS 已经在另一个事务中提交,向量失败无法回滚前面的修改。
|
||||
|
||||
### 5.2 后果
|
||||
|
||||
一次仅修改标题的 PATCH 就可能出现:
|
||||
|
||||
```text
|
||||
HTTP:500
|
||||
Markdown:已写入
|
||||
notes / blocks / FTS:已更新
|
||||
vec_blocks:写入失败
|
||||
```
|
||||
|
||||
调用方看到的是“更新失败”,系统内部却已经改变。这类部分提交比直接失败更危险,因为客户端重试可能继续扩大不一致。
|
||||
|
||||
### 5.3 解决思路
|
||||
|
||||
- upsert 必须具备幂等性;
|
||||
- notes、blocks、FTS 与 vec_blocks 都在同一个 SQLite 文件内,应共用连接和事务;
|
||||
- Repository 和 VectorStore 既要支持独立调用,也要支持加入调用方事务。
|
||||
|
||||
### 5.4 最终方案
|
||||
|
||||
VectorStore 的 upsert 改为:
|
||||
|
||||
```text
|
||||
DELETE existing block_id
|
||||
→ INSERT new embedding
|
||||
```
|
||||
|
||||
`repository.replace_note_metadata()`、`vector_store.upsert()` 和 `vector_store.delete()` 都可接收外部连接。`index_note()` 打开一个连接和事务,把元数据、FTS、失效向量删除、新向量插入放在同一提交边界中。
|
||||
|
||||
## 6. P-04:失效向量残留
|
||||
|
||||
### 6.1 问题原因
|
||||
|
||||
更新正文后,Repository 会删除旧 Block 并插入新 Block,但早期实现只写入新向量,没有删除内容已经不存在的旧向量。vec0 没有到 blocks 表的外键约束,因此这些向量不会自动级联删除。
|
||||
|
||||
### 6.2 后果
|
||||
|
||||
实测一次全文更新后出现:
|
||||
|
||||
```text
|
||||
blocks = 1
|
||||
vec_blocks = 2
|
||||
```
|
||||
|
||||
旧向量仍会参加 KNN 排名,但随后无法取得 Block 上下文。反复编辑后,失效向量可能占满 Top-K,使真正有效的结果无法进入候选集。
|
||||
|
||||
### 6.3 解决思路与方案
|
||||
|
||||
更新前取得旧 `block_id`,解析后得到新 `block_id`,计算集合差:
|
||||
|
||||
```text
|
||||
stale = old_ids - new_ids
|
||||
missing = new_ids - old_ids
|
||||
```
|
||||
|
||||
- 删除 `stale` 对应向量;
|
||||
- 只为 `missing` 写入新向量;
|
||||
- 未变化 Block 沿用原向量;
|
||||
- 整个过程与 Block 替换使用同一事务。
|
||||
|
||||
测试以集合不变量验证修复:
|
||||
|
||||
```text
|
||||
set(vec_blocks.block_id) == set(blocks.block_id)
|
||||
```
|
||||
|
||||
## 7. P-05:PATCH tags 语义错误
|
||||
|
||||
### 7.1 问题原因
|
||||
|
||||
解析器曾使用真值判断:
|
||||
|
||||
```python
|
||||
resolved_tags = list(tags) if tags else parse_frontmatter_tags()
|
||||
```
|
||||
|
||||
但 PATCH 中的 `None` 和 `[]` 含义不同:
|
||||
|
||||
- `None`:客户端没有提交 tags,应保留原值;
|
||||
- `[]`:客户端明确要求清空标签。
|
||||
|
||||
真值判断把两种状态混为一谈。另外,更新服务直接把 `None` 传给解析器,导致原有 API 标签被 frontmatter 或空数组替换。
|
||||
|
||||
### 7.2 后果
|
||||
|
||||
- 仅修改标题也会意外清空标签;
|
||||
- 显式提交空数组时,反而可能重新读取 frontmatter 标签,无法清空。
|
||||
|
||||
### 7.3 最终方案
|
||||
|
||||
解析器改为判断 `tags is not None`。更新服务根据 PATCH 语义计算有效标签:
|
||||
|
||||
```python
|
||||
effective_tags = record.tags if tags is None else tags
|
||||
```
|
||||
|
||||
回归测试分别覆盖省略、替换和清空三种情况。
|
||||
|
||||
## 8. P-06:候选池、Metadata Filter 与分页
|
||||
|
||||
### 8.1 第一阶段问题
|
||||
|
||||
初版 FTS 和向量召回都固定只取 50 条候选,然后才执行 Metadata Filter 和分页:
|
||||
|
||||
```text
|
||||
Top 50 → Metadata Filter → offset/limit
|
||||
```
|
||||
|
||||
这会产生两个错误:
|
||||
|
||||
1. 第 51 条以后的页面永远无法访问;
|
||||
2. 满足 folder/tag 条件的结果如果排在第 51 条以后,会被错误地过滤掉。
|
||||
|
||||
实测 60 个匹配 Block 请求 `offset=50` 时,响应为 `total=50, items=[]`。
|
||||
|
||||
### 8.2 中间修复为什么仍不完整
|
||||
|
||||
第一轮修复根据 `offset + limit` 扩大候选池,并在有过滤条件时 overscan;FTS 模式则把上限扩大到 1000。
|
||||
|
||||
这解决了常见的小数据场景,但本质只是把硬边界从 50 移到了 1000。第二轮实测 1010 个匹配 Block,请求 `offset=1000` 时仍得到:
|
||||
|
||||
```json
|
||||
{"total": 1000, "items": []}
|
||||
```
|
||||
|
||||
### 8.3 最终解决思路
|
||||
|
||||
全文检索分页不应先把所有结果加载到 Python 再切片,应由数据库完成:
|
||||
|
||||
```text
|
||||
FTS MATCH
|
||||
+ Metadata WHERE
|
||||
→ COUNT(*) 得到精确 total
|
||||
→ ORDER BY bm25
|
||||
→ LIMIT / OFFSET
|
||||
```
|
||||
|
||||
### 8.4 最终方案
|
||||
|
||||
Repository 新增 FTS 精确分页查询:
|
||||
|
||||
- JOIN `blocks` 与 `notes`;
|
||||
- folder 和 note_id 使用 SQL `IN`;
|
||||
- tags 使用 `json_each()`;
|
||||
- 时间范围使用 `julianday()` 比较;
|
||||
- 相同过滤条件分别用于 COUNT 和数据页查询;
|
||||
- `RetrievalEngine` 为纯 FTS 模式使用专用查询路径。
|
||||
|
||||
最终测试确认 1010 个匹配 Block 在 `offset=1000, limit=10` 时返回:
|
||||
|
||||
```text
|
||||
total = 1010
|
||||
items = 10
|
||||
```
|
||||
|
||||
向量与 Hybrid 模式仍属于 Top-K 近邻召回,其 `total` 表示候选集数量,不等价于全文数据库的全局命中数。这是检索模型本身的语义差异,需要在接口文档和前端展示中保持明确。
|
||||
|
||||
## 9. P-07:rebuild 请求语义与失败恢复
|
||||
|
||||
### 9.1 问题原因
|
||||
|
||||
接口模型公开了 `scope=all/notes/vectors` 和 `note_ids`,但最初所有请求都会无条件清空全部索引并全量扫描 Vault。旧索引在扫描和 embedding 之前就被删除,中途失败会留下空库或半成品。
|
||||
|
||||
### 9.2 后果
|
||||
|
||||
- 调用方请求只重建向量,实际却清空 notes 和 FTS;
|
||||
- 指定 note_ids 仍触发全量重建;
|
||||
- 单个损坏文件或 embedding 异常即可破坏原本可用的索引。
|
||||
|
||||
### 9.3 最终方案
|
||||
|
||||
当前 MVP 明确只支持全量重建:
|
||||
|
||||
- 非 `scope="all"` 或非空 `note_ids` 返回 `400 UNSUPPORTED_SCOPE`;
|
||||
- 清理旧索引前先把 Vault 文件全部读取到内存,读取失败不影响旧索引;
|
||||
- 重建前备份 SQLite 文件;
|
||||
- 中途失败时恢复备份,并记录 failed Job;
|
||||
- 成功或失败后清理临时备份。
|
||||
|
||||
`force` 当前仍是预留字段,没有行为差异。后续实现增量重建时,应一起明确它的契约,避免再次出现“参数被接受但没有效果”。
|
||||
|
||||
## 10. P-08:`index_meta` 位于主事务之外
|
||||
|
||||
### 10.1 问题如何被复审发现
|
||||
|
||||
第一轮修复已经把 notes、blocks、FTS 和向量放进同一事务,看起来解决了原子性问题。但 `index_meta` 仍在该事务提交后,通过新连接单独写入。
|
||||
|
||||
通过故障注入让 `set_index_meta()` 抛出异常后发现:
|
||||
|
||||
```text
|
||||
主索引事务:已经提交新内容
|
||||
index_meta:失败
|
||||
update_note:捕获异常,恢复旧 Markdown
|
||||
最终结果:Markdown 是旧内容,索引是新内容
|
||||
```
|
||||
|
||||
这说明“把主要写操作放进事务”还不够,只要事务外仍有能够决定接口成功或失败的步骤,部分提交依然存在。
|
||||
|
||||
### 10.2 最终方案
|
||||
|
||||
`repository.set_index_meta()` 增加可选连接参数:
|
||||
|
||||
- 独立调用时自行开连接、开事务并提交;
|
||||
- 收到外部连接时加入已有事务,不自行提交和关闭。
|
||||
|
||||
`index_note()` 在主事务退出前写入 `index_meta`。现在任何一步失败都会回滚全部 SQLite 修改,外层再恢复 Markdown,从而保持文件与索引一致。
|
||||
|
||||
## 11. P-09:POST 同路径静默覆盖
|
||||
|
||||
### 11.1 问题原因
|
||||
|
||||
笔记的相对路径和 note_id 都由 `folder + title` 确定。创建接口写文件前没有检查同路径资源是否存在,Repository 又使用 `ON CONFLICT DO UPDATE`,因此第二次 POST 会被当作更新执行。
|
||||
|
||||
### 11.2 后果
|
||||
|
||||
连续创建同目录、同标题笔记时:
|
||||
|
||||
```text
|
||||
第一次:original
|
||||
第二次:replacement
|
||||
note_id:相同
|
||||
最终正文:replacement
|
||||
```
|
||||
|
||||
原笔记没有任何冲突提示就被覆盖。更严重的是,早期失败回滚逻辑会在第二次索引失败时删除目标文件,连第一篇原文也一并丢失。
|
||||
|
||||
### 11.3 解决思路与方案
|
||||
|
||||
创建和更新必须具有不同语义:
|
||||
|
||||
- POST 只创建新资源,冲突时返回 409;
|
||||
- PATCH 才允许修改已有资源。
|
||||
|
||||
最终实现包含双重保护:
|
||||
|
||||
1. 根据稳定 note_id 查询 SQLite,存在则返回 `409 RESOURCE_CONFLICT`;
|
||||
2. 使用 `Path.open("x")` 排他创建文件,处理数据库检查与文件写入之间的并发竞争。
|
||||
|
||||
只有本次请求确实创建了新文件,后续索引失败时才会删除它,因此不会误删旧文件。
|
||||
|
||||
## 12. P-10:删除操作部分提交
|
||||
|
||||
### 12.1 问题原因
|
||||
|
||||
早期删除流程是三个独立步骤:
|
||||
|
||||
```text
|
||||
提交 notes / blocks / FTS 删除
|
||||
→ 单独提交向量删除
|
||||
→ 删除 Markdown
|
||||
```
|
||||
|
||||
如果第二步向量删除失败,第一步无法回滚,第三步不会执行。
|
||||
|
||||
### 12.2 后果
|
||||
|
||||
故障注入后的状态为:
|
||||
|
||||
```text
|
||||
notes:已删除
|
||||
blocks / FTS:已删除
|
||||
vec_blocks:仍存在
|
||||
Markdown:仍存在
|
||||
HTTP:失败
|
||||
```
|
||||
|
||||
系统无法通过普通读取接口找到笔记,但文件和孤立向量仍在,调用方也无法判断是否应该重试。
|
||||
|
||||
### 12.3 解决思路
|
||||
|
||||
SQLite 内的四类数据可以使用同一事务;Markdown 文件不能加入 SQLite 事务,因此需要补偿事务。直接先删文件不利于恢复,先提交数据库又会在文件删除失败时不一致,所以采用可恢复的 tombstone:
|
||||
|
||||
```text
|
||||
Markdown 原子 rename 为非 .md tombstone
|
||||
→ 同一 SQLite 事务删除 metadata / blocks / FTS / vectors
|
||||
→ 成功:删除 tombstone
|
||||
→ 失败:rollback SQLite,并把 tombstone rename 回原路径
|
||||
```
|
||||
|
||||
### 12.4 最终方案
|
||||
|
||||
- `repository.delete_note()` 和 `vector_store.delete()` 都支持外部连接;
|
||||
- `note_service.delete_note()` 负责建立统一事务;
|
||||
- 原文件先移动为同目录下带随机 UUID 的 `.deleting` 文件;
|
||||
- SQLite 失败时恢复原文件;
|
||||
- SQLite 成功后清理 tombstone;
|
||||
- tombstone 不以 `.md` 结尾,因此即使最终清理因系统原因失败,也不会被 Vault 扫描器重新索引。
|
||||
|
||||
回归测试通过让向量删除主动失败,确认 notes、Block、向量和 Markdown 全部恢复到删除前状态。
|
||||
|
||||
## 13. 测试策略
|
||||
|
||||
这次审阅新增的测试不只验证成功路径,还验证跨层不变量和失败恢复。
|
||||
|
||||
### 13.1 路径安全
|
||||
|
||||
- `test_create_note_rejects_path_traversal`
|
||||
|
||||
### 13.2 写入与回滚
|
||||
|
||||
- `test_update_note_rolls_back_file_on_index_error`
|
||||
- `test_update_note_rolls_back_index_when_index_meta_fails`
|
||||
- `test_create_note_rejects_existing_path_without_overwrite`
|
||||
- `test_delete_note_rolls_back_when_vector_delete_fails`
|
||||
|
||||
### 13.3 向量一致性
|
||||
|
||||
- `test_update_removes_stale_vectors`
|
||||
- `test_patch_partial_content_no_orphan_vectors`
|
||||
|
||||
### 13.4 PATCH 契约
|
||||
|
||||
- `test_patch_tags_semantics`
|
||||
|
||||
### 13.5 检索过滤与分页
|
||||
|
||||
- `test_search_pagination_total_reflects_all_matches`
|
||||
- `test_fts_pagination_is_not_truncated_at_one_thousand`
|
||||
- `test_fts_metadata_filter_recalls_beyond_candidate_pool`
|
||||
|
||||
### 13.6 rebuild
|
||||
|
||||
- `test_rebuild_rejects_unsupported_scope_and_note_ids`
|
||||
- `test_rebuild_failure_restores_old_index`
|
||||
|
||||
测试运行方式:
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
## 14. 可复用的工程经验
|
||||
|
||||
### 14.1 函数名不能替代语义验证
|
||||
|
||||
一个函数叫 `upsert`,不代表它真的具备幂等更新能力。审阅时应继续检查底层 SQL、唯一键行为和重复调用结果。
|
||||
|
||||
### 14.2 原子性要覆盖完整的成功判定路径
|
||||
|
||||
只把主要数据表放进事务还不够。日志、索引版本、元信息等后续步骤如果仍会让接口失败,也必须加入事务,或者被设计为不影响主操作结果。
|
||||
|
||||
### 14.3 文件系统与数据库需要补偿事务
|
||||
|
||||
SQLite 可以回滚,文件系统通常不能参与数据库事务。跨两种存储介质时,可采用临时文件、原子 rename、tombstone 和失败恢复构造可补偿流程。
|
||||
|
||||
### 14.4 PATCH 必须区分“未提供”和“显式为空”
|
||||
|
||||
`None`、空列表、空字符串和缺失字段经常具有不同业务含义。使用简单真值判断容易破坏部分更新语义。
|
||||
|
||||
### 14.5 固定扩大候选池不是分页方案
|
||||
|
||||
把上限从 50 调到 1000 只能推迟问题。只要 API 允许更大的 offset,硬截断就会再次暴露。能由数据库完成的过滤、计数和分页,应尽量下推到数据库。
|
||||
|
||||
### 14.6 回归测试应验证失败后的状态
|
||||
|
||||
只断言“抛出了异常”无法证明回滚正确。异常后还应重新读取每个存储层,检查文件、元数据、FTS 和向量是否满足不变量。
|
||||
|
||||
## 15. 后续可以继续改进的方向
|
||||
|
||||
- 为 rebuild 使用临时数据库或 SQLite Backup API,成功后原子切换,替代直接复制数据库文件;
|
||||
- 明确并实现 `force`、`scope` 和 `note_ids` 的完整语义;
|
||||
- 为 tombstone 增加启动时清理或恢复机制;
|
||||
- 对并发创建、更新、删除增加集成测试;
|
||||
- 明确 FTS 的精确 total 与 Vector/Hybrid 候选 total 在 API 层的差异;
|
||||
- 建立更大规模的检索 Benchmark,持续观察召回率、延迟和索引体积;
|
||||
- 在服务层引入结构化日志,记录回滚、tombstone 清理失败和 rebuild 恢复结果。
|
||||
|
||||
## 16. 相关文件
|
||||
|
||||
```text
|
||||
backend/app/services/note_service.py Note CRUD、文件补偿与索引编排
|
||||
backend/app/repository.py SQLite 元数据、FTS 查询与事务接入
|
||||
backend/app/retrieval/vectorstore.py sqlite-vec 幂等写入和删除
|
||||
backend/app/retrieval/engine.py FTS/Vector/Hybrid 检索编排
|
||||
backend/app/services/index_service.py 全量重建与失败恢复
|
||||
backend/app/knowledge/parser.py Block ID 与 tags 解析语义
|
||||
backend/tests/test_retrieval.py 审阅回归测试
|
||||
docs/development/Knowledge与Retrieval-Core开发说明.md 模块开发说明
|
||||
```
|
||||
@@ -0,0 +1,284 @@
|
||||
# 前端合并审阅问题与修复复盘
|
||||
|
||||
> 审阅与修复日期:2026-08-29
|
||||
> 涉及提交:`f9efc4f`,合并提交 `c6c28e4`。
|
||||
> 文档用途:记录前端分支合并后暴露的问题域、形成原因、实际后果、修复思路和落地方案,供后续技术文档、比赛材料与博客写作使用。
|
||||
|
||||
> 2026-08-30 状态补充:在本文两轮修复之后,项目又完成 Milkdown 写作工具栏、文件切换二次竞态修复、Shiki 只读高亮、Provider 预设/模型发现/加密 API Key 输入,以及智能体页面汉化。当前前端回归基线为 14 项测试和生产构建通过。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
原前端提交一次增加了 42 个文件和约 8000 行内容,但没有在提交前执行成功的生产构建。合并后同时存在工程配置、组件完整性、接口契约、流式协议和文件树状态五个问题域。
|
||||
|
||||
本轮处理结果:
|
||||
|
||||
| 编号 | 问题域 | 原级别 | 处理结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-01 | TypeScript 与生产构建不可用 | P0 | 已修复,`pnpm build` 通过 |
|
||||
| F-02 | 路由引用未提交页面 | P0 | 已改为统一占位页,并补充基础编辑器组件 |
|
||||
| F-03 | 前后端 Contract 系统性漂移 | P1 | 已增加 Wire DTO 和显式 Service 映射 |
|
||||
| F-04 | SSE 跨网络分片丢失事件 | P1 | 已重写增量解析状态机 |
|
||||
| F-05 | 文件树右键操作目标错误 | P1 | 已改为保存实际右键节点 |
|
||||
| F-06 | 根目录新增文件不可见且路径异常 | P2 | 已处理顶层插入与路径拼接 |
|
||||
| F-07 | Chat 仍使用模拟流式输出 | P2 | 已接入真实 `/api/chat` SSE |
|
||||
|
||||
## 2. F-01:TypeScript 与生产构建不可用
|
||||
|
||||
### 原因
|
||||
|
||||
Vite 配置了 `@` 指向 `src`,但 `tsconfig.app.json` 没有配置 `baseUrl` 和 `paths`。Vite 和 TypeScript 使用不同的模块解析配置,只配置其中一侧后,开发服务器可能暂时工作,`vue-tsc` 仍无法解析全部别名。
|
||||
|
||||
提交中还存在多个独立错误:
|
||||
|
||||
- `ComputedRef` 与字符串直接比较,缺少 `.value`;
|
||||
- `FileTreePanel.vue` 在两个 Script 中重复导入 `FileNode`;
|
||||
- 浏览器 ESM 代码调用 CommonJS `require()`;
|
||||
- Chat Store 使用不存在的 `conversation_id` 变量;
|
||||
- Service 聚合文件导出不存在的 `ApiError`;
|
||||
- StatusBar 使用没有表达式的 `@click`。
|
||||
|
||||
### 后果
|
||||
|
||||
- `pnpm build` 无法生成生产包;
|
||||
- CI 无法验证前端;
|
||||
- 别名错误产生的大量隐式 `any` 干扰真正错误定位;
|
||||
- `main` 不再满足“可构建”要求。
|
||||
|
||||
### 解决思路
|
||||
|
||||
先恢复唯一可信的构建基线,再处理运行时问题。路径别名同时配置给 Vite 和 TypeScript,其余错误按 Vue 3 Composition API 和浏览器 ESM 规则逐项修复。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 在 `tsconfig.app.json` 增加 `baseUrl` 和 `@/*` 映射;
|
||||
- 在 Script 中通过 `.value` 读取 ComputedRef;
|
||||
- 拆分递归文件树组件,删除第二个 Script 和 `require()`;
|
||||
- 修正 Chat 变量名和类型导出;
|
||||
- 删除无意义的空事件绑定;
|
||||
- 将 `pnpm build` 作为提交前强制检查。
|
||||
|
||||
## 3. F-02:路由和公共组件引用未提交文件
|
||||
|
||||
### 原因
|
||||
|
||||
路由表和 Secondary Sidebar 按最终页面结构一次性写完,但对应页面没有随提交进入仓库。Workspace 也引用了不存在的 `EditorHeader.vue` 和 `EditorPane.vue`。缺失项覆盖 Search、Chat、Agent、Task、Skill、Plugin、Theme、Settings 和多个 Sidebar Panel,共 21 个 Vue 文件。
|
||||
|
||||
### 后果
|
||||
|
||||
- 修复路径别名后,TypeScript 和 Vite 仍因模块不存在而失败;
|
||||
- 开发者无法判断页面是遗漏提交,还是尚未实现;
|
||||
- 后续成员可能分别创建同名但职责不同的组件。
|
||||
|
||||
### 解决思路
|
||||
|
||||
路由只能引用当前提交真实存在的组件。为保留产品信息架构,使用一个明确标注“功能开发中”的公共占位页,避免建立一批内容为空的伪页面。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 新增统一 `PlaceholderView.vue`;
|
||||
- 未实现功能路由暂时指向占位页;
|
||||
- Secondary Sidebar 对未实现 Panel 显示说明文本;
|
||||
- 增加可运行的基础 Editor Header 和 Textarea Pane;
|
||||
- 文档明确占位路由不代表业务页面完成。
|
||||
|
||||
## 4. F-03:前后端 Contract 系统性漂移
|
||||
|
||||
### 原因
|
||||
|
||||
前端先按页面需要定义了扁平 View Model,并直接把它们作为 HTTP 请求和响应类型。后端已经形成明确的 Pydantic Contract,包括分页包装、嵌套 Manifest、枚举和值对象,两边没有通过 OpenAPI 或人工核对完成同步。
|
||||
|
||||
典型差异:
|
||||
|
||||
| 模块 | 原前端假设 | FastAPI 实际 Contract |
|
||||
| --- | --- | --- |
|
||||
| Notes | `folder_path/content` | `folder/markdown` |
|
||||
| Search | 单值筛选、`results/total` | 数组筛选、`items/page` |
|
||||
| Agent | `task`、可选 Provider | `input`、Provider 与 Model 必填 |
|
||||
| Permission | `allow + scope` | `allow_once/allow_session/deny` |
|
||||
| Skill / Plugin | 扁平对象 | `manifest + runtime status` |
|
||||
| Provider | Capability 对象 | Capability 数组 |
|
||||
| Task | `due_date`、priority、source | `due_at`,后两项尚未进入后端 |
|
||||
| Index | `full/fts/vector` | `all/notes/vectors` |
|
||||
|
||||
### 后果
|
||||
|
||||
- Agent 创建、Permission 响应等请求稳定返回 422;
|
||||
- 列表接口拿到对象后被当成数组使用;
|
||||
- Skill、Plugin 和 Provider 页面读取不到标识和能力;
|
||||
- TypeScript 声称调用安全,但运行时结构完全不同;
|
||||
- 捕获异常后回退 Mock 会掩盖真实联调失败。
|
||||
|
||||
### 解决思路
|
||||
|
||||
区分 Wire DTO 和 View Model。HTTP 边界严格使用与 FastAPI 一致的 `Api*` 类型,Service 显式完成转换,页面展示字段不反向污染后端请求。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 增加 `ApiNote`、`ApiAgentRun`、`ApiSkill`、`ApiPlugin`、`ApiProviderConfig`、`ApiTask`、`ApiIndexStatus` 等 Wire DTO;
|
||||
- Notes Service 改用 `folder`、`markdown` 和真实分页结构;
|
||||
- Search Service 将单值 UI Filter 转换为后端数组,并映射 `items/page`;
|
||||
- Agent Service 使用 `input`、`tool_timeout_seconds` 和 `run_timeout_seconds`;
|
||||
- Permission Store 将 `allow + once/session` 转换为后端枚举;
|
||||
- Skill 和 Plugin Service 展开嵌套 Manifest;
|
||||
- 增加 Plugin Permission PUT;
|
||||
- Provider Capability 数组转换为界面布尔 Map;
|
||||
- Task Service 只发送后端支持字段,并转换 `due_date/due_at`;
|
||||
- Index Service 显式转换 Scope;
|
||||
- 默认离线 Provider ID 统一为后端的 `mock`。
|
||||
|
||||
## 5. F-04:SSE 跨网络分片丢失事件
|
||||
|
||||
### 原因
|
||||
|
||||
旧解析器把 `eventName` 和 `dataStr` 声明在每次 `reader.read()` 的循环内部。网络 Chunk 与 SSE Event 没有一一对应关系,一个事件的 `event:`、`data:` 和结尾空行可以分别落在多个 Chunk 中。
|
||||
|
||||
```text
|
||||
Chunk 1: event: TextDelta\n
|
||||
Chunk 2: data: {"event":"TextDelta", ...}\n\n
|
||||
```
|
||||
|
||||
读取 Chunk 2 时事件名已被重置成 `message`。如果 data 与空行分开,data 内容也会丢失。
|
||||
|
||||
### 后果
|
||||
|
||||
- Chat 增量文本偶发不显示;
|
||||
- Agent 终态事件无法触发完成回调;
|
||||
- 问题受网络分片影响,开发机难以稳定复现;
|
||||
- 长回答和远程 Provider 更容易出现错误。
|
||||
|
||||
### 解决思路
|
||||
|
||||
SSE 解析状态必须跨 Chunk 保存,以完整行和空行结束事件为边界,不能以单次网络读取为边界。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 将 Event Name 和 Data Lines 移到读取循环外;
|
||||
- Buffer 只移除已经形成完整行的内容;
|
||||
- 支持 LF、CRLF、多行 data 和注释行;
|
||||
- 流结束时 flush TextDecoder 和剩余 Event;
|
||||
- 终态回调增加去重;
|
||||
- SSE URL 复用普通 HTTP 的 Base URL 解析。
|
||||
|
||||
## 6. F-05:文件树右键操作目标错误
|
||||
|
||||
### 原因
|
||||
|
||||
右键菜单打开时保存了 `contextMenuPath`,但执行删除和重命名时读取的是 `workspaceStore.activeFile`。右键节点与当前编辑节点是两个独立状态。
|
||||
|
||||
### 后果
|
||||
|
||||
用户右键未激活文件并点击删除时,可能关闭或修改正在编辑的另一个文件。这属于潜在数据破坏问题。
|
||||
|
||||
### 解决思路
|
||||
|
||||
菜单操作必须绑定菜单打开时的目标对象,不能在点击命令时从无关的 Active State 推断。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 使用 `contextTarget: Ref<FileNode | null>` 保存右键节点;
|
||||
- Rename 和 Delete 只消费 `contextTarget`;
|
||||
- 菜单关闭后清空目标;
|
||||
- 将递归 Node 独立为 `FileTreeNode.vue`,通过类型化 Emit 向上传递节点。
|
||||
|
||||
## 7. F-06:根目录新增文件不可见且路径异常
|
||||
|
||||
### 原因
|
||||
|
||||
Store 把 `/` 当作普通父节点查找,但文件树没有代表根目录的虚拟节点。Service 直接使用 `folderPath + '/' + name` 拼接路径,根目录会得到 `//name.md`。
|
||||
|
||||
### 后果
|
||||
|
||||
- Service 返回成功,但新建项目没有加入界面文件树;
|
||||
- 打开的文件路径带双斜杠;
|
||||
- 接入真实文件系统后可能产生平台间路径差异。
|
||||
|
||||
### 解决思路与方案
|
||||
|
||||
顶层数组本身就是根节点的 children。当 `parentPath` 为 `/` 或空字符串时直接写入 `fileTree.value`,Mock Service 拼接根目录路径时只保留一个 `/`。
|
||||
|
||||
## 8. F-07:Chat 使用模拟流式输出
|
||||
|
||||
### 原因
|
||||
|
||||
Chat Store 已经存在 SSE Service,但发送消息后仍通过 `setInterval` 拼接固定文本,没有调用后端。
|
||||
|
||||
### 后果
|
||||
|
||||
- 后端 Provider、RAG、错误事件和取消无法通过前端验证;
|
||||
- 页面看似工作,实际没有形成前后端链路;
|
||||
- SSE 解析缺陷长期被 Mock 掩盖。
|
||||
|
||||
### 解决思路与方案
|
||||
|
||||
保留初始展示数据,但用户主动发送消息时调用真实 `/api/chat`。请求使用当前 Provider、Model、RAG 开关和消息历史;TextDelta 追加到 Assistant Message;Error、网络失败、Done 和主动取消同步更新 Streaming State。默认使用离线 `mock / mock-1`,无需外部 API Key。
|
||||
|
||||
## 9. 验证
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
cd ../backend
|
||||
uv run pytest
|
||||
|
||||
cd ..
|
||||
git diff --check
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
frontend production build passed
|
||||
73 frontend modules transformed
|
||||
71 backend tests passed
|
||||
preview returned HTTP 200
|
||||
git diff --check passed
|
||||
```
|
||||
|
||||
## 10. 预防措施
|
||||
|
||||
- PR 创建前必须执行与 CI 相同的 `pnpm build`;
|
||||
- 路由只引用当前提交存在的文件;
|
||||
- FastAPI `/openapi.json` 是 Wire Contract 的唯一事实来源;
|
||||
- View Model 与 API DTO 分层,Service 必须显式转换;
|
||||
- 不用 Mock Fallback 掩盖 4xx、5xx 和契约错误;
|
||||
- SSE 测试按任意 Chunk 边界构造数据,不能假定一次 read 等于一次 Event;
|
||||
- 删除、移动和覆盖等高影响操作必须携带明确目标 ID 或对象;
|
||||
- 合并后如果发现 P0,先恢复主分支构建,再继续业务页面开发。
|
||||
|
||||
## 11. 当前边界与后续事项
|
||||
|
||||
首次修复解决了前端壳子的工程正确性和接口边界。之后已继续补齐全部业务路由页面;Tauri Host、Stronghold 和真实文件系统仍属于桌面集成阶段。后续仍需要:
|
||||
|
||||
- 为 Service DTO 映射增加自动化契约测试;
|
||||
- 为 SSE Parser 增加跨 Chunk 单元测试;
|
||||
- 用 Tauri Command 替换 Mock Workspace Service;
|
||||
- 在 CI 中加入前端构建和后端测试两个必需检查。
|
||||
|
||||
## 12. 全页面完成后的第二轮审阅与修复
|
||||
|
||||
全部页面接通后再次审阅,发现编译通过并不等于交互状态正确。本轮问题与处理如下:
|
||||
|
||||
| 编号 | 问题 | 原因与后果 | 解决方案 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-08 | Markdown 没有真实渲染 | 写作与源码模式共用同一个 `textarea`,Chat 也把 Markdown 当纯文本显示 | 使用 `marked` 解析 GFM,使用 DOMPurify 清洗 HTML;编辑页提供源码输入和实时预览,Chat 回答复用安全渲染器 |
|
||||
| F-09 | 重命名后路径仍是旧值 | 只修改节点名称,没有同步节点、子节点、打开文件和编辑器路径,后续保存或删除会作用于旧路径 | 新增递归路径迁移,同时更新 `openFiles`、活动路径和 Editor 当前路径;Mock 内容缓存也随路径迁移 |
|
||||
| F-10 | 删除活动文件后正文错位 | Workspace 切换了活动文件,但 Editor 仍保留已删除文件正文 | 删除文件或文件夹时统一清理其所有打开路径;若存在下一个文件则加载,否则关闭编辑器 |
|
||||
| F-11 | 异步读取和保存存在竞态 | 快速切换文件可能让较早请求覆盖较新文件;保存过程中继续编辑会被错误标记为已保存 | 使用读取版本号丢弃过期结果;保存使用路径和正文快照,只有快照仍是最新内容时才标记 `saved` |
|
||||
| F-12 | Agent 权限弹窗跨 Run 残留 | 切换 Run、ToolResult 和终态事件没有释放 PermissionRequest | 加载 Run 前清空请求,并在 ToolResult、Completed、Failed、Cancelled 时同步清理;同时更新本地 Run 状态 |
|
||||
| F-13 | Chat 丢弃非文本 SSE 事件 | Store 只处理 TextDelta 和 Error,Tool Call 请求会留下空消息 | 增加 Thinking、ToolCall Start/Delta/End、Usage 和 Citation 状态处理及页面卡片展示 |
|
||||
| F-14 | Task 字段表现为保存但实际丢失 | 前端展示后端不支持的 Priority/Source,更新请求又漏掉后端已支持的 `note_id` | 暂时移除不可持久化字段的编辑与筛选;补齐 `note_id` 更新与解除关联的 `null` 语义 |
|
||||
| F-15 | 编辑器设置不生效 | Settings Store 与 Editor/Theme 没有联动,自动保存固定为 1500ms | 自动保存、默认模式、拼写检查、字号、行高和行宽改为实际驱动编辑器,并保存到 Local Storage |
|
||||
| F-16 | Vector 降级提示永远不可达 | `vectorUnavailable` 只声明不赋值,向量错误直接清空结果 | 对明确的向量、Embedding、模型和 Provider 不可用错误自动重试 FTS,并显示降级状态 |
|
||||
| F-17 | 护眼主题与 Plugin 导航状态异常 | Sepia 只有预览卡没有 Token;Plugin 路由被错误映射为 Skill 激活状态 | 增加 Sepia Design Token,并按真实路由名计算主导航选中项 |
|
||||
| F-18 | 文件切换仍可能丢失未保存内容 | `loadFile` 直接替换路径和正文,且旧的自动保存定时器会在切换后保存错误文件 | 切换前取消定时器,等待正在执行的保存并保存最新快照;保存失败或存在冲突时阻止切换;各入口仅在加载成功后更新 Workspace 活动路径 |
|
||||
| F-19 | 编辑器外观启动时被默认值覆盖 | Theme Store 的立即监听早于 `initTheme` 执行,先把默认值写进 Local Storage | 增加 Hydration 状态,初始化前监听只更新 CSS,不持久化;读取本地配置完成后再允许写入 |
|
||||
|
||||
安全边界:Markdown 解析结果不得直接使用未经清洗的 `v-html`。DOMPurify 是渲染链路的必需依赖,后续升级 `marked` 或允许扩展 Markdown 时也必须保留清洗步骤。
|
||||
|
||||
## 13. 写作、Provider 与智能体页面的后续修复
|
||||
|
||||
第三轮交互完善继续处理了文件切换、Markdown 选区格式、代码块默认状态、亮暗主题对比度和浮动工具栏失效问题。Provider 设置页增加 OpenAI、DeepSeek、Ollama 预设与模型自动发现,API Key 改为提交给后端加密保存,不进入 Pinia 或 Local Storage。智能体页面的运行状态、事件、工具、权限及导航文案已完成中文化,同时保留技术 ID 便于排障。
|
||||
|
||||
该轮新增 Store、Workspace、文件树、编辑器和中文标签回归测试;当前结果为前端 14 项、后端 71 项测试通过,生产构建通过。
|
||||
@@ -0,0 +1,338 @@
|
||||
# 后端全面审阅问题与修复复盘
|
||||
|
||||
> 审阅日期: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-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
|
||||
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。
|
||||
Reference in New Issue
Block a user