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 }