主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。
① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
`applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
- 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
- `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
- 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
(**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
- `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
- **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
(群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
**可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
(`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。
验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。
⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
124 lines
5.3 KiB
Go
124 lines
5.3 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是整理候选的**批量决定**路径(2026-10-07 主人「那么多 link 是干什么的?
|
||
// 该删就删」)。
|
||
//
|
||
// 它解决的问题很具体:库里的 `link` 候选**没有执行语义**(design/23 §14 把「link
|
||
// 关系如何存储与呈现」列为待细化 ⇒ 无落点、无数据模型,详见 tidy_rules.go 的
|
||
// containNone 分支),因此它们永远不会被应用,只会一直 pending、把面板的待办列表
|
||
// 淹没(活库实测 501 条)。规则层已改成不产它们,但**存量**必须有一条干净的出路
|
||
// —— 一条条点不现实,所以这里给一个幂等的按动作批量决定口。
|
||
//
|
||
// 为什么叫「拒绝(rejected)」而不是「删除」:候选是**建议**,不是记忆。拒绝只写
|
||
// 候选表的状态列(design/23 §10 的 status 闭集第三档,此前只实现了 pending/applied),
|
||
// **不碰 memory_items 任何一行**,且幂等(`WHERE status='pending'` 条件更新)——
|
||
// 重复调用第二次就是 0 条,不是错误。
|
||
|
||
// tidyCandidateRejected 是候选被**明确拒绝**的状态(design/23 §10 的 status 闭集:
|
||
// pending / applied / rejected)。落它的意义是「这条建议已经被看过了、决定不做」,
|
||
// 于是它不再出现在默认(pending)的待办理列表里,也不再被轮末自动档捞出来。
|
||
const tidyCandidateRejected = "rejected"
|
||
|
||
// ErrTidyUnknownAction 在按动作批量决定时收到了闭集之外的动作名时返回。
|
||
// 它与「查不到」不同:那是调用方拼错了动作,必须回 400 而不是静默改 0 条。
|
||
var ErrTidyUnknownAction = errors.New("memory: unknown tidy action")
|
||
|
||
// 按动作批量拒绝候选的两条 SQL。第一条是**幂等闸**:只动 pending 的行,因此重复
|
||
// 调用天然收敛;LIMIT 放在子查询里(UPDATE ... LIMIT 需要 SQLite 的编译开关)。
|
||
const rejectTidyCandidatesByActionSQL = `UPDATE memory_tidy_candidates
|
||
SET status = ?, decided_at = ?, decided_by = ?
|
||
WHERE id IN (
|
||
SELECT id FROM memory_tidy_candidates
|
||
WHERE status = ? AND action = ?
|
||
ORDER BY created_at DESC, rowid DESC LIMIT ?
|
||
)`
|
||
|
||
const countTidyCandidatesByActionSQL = `SELECT COUNT(*) FROM memory_tidy_candidates
|
||
WHERE status = ? AND action = ?`
|
||
|
||
// TidyRejectBatch 是一次按动作批量决定的回执。Total 是**筛选真总数**(该动作下
|
||
// 此刻的 pending 条数),Rejected 才是这次真正改动的条数 —— 两者之差由 Truncated
|
||
// 表达(超限绝不静默少做,与扫描/候选/应用上限同一口径)。
|
||
type TidyRejectBatch struct {
|
||
Action TidyAction `json:"action"`
|
||
Limit int `json:"limit"`
|
||
Total int `json:"total"`
|
||
Rejected int `json:"rejected"`
|
||
Truncated bool `json:"truncated"`
|
||
}
|
||
|
||
// valid 报告一个动作是否在 design/23 §10 的动作闭集内。
|
||
func (a TidyAction) valid() bool {
|
||
switch a {
|
||
case TidyMerge, TidyLink, TidyArchive, TidyReclassify, TidyKeep:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// normalizedRejectLimit 归一化「按动作批量拒绝」的上限:非正取
|
||
// defaultTidyRejectBatchLimit,超过 maxTidyRejectBatchLimit 时钳到它
|
||
// (钳位而不是报错,与既有查询面同一约定)。
|
||
func (o TidyApplyOptions) normalizedRejectLimit() int {
|
||
if o.Limit <= 0 {
|
||
return defaultTidyRejectBatchLimit
|
||
}
|
||
if o.Limit > maxTidyRejectBatchLimit {
|
||
return maxTidyRejectBatchLimit
|
||
}
|
||
return o.Limit
|
||
}
|
||
|
||
// RejectTidyCandidatesByAction 把某个动作下**全部待定**候选标记为 rejected
|
||
// (一次最多 limit 条,重复调用幂等)。它是给存量候选(尤其是无执行语义的 link)
|
||
// 用的清洁口,也是「reject 记入审计、用于后续调参与防重复提议」这条设计口径的落点。
|
||
func (s *Store) RejectTidyCandidatesByAction(ctx context.Context, action TidyAction, opts TidyApplyOptions) (TidyRejectBatch, error) {
|
||
if !action.valid() {
|
||
return TidyRejectBatch{}, fmt.Errorf("%w: %q", ErrTidyUnknownAction, string(action))
|
||
}
|
||
batch := TidyRejectBatch{
|
||
Action: action,
|
||
Limit: opts.normalizedRejectLimit(),
|
||
}
|
||
total, err := s.countTidyCandidatesByAction(ctx, action, tidyCandidatePending)
|
||
if err != nil {
|
||
return batch, err
|
||
}
|
||
batch.Total = total
|
||
res, err := s.db.ExecContext(ctx, rejectTidyCandidatesByActionSQL,
|
||
tidyCandidateRejected, formatTime(time.Now().UTC()), opts.Actor,
|
||
tidyCandidatePending, string(action), batch.Limit)
|
||
if err != nil {
|
||
return batch, fmt.Errorf("memory/tidy: reject candidates of action %s: %w", action, err)
|
||
}
|
||
n, err := res.RowsAffected()
|
||
if err != nil {
|
||
return batch, fmt.Errorf("memory/tidy: reject candidates rows: %w", err)
|
||
}
|
||
batch.Rejected = int(n)
|
||
batch.Truncated = batch.Total > batch.Rejected
|
||
return batch, nil
|
||
}
|
||
|
||
// countTidyCandidatesByAction 数某动作在某状态下的候选条数(真总数,不受 LIMIT 影响)。
|
||
func (s *Store) countTidyCandidatesByAction(ctx context.Context, action TidyAction, status string) (int, error) {
|
||
if strings.TrimSpace(status) == "" {
|
||
status = tidyCandidatePending
|
||
}
|
||
var total int
|
||
if err := s.db.QueryRowContext(ctx, countTidyCandidatesByActionSQL, status, string(action)).
|
||
Scan(&total); err != nil {
|
||
return 0, fmt.Errorf("memory/tidy: count candidates of action %s: %w", action, err)
|
||
}
|
||
return total, nil
|
||
}
|