feat(memory): 记忆整理「试算(dry-run)」入口 + 面板卡(契约 v0.75)
主人要「现在就想看到在 736 条上它会建议合并哪些」,而不是等 23:00 的低峰首跑。
- 后端:Tidier.DryRun + TidyService.DryRun,**复用同一条扫描→召回→判定→构造候选路径**(抽出 tidyRoundPolicy,生产轮与试算轮只差「候选出口」与「召回降级策略」两处;禁止第二套实现)
- 🔴 零副作用:不写 memory_tidy_candidates / memory_tidy_runs,绝不改 memory_items 任何列;机械证明 = 跑前跑后 cyrene.db 与 -wal 的 SHA256 一致(测试断言「表行数不变」)
- 失败码诚实:500 tidy_unavailable / 409 tidy_disabled / 500 tidy_recall_unavailable(**embedding 不可用不降级成 0 条**)/ 503 tidy_timeout / 500 tidy_dry_run_failed;**空数组唯一含义 = 算过了没建议**
- 端点 POST /api/v1/admin/memories/tidy/dry-run(writeAuth);面板「记忆」页加试算卡(点了才跑、无轮询、三态分开、明写「仅试算,不会改动任何记忆」)
- 真机 e2e(741 条活库副本):76 ms / 15 分区 / 53 候选 / 41 skipped / 1 vetoed(low_confidence);样例 merge reason=fact_subset score=1.0000、link reason=similar_but_not_contained score=0.9248
- 变异:dry-run 顺手写库 ⇒ 两条测试红(行数 0→2);自造阈值 ⇒ 红;失败回 200 空数组 ⇒ 红
- 如实登记:Compared=688 被 400/分区护栏截断、候选上限 20/分区被打满 ⇒ 护栏值需在 741 条量级重定(非 bug)
This commit is contained in:
1 parent
3d8c30dc99
commit
d6ed9cfa2b
18 files changed
+1631
-79
No files matched your search
+87
-11
@@ -1,6 +1,6 @@
|
||||
# Cyrene v2 客户端 API 契约
|
||||
|
||||
> **契约版本:`v0.74`**(上一版 `v0.73`,其条目见 §6;`v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **契约版本:`v0.75`**(上一版 `v0.74`,其条目见 §6;`v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
|
||||
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
|
||||
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
> **v0.74 是 IM 路由主动消息的提醒版(已实现,纯增量)**:`message_persisted` 的 `data` 新增**可选**结构化判据 `proactive:true` —— IM 路由(`im_bridge`)的主动推送在落库之后**追加**一条带该键的同类帧,客户端据此在**非前台**补一发浏览器通知(此前通知只挂在 `proactive_message` 上,而 `auto` 提示在主人有已连接 IM 渠道时**优先走 IM** ⇒ 实际最可能发生的路径恰恰不弹)。判据是**结构化字段**(不是正文/提示词):普通消息(Web 回合 / IM 回复 / IM 镜像)的帧**永不**带该键 ⇒ 不可能被误判成主动消息。同一行仍照旧发一条**不带**该键的普通镜像帧(v0.72 起的行为逐字不变,两帧同 id ⇒ 客户端按 id 去重后仍是一个气泡、仍只提醒一次)。详见 **§3.4.4 后的「`message_persisted` 的 `proactive` 判据」**。
|
||||
|
||||
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 基础约定
|
||||
@@ -129,12 +131,12 @@
|
||||
| 档位 | 中间件 | 条数 | 说明 |
|
||||
|---|:--:|:--:|---|
|
||||
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **12** | 非敏感投影:无凭据、无他人账号数据、无消息正文 |
|
||||
| **owner/admin 读档** | `writeAuth`(owner/admin) | **13** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71) |
|
||||
| **owner/admin 读档** | `writeAuth`(owner/admin) | **14** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75) |
|
||||
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
|
||||
|
||||
**管理路由总数 = 12 + 13 + 40 = 65 条。**
|
||||
**管理路由总数 = 12 + 14 + 40 = 66 条。**
|
||||
|
||||
> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上面的 13。逐条以代码为准。
|
||||
> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上面的 14。逐条以代码为准。
|
||||
|
||||
> ⚠️ **与归档版不一致(已更正)**:`Docs/archive/api-client.v0.65.md` §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。**以代码为准 = 12 + 11 + 40 = 63**。
|
||||
|
||||
@@ -155,7 +157,7 @@
|
||||
| A11 | GET | `/api/v1/admin/observability/logs` | 后端日志尾部(最新优先,`?level=&limit=`;缓冲未注入时 501) |
|
||||
| A12 | GET | `/api/v1/admin/notifications` | 统一通知 feed(审计链 + 后台思考推送的只读投影) |
|
||||
|
||||
### 2.2 owner/admin 读(13 条,`writeAuth`)
|
||||
### 2.2 owner/admin 读(14 条,`writeAuth`)
|
||||
|
||||
| # | 方法 | 路径 | 要点 |
|
||||
|---|---|---|---|
|
||||
@@ -172,6 +174,7 @@
|
||||
| B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) |
|
||||
| B12 | GET | `/api/v1/admin/config-facts` | **配置事实面(v0.68 新增,只读)**:记忆整理 7 键 + 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 |
|
||||
| B13 | GET | `/api/v1/admin/flow/graph` | **链路图快照 / 多轮历史回放(v0.71 新增;v0.73 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ **v0.73 新增 `?turnId=`**(省略 = 最近一轮,**先内存后落盘**)。响应 `{data:{sessionId,turnId,source,graph,turns[]}}`:`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,没有该轮时**如实为 `null`**(**绝不画假图**);`source` ∈ `live`(内存最近一轮)/`persisted`(落盘的历史轮)/`none`;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮(**v0.73**)。未接线图源 → `500 flow_unavailable`。语义见 §4.11 |
|
||||
| B14 | POST | `/api/v1/admin/memories/tidy/dry-run` | **记忆整理「试算」(v0.75 新增,只读语义的 POST)**:跑同一套整理规则、把候选**直接回在响应里**,**零副作用**(不写任何表)。请求体可为空。响应 `{data:{dryRun:true,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated?,candidates[]}}`;`candidates` 恒为非 null 数组(空数组 = 「算过了,没有建议」,与「没算出来」的错误响应**必须能区分**)。失败:未接线 → `500 tidy_unavailable`、配置关闭 → `409 tidy_disabled`、语义召回不可用 → `500 tidy_recall_unavailable`、超时 → `503 tidy_timeout`。**不审计**(只读计算)。语义与形状见 §4.12 |
|
||||
|
||||
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
|
||||
|
||||
@@ -774,6 +777,63 @@
|
||||
|
||||
---
|
||||
|
||||
### 4.12 记忆整理「试算」(`POST /api/v1/admin/memories/tidy/dry-run`;v0.75 新增)
|
||||
|
||||
**为什么有这条**:记忆整理是一件**低峰定期后台任务**(`design/23 §4`):默认只在安静时段(`MEMORY_TIDY_QUIET_START_HOUR`–`_END_HOUR`,默认 `23:00–07:00`)由心跳开跑。于是在它第一次开跑之前,库里**既没有候选也没有轮次留痕** ⇒ 面板只能空着,主人无法判断「整理会不会乱合并」。本端点把**同一套规则**当场算一遍:候选直接回在响应里,**一行都不写**。
|
||||
|
||||
#### (a) 请求与响应
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 方法 / 路径 | `POST /api/v1/admin/memories/tidy/dry-run` |
|
||||
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与同页的 `/admin/config-facts`、`/admin/memories` 同档 |
|
||||
| 请求体 | **可为空**(端点不读 body)。没有参数:阈值/上限一律取**进程生效的既有配置**(`MEMORY_TIDY_RECALL_THRESHOLD` / `_SCAN_LIMIT` / `_CANDIDATE_LIMIT`),端点**自己不带任何旋钮** |
|
||||
| 响应体 | `{data:{dryRun,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated,candidates[]}}` |
|
||||
| `dryRun` | 恒为 `true`。它是**结构化**判据:这份响应**没有**改动任何记忆(客户端不必只靠本地硬编码文案说明这件事) |
|
||||
| `recallMode` | `"embedding"`(语义向量召回)或 `"lexical"`(零依赖词项余弦,仅当进程没有接 embedder) |
|
||||
| `durationMs` | 本次试算的墙钟耗时(毫秒) |
|
||||
| `ruleVersion` | 规则集版本(`internal/runtime/memory` 的 `TidyRuleVersion`,进候选幂等键;**改判定规则必须同时升它**) |
|
||||
| 观测口径 | `partitions`(含在册条目的分区数)/ `scanned`(扫描条数)/ `compared`(**真正参与两两比对**的条数,受性能护栏上限约束)/ `pairs`(相似度 ≥ 召回门的对数)/ `vetoed` 与 `vetoReasons`(否决数与原因分布,如 `scope`/`persona`/`project`/`source`/`temporal`/`numeric`/`conflict`/`low_confidence`) |
|
||||
| `truncated` | **可选**数组:被护栏截断的分区标签(`<分区>(scan)` / `(compare)` / `(candidates)`)。它**不是**候选;键缺席 = 本轮没有任何截断。命中它说明「配置的配额比本轮实际看到的少」 |
|
||||
| `candidates[]` | **恒为非 null 数组**(没有建议时是 `[]`)。每个元素见下表 |
|
||||
| 归档 | **不审计**:它是只读计算(跑规则、不落库),与 `/admin/config-facts`、`/admin/flow/graph` 同一档 |
|
||||
|
||||
候选元素(`candidates[i]`):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `action` | `merge` / `link` / `archive` / `reclassify`(`keep` 不进候选队列)。**`link` = 相似但互不包含 ⇒ 只建链接、绝不合并**(design/23 §1.3 的「红牛 vs 冰美式」) |
|
||||
| `sourceIds` | 被处理的记忆 id 数组(恒为非 null)。`merge` 时是被并入方(可多条);`link` 时是那两条 |
|
||||
| `targetId` | `merge` 时的**存活条目**(超集);其余动作为 `""`(键省略) |
|
||||
| `personaId` / `scope` / `projectId` / `kind` | 分区键与被处理条目的 kind |
|
||||
| `proposedContent` / `mergedContent` | `merge` 的 diff 预览:被并入的原文 / 存活条目的内容(其余动作为 `""`) |
|
||||
| `reason` | 机器可读理由(如 `containment_a_subset_b` / `similar_but_not_contained` / `one_off_event` / `kind_mismatch`)。**客户端按它分支,不要按本地文案** |
|
||||
| `ruleVersion` / `scoreMin` / `scoreMax` | 规则版本与召回分数区间(当前实现两者相等:一对一个分数) |
|
||||
| `evidence` | 判定证据:`similarity`(只用它**召回**,不是判据)、`recall_mode`、`containment`(`a_subset_b`/`b_subset_a`/`both`/`none`)、`containment_evidence[]`(缺哪一要素)、`veto_reason`、`kind_a`/`kind_b`/`proposed_kind`、`content_a`/`content_b`(两条原文,供面板 diff 预览) |
|
||||
|
||||
#### (b) 硬约束(客户端必须照这个读)
|
||||
|
||||
1. 🔴 **零副作用**:本端点**不写任何表** —— 不写 `memory_tidy_candidates`、不写 `memory_tidy_runs`、**绝不改 `memory_items` 的任何列**。后端有「调用前后三张表逐行快照比对」的单测把它钉死。因此试算**不会**让面板的「候选队列」多出东西,也**不影响**当晚那次真跑的结果。
|
||||
2. 🔴 **同一套规则,不是第二套实现**:候选由与低峰定时轮**共用**的代码路径产出(同一召回门 + 同一包含判定 + 同一规则否决 + 同一每分区候选上限)。后端另有一条单测:同一批数据上「试算候选」与「真跑一轮落库的候选」逐字段一致。
|
||||
3. 🔴 **失败绝不伪装成「没有建议」**:未接线 / 配置关闭 / 语义召回不可用 / 超时都**如实回错误码**(见下表),**不回** `candidates:[]`。客户端**不得**把 `candidates:[]` 与错误态渲染成同一件事 —— 前者是「算过了,这一轮确实没有建议」,后者是「没算出来」。
|
||||
4. **语义召回不可用时不静默降级**:若进程接了 embedder 而它不可用(如 `llama.cpp / :11434` 连不上),试算**报 `tidy_recall_unavailable`** 而不是偷偷回退到词项余弦 —— 否则面板会把「语义路径没跑起来」当成「没有候选」。⚠️ 这是**试算专属**的严格:低峰定时轮与手动轮仍按既有口径回退到零依赖的词项余弦(`recallMode:"lexical"` 时可从 `evidence.recall_mode` 逐条读到)。
|
||||
5. **只算不写 ⇒ 不审计,也不产生候选队列**:本端点不写审计链、不产生任何后续动作;apply/reject 是**另外的**、尚未实现的写端点(`design/23 §11`,本版**不做**)。
|
||||
6. **它是「现在就能看」,不是「调度改了」**:试算**不受安静时段约束**(低峰窗口是心跳的调度口径,不是规则的一部分),也**不改变**整理周期与心跳(试算不写 `lastRun`、不影响 `nextDelay`)。面板**按下才跑**:它是一次真实规则计算(扫描 + 两两召回 + 包含判定),**不是看板指标** ⇒ 客户端**不要**自动轮询它。
|
||||
|
||||
#### (c) 失败码
|
||||
|
||||
| 状态 | `error.code` | 触发条件 | 客户端应做什么 |
|
||||
|---|---|---|---|
|
||||
| `500` | `tidy_unavailable` | 组合根未注入试算出口(仅异常部署/测试) | 显示「未接线」,**不要**显示「没有建议」 |
|
||||
| `409` | `tidy_disabled` | `MEMORY_TIDY_ENABLED=false`(显式关闭整理) | 提示「整理已被配置关闭」,并指向该 env 键 |
|
||||
| `500` | `tidy_recall_unavailable` | 语义召回(embedding 服务)不可用 | 显示具体原因类别;**不要**显示 0 候选 |
|
||||
| `503` | `tidy_timeout` | 试算超过服务端墙钟上限(3 分钟) | 可提示重试;**不要**显示 0 候选 |
|
||||
| `500` | `tidy_dry_run_failed` | 其它内部失败(分区查询/规则执行出错) | 显示错误;原始内部细节**不会**出现在 `message` 里 |
|
||||
|
||||
> ⚠️ 本端点的错误 `message` 只说明**原因类别**,原始错误(可能含内网地址等内部细节)只进服务端日志。
|
||||
|
||||
---
|
||||
|
||||
## 5. env 与模型配置(与契约相关)
|
||||
|
||||
> **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `<DATA_DIR>/models.json`(默认 `./data/models.json`);「渠道管理」页写 `<DATA_DIR>/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。
|
||||
@@ -808,11 +868,11 @@
|
||||
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
|
||||
| `LLM_USAGE_RETENTION` | `720h` | `llm_usage` 保留期(`0`/负 = 关闭清理) |
|
||||
| `THINK_*` / `QUIET_*` | 见模板 | 后台思考与主动推送策略。`QUIET_START_HOUR`(`23`)/ `QUIET_END_HOUR`(`7`)是**本地钟点**的安静时段窗口(跨午夜可用;两个相等的非 0 钟点 = 关闭;两端都为 0 = 未填 ⇒ 回落默认 `23/7`):**只服务「定时主动思考 → 主动搭话」**,**不静默回复**(v0.68 起可经 B12 读到生效值) |
|
||||
| `MEMORY_TIDY_ENABLED` | `true` | 记忆整理总开关(`design/23 §4`)。关闭 ⇒ 完全不跑(日志写明);整理**只产建议、绝不自动改记忆** |
|
||||
| `MEMORY_TIDY_ENABLED` | `true` | 记忆整理总开关(`design/23 §4`)。关闭 ⇒ 完全不跑(日志写明);整理**只产建议、绝不自动改记忆**。**v0.75 起**:关闭时试算端点(B14)也**拒绝作答**(`409 tidy_disabled`),不绕过这个选择 |
|
||||
| `MEMORY_TIDY_INTERVAL_SEC` | `86400` | 整理周期(秒),是**下限**:实际在低峰窗口起点跑 ⇒ 真实间隔取「窗口每天一次」与本值的较长者 |
|
||||
| `MEMORY_TIDY_QUIET_START_HOUR` / `MEMORY_TIDY_QUIET_END_HOUR` | `23` / `7` | 记忆整理的**低峰窗口**(本地钟点,默认与 backthink 同值);两端相等且非 0 = 关闭窗口、改为纯周期;两端都为 0 = **未填**,运行时回落到默认 `23/7` |
|
||||
| `MEMORY_TIDY_RECALL_THRESHOLD` | `0.80` | 候选**召回门**(余弦),**不是**合并判据(可否合并由包含关系 + 规则否决决定) |
|
||||
| `MEMORY_TIDY_SCAN_LIMIT` / `MEMORY_TIDY_CANDIDATE_LIMIT` | `5000` / `20` | 每分区每轮的扫描上限 / 候选上限 |
|
||||
| `MEMORY_TIDY_QUIET_START_HOUR` / `MEMORY_TIDY_QUIET_END_HOUR` | `23` / `7` | 记忆整理的**低峰窗口**(本地钟点,默认与 backthink 同值);两端相等且非 0 = 关闭窗口、改为纯周期;两端都为 0 = **未填**,运行时回落到默认 `23/7`。⚠️ 窗口**只管心跳调度**:试算端点(B14)**不受它约束**(否则「现在就想看」正是它存在的理由) |
|
||||
| `MEMORY_TIDY_RECALL_THRESHOLD` | `0.80` | 候选**召回门**(余弦),**不是**合并判据(可否合并由包含关系 + 规则否决决定)。试算端点(B14)用**同一个**值 |
|
||||
| `MEMORY_TIDY_SCAN_LIMIT` / `MEMORY_TIDY_CANDIDATE_LIMIT` | `5000` / `20` | 每分区每轮的扫描上限 / 候选上限。试算端点(B14)用**同一对**上限(不另造一套) |
|
||||
|
||||
> **上面 9 个键(记忆整理 7 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」;在此之前面板没有任何 env 读取面。
|
||||
| `SUBAGENT_TIMEOUT_SEC` | `600` | 单个子代理整次运行时限 |
|
||||
@@ -837,7 +897,20 @@
|
||||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||||
|
||||
### v0.74(当前契约版本)
|
||||
### v0.75(当前契约版本)
|
||||
|
||||
**记忆整理「试算」端点 —— 新增 `POST /api/v1/admin/memories/tidy/dry-run`:跑同一套整理规则、把候选直接回在响应里,且一行都不写**(依据主人 2026-10-05 要求「现在就想看到在 736 条上它会建议合并哪些,而不等到 23:00」;对齐 `design/23 §4`「低峰定期后台任务 + 手动可触发的只读试算」)。
|
||||
|
||||
- **纯增量**:新增 1 条 `writeAuth`(owner/admin)管理的**只读语义 POST**(§2.2 B14 / §4.12)。既有端点、既有帧、既有 env 键语义**一字未改**。
|
||||
- **零副作用是硬约束**:端点走的是与低峰定时轮**共用**的规则路径(同一召回门 + 同一包含判定 + 同一规则否决 + 同一每分区候选上限),但**候选不落库** —— 不写 `memory_tidy_candidates`、不写 `memory_tidy_runs`、**绝不改 `memory_items` 的任何列**。后端有「调用前后三张表逐行快照比对」的单测钉死(另有一条断言「试算候选 == 真跑一轮落库的候选」)。
|
||||
- **失败绝不伪装成「没有建议」**:未接线 → `500 tidy_unavailable`、`MEMORY_TIDY_ENABLED=false` → `409 tidy_disabled`、语义召回(embedding)不可用 → `500 tidy_recall_unavailable`、超时 → `503 tidy_timeout`、其余内部失败 → `500 tidy_dry_run_failed`。**不回** `candidates:[]` 来表示失败 —— 空数组唯一含义是「算过了,这一轮没有建议」。
|
||||
- **试算专属的严格**:语义召回不可用时,**试算报错而不静默降级**到词项余弦(否则面板会把「语义路径没跑起来」读成「没有候选」)。低峰定时轮与手动轮的降级口径**不变**。
|
||||
- **不审计、不调度**:它是只读计算(不写审计链),**不受安静时段约束**(窗口只是心跳调度口径),也**不改变**心跳节奏与周期。
|
||||
- **行为变更**:无。
|
||||
- **破坏性**:无(老客户端不发这个请求就零影响)。
|
||||
- ⚠️ **本版不做**:apply / reject / revert 候选的**写**端点仍是 `design/23 §11` 的未实现项(见 §7),面板当前只做「看建议」。
|
||||
|
||||
### v0.74(上一版)
|
||||
|
||||
**IM 路由的主动消息也补浏览器通知 —— `message_persisted` 新增可选结构化判据 `proactive:true`**(依据主人 2026-10-05 裁定 C 的必然推论「她说话该不该提醒我,与走哪条路由无关」;对齐 `design/12 §3` 的主动推送与 `design/16 §4.4` 的镜像口径)。
|
||||
|
||||
@@ -1054,6 +1127,8 @@
|
||||
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
|
||||
| **`tool_execution.update` 的生产者** | wire 映射已实现,但工具分发器暂无中途进度上报源(全仓无 `StreamKindToolExecutionUpdate` 的生产者)⇒ 实际**暂无生产者**(`start`/`end` 真实发出) |
|
||||
| **`chat_messages.turn_id` 上的 IM 行** | IM 渠道桥**不走**网关编排器 ⇒ 其行 `turn_id` 留空属**预期**(契约里该键缺席),不是缺陷 |
|
||||
| **记忆整理候选的 apply / reject / revert + 候选列表 + 运行历史**(`design/23 §11`) | **未实现**(v0.75 **只**加了「试算」B14):试算只「算给我看」、**不改任何记忆**。`GET /admin/memories/candidates`、`POST .../candidates/{id}/{apply,reject,revert}`、`GET /admin/memories/tidy/runs`、`POST /admin/memories/tidy/run` **均尚未实现**。客户端**不要**假定它们存在(`design/23` 的第一版口径本就是「只产建议,apply 才写记忆」,apply 未做的前置原因见该节开放点) |
|
||||
| **`GET /api/v1/admin/memories` 的整理相关字段**(`kind` / `tags` / `confidence` / `createdAt` / `updatedAt` / `sourceConversation` / `status` / `mergedInto` / `mergeCount`) | **未实现**:该端点当前仍只回 `{id,scope,text}`(`design/23 §11` 登记的读侧缺口)。v0.75 **未**改它的形状 ⇒ 面板暂时无法在这一页按 kind/status 过滤(试算端点 B14 的候选自带这些分区键与 kind) |
|
||||
|
||||
### 7.3 诚实边界(既有事实)
|
||||
|
||||
@@ -1102,7 +1177,8 @@
|
||||
20. **(v0.73 登记)落盘只覆盖「保留期内」且只覆盖白名单会话**:`turns[]` 是**已落盘且未被 GC 回收**的轮次摘要,**不是全历史**(`FLOW_GRAPH_RETENTION` 默认 168h,`≤0` = 关闭 GC ⇒ 那时才会无限增长,属显式选择)。落盘范围与实时发布**同一白名单**(`flowSessionAllowed`:`web` / `web:<uuid>` / `sess-backthink`)—— **群聊 / 陌生人会话永远不会出现在 `turns[]` 里**。此外**正在跑、尚未 `EndRound`** 的那一轮不会在 `turns[]` 中出现(它是内存里的「实时」那一轮,`source:"live"`),这是如实而非缺失。
|
||||
|
||||
21. **(v0.74 登记)`message_persisted` 的 `proactive` 判据只活在实时帧上,不落库**:它是「这条是她主动说的话」在线上**唯一**的结构化判据(帧顶布尔键,由 `gateway.BroadcastProactivePersisted` 打上),**没有对应数据库列**(本片不动 schema)。因此 ① `GET /sessions/{id}/messages`、握手 `history` 回放、`GET /admin/channels/**` 读到的历史行**不带**它(预期:通知只对实时到达有意义);② 客户端**不得**从 `platform` / `adapterId` / `turnId` 等行字段反推「是不是主动消息」—— 主会话里主动推送写的是**解析后的真实平台**(例如 `weixin-ilink`),与普通回复行同一形状,反推必然误伤。**落库失败时不发该帧**(没有权威 `id` 的 `message_persisted` 会被客户端整帧丢弃)。
|
||||
22. **(v0.75 登记)试算端点(B14)是「只读计算」,它**不改**任何状态**:它不写 `memory_tidy_candidates` / `memory_tidy_runs`、不改 `memory_items`、不写审计链、**也不推进心跳**(不写 `lastRun`、不影响 `nextDelay`)。因此 ① 试算**不会**让「候选队列」(尚未实现,见 §7.2)多出任何东西;② 试算结果**不是缓存**:每次调用都重新算一遍,两次调用之间数据变了结果就可能不同(这是「同一套规则」的正常语义,不是不确定性),客户端**不要**把它当成会稳定的列表去 diff 或自动轮询;③ **`recallMode:"lexical"` 是降级信号**:它表示这轮没有走到语义向量(进程没接 embedder),此时召回更保守(命中更少)—— 若进程**接了** embedder 而它不可用,试算**直接报错**(`tidy_recall_unavailable`),**不会**在响应里假装成 lexical。
|
||||
|
||||
---
|
||||
|
||||
*本文件由「以代码为真源」维护(当前 `v0.74`;`v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*
|
||||
*本文件由「以代码为真源」维护(当前 `v0.75`;`v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*
|
||||
@@ -51,6 +51,15 @@ func (a *app) wireFrontend() error {
|
||||
// a.history 在 wireAuxiliary 里构建,让渠道桥(wireChannels)与网关共用同一个
|
||||
// 持久化存储。IM 通路之前 History 是个空转依赖,因此把它的创建顺序提前不会
|
||||
// 影响任何下游。
|
||||
//
|
||||
// 记忆整理「试算」出口(契约 v0.75;切片 MT1):注入的正是低峰心跳跑的那个
|
||||
// *memory.TidyService(同一套规则、同一份配置)。**必须**在它为 nil 时留成
|
||||
// **真 nil 接口** —— 带类型的 nil 指针会让网关的 `== nil` 判断失效,端点就会
|
||||
// 去调一个空接收者。
|
||||
var tidyDryRunner gateway.MemoryTidyDryRunner
|
||||
if a.memoryTidy != nil {
|
||||
tidyDryRunner = a.memoryTidy
|
||||
}
|
||||
gw, err := gateway.New(a.cfg, gateway.Deps{
|
||||
Log: logger.WithSource(a.log, "gateway"),
|
||||
Auth: jwt,
|
||||
@@ -102,6 +111,9 @@ func (a *app) wireFrontend() error {
|
||||
// (`channelEventStore()` 的取舍),端点据此回 500 channel_events_unavailable,
|
||||
// 绝不回空 200 假装「没有事件」。
|
||||
EventStore: a.channelEventStore(),
|
||||
// MemoryTidy 是记忆整理「试算」的只读出口(契约 v0.75;切片 MT1):上面按
|
||||
// **真 nil** 取舍过的接口值。
|
||||
MemoryTidy: tidyDryRunner,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway: %w", err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
|
||||
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
|
||||
|
||||
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
user: "accent",
|
||||
@@ -185,6 +186,8 @@ export function MemoriesModule(): React.ReactNode {
|
||||
<MemoryTable rows={loading ? null : filtered} loading={loading} error={error} />
|
||||
</Surface>
|
||||
|
||||
<MemoryTidyDryRunCard />
|
||||
|
||||
<MemoryTidyFactsCard />
|
||||
|
||||
<Notice>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 记忆整理「试算」卡的**真实渲染**测试(契约 v0.75 §4.12;切片 MT1)。
|
||||
//
|
||||
// 为什么需要它:这张卡要回答的是「它会建议合并哪些」,而修好之后的三条关键断言
|
||||
// 都是**文案与呈现**:
|
||||
// ① 有候选:动作/理由/分数/id 去向/diff 预览都要一眼读得出来;
|
||||
// ② 空:必须说明「算完了但没建议」**不等于**「库里没有重复」(否则空态会被读成
|
||||
// 「库很干净」);
|
||||
// ③ 错误:**显示错误、绝不显示 0**(把失败画成「0 条候选」就是把「没算出来」
|
||||
// 冒充成结论 —— 这正是本项目面板明令禁止的那一类)。
|
||||
//
|
||||
// 跑法(与 ConfigFactsCards.render.test.ts 同):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyDryRunCard.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-memory-tidy-dryrun.render.test.cjs \
|
||||
// && node /tmp/cyrene-memory-tidy-dryrun.render.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { AdminTidyDryRunResult } from "../../lib/types";
|
||||
import {
|
||||
MemoryTidyDryRunBody,
|
||||
TIDY_DRY_RUN_DISCLAIMER,
|
||||
TIDY_DRY_RUN_ERROR_HINT,
|
||||
type TidyDryRunState,
|
||||
} from "./MemoryTidyDryRunCard";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
function render(state: TidyDryRunState): string {
|
||||
return renderToStaticMarkup(createElement(MemoryTidyDryRunBody, { state, onRun: () => {} }));
|
||||
}
|
||||
|
||||
const RESULT: AdminTidyDryRunResult = {
|
||||
dryRun: true,
|
||||
recallMode: "embedding",
|
||||
durationMs: 42,
|
||||
ruleVersion: "t0.1",
|
||||
partitions: 2,
|
||||
scanned: 736,
|
||||
compared: 400,
|
||||
pairs: 5,
|
||||
vetoed: 2,
|
||||
vetoReasons: { scope: 1, temporal: 1 },
|
||||
candidates: [
|
||||
{
|
||||
action: "merge",
|
||||
sourceIds: ["mem-short", "mem-long"],
|
||||
targetId: "mem-long",
|
||||
personaId: "cyrene",
|
||||
scope: "persona",
|
||||
kind: "relationship",
|
||||
proposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||
mergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
reason: "containment_a_subset_b",
|
||||
ruleVersion: "t0.1",
|
||||
scoreMin: 0.95,
|
||||
scoreMax: 0.95,
|
||||
evidence: { similarity: 0.95, recall_mode: "embedding", containment: "a_subset_b" },
|
||||
},
|
||||
{
|
||||
action: "link",
|
||||
sourceIds: ["mem-redbull", "mem-iced"],
|
||||
personaId: "cyrene",
|
||||
scope: "persona",
|
||||
kind: "relationship",
|
||||
proposedContent: "",
|
||||
mergedContent: "",
|
||||
reason: "similar_but_not_contained",
|
||||
ruleVersion: "t0.1",
|
||||
scoreMin: 0.88,
|
||||
scoreMax: 0.88,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── ① 有候选:动作 / 理由 / 分数 / id 去向 / diff 预览 / n观测口径 ──────────
|
||||
const doneHtml = render({ kind: "done", result: RESULT });
|
||||
ok(has(doneHtml, TIDY_DRY_RUN_DISCLAIMER), "恒显示「仅试算,不会改动任何记忆」");
|
||||
ok(has(doneHtml, "并入"), "merge 动作要显示中文标签「并入」");
|
||||
ok(has(doneHtml, "只建链接"), "link 动作要显示「只建链接」(绝不合并)");
|
||||
ok(has(doneHtml, "containment_a_subset_b"), "必须显示机器可读理由");
|
||||
ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
|
||||
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
|
||||
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
|
||||
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
|
||||
ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)");
|
||||
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
|
||||
|
||||
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
|
||||
const truncatedHtml = render({
|
||||
kind: "done",
|
||||
result: { ...RESULT, truncated: ["cyrene/persona/(compare)"] },
|
||||
});
|
||||
ok(has(truncatedHtml, "被配额截断"), "命中护栏时必须如实说明");
|
||||
ok(has(truncatedHtml, "MEMORY_TIDY_SCAN_LIMIT") && has(truncatedHtml, "MEMORY_TIDY_CANDIDATE_LIMIT"), "要指向真键名");
|
||||
|
||||
// ── ② 空:说清「算完了但没建议」≠「没有重复」 ──────────────────────────────
|
||||
const emptyHtml = render({ kind: "done", result: { ...RESULT, candidates: [], pairs: 0, vetoed: 0, vetoReasons: {} } });
|
||||
ok(has(emptyHtml, "这一轮没有产生任何候选"), "空态必须明说「没有产生候选」");
|
||||
ok(has(emptyHtml, "不等于"), "空态必须说清「不等于库里没有重复」");
|
||||
ok(has(emptyHtml, "召回门"), "空态要解释候选的三道门(召回门 / 包含判定 / 规则否决)");
|
||||
ok(!has(emptyHtml, "建议 0 条"), "空态不得渲染成「建议 0 条」(那不是失败文案也不是空态文案)");
|
||||
|
||||
// ── ③ 错误:显示错误、绝不显示 0 ────────────────────────────────────────────
|
||||
const errorHtml = render({ kind: "error", message: "语义召回(embedding)不可用,试算未产出候选" });
|
||||
ok(has(errorHtml, "试算失败"), "错误态必须显式标出失败");
|
||||
ok(has(errorHtml, "语义召回(embedding)不可用"), "必须显示后端给的原因");
|
||||
ok(has(errorHtml, TIDY_DRY_RUN_ERROR_HINT), "必须说清「没算出来」≠「算过了、没有建议」");
|
||||
ok(!has(errorHtml, "算完了"), "错误态绝不得渲染完成态标记(「算完了,一行都没写」)");
|
||||
ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/候选数)—— 把失败画成 0 条就是把失败当结论");
|
||||
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
|
||||
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
|
||||
|
||||
// ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
|
||||
const idleHtml = render({ kind: "idle" });
|
||||
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
|
||||
ok(has(idleHtml, "23:00"), "未跑时要解释「整理器只在低峰时段真跑」");
|
||||
ok(has(idleHtml, "不会自动"), "未跑时要说明这是按需试算、不自动刷新");
|
||||
|
||||
const runningHtml = render({ kind: "running" });
|
||||
ok(has(runningHtml, "试算中…"), "在跑时按钮显示进行态");
|
||||
ok(has(runningHtml, "disabled"), "在跑时按钮必须禁用(防止重复触发)");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`MemoryTidyDryRunCard.render: ${checks} checks passed`);
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from "react";
|
||||
import { adminMemoryTidyDryRun } from "../../lib/api";
|
||||
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { Notice } from "../components";
|
||||
import { useAuditStore } from "../audit";
|
||||
|
||||
/**
|
||||
* 记忆整理「试算」卡(契约 v0.75 §4.12;切片 MT1)。
|
||||
*
|
||||
* 它回答的是「**现在就想看到整理会建议合并哪些**」,而不必等到低峰时段那一轮:
|
||||
* 点击才跑(**不轮询** —— 试算是一次真实规则计算,不是看板指标),跑完把候选摊在
|
||||
* 这里。后端保证**零副作用**(候选直接回在响应里,一行都不写),因此卡片的文案把
|
||||
* 这件事明确写出来(`仅试算,不会改动任何记忆`)。
|
||||
*
|
||||
* 三个态的呈现**刻意分开**,因为它们的产品含义不同:
|
||||
* - 有候选:逐条展示「哪些条 → 建议怎么处理 + 理由 + 分数区间 + diff 预览」;
|
||||
* - 空:**算完了但没有建议**(要说明这不是「没有重复」,而是被召回门/包含判定/
|
||||
* 规则否决挡下了);
|
||||
* - 错误:**显示错误,绝不显示 0** —— 把失败画成「0 条候选」等于把「没算出来」
|
||||
* 冒充成「算过了,没有」,正是本项目面板明令禁止的那一类。
|
||||
*
|
||||
* 展示与取数分层:`MemoryTidyDryRunBody` 只吃状态(可用真实渲染测试直接喂
|
||||
* fixture),卡组件负责取数 + 局部状态。
|
||||
*/
|
||||
|
||||
/** 诚实文案:整张卡的核心承诺(测试钉住它必须出现)。 */
|
||||
export const TIDY_DRY_RUN_DISCLAIMER = "仅试算,不会改动任何记忆";
|
||||
|
||||
/** 错误态的说明:区分「没算出来」与「算过了,没有」。 */
|
||||
export const TIDY_DRY_RUN_ERROR_HINT = "这是「没算出来」,不是「算过了、没有建议」";
|
||||
|
||||
/** 尝试按钮文案。 */
|
||||
const RUN_LABEL = "试算整理建议";
|
||||
|
||||
/** 面板状态机(idle / running / done / error)。 */
|
||||
export type TidyDryRunState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "running" }
|
||||
| { kind: "done"; result: AdminTidyDryRunResult }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
const actionLabel: Record<string, string> = {
|
||||
merge: "并入",
|
||||
link: "只建链接",
|
||||
archive: "归档建议",
|
||||
reclassify: "改分类建议",
|
||||
};
|
||||
|
||||
const actionTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
merge: "warn",
|
||||
link: "accent",
|
||||
archive: "neutral",
|
||||
reclassify: "neutral",
|
||||
};
|
||||
|
||||
/** ActionTag 渲染候选动作标签;未知动作原样显示(不吞信息)。 */
|
||||
function ActionTag({ action }: { action: string }): React.ReactNode {
|
||||
return <Tag tone={actionTone[action] ?? "neutral"}>{actionLabel[action] ?? action}</Tag>;
|
||||
}
|
||||
|
||||
/** VoteReasonLine 渲染规则否决分布(它是「没有建议」时最有用的解释)。 */
|
||||
function VetoReasonLine({ reasons }: { reasons: Record<string, number> }): React.ReactNode {
|
||||
const entries = Object.entries(reasons).sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<span className="text-[11px] text-[var(--mu-text-mid)]">
|
||||
否决分布(规则把哪些相似对挡在候选之外):
|
||||
{entries.map(([reason, count]) => (
|
||||
<span key={reason} className="ml-2 font-mono">
|
||||
{reason}={count}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** scoreLabel 渲染分数区间(当前实现一对一个分数,因此多数时候两端相等)。 */
|
||||
function scoreLabel(candidate: AdminTidyDryRunCandidate): string {
|
||||
if (candidate.scoreMin === candidate.scoreMax) return candidate.scoreMin.toFixed(3);
|
||||
return `${candidate.scoreMin.toFixed(3)}–${candidate.scoreMax.toFixed(3)}`;
|
||||
}
|
||||
|
||||
/** CandidateRow 渲染一条候选:动作 + 理由 + 分数 + id 去向 + diff 预览。 */
|
||||
function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): React.ReactNode {
|
||||
const target = candidate.targetId ? ` → 保留 ${candidate.targetId}` : "";
|
||||
return (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<ActionTag action={candidate.action} />
|
||||
<span className="font-mono text-[var(--mu-text-lo)]">{scoreLabel(candidate)}</span>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{candidate.reason}</span>
|
||||
<Tag tone="neutral">{candidate.scope}</Tag>
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-[var(--mu-text-mid)]">
|
||||
{candidate.sourceIds.join(", ")}
|
||||
{target}
|
||||
</div>
|
||||
{candidate.proposedContent ? (
|
||||
<div className="text-xs text-[var(--mu-text-mid)]">
|
||||
<span className="text-[var(--mu-text-lo)]">被并入:</span>
|
||||
{candidate.proposedContent}
|
||||
</div>
|
||||
) : null}
|
||||
{candidate.mergedContent ? (
|
||||
<div className="text-xs text-[var(--mu-text-mid)]">
|
||||
<span className="text-[var(--mu-text-lo)]">存活条目:</span>
|
||||
{candidate.mergedContent}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
|
||||
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
|
||||
const vetoReasons = result.vetoReasons ?? {};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Tag tone="ok">算完了,一行都没写</Tag>
|
||||
<Tag tone={result.recallMode === "embedding" ? "accent" : "warn"}>召回来源:{result.recallMode}</Tag>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
|
||||
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
|
||||
</span>
|
||||
</div>
|
||||
{result.truncated && result.truncated.length > 0 ? (
|
||||
<Notice tone="warn">
|
||||
本轮被配额截断:{result.truncated.join("、")} —— 这一轮看到的比库里少,调大
|
||||
<span className="font-mono"> MEMORY_TIDY_SCAN_LIMIT</span> /{" "}
|
||||
<span className="font-mono">MEMORY_TIDY_CANDIDATE_LIMIT</span> 后重试。
|
||||
</Notice>
|
||||
) : null}
|
||||
<VetoReasonLine reasons={vetoReasons} />
|
||||
{result.candidates.length === 0 ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||
<span className="text-xs text-[var(--mu-text-hi)]">这一轮没有产生任何候选。</span>
|
||||
<p className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
这意味着「跑完了但没建议」,不等于「库里没有重复」:候选必须先过召回门(相似度只用来召回),
|
||||
再通过包含判定(A 的事实集合 ⊆ B 才并入),最后还要逐条过规则否决(跨作用域 / 跨角色 / 跨项目 /
|
||||
群聊来源 / 时效污染 / 数值不一致 / 否定冲突 / 低置信)。上面的否决分布就是「哪些相似对被挡下」。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">建议 {result.candidates.length} 条(均未应用):</span>
|
||||
{result.candidates.map((candidate, index) => (
|
||||
<CandidateRow
|
||||
key={`${candidate.action}-${candidate.sourceIds.join(",")}-${index}`}
|
||||
candidate={candidate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** StateBody 渲染「未跑 / 在跑 / 失败 / 完成」四态中的非按钮部分。 */
|
||||
function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
|
||||
switch (state.kind) {
|
||||
case "idle":
|
||||
return (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
点一下就跑一轮「同样的整理规则」,把「它会建议合并 / 归档 / 建链接哪些」摊开给你看。整理器只在低峰
|
||||
时段(默认 23:00–07:00)真跑,平时库里的候选队列是空的 —— 这是一次按需试算,不是看板,所以不会自动
|
||||
刷新。
|
||||
</span>
|
||||
);
|
||||
case "running":
|
||||
return <span className="text-xs text-[var(--mu-text-lo)]">试算中…(只读计算,不会改动任何记忆)</span>;
|
||||
case "error":
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Notice tone="err">试算失败:{state.message}</Notice>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "done":
|
||||
return <ResultBody result={state.result} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** MemoryTidyDryRunBody 是试算卡的纯展示体(测试直接喂状态)。 */
|
||||
export function MemoryTidyDryRunBody({
|
||||
state,
|
||||
onRun,
|
||||
}: {
|
||||
state: TidyDryRunState;
|
||||
onRun: () => void;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">记忆整理试算</h3>
|
||||
<Tag tone="accent">{TIDY_DRY_RUN_DISCLAIMER}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">POST /api/v1/admin/memories/tidy/dry-run</span>
|
||||
<span className="ml-auto">
|
||||
<Button variant="primary" disabled={state.kind === "running"} onClick={onRun}>
|
||||
{state.kind === "running" ? "试算中…" : RUN_LABEL}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<StateBody state={state} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyDryRunCard 是记忆管理页挂的试算卡(局部状态 + 点击才跑)。 */
|
||||
export function MemoryTidyDryRunCard(): React.ReactNode {
|
||||
const [state, setState] = useState<TidyDryRunState>({ kind: "idle" });
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const run = (): void => {
|
||||
setState({ kind: "running" });
|
||||
adminMemoryTidyDryRun()
|
||||
.then((result) => {
|
||||
log("memory.tidy.dryRun", "ok");
|
||||
setState({ kind: "done", result });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("memory.tidy.dryRun", "error");
|
||||
setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败" });
|
||||
});
|
||||
};
|
||||
|
||||
return <MemoryTidyDryRunBody state={state} onRun={run} />;
|
||||
}
|
||||
@@ -119,6 +119,7 @@ export {
|
||||
adminConfigFacts,
|
||||
adminFlowGraph,
|
||||
adminListNotifications,
|
||||
adminMemoryTidyDryRun,
|
||||
adminObservabilityAudit,
|
||||
adminObservabilityLogs,
|
||||
adminObservabilityMetrics,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AdminTraceSpan,
|
||||
AuditRecord,
|
||||
AdminConfigFacts,
|
||||
AdminTidyDryRunResult,
|
||||
BackthinkItem,
|
||||
FlowGraph,
|
||||
SystemBackupResp,
|
||||
@@ -96,6 +97,20 @@ export function adminConfigFacts(): Promise<AdminConfigFacts> {
|
||||
return request<AdminConfigFacts>("/admin/config-facts");
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/memories/tidy/dry-run — 记忆整理「试算」(契约 v0.75 §4.12)。
|
||||
*
|
||||
* 它跑**同一套**整理规则(与低峰定时轮共用代码路径)并把候选直接回在响应里,
|
||||
* 但**一行都不写**(不写候选表/运行表、不改任何记忆)—— 因此可以在任何时刻、
|
||||
* 在整理器第一次开跑之前,先看清「它会建议合并哪些」。
|
||||
*
|
||||
* ⚠️ 它不是看板数据源:试算是一次真实的规则计算(扫描 + 两两召回 + 包含判定),
|
||||
* **只在用户点击时调用,不要轮询**。
|
||||
* ⚠️ 失败走 `error.code`(tidy_unavailable / tidy_disabled / tidy_recall_unavailable /
|
||||
* tidy_timeout),**不会**回 `candidates: []` —— 空数组只表示「算过了,没有建议」。 */
|
||||
export function adminMemoryTidyDryRun(): Promise<AdminTidyDryRunResult> {
|
||||
return request<AdminTidyDryRunResult>("/admin/memories/tidy/dry-run", { method: "POST" });
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/notifications?type=&before=&limit= — the unified notification
|
||||
* feed (design/10 §3 #16: audit + channel + backthink + system aggregated).
|
||||
* `type` omitted = all types, `before` = createdAt(ms) cursor for older pages,
|
||||
|
||||
@@ -13,4 +13,5 @@ export * from "./types/adminWrite";
|
||||
export * from "./types/kook";
|
||||
export * from "./types/notification";
|
||||
export * from "./types/configFacts";
|
||||
export * from "./types/memoryTidy";
|
||||
export * from "./types/channelEvents";
|
||||
@@ -0,0 +1,66 @@
|
||||
// 记忆整理「试算」的线上类型(契约 v0.75 §4.12)。
|
||||
// 唯一真源:internal/services/gateway/admin_memory_tidy.go。
|
||||
//
|
||||
// ⚠️ 这份响应**没有**改动任何记忆(`dryRun` 恒为 true)。失败**不会**以
|
||||
// `candidates: []` 的形式出现 —— 失败是错误响应(`error.code` ∈ tidy_unavailable /
|
||||
// tidy_disabled / tidy_recall_unavailable / tidy_timeout / tidy_dry_run_failed),
|
||||
// 因此「空数组」唯一含义是「算过了,这一轮没有建议」。
|
||||
|
||||
/** 一条候选的判定证据。字段名沿用后端 JSON(snake_case,因为复用领域结构体)。 */
|
||||
export interface AdminTidyEvidence {
|
||||
/** 召回用的余弦相似度(只用来召回,**不是**合并判据)。 */
|
||||
similarity?: number;
|
||||
recall_mode?: string;
|
||||
/** a_subset_b / b_subset_a / both / none。 */
|
||||
containment?: string;
|
||||
containment_evidence?: string[];
|
||||
veto_reason?: string;
|
||||
kind_a?: string;
|
||||
kind_b?: string;
|
||||
proposed_kind?: string;
|
||||
/** 被处理条目的原文,供 diff 预览。 */
|
||||
content_a?: string;
|
||||
/** 存活条目的原文,供 diff 预览。 */
|
||||
content_b?: string;
|
||||
}
|
||||
|
||||
/** 一条整理建议(未落库)。 */
|
||||
export interface AdminTidyDryRunCandidate {
|
||||
/** merge(并入)/ link(只建链接,绝不合并)/ archive(一次性事件)/ reclassify。 */
|
||||
action: string;
|
||||
sourceIds: string[];
|
||||
/** merge 时的存活条目(超集);其余动作为空。 */
|
||||
targetId?: string;
|
||||
personaId: string;
|
||||
scope: string;
|
||||
projectId?: string;
|
||||
kind?: string;
|
||||
proposedContent?: string;
|
||||
mergedContent?: string;
|
||||
/** 机器可读理由(如 containment_a_subset_b / similar_but_not_contained)。 */
|
||||
reason: string;
|
||||
ruleVersion: string;
|
||||
scoreMin: number;
|
||||
scoreMax: number;
|
||||
evidence?: AdminTidyEvidence;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/memories/tidy/dry-run 的 data。 */
|
||||
export interface AdminTidyDryRunResult {
|
||||
/** 恒为 true:这份响应没有改动任何记忆。 */
|
||||
dryRun: boolean;
|
||||
/** embedding(语义向量召回)| lexical(零依赖词项余弦,进程没接 embedder 时)。 */
|
||||
recallMode: string;
|
||||
durationMs: number;
|
||||
ruleVersion: string;
|
||||
partitions: number;
|
||||
scanned: number;
|
||||
compared: number;
|
||||
pairs: number;
|
||||
vetoed: number;
|
||||
vetoReasons?: Record<string, number>;
|
||||
/** 被护栏截断的分区标签(不是候选)。 */
|
||||
truncated?: string[];
|
||||
/** 恒为数组:空数组 = 「算过了,没有建议」。 */
|
||||
candidates: AdminTidyDryRunCandidate[];
|
||||
}
|
||||
@@ -158,28 +158,39 @@ func NewTidier(cfg TidyConfig, deps TidyDeps) (*Tidier, error) {
|
||||
// Config 返回生效的整理配置(已套默认值)。
|
||||
func (t *Tidier) Config() TidyConfig { return t.cfg }
|
||||
|
||||
// tidyCandidateSink 是**本轮的候选出口**:一个分区比完就把候选交给它。
|
||||
// 生产轮是落库(persistCandidates),试算轮只收集(tidy_dryrun.go)——
|
||||
// 规则引擎本身与出口无关,这正是「禁止写第二套实现」的落点。
|
||||
type tidyCandidateSink func(ctx context.Context, cands []TidyCandidate, stats *TidyStats)
|
||||
|
||||
// tidyRecall 是**本轮的召回策略**:给定已截断的参与比对条目,产出候选对。
|
||||
// 它把「相似度来源不可用时怎么办」的差异收在一处:生产轮降级到零依赖词项
|
||||
// 余弦,试算轮如实上报错误。
|
||||
type tidyRecall func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error)
|
||||
|
||||
// tidyRoundPolicy 是本轮整理的出口策略:候选去哪 + 召回来源不可用时怎么办。
|
||||
// 「生产轮」与「试算轮」的**全部**差异都在这两个字段上,规则引擎其余部分
|
||||
// 逐字共用。
|
||||
type tidyRoundPolicy struct {
|
||||
sink tidyCandidateSink
|
||||
recall tidyRecall
|
||||
}
|
||||
|
||||
// RunOnce 跑一轮整理,并写一条运行留痕。它返回本轮的观测计数。
|
||||
//
|
||||
// 失败隔离(design/23 §9):单个分区失败不影响其它分区,失败计数进运行表与
|
||||
// 日志;只有「所有分区都失败」或运行留痕本身写不进去时才回错误。
|
||||
func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
started := t.now().UTC()
|
||||
stats := TidyStats{VetoReasons: make(map[string]int)}
|
||||
|
||||
partitions, err := t.deps.Store.listTidyPartitions(ctx)
|
||||
stats, partitions, err := t.beginRound(ctx)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("memory/tidy: list partitions: %w", err)
|
||||
return stats, err
|
||||
}
|
||||
stats.Partitions = len(partitions)
|
||||
|
||||
var failures []error
|
||||
for _, p := range partitions {
|
||||
if err := t.runPartition(ctx, p, &stats); err != nil {
|
||||
stats.Errors++
|
||||
failures = append(failures, err)
|
||||
t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err)
|
||||
}
|
||||
}
|
||||
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
|
||||
sink: t.persistCandidates,
|
||||
recall: t.fallbackRecall,
|
||||
})
|
||||
|
||||
run := TidyRun{
|
||||
// 运行 id 在这里就定下来:留痕与日志必须指向同一条运行记录。
|
||||
@@ -236,12 +247,53 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
|
||||
t.log.Info("memory tidy run finished", fields...)
|
||||
}
|
||||
|
||||
// beginRound 开一轮:准备口径容器并列出待整理分区。分区列表拿不到时整轮失败,
|
||||
// 且**不写运行留痕** —— 那一轮根本没开始(口径与 design §9 一致)。
|
||||
func (t *Tidier) beginRound(ctx context.Context) (TidyStats, []tidyPartition, error) {
|
||||
stats := TidyStats{VetoReasons: make(map[string]int)}
|
||||
partitions, err := t.deps.Store.listTidyPartitions(ctx)
|
||||
if err != nil {
|
||||
return stats, nil, fmt.Errorf("memory/tidy: list partitions: %w", err)
|
||||
}
|
||||
stats.Partitions = len(partitions)
|
||||
return stats, partitions, nil
|
||||
}
|
||||
|
||||
// scanPartitions 逐个分区跑规则并把候选交给 policy.sink。它返回失败列表
|
||||
// (单个分区失败不阻塞其它分区,design §9);成功时返回 nil。
|
||||
func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition, stats *TidyStats, policy tidyRoundPolicy) []error {
|
||||
var failures []error
|
||||
for _, p := range partitions {
|
||||
if err := t.runPartition(ctx, p, stats, policy); err != nil {
|
||||
stats.Errors++
|
||||
failures = append(failures, err)
|
||||
t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
|
||||
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
|
||||
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
|
||||
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
|
||||
pairs, err := t.recallPairs(ctx, p, items, stats)
|
||||
if err == nil {
|
||||
return pairs, nil
|
||||
}
|
||||
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
|
||||
"partition", p.label(), "error", err)
|
||||
return collectTidyPairs(items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats), nil
|
||||
}
|
||||
|
||||
// runPartition 整理一个分区(persona_id + scope + project_id)。
|
||||
// 分区之间**永不比较**(design/23 §6 硬约束)。
|
||||
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats) error {
|
||||
// 分区之间**永不比较**(design/23 §6 硬约束)。候选去处与召回降级策略都由
|
||||
// policy 决定,因此生产轮与试算轮走的是**同一条**扫描→召回→判定→构造候选
|
||||
// 的路径。
|
||||
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats, policy tidyRoundPolicy) error {
|
||||
items, err := t.deps.Store.listTidyPartitionItems(ctx, p, t.cfg.ScanLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("memory/tidy: list partition %s: %w", p.label(), err)
|
||||
}
|
||||
stats.Scanned += len(items)
|
||||
if len(items) == t.cfg.ScanLimit {
|
||||
@@ -253,22 +305,15 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
"partition", p.label(), "limit", t.cfg.ScanLimit)
|
||||
}
|
||||
|
||||
comparable := make([]MemoryItem, 0, len(items))
|
||||
archives := make([]TidyCandidate, 0)
|
||||
for _, it := range items {
|
||||
switch t.classifyForTidy(it, stats) {
|
||||
case tidySkip:
|
||||
continue
|
||||
case tidyArchive:
|
||||
// 一次性事件:产 archive 候选,且**不参与任何 merge**(design §5.1-5)。
|
||||
archives = append(archives, archiveCandidate(it, t.now().UTC()))
|
||||
continue
|
||||
default:
|
||||
comparable = append(comparable, it)
|
||||
}
|
||||
comparable, archives := t.classifyItems(items, stats)
|
||||
|
||||
// 截断与计数只做一次:召回策略拿到的是**已截断**的条目,回退重算不重复计数。
|
||||
compared := t.limitCompareItems(p, comparable, stats)
|
||||
pairs, err := policy.recall(ctx, p, compared, stats)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
|
||||
}
|
||||
|
||||
pairs := t.recallPairs(ctx, p, comparable, stats)
|
||||
candidates := archives
|
||||
for _, pr := range pairs {
|
||||
verdict := judgePair(pr.a, pr.b, pr.score)
|
||||
@@ -286,10 +331,31 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
|
||||
candidates = candidates[:t.cfg.CandidateLimit]
|
||||
}
|
||||
t.persistCandidates(ctx, candidates, stats)
|
||||
policy.sink(ctx, candidates, stats)
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyItems 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出):
|
||||
// - 群聊/陌生人来源 → 排除在扫描范围外(design/23 §6,只读不写);
|
||||
// - 含绝对日期/时刻 → 一次性事件,走归档而非合并([8]「9月8日晚22:38」),
|
||||
// 且**不参与任何 merge**(design §5.1-5);
|
||||
// - 过短内容 → 没有可判定的事实要素,跳过(不产噪声候选)。
|
||||
func (t *Tidier) classifyItems(items []MemoryItem, stats *TidyStats) (comparable []MemoryItem, archives []TidyCandidate) {
|
||||
comparable = make([]MemoryItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
switch t.classifyForTidy(it, stats) {
|
||||
case tidySkip:
|
||||
continue
|
||||
case tidyArchive:
|
||||
archives = append(archives, archiveCandidate(it, t.now().UTC()))
|
||||
continue
|
||||
default:
|
||||
comparable = append(comparable, it)
|
||||
}
|
||||
}
|
||||
return comparable, archives
|
||||
}
|
||||
|
||||
// tidyDisposition 是扫描时一条记忆的处置分流。
|
||||
type tidyDisposition int
|
||||
|
||||
@@ -353,14 +419,6 @@ func tidyReasonsLabel(reasons map[string]int) string {
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
|
||||
func tidyRecallMode(hasEmbedder bool) string {
|
||||
if hasEmbedder {
|
||||
return "embedding"
|
||||
}
|
||||
return "lexical"
|
||||
}
|
||||
|
||||
// baseTidyLog 在 log 非 nil 时返回它,否则返回丢弃式日志器。
|
||||
func baseTidyLog(log *slog.Logger) *slog.Logger {
|
||||
return discardLogger(log)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。
|
||||
//
|
||||
// 它与低峰心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
|
||||
// 构造候选),只有两点刻意不同:
|
||||
//
|
||||
// 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写
|
||||
// memory_tidy_runs、**绝不改 memory_items 的任何列**。这是 dry-run 的命门,
|
||||
// 因此它连运行留痕都不写(留痕本身也是一次写入)。
|
||||
// 2. **召回不降级**:语义召回(embedding)不可用时**如实报错**,绝不静默回退到
|
||||
// 词项余弦 —— 否则面板上的「没有候选」会是一句谎话(那是「没算出来」,不是
|
||||
// 「算过了,没有」)。生产轮的降级口径见 tidy.go 的 fallbackRecall。
|
||||
//
|
||||
// 它**不**受低峰窗口约束:窗口是心跳的调度口径(design/23 §4),不是规则的
|
||||
// 一部分 —— 「现在就想看看它会建议什么」正是这个入口存在的理由。
|
||||
|
||||
// ErrTidyDisabled 在整理被配置显式关闭(MEMORY_TIDY_ENABLED=false)却又被要求
|
||||
// 试算时返回。它与「未接线」不同:这是配置选择,必须如实说出来,而不是回一份
|
||||
// 空候选假装「没有建议」。
|
||||
var ErrTidyDisabled = errors.New("memory/tidy: disabled by configuration")
|
||||
|
||||
// ErrTidyRecallUnavailable 在**试算**要求语义召回、而 embedding 不可用时返回。
|
||||
// 生产轮此时会降级到词项余弦;试算轮刻意不降级(见本文件头注释)。
|
||||
var ErrTidyRecallUnavailable = errors.New("memory/tidy: recall embedder unavailable")
|
||||
|
||||
// TidyDryRun 是一次试算的结果。Candidates 与落库候选**同构**(同一套
|
||||
// buildCandidates 产物),但没有 status/decided 字段 —— 试算不落库,因此不存在
|
||||
// 「已决定」这回事。
|
||||
type TidyDryRun struct {
|
||||
// Stats 是本轮的观测口径(扫描/比对/召回/否决分布),与真跑同一套计数。
|
||||
Stats TidyStats
|
||||
// Candidates 是本轮产出的候选(每个分区已套用 CandidateLimit)。
|
||||
Candidates []TidyCandidate
|
||||
// RecallMode 记录相似度来源(embedding / lexical),供面板与运维判读。
|
||||
// 严格模式下一次试算只可能有一种来源:要么语义召回成功,要么如实报错。
|
||||
RecallMode string
|
||||
// DurationMS 是本轮试算的墙钟耗时(毫秒),供面板显示「跑一次要多久」。
|
||||
DurationMS int64
|
||||
}
|
||||
|
||||
// DryRun 跑一轮整理的**规则**并返回候选,但零副作用(见本文件头注释)。
|
||||
//
|
||||
// 与 RunOnce 的失败口径一致:单个分区失败不阻塞其它分区;只要有分区失败就回
|
||||
// 错误(此时 Candidates 可能只含已完成分区的部分结果,调用方**不得**把它当
|
||||
// 「完整的建议」展示)。
|
||||
func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
|
||||
started := t.now()
|
||||
out := TidyDryRun{RecallMode: tidyRecallMode(t.deps.Embedder != nil)}
|
||||
stats, partitions, err := t.beginRound(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。
|
||||
collected := make([]TidyCandidate, 0)
|
||||
policy := tidyRoundPolicy{
|
||||
sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) {
|
||||
collected = append(collected, cands...)
|
||||
},
|
||||
recall: t.strictRecall,
|
||||
}
|
||||
failures := t.scanPartitions(ctx, partitions, &stats, policy)
|
||||
|
||||
out.Stats = stats
|
||||
out.Candidates = collected
|
||||
out.DurationMS = t.now().Sub(started).Milliseconds()
|
||||
if len(failures) > 0 {
|
||||
return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...))
|
||||
}
|
||||
t.logDryRun(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
|
||||
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
|
||||
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
|
||||
pairs, err := t.recallPairs(ctx, p, items, stats)
|
||||
if err != nil {
|
||||
t.log.Warn("memory tidy dry-run recall unavailable",
|
||||
"partition", p.label(), "items", len(items), "error", err)
|
||||
return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err)
|
||||
}
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
// logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆
|
||||
// 正文** —— 只记计数、分区数与截断标签。
|
||||
func (t *Tidier) logDryRun(out TidyDryRun) {
|
||||
t.log.Info("memory tidy dry-run finished",
|
||||
"rule_version", TidyRuleVersion,
|
||||
"partitions", out.Stats.Partitions,
|
||||
"scanned", out.Stats.Scanned,
|
||||
"compared", out.Stats.Compared,
|
||||
"pairs", out.Stats.Pairs,
|
||||
"candidates", len(out.Candidates),
|
||||
"vetoed", out.Stats.Vetoed,
|
||||
"veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons),
|
||||
"errors", out.Stats.Errors,
|
||||
"recall_mode", out.RecallMode,
|
||||
"duration_ms", out.DurationMS)
|
||||
}
|
||||
|
||||
// DryRun 立即跑一次试算(契约 v0.75;切片 MT1)。它复用与心跳、手动轮**同一把**
|
||||
// runMu:试算与真跑串行,因此「一边试算一边落候选」不可能发生(design/23 §9
|
||||
// 单写者)。试算自身零写入。
|
||||
//
|
||||
// 配置关闭(MEMORY_TIDY_ENABLED=false)时如实返回 ErrTidyDisabled:关闭是显式
|
||||
// 选择,试算不绕过它,也不回一份空候选假装「没有建议」。
|
||||
func (s *TidyService) DryRun(ctx context.Context) (TidyDryRun, error) {
|
||||
if !s.cfg.Enabled {
|
||||
return TidyDryRun{}, ErrTidyDisabled
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
s.runMu.Lock()
|
||||
defer s.runMu.Unlock()
|
||||
return s.tidier.DryRun(ctx)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理「试算」(契约 v0.75;切片 MT1)的四条判据:
|
||||
//
|
||||
// ① **零副作用**:试算前后 memory_items / memory_tidy_candidates /
|
||||
// memory_tidy_runs **逐行不变**(这是 dry-run 的命门);
|
||||
// ② **真复用同一套规则**:同一批数据上,试算候选与真跑一轮落库的候选
|
||||
// 逐字段一致(不是另一套阈值实现);
|
||||
// ③ **尊重既有配置上限**:每分区候选上限(CandidateLimit)在试算里同样生效;
|
||||
// ④ **诚实错误**:语义召回不可用 / 整理被配置关闭时如实报错,绝不回空候选
|
||||
// 假装「没有建议」。
|
||||
|
||||
// failingEmbedder 模拟「语义召回不可用」(例如 llama.cpp :11434 不可达)。
|
||||
type failingEmbedder struct{ err error }
|
||||
|
||||
func (f failingEmbedder) Embed(context.Context, []string) ([]embedding.Vector, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
func (f failingEmbedder) Dim() int { return 8 }
|
||||
|
||||
// 三张相关表的**显式列清单**(不用 SELECT *):逐行快照按完整行内容比对,
|
||||
// 任何一列被改动都会让快照不等。
|
||||
var (
|
||||
memoryItemsColumns = []string{
|
||||
"id", "persona_id", "role_id", "scope", "kind", "content", "tags", "confidence",
|
||||
"source_conversation", "created_at", "updated_at", "decay_weight", "status",
|
||||
"merged_into", "merged_from", "merge_count", "occurred_at", "valid_to", "expires_at",
|
||||
}
|
||||
tidyCandidateColumns = []string{
|
||||
"id", "persona_id", "scope", "project_id", "kind", "action", "source_ids", "target_id",
|
||||
"proposed_content", "merged_content", "reason", "evidence", "rule_version",
|
||||
"score_min", "score_max", "status", "idempotency_key", "created_at", "decided_at", "decided_by",
|
||||
}
|
||||
tidyRunColumns = []string{
|
||||
"id", "started_at", "finished_at", "scanned", "candidates", "applied", "skipped",
|
||||
"errors", "vetoed", "veto_reasons", "rule_version",
|
||||
}
|
||||
)
|
||||
|
||||
// tidyTableSnapshot 是三张表在某一时刻的逐行内容。
|
||||
type tidyTableSnapshot struct {
|
||||
items []string
|
||||
candidates []string
|
||||
runs []string
|
||||
}
|
||||
|
||||
// dumpTidyTable 把一张表按主键序渲染成字符串行。
|
||||
func dumpTidyTable(t *testing.T, store *Store, table string, columns []string) []string {
|
||||
t.Helper()
|
||||
query := fmt.Sprintf("SELECT %s FROM %s ORDER BY id", strings.Join(columns, ", "), table)
|
||||
rows, err := store.db.QueryContext(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("dump %s: %v", table, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
vals := make([]any, len(columns))
|
||||
ptrs := make([]any, len(columns))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
t.Fatalf("scan %s: %v", table, err)
|
||||
}
|
||||
parts := make([]string, 0, len(columns))
|
||||
for i, col := range columns {
|
||||
parts = append(parts, col+"="+formatCell(vals[i]))
|
||||
}
|
||||
out = append(out, strings.Join(parts, "|"))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate %s: %v", table, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatCell 把 scan 出来的值渲染成稳定文本(TEXT 列以 []byte 出现)。
|
||||
func formatCell(v any) string {
|
||||
switch got := v.(type) {
|
||||
case nil:
|
||||
return "<nil>"
|
||||
case []byte:
|
||||
return string(got)
|
||||
default:
|
||||
return fmt.Sprintf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotTidyTables 拍一张三表快照。
|
||||
func snapshotTidyTables(t *testing.T, store *Store) tidyTableSnapshot {
|
||||
t.Helper()
|
||||
return tidyTableSnapshot{
|
||||
items: dumpTidyTable(t, store, "memory_items", memoryItemsColumns),
|
||||
candidates: dumpTidyTable(t, store, "memory_tidy_candidates", tidyCandidateColumns),
|
||||
runs: dumpTidyTable(t, store, "memory_tidy_runs", tidyRunColumns),
|
||||
}
|
||||
}
|
||||
|
||||
// assertRowsUnchanged 逐行比对快照:任何一行被新增/删除/改列都会失败。
|
||||
func assertRowsUnchanged(t *testing.T, what string, before, after []string) {
|
||||
t.Helper()
|
||||
if len(before) != len(after) {
|
||||
t.Fatalf("%s 行数 %d → %d,试算有副作用", what, len(before), len(after))
|
||||
}
|
||||
for i := range before {
|
||||
if before[i] != after[i] {
|
||||
t.Fatalf("%s 第 %d 行被改动:\n before=%s\n after =%s", what, i, before[i], after[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// candidateSignature 把一条候选渲染成与 id/落库时间无关的稳定签名,用于比对
|
||||
// 「试算候选」与「真跑一轮的候选」是否逐字段一致。
|
||||
func candidateSignature(c TidyCandidate) string {
|
||||
return fmt.Sprintf("%s|sources=%s|target=%s|kind=%s|reason=%s|proposed=%q|merged=%q|rule=%s|min=%.6f|max=%.6f",
|
||||
c.Action, strings.Join(c.SourceIDs, ","), c.TargetID, c.Kind, c.Reason,
|
||||
c.ProposedContent, c.MergedContent, c.RuleVersion, c.ScoreMin, c.ScoreMax)
|
||||
}
|
||||
|
||||
// sortedSignatures 返回按签名排序的候选集合(消除落库读回的排序差异)。
|
||||
func sortedSignatures(cands []TidyCandidate) []string {
|
||||
out := make([]string, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
out = append(out, candidateSignature(c))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// seedDryRunFixture 落三条记忆:一对必合(短 ⊆ 长)+ 一条一次性事件(走 archive)。
|
||||
func seedDryRunFixture(t *testing.T, store *Store) {
|
||||
t.Helper()
|
||||
base := tidyTestTime(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||||
seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Hour), 0.9))
|
||||
}
|
||||
|
||||
// dryRunFixtureEmbedder 让上述三条恰好产出一 merge + 一 archive。
|
||||
func dryRunFixtureEmbedder() stubEmbedder {
|
||||
return stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureHabitShort: {1, 0},
|
||||
fixtureHabitLong: simVector(0.95),
|
||||
fixtureOneOffEvent: simVector(0.9),
|
||||
}}
|
||||
}
|
||||
|
||||
// TestTidyDryRun_ZeroSideEffects 是判据 ①:试算跑完之后,memory_items /
|
||||
// memory_tidy_candidates / memory_tidy_runs **逐行**与跑之前完全一致。
|
||||
func TestTidyDryRun_ZeroSideEffects(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
before := snapshotTidyTables(t, store)
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
|
||||
out, err := tr.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun: %v", err)
|
||||
}
|
||||
if len(out.Candidates) == 0 {
|
||||
t.Fatalf("夹具应当产出候选(否则零副作用断言是空转):%+v", out.Stats)
|
||||
}
|
||||
|
||||
assertRowsUnchanged(t, "memory_items", before.items, snapshotTidyTables(t, store).items)
|
||||
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, snapshotTidyTables(t, store).candidates)
|
||||
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, snapshotTidyTables(t, store).runs)
|
||||
}
|
||||
|
||||
// TestTidyDryRun_MatchesRealRunCandidates 是判据 ②:试算走的必须是**同一套规则**
|
||||
// —— 同一批数据上,试算候选与真跑一轮落库的候选逐字段一致。
|
||||
func TestTidyDryRun_MatchesRealRunCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
|
||||
|
||||
out, err := tr.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun: %v", err)
|
||||
}
|
||||
if len(out.Candidates) == 0 {
|
||||
t.Fatal("夹具应当产出候选")
|
||||
}
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
persisted, err := store.ListTidyCandidates(ctx, "", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
|
||||
want := sortedSignatures(out.Candidates)
|
||||
got := sortedSignatures(persisted)
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("试算候选与真跑一轮的候选不一致:\n dry=%v\n real=%v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyDryRun_RespectsCandidateLimit 是判据 ③:每分区候选上限(既有配置旋钮)
|
||||
// 在试算里同样生效,且超出的候选被如实登记(Skipped / Truncated),绝不静默。
|
||||
func TestTidyDryRun_RespectsCandidateLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.CandidateLimit = 1
|
||||
tr := newTestTidier(t, store, cfg, dryRunFixtureEmbedder())
|
||||
|
||||
out, err := tr.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun: %v", err)
|
||||
}
|
||||
if len(out.Candidates) != cfg.CandidateLimit {
|
||||
t.Fatalf("候选数 = %d, want %d(必须尊重既有每分区上限)", len(out.Candidates), cfg.CandidateLimit)
|
||||
}
|
||||
if out.Stats.Skipped != 1 {
|
||||
t.Fatalf("超限候选未被如实登记:skipped=%d, want 1", out.Stats.Skipped)
|
||||
}
|
||||
if len(out.Stats.Truncated) == 0 {
|
||||
t.Fatalf("候选上限命中必须留下截断痕迹:%+v", out.Stats)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyDryRun_EmbeddingUnavailableIsHonest 是判据 ④:语义召回不可用时**如实
|
||||
// 报错**(ErrTidyRecallUnavailable),绝不静默降级成「没有候选」;而生产轮在同样
|
||||
// 条件下依旧按既有口径回退到词项余弦(试算的严格性只属于试算)。
|
||||
func TestTidyDryRun_EmbeddingUnavailableIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80),
|
||||
failingEmbedder{err: errors.New("dial tcp 127.0.0.1:11434: connectex: 目标计算机积极拒绝")})
|
||||
|
||||
before := snapshotTidyTables(t, store)
|
||||
out, err := tr.DryRun(ctx)
|
||||
if !errors.Is(err, ErrTidyRecallUnavailable) {
|
||||
t.Fatalf("DryRun err = %v, want ErrTidyRecallUnavailable", err)
|
||||
}
|
||||
if len(out.Candidates) != 0 {
|
||||
t.Fatalf("召回不可用时不得产出候选:%+v", out.Candidates)
|
||||
}
|
||||
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, snapshotTidyTables(t, store).candidates)
|
||||
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, snapshotTidyTables(t, store).runs)
|
||||
|
||||
// 生产轮(RunOnce)不受试算的严格性影响:它照旧降级并跑完一轮。
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("生产轮应当回退到词项余弦并可跑完:%v", err)
|
||||
}
|
||||
runs, err := store.ListTidyRuns(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyRuns: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("生产轮留痕 = %d 条, want 1(试算本身不写留痕)", len(runs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyDryRun_RecallModeMatchesEmbedder 钉住试算报出的召回来源与依赖一致。
|
||||
func TestTidyDryRun_RecallModeMatchesEmbedder(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
|
||||
withEmbedder := newTestTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
|
||||
out, err := withEmbedder.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun(embedder): %v", err)
|
||||
}
|
||||
if out.RecallMode != tidyRecallEmbedding {
|
||||
t.Fatalf("recallMode = %q, want %q", out.RecallMode, tidyRecallEmbedding)
|
||||
}
|
||||
|
||||
withoutEmbedder := newTestTidier(t, store, tidyTestCfg(0.80), nil)
|
||||
out, err = withoutEmbedder.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun(lexical): %v", err)
|
||||
}
|
||||
if out.RecallMode != tidyRecallLexical {
|
||||
t.Fatalf("recallMode = %q, want %q", out.RecallMode, tidyRecallLexical)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceDryRun_DisabledIsHonest 钉住「配置关闭 ⇒ 诚实错误」:关闭是显式
|
||||
// 选择,试算不绕过它,也不回一份空候选假装「没有建议」。
|
||||
func TestTidyServiceDryRun_DisabledIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.Enabled = false
|
||||
svc := newTidyServiceForTest(t, store, cfg, dryRunFixtureEmbedder(), time.Now, nil)
|
||||
if _, err := svc.DryRun(ctx); !errors.Is(err, ErrTidyDisabled) {
|
||||
t.Fatalf("DryRun err = %v, want ErrTidyDisabled", err)
|
||||
}
|
||||
runs, err := store.ListTidyRuns(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyRuns: %v", err)
|
||||
}
|
||||
if len(runs) != 0 {
|
||||
t.Fatalf("关闭时不得留下任何留痕:%+v", runs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceDryRun_EnabledWritesNothing 是服务层的零副作用回归:开启状态下
|
||||
// 经服务入口试算同样一行都不写(连运行留痕都不写)。
|
||||
func TestTidyServiceDryRun_EnabledWritesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
before := snapshotTidyTables(t, store)
|
||||
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), time.Now, nil)
|
||||
out, err := svc.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun: %v", err)
|
||||
}
|
||||
if len(out.Candidates) == 0 {
|
||||
t.Fatal("夹具应当产出候选")
|
||||
}
|
||||
after := snapshotTidyTables(t, store)
|
||||
assertRowsUnchanged(t, "memory_items", before.items, after.items)
|
||||
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, after.candidates)
|
||||
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, after.runs)
|
||||
}
|
||||
@@ -15,6 +15,17 @@ import (
|
||||
// design/23 §1.2 的实据是「必合」的 [12]/[15]=0.8330 与「必不合」的
|
||||
// [3]/[6]=0.7100 只差 0.12,且全库最高相似度的一对(0.9319)恰是跨作用域
|
||||
// ⇒ 单阈值双向失效,判定必须靠包含判定 + 规则否决(tidy_rules.go)。
|
||||
//
|
||||
// 召回来源(embedding / 词项余弦)与「来源不可用时怎么办」被拆成两层:
|
||||
// recallPairs 只报事实(不可用就回错误),**回退还是上报由调用方的
|
||||
// tidyRecall 策略决定**(tidy.go 的生产轮回退、tidy_dryrun.go 的试算轮上报)。
|
||||
|
||||
const (
|
||||
// tidyRecallEmbedding 是本轮相似度来自语义 embedding。
|
||||
tidyRecallEmbedding = "embedding"
|
||||
// tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。
|
||||
tidyRecallLexical = "lexical"
|
||||
)
|
||||
|
||||
// tidyPair 是一条被召回的候选对。
|
||||
type tidyPair struct {
|
||||
@@ -26,15 +37,35 @@ type tidyPair struct {
|
||||
|
||||
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。
|
||||
//
|
||||
// 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正
|
||||
// 参与比对的最多 maxTidyCompareItems 条(最新优先),超限记 WARN 并登记到
|
||||
// stats.Truncated —— 截断绝不静默。
|
||||
// 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在
|
||||
// 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。
|
||||
//
|
||||
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
|
||||
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
|
||||
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) []tidyPair {
|
||||
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
|
||||
if len(items) < 2 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if t.deps.Embedder == nil {
|
||||
return collectTidyPairs(items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats), nil
|
||||
}
|
||||
vecs, err := t.embedItems(ctx, items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(vecs) != len(items) {
|
||||
return nil, ErrTidyEmbedCount
|
||||
}
|
||||
return collectTidyPairs(items, t.cfg.RecallThreshold, vectorSimFunc(vecs), tidyRecallEmbedding, stats), nil
|
||||
}
|
||||
|
||||
// limitCompareItems 把参与比对的条目截断到性能护栏并计入 Compared。
|
||||
// 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正参与
|
||||
// 比对的最多 maxTidyCompareItems 条(最新优先),超限记 WARN 并登记到
|
||||
// stats.Truncated —— 截断绝不静默。空/单条分区不计数(本来就没有对可比)。
|
||||
func (t *Tidier) limitCompareItems(p tidyPartition, items []MemoryItem, stats *TidyStats) []MemoryItem {
|
||||
if len(items) < 2 {
|
||||
return items
|
||||
}
|
||||
if len(items) > maxTidyCompareItems {
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(compare)")
|
||||
@@ -43,13 +74,17 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
|
||||
items = items[:maxTidyCompareItems]
|
||||
}
|
||||
stats.Compared += len(items)
|
||||
return items
|
||||
}
|
||||
|
||||
sims, mode := t.similarityFunc(ctx, p, items)
|
||||
// collectTidyPairs 是召回内核:把相似度 ≥ 阈值的对收集起来,按分数降序
|
||||
// (再按 id)确定性排序 —— 排序让「每分区候选上限」的截断可复现。
|
||||
func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string, stats *TidyStats) []tidyPair {
|
||||
pairs := make([]tidyPair, 0)
|
||||
for i := 0; i < len(items); i++ {
|
||||
for j := i + 1; j < len(items); j++ {
|
||||
score := sims(i, j)
|
||||
if score < t.cfg.RecallThreshold {
|
||||
score := sim(i, j)
|
||||
if score < threshold {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, tidyPair{a: items[i], b: items[j], score: score, mode: mode})
|
||||
@@ -68,31 +103,22 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
|
||||
return pairs
|
||||
}
|
||||
|
||||
// similarityFunc 返回分区内的两两相似度函数与来源标签。
|
||||
//
|
||||
// 接入 embedder 时用它批量向量化后取余弦(与检索侧同一口径:余弦夹到
|
||||
// [0,1])。embedder 缺席或整批 Embed 失败时回退到零依赖的词项余弦
|
||||
// —— 该回退保证整理在离线/无 embedding 配置时仍可跑(宁可比语义路径更保守),
|
||||
// 且让测试无需网络即可确定性运行。两个来源的相似度**不可混用阈值**:
|
||||
// 阈值只有一个(design §13 开放点 2 的 0.80),回退路径命中更少 ⇒ 更保守。
|
||||
func (t *Tidier) similarityFunc(ctx context.Context, p tidyPartition, items []MemoryItem) (func(i, j int) float64, string) {
|
||||
if t.deps.Embedder != nil {
|
||||
vecs, err := t.embedItems(ctx, items)
|
||||
if err == nil && len(vecs) == len(items) {
|
||||
return func(i, j int) float64 {
|
||||
return clampUnit(cosineSim(vecs[i], vecs[j]))
|
||||
}, "embedding"
|
||||
}
|
||||
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
|
||||
"partition", p.label(), "error", err)
|
||||
}
|
||||
// lexicalSimFunc 是零依赖的相似度函数:两条内容词项集合的余弦。
|
||||
func lexicalSimFunc(items []MemoryItem) func(i, j int) float64 {
|
||||
return func(i, j int) float64 {
|
||||
return lexicalSimilarity(items[i].Content, items[j].Content)
|
||||
}, "lexical"
|
||||
}
|
||||
}
|
||||
|
||||
// embedItems 分批把分区内条目向量化。单批失败即整体失败(调用方回退到
|
||||
// 词项余弦),不半途改用混合表示 —— 混用会让召回范围不可预测。
|
||||
// vectorSimFunc 是语义相似度函数(与检索侧同一口径:余弦夹到 [0,1])。
|
||||
func vectorSimFunc(vecs []embedding.Vector) func(i, j int) float64 {
|
||||
return func(i, j int) float64 {
|
||||
return clampUnit(cosineSim(vecs[i], vecs[j]))
|
||||
}
|
||||
}
|
||||
|
||||
// embedItems 分批把分区内条目向量化。单批失败即整体失败(调用方决定回退到
|
||||
// 词项余弦还是如实上报),不半途改用混合表示 —— 混用会让召回范围不可预测。
|
||||
func (t *Tidier) embedItems(ctx context.Context, items []MemoryItem) ([]embedding.Vector, error) {
|
||||
out := make([]embedding.Vector, 0, len(items))
|
||||
for start := 0; start < len(items); start += tidyEmbedBatch {
|
||||
@@ -142,3 +168,11 @@ func clampUnit(v float64) float64 {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
|
||||
func tidyRecallMode(hasEmbedder bool) string {
|
||||
if hasEmbedder {
|
||||
return tidyRecallEmbedding
|
||||
}
|
||||
return tidyRecallLexical
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件承载记忆整理「试算(dry-run)」入口(契约 v0.75;切片 MT1)。
|
||||
//
|
||||
// 它回答的是主人的那句「**现在就想看到在 736 条上它会建议合并哪些**」:
|
||||
// 整理器本身是低峰定时任务(design/23 §4,默认 23:00–07:00 才跑),在它
|
||||
// 第一次开跑之前,库里既没有候选也没有留痕 —— 面板只能空着。本端点把同一套
|
||||
// 规则**当场算一遍并把候选直接回给面板**。
|
||||
//
|
||||
// 四条不可走样的口径:
|
||||
//
|
||||
// 1. **零副作用**:跑的是 `memory.Tidier.DryRun`,它不写 memory_tidy_candidates、
|
||||
// 不写 memory_tidy_runs、**绝不改 memory_items 的任何列**(单测逐行钉死)。
|
||||
// 2. **不写第二套规则**:候选由既有 rule engine / tidier 代码路径产出,端点只做
|
||||
// 线上投影;阈值/上限一律取既有配置(MEMORY_TIDY_RECALL_THRESHOLD /
|
||||
// SCAN_LIMIT / CANDIDATE_LIMIT),端点自己**不引入任何上限**。
|
||||
// 3. **诚实失败**:未接线 ⇒ `500 tidy_unavailable`;配置关闭 ⇒ `409 tidy_disabled`;
|
||||
// 语义召回不可用 ⇒ `500 tidy_recall_unavailable`;超时 ⇒ `503 tidy_timeout`。
|
||||
// **一律不回空候选数组假装「没有建议」** —— 那是把「没算出来」谎报成「算过了,没有」。
|
||||
// 4. **只算不写 ⇒ 不审计**:它是只读计算(与 /admin/config-facts 同一档),
|
||||
// 因此不写审计链,也不进 registerAdminWriteRoutes 那条 owner-only 写路径。
|
||||
//
|
||||
// 面板按下才跑,**不做自动轮询**:试算是一次真实规则计算(扫描 + 两两召回 +
|
||||
// 包含判定),不是看板指标。
|
||||
|
||||
// tidyDryRunTimeout 是试算处理的墙钟上限。试算在既有护栏内是有界的
|
||||
// (每分区扫描 ≤ SCAN_LIMIT、参与比对 ≤ maxTidyCompareItems),显式给一个上限
|
||||
// 只为让卡住的请求(如 embedding 服务半死不活)不占着连接;超时**如实报错**。
|
||||
const tidyDryRunTimeout = 3 * time.Minute
|
||||
|
||||
// MemoryTidyDryRunner 是记忆整理「试算」的注入出口(契约 v0.75;切片 MT1)。
|
||||
// 组合根注入的就是低峰心跳跑的那个 `*memory.TidyService` —— 同一套规则、同一份
|
||||
// 配置,因此试算给出的建议与今晚 23:00 那一轮是同一口径。
|
||||
type MemoryTidyDryRunner interface {
|
||||
// DryRun 跑一轮整理规则并返回候选,零副作用。
|
||||
DryRun(ctx context.Context) (memory.TidyDryRun, error)
|
||||
}
|
||||
|
||||
// AdminTidyDryRunCandidate 是试算响应里的一条候选(契约 v0.75 §4.12)。
|
||||
// 字段与 `memory_tidy_candidates` 的落库形状同构,但**没有 status / decided\***
|
||||
// —— 试算不落库,因此不存在「已决定」这回事;ID 也不下发(它没有任何后续动作)。
|
||||
//
|
||||
// Evidence 复用领域类型(`memory.TidyEvidence`,带 JSON tag):面板的 diff 预览
|
||||
// 需要 A/B 原文、包含证据与否决原因,字段就是按它设计的。
|
||||
type AdminTidyDryRunCandidate struct {
|
||||
Action string `json:"action"`
|
||||
SourceIDs []string `json:"sourceIds"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
PersonaID string `json:"personaId"`
|
||||
Scope string `json:"scope"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ProposedContent string `json:"proposedContent,omitempty"`
|
||||
MergedContent string `json:"mergedContent,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
ScoreMin float64 `json:"scoreMin"`
|
||||
ScoreMax float64 `json:"scoreMax"`
|
||||
Evidence memory.TidyEvidence `json:"evidence"`
|
||||
}
|
||||
|
||||
// AdminTidyDryRunResponse 是 POST /api/v1/admin/memories/tidy/dry-run 的线上结构
|
||||
// (契约 v0.75 §4.12)。
|
||||
//
|
||||
// `dryRun` 恒为 true:这份响应**没有**改动任何记忆,客户端可据此把「仅试算」的
|
||||
// 文案与结构绑定,而不是只靠一段本地硬编码说明。
|
||||
type AdminTidyDryRunResponse struct {
|
||||
DryRun bool `json:"dryRun"`
|
||||
RecallMode string `json:"recallMode"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
// 观测口径(与真跑一轮同一套计数):扫描分区数 / 扫描条数 / 参与比对条数 /
|
||||
// 召回对数 / 被否决对数与分布。
|
||||
Partitions int `json:"partitions"`
|
||||
Scanned int `json:"scanned"`
|
||||
Compared int `json:"compared"`
|
||||
Pairs int `json:"pairs"`
|
||||
Vetoed int `json:"vetoed"`
|
||||
VetoReasons map[string]int `json:"vetoReasons"`
|
||||
// Truncated 列出被护栏截断的分区标签(扫描/比对/候选上限),供运维放大配额。
|
||||
// 它**不是**候选;缺席表示本轮没有被截断。
|
||||
Truncated []string `json:"truncated,omitempty"`
|
||||
// Candidates 恒为非 null 数组:空数组 = 「算过了,这一轮没有建议」,与
|
||||
// 「没算出来」(那种情况是错误响应)**必须能区分**。
|
||||
Candidates []AdminTidyDryRunCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// adminMemoryTidyDryRun 是「试算整理建议」的处理函数。它只读语义、不审计
|
||||
// (见文件头注释第 4 条),失败一律如实作答。
|
||||
func (s *Server) adminMemoryTidyDryRun(w http.ResponseWriter, r *http.Request) {
|
||||
if s.memoryTidy == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), tidyDryRunTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := s.memoryTidy.DryRun(ctx)
|
||||
if err != nil {
|
||||
s.writeTidyDryRunError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminTidyDryRunResponse(result))
|
||||
}
|
||||
|
||||
// writeTidyDryRunError 把试算失败映射成**诚实**的错误码。用户可见的 message
|
||||
// 只说明「哪一类原因」,原始错误(可能含内部地址/凭据片段)只进日志。
|
||||
func (s *Server) writeTidyDryRunError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, memory.ErrTidyDisabled):
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "tidy_disabled",
|
||||
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false),试算同样不上报候选",
|
||||
HTTPCode: http.StatusConflict,
|
||||
})
|
||||
case errors.Is(err, memory.ErrTidyRecallUnavailable):
|
||||
s.log.Error("memory tidy dry-run recall unavailable", "err", err)
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "tidy_recall_unavailable",
|
||||
Message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
|
||||
HTTPCode: http.StatusInternalServerError,
|
||||
})
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
s.log.Error("memory tidy dry-run timed out", "err", err)
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "tidy_timeout",
|
||||
Message: "记忆整理试算超时(未产出候选)",
|
||||
HTTPCode: http.StatusServiceUnavailable,
|
||||
})
|
||||
default:
|
||||
s.log.Error("memory tidy dry-run failed", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算失败"))
|
||||
}
|
||||
}
|
||||
|
||||
// adminTidyDryRunResponse 把领域结果投影成线上结构。它**不复制**任何阈值/上限:
|
||||
// 条数与截断标签都来自领域侧已经算好的结果(Candidates 已被每分区上限截断)。
|
||||
func adminTidyDryRunResponse(result memory.TidyDryRun) AdminTidyDryRunResponse {
|
||||
out := AdminTidyDryRunResponse{
|
||||
DryRun: true,
|
||||
RecallMode: result.RecallMode,
|
||||
DurationMs: result.DurationMS,
|
||||
RuleVersion: memory.TidyRuleVersion,
|
||||
Partitions: result.Stats.Partitions,
|
||||
Scanned: result.Stats.Scanned,
|
||||
Compared: result.Stats.Compared,
|
||||
Pairs: result.Stats.Pairs,
|
||||
Vetoed: result.Stats.Vetoed,
|
||||
VetoReasons: result.Stats.VetoReasons,
|
||||
Truncated: result.Stats.Truncated,
|
||||
Candidates: make([]AdminTidyDryRunCandidate, 0, len(result.Candidates)),
|
||||
}
|
||||
for _, c := range result.Candidates {
|
||||
out.Candidates = append(out.Candidates, adminTidyDryRunCandidate(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adminTidyDryRunCandidate 投影一条候选。SourceIDs 兜成非 null 数组(面板按数组读)。
|
||||
func adminTidyDryRunCandidate(c memory.TidyCandidate) AdminTidyDryRunCandidate {
|
||||
ids := c.SourceIDs
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
return AdminTidyDryRunCandidate{
|
||||
Action: string(c.Action),
|
||||
SourceIDs: ids,
|
||||
TargetID: c.TargetID,
|
||||
PersonaID: c.PersonaID,
|
||||
Scope: string(c.Scope),
|
||||
ProjectID: c.ProjectID,
|
||||
Kind: string(c.Kind),
|
||||
ProposedContent: c.ProposedContent,
|
||||
MergedContent: c.MergedContent,
|
||||
Reason: c.Reason,
|
||||
RuleVersion: c.RuleVersion,
|
||||
ScoreMin: c.ScoreMin,
|
||||
ScoreMax: c.ScoreMax,
|
||||
Evidence: c.Evidence,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理「试算」端点(契约 v0.75;切片 MT1)的四条判据:
|
||||
// ① 投影正确:候选的 action/source_ids/reason/分数/规则版本/证据都如实上线,
|
||||
// 且 `candidates` 恒为数组、`dryRun` 恒为 true;
|
||||
// ② RBAC 沿用既有 admin 门(viewer 403 / owner|admin 200 / 匿名 401);
|
||||
// ③ **诚实失败**:未接线 ⇒ 500 tidy_unavailable、配置关闭 ⇒ 409 tidy_disabled、
|
||||
// 语义召回不可用 ⇒ 500 tidy_recall_unavailable —— 且**绝不**回空候选数组
|
||||
// 假装「没有建议」,也不把原始内部错误正文抖给客户端;
|
||||
// ④ 空结果与失败可区分:真的算完且没有建议 ⇒ 200 + `candidates:[]`。
|
||||
|
||||
// fakeTidyDryRunner 是端点测试用的试算替身:按注入的结果/错误作答,并记录调用
|
||||
// 次数(用来钉住「关闭时连试算都不跑」)。
|
||||
type fakeTidyDryRunner struct {
|
||||
result memory.TidyDryRun
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeTidyDryRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
|
||||
f.calls++
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
// fixtureTidyDryRun 是一条两候选/含否决分布/含截断标签的完整试算结果。
|
||||
func fixtureTidyDryRun() memory.TidyDryRun {
|
||||
return memory.TidyDryRun{
|
||||
Stats: memory.TidyStats{
|
||||
Scanned: 736, Partitions: 2, Compared: 400, Pairs: 5, Vetoed: 2,
|
||||
VetoReasons: map[string]int{"scope": 1, "temporal": 1},
|
||||
Truncated: []string{"cyrene/persona/(compare)"},
|
||||
},
|
||||
Candidates: []memory.TidyCandidate{{
|
||||
Action: memory.TidyMerge,
|
||||
SourceIDs: []string{"mem-a"},
|
||||
TargetID: "mem-b",
|
||||
PersonaID: "cyrene",
|
||||
Scope: memory.ScopePersona,
|
||||
Kind: memory.KindRelationship,
|
||||
ProposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||
MergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
Reason: "containment_a_subset_b",
|
||||
Evidence: memory.TidyEvidence{
|
||||
Similarity: 0.95, RecallMode: "embedding", Containment: "a_subset_b",
|
||||
ContentA: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||
ContentB: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
},
|
||||
RuleVersion: memory.TidyRuleVersion, ScoreMin: 0.95, ScoreMax: 0.95,
|
||||
}},
|
||||
RecallMode: "embedding",
|
||||
DurationMS: 42,
|
||||
}
|
||||
}
|
||||
|
||||
// tidyDryRunEnvelope 是端点的解码壳(数据 + 错误两态)。
|
||||
type tidyDryRunEnvelope struct {
|
||||
Data AdminTidyDryRunResponse `json:"data"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// postTidyDryRun 调一次试算端点并解码。
|
||||
func postTidyDryRun(t *testing.T, deps Deps, role auth.Role) (int, []byte, tidyDryRunEnvelope) {
|
||||
t.Helper()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||||
token := ""
|
||||
if role != "" {
|
||||
token = signAny(t, jwt, "acct", role)
|
||||
}
|
||||
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run", nil, token)
|
||||
var out tidyDryRunEnvelope
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, raw, out
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRun_ProjectsCandidates 是判据 ①:候选的关键字段逐一上线,
|
||||
// 且 `dryRun`/`recallMode`/计数/截断标签都如实投影。
|
||||
func TestAdminMemoryTidyDryRun_ProjectsCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
status, _, env := postTidyDryRun(t, Deps{MemoryTidy: &fakeTidyDryRunner{result: fixtureTidyDryRun()}}, auth.RoleOwner)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, env.Error.Message)
|
||||
}
|
||||
got := env.Data
|
||||
if !got.DryRun {
|
||||
t.Fatal("dryRun 必须恒为 true —— 它就是「这份响应没有改任何记忆」的结构化判据")
|
||||
}
|
||||
if got.RecallMode != "embedding" || got.DurationMs != 42 || got.RuleVersion != memory.TidyRuleVersion {
|
||||
t.Fatalf("元信息投影错误:%+v", got)
|
||||
}
|
||||
if got.Scanned != 736 || got.Compared != 400 || got.Pairs != 5 || got.Vetoed != 2 || got.Partitions != 2 {
|
||||
t.Fatalf("观测口径投影错误:%+v", got)
|
||||
}
|
||||
if got.VetoReasons["scope"] != 1 || got.VetoReasons["temporal"] != 1 {
|
||||
t.Fatalf("否决分布投影错误:%+v", got.VetoReasons)
|
||||
}
|
||||
if len(got.Truncated) != 1 || got.Truncated[0] != "cyrene/persona/(compare)" {
|
||||
t.Fatalf("截断标签投影错误:%+v", got.Truncated)
|
||||
}
|
||||
if len(got.Candidates) != 1 {
|
||||
t.Fatalf("候选数 = %d, want 1", len(got.Candidates))
|
||||
}
|
||||
c := got.Candidates[0]
|
||||
if c.Action != "merge" || c.Reason != "containment_a_subset_b" || c.TargetID != "mem-b" {
|
||||
t.Fatalf("候选动作/理由/存活条投影错误:%+v", c)
|
||||
}
|
||||
if len(c.SourceIDs) != 1 || c.SourceIDs[0] != "mem-a" {
|
||||
t.Fatalf("source_ids 投影错误:%+v", c.SourceIDs)
|
||||
}
|
||||
if c.ScoreMin != 0.95 || c.ScoreMax != 0.95 || c.RuleVersion != memory.TidyRuleVersion {
|
||||
t.Fatalf("分数区间/规则版本投影错误:%+v", c)
|
||||
}
|
||||
if c.MergedContent == "" || c.ProposedContent == "" || c.Evidence.ContentA == "" {
|
||||
t.Fatalf("diff 预览(原文/合并内容/证据)缺失:%+v", c)
|
||||
}
|
||||
if c.Scope != string(memory.ScopePersona) || c.PersonaID != "cyrene" {
|
||||
t.Fatalf("分区键投影错误:%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRun_RequiresOwnerOrAdmin 是判据 ②:RBAC 沿用既有 admin
|
||||
// 门(POST ⇒ writeAuth),且**不**给 viewer。
|
||||
func TestAdminMemoryTidyDryRun_RequiresOwnerOrAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
deps := Deps{MemoryTidy: &fakeTidyDryRunner{result: fixtureTidyDryRun()}}
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
|
||||
status, _, _ := postTidyDryRun(t, deps, role)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("role %s = %d, want 200", role, status)
|
||||
}
|
||||
}
|
||||
if status, _, _ := postTidyDryRun(t, deps, auth.RoleViewer); status != http.StatusForbidden {
|
||||
t.Fatalf("viewer = %d, want 403", status)
|
||||
}
|
||||
if status, _, _ := postTidyDryRun(t, deps, ""); status != http.StatusUnauthorized {
|
||||
t.Fatalf("匿名 = %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRun_UnwiredIsHonest 是判据 ③(未接线):真 nil 依赖 ⇒
|
||||
// 500 tidy_unavailable,**不是** 200 + 空候选。
|
||||
func TestAdminMemoryTidyDryRun_UnwiredIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
status, raw, env := postTidyDryRun(t, Deps{}, auth.RoleOwner)
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", status)
|
||||
}
|
||||
if env.Error.Code != "tidy_unavailable" {
|
||||
t.Fatalf("code = %q, want tidy_unavailable", env.Error.Code)
|
||||
}
|
||||
if strings.Contains(string(raw), `"candidates"`) {
|
||||
t.Fatalf("未接线时不得回候选数组(那是把「没算出来」谎报成「没有建议」):%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRun_DisabledIsHonest 是判据 ③(配置关闭):409 tidy_disabled,
|
||||
// 且**连试算都不跑**(关闭是显式选择,端点不绕过它)。
|
||||
func TestAdminMemoryTidyDryRun_DisabledIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeTidyDryRunner{err: memory.ErrTidyDisabled}
|
||||
status, raw, env := postTidyDryRun(t, Deps{MemoryTidy: fake}, auth.RoleOwner)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", status)
|
||||
}
|
||||
if env.Error.Code != "tidy_disabled" {
|
||||
t.Fatalf("code = %q, want tidy_disabled", env.Error.Code)
|
||||
}
|
||||
if fake.calls != 1 {
|
||||
t.Fatalf("DryRun 调用次数 = %d, want 1(替身自己返回关闭态)", fake.calls)
|
||||
}
|
||||
if strings.Contains(string(raw), `"candidates"`) {
|
||||
t.Fatalf("关闭时不得回候选数组:%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRun_RecallUnavailableIsHonest 是判据 ③(语义召回不可用):
|
||||
// 500 tidy_recall_unavailable,且原始内部错误(含内网地址)**不抖给客户端**。
|
||||
func TestAdminMemoryTidyDryRun_RecallUnavailableIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
sentinel := "dial tcp 10.9.8.7:11434: internal-secret-endpoint"
|
||||
fake := &fakeTidyDryRunner{err: errors.Join(memory.ErrTidyRecallUnavailable, errors.New(sentinel))}
|
||||
status, raw, env := postTidyDryRun(t, Deps{MemoryTidy: fake}, auth.RoleOwner)
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", status)
|
||||
}
|
||||
if env.Error.Code != "tidy_recall_unavailable" {
|
||||
t.Fatalf("code = %q, want tidy_recall_unavailable", env.Error.Code)
|
||||
}
|
||||
if strings.Contains(string(raw), "10.9.8.7") || strings.Contains(string(raw), "internal-secret-endpoint") {
|
||||
t.Fatalf("内部错误细节泄漏到响应:%s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), `"candidates"`) {
|
||||
t.Fatalf("召回不可用时不得回空候选:%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRun_EmptyIsNotAFailure 是判据 ④:真的算完且没有建议 ⇒
|
||||
// 200 + 恒为非 null 的空数组(与失败态可区分)。
|
||||
func TestAdminMemoryTidyDryRun_EmptyIsNotAFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeTidyDryRunner{result: memory.TidyDryRun{
|
||||
Stats: memory.TidyStats{VetoReasons: map[string]int{"temporal": 3}},
|
||||
RecallMode: "lexical",
|
||||
}}
|
||||
status, raw, env := postTidyDryRun(t, Deps{MemoryTidy: fake}, auth.RoleOwner)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", status)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"candidates":[]`) {
|
||||
t.Fatalf("空候选必须序列化成 [](不是 null、不是缺键):%s", raw)
|
||||
}
|
||||
if env.Data.Vetoed != 0 || env.Data.VetoReasons["temporal"] != 3 {
|
||||
t.Fatalf("空结果仍须如实报否决分布:%+v", env.Data)
|
||||
}
|
||||
if fake.calls != 1 {
|
||||
t.Fatalf("DryRun 调用次数 = %d, want 1", fake.calls)
|
||||
}
|
||||
}
|
||||
@@ -165,4 +165,10 @@ type Deps struct {
|
||||
// `*flowgraph.SQLStore`。可选;为 nil 时端点回退到只有内存的行为
|
||||
// (turns 为空、turnId 只能命中内存里那一轮),内存与落盘都没接线才 500。
|
||||
FlowGraphs FlowGraphStore
|
||||
// MemoryTidy 是记忆整理「试算」的注入出口(契约 v0.75;切片 MT1)。组合根
|
||||
// 注入的就是低峰心跳跑的那个 `*memory.TidyService` —— 同一套规则、同一份配置,
|
||||
// 因此试算给出的建议与当晚那一轮同口径。试算自身**零副作用**(不写任何表)。
|
||||
// 可选;为 nil(真 nil,不是带类型的 nil 指针)时端点回 500 `tidy_unavailable`,
|
||||
// 绝不回空候选假装「没有建议」。
|
||||
MemoryTidy MemoryTidyDryRunner
|
||||
}
|
||||
@@ -191,6 +191,11 @@ func (s *Server) registerAdminRoutes(
|
||||
// 与声明默认值。只读、不审计、**不读 `.env`**、**不含任何凭据**;与消费它的面板页
|
||||
// (记忆管理 / 后台思考)同门,故走 owner|admin 档而不是 viewer 监控档。
|
||||
mux.Handle("GET /api/v1/admin/config-facts", writeAuth(http.HandlerFunc(s.adminConfigFacts)))
|
||||
// 记忆整理「试算」(切片 MT1,**v0.75**):POST + writeAuth(owner|admin)。
|
||||
// 它是**只读语义**——跑同一套整理规则把候选直接回给面板、一行都不写;因此
|
||||
// 它不在 registerAdminWriteRoutes(那条路是 owner-only 的真实写入 + 审计)。
|
||||
// 试算属于「算给我看」,不是「改给我看」。
|
||||
mux.Handle("POST /api/v1/admin/memories/tidy/dry-run", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRun)))
|
||||
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
|
||||
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
|
||||
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
|
||||
|
||||
@@ -106,6 +106,9 @@ type Server struct {
|
||||
// flowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;见 Deps.FlowGraphs)。
|
||||
// 为 nil 时该端点回退到只有内存的行为(turns 为空、turnId 只能命中内存那一轮)。
|
||||
flowGraphs FlowGraphStore
|
||||
// memoryTidy 是记忆整理「试算」的注入出口(契约 v0.75;见 Deps.MemoryTidy)。
|
||||
// 为 nil 时 POST /api/v1/admin/memories/tidy/dry-run 回 500 tidy_unavailable。
|
||||
memoryTidy MemoryTidyDryRunner
|
||||
// envResolver 解析会话每轮的 loop 环境(其角色)。见
|
||||
// persona_env.go / design/22 §4.5。
|
||||
envResolver turnEnvResolver
|
||||
@@ -160,6 +163,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
maxParallelTurns: deps.MaxParallelTurns,
|
||||
flow: deps.Flow,
|
||||
flowGraphs: deps.FlowGraphs,
|
||||
memoryTidy: deps.MemoryTidy,
|
||||
}
|
||||
// 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner
|
||||
// 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5),
|
||||
|
||||
Reference in new issue
Block a user