Files
Cyrene_v2/internal/runtime/memory/tidy_reject.go
T
AskaEth 6d92536276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
2026-10-07 11:07:00 +08:00

124 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}