主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
91 lines
3.9 KiB
Go
91 lines
3.9 KiB
Go
package gateway
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
)
|
||
|
||
// 本文件承载整理候选的**批量拒绝**(按动作)管理面:
|
||
//
|
||
// POST /api/v1/admin/memories/candidates/reject?action=<merge|link|archive|reclassify>&limit=
|
||
//
|
||
// 动因(2026-10-07 主人:「那么多 link 是干什么的?该删就删」):活库里积压了 501 条
|
||
// `link` 候选,而 link **没有执行语义**(design/23 §14 把「link 关系如何存储与呈现」
|
||
// 列为待细化 ⇒ 无落点、无数据模型,详见 memory 包的 tidy_rules.go 与 tidy_apply.go 的
|
||
// applyable())—— 它们永远不会被应用,只会一直占着待办列表。规则层已改成不产它们,
|
||
// 存量则需要一条**批量**的出路:一条条点不现实(这正是主人这次投诉的形状)。
|
||
//
|
||
// 口径:
|
||
//
|
||
// 1. **它是「决定」不是「删除」**:只写候选表的状态列(`rejected`),**不碰
|
||
// memory_items 任何一行**。记忆的删除/归档语义仍只有两条路(archive 候选 + §8 的
|
||
// 软删除口径),候选拒绝与它无关。
|
||
// 2. **幂等**:只动 `status='pending'` 的行,重复调用第二次就是 0 条,不是错误。
|
||
// 3. **门同族**:走 `writeAuth`(owner|admin),与 `…/candidates/apply`、
|
||
// `…/candidates/{id}/revert` 同档。
|
||
// 4. **动作名必须合法**:不在 design/23 §10 的动作闭集里 ⇒ 400 `bad_request`,
|
||
// 而不是静默改 0 条(拼错动作与「没有这类候选」必须能分辨)。
|
||
// 5. **写审计**:动作名 `memory.tidy.reject`(design/23 §8 的动作名族),只带计数,
|
||
// 不带候选正文。
|
||
func (s *Server) adminTidyCandidatesReject(w http.ResponseWriter, r *http.Request) {
|
||
store := s.tidyCandidates()
|
||
if store == nil {
|
||
s.auditClaims(r, tidyRejectAction, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||
return
|
||
}
|
||
action := memory.TidyAction(r.URL.Query().Get("action"))
|
||
if action == "" {
|
||
s.auditClaims(r, tidyRejectAction, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "action is required"))
|
||
return
|
||
}
|
||
limit, ok := parseTidyApplyLimit(r)
|
||
if !ok {
|
||
s.auditClaims(r, tidyRejectAction, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
|
||
return
|
||
}
|
||
batch, err := store.RejectTidyCandidatesByAction(r.Context(), action, memory.TidyApplyOptions{
|
||
Actor: s.tidyActor(r),
|
||
Limit: limit,
|
||
})
|
||
if err != nil {
|
||
s.auditClaims(r, tidyRejectAction, "error")
|
||
if errors.Is(err, memory.ErrTidyUnknownAction) {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "unknown action %q", string(action)))
|
||
return
|
||
}
|
||
s.log.Error("reject tidy candidates", "err", err, "action", string(action))
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_reject_failed", "批量拒绝整理候选失败"))
|
||
return
|
||
}
|
||
s.auditClaims(r, tidyRejectAction, tidyRejectAuditResult(batch))
|
||
writeJSON(w, http.StatusOK, adminTidyRejectBatch(batch))
|
||
}
|
||
|
||
// tidyRejectAuditResult 渲染批量拒绝的审计 result(以 ok 开头,与同族一致):
|
||
// 带真总数与超限标记,因此审计能回答「这次到底决定掉多少、有没有被上限截住」。
|
||
func tidyRejectAuditResult(batch memory.TidyRejectBatch) string {
|
||
return "ok action=" + string(batch.Action) +
|
||
" limit=" + strconv.Itoa(batch.Limit) +
|
||
" total=" + strconv.Itoa(batch.Total) +
|
||
" rejected=" + strconv.Itoa(batch.Rejected) +
|
||
" truncated=" + strconv.FormatBool(batch.Truncated)
|
||
}
|
||
|
||
// adminTidyRejectBatch 投影批量拒绝回执。
|
||
func adminTidyRejectBatch(batch memory.TidyRejectBatch) AdminTidyRejectResponse {
|
||
return AdminTidyRejectResponse{
|
||
Action: string(batch.Action),
|
||
Limit: batch.Limit,
|
||
Total: batch.Total,
|
||
Rejected: batch.Rejected,
|
||
Truncated: batch.Truncated,
|
||
}
|
||
}
|