Files
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

91 lines
3.9 KiB
Go
Raw Permalink 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 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,
}
}