feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
This commit is contained in:
AskaEth committed 2026-10-07 11:07:00 +08:00
1 parent 353293e807
commit 6d92536276
27 files changed
+2063 -197

No files matched your search

@@ -65,6 +65,7 @@ type tidyApplyStore interface {
ApplyTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
ApplyTidyCandidates(ctx context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error)
RevertTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
RejectTidyCandidatesByAction(ctx context.Context, action memory.TidyAction, opts memory.TidyApplyOptions) (memory.TidyRejectBatch, error)
}
const (
@@ -77,6 +78,9 @@ const (
// `memory.review.revert`(按**记忆 id**撤销一次软合并)**不同**:本条按**候选 id**
// 回滚,并把候选退回 pending,因此不复用。
tidyRevertAction = "memory.tidy.revert"
// tidyRejectAction 是候选**批量拒绝**的审计动作名(design/23 §8 的 `memory.tidy.*`
// 族)。它与「拒绝」在设计里的定位一致:`reject` 记入审计,用于后续调参与防重复提议。
tidyRejectAction = "memory.tidy.reject"
// tidyRunTimeout 是手动真跑的墙钟上限。真跑要写候选 + 留痕,比试算多几次写入;
// 上限存在的意义是「不让卡住的请求占着连接」,超时**如实报错**(503 tidy_timeout)。
tidyRunTimeout = 5 * time.Minute
@@ -70,8 +70,11 @@ type AdminTidyApplyBatchResponse struct {
// 「新增 / 合并 / 软删 各多少 + 耗时」就是 `candidatesCreated` /
// `candidatesByAction{merge|archive}` / `durationMs`。
//
// 注意它是**建议侧**计数:真跑只产候选、写运行留痕,**不自动应用**(design §7 的
// 人工在环)。要落地改动,接着调 candidates 的 apply / 批量 apply。
// ⚠️ 它是**建议侧**计数。2026-10-07 起真跑还会跑**轮末自动档**(规则层判为高置信的
// 候选直接落地,见 memory 包的 tidy_auto.go),但那份计数**不在本结构里** —— 它走
// 审计(`memory.tidy.apply` + `auto=true`)与日志,以及 `GET …/candidates?status=applied`
// 里 `decidedBy=system/memorytidy` 的行。要把自动档计数搬上本回执,需同时补
// `Docs/api-client.md` 的 B24 段(属文档侧改动)。
type AdminTidyRunResponse struct {
RunID string `json:"runId"`
Trigger string `json:"trigger"`
@@ -92,6 +95,18 @@ type AdminTidyRunResponse struct {
// 挡下(同一批数据重复跑)的数量。
CandidatesCreated int `json:"candidatesCreated"`
CandidatesDuplicate int `json:"candidatesDuplicate"`
// CandidatesByAction 是新建候选按动作的分布(merge / link / archive / reclassify)。
// CandidatesByAction 是新建候选按动作的分布(merge / archive / reclassify)。
CandidatesByAction map[string]int `json:"candidatesByAction"`
}
// AdminTidyRejectResponse 是「按动作批量拒绝候选」的回执(契约新增,2026-10-07)。
// `total` 是该动作下**此刻**的待定真总数,`rejected` 才是本次真正决定掉的条数 ——
// 两者不等时 `truncated=true`(超限绝不静默少做)。重复调用同一动作是幂等的:
// 第二次 `total`/`rejected` 都会如实变小,而不是报错。
type AdminTidyRejectResponse struct {
Action string `json:"action"`
Limit int `json:"limit"`
Total int `json:"total"`
Rejected int `json:"rejected"`
Truncated bool `json:"truncated"`
}
@@ -48,6 +48,11 @@ type fakeTidyApply struct {
revertErr error
revertID string
revertOpts memory.TidyApplyOptions
rejectResult memory.TidyRejectBatch
rejectErr error
rejectAction memory.TidyAction
rejectOpts memory.TidyApplyOptions
}
func (f *fakeTidyApply) List(context.Context) ([]memory.MemoryItem, error) {
@@ -74,6 +79,11 @@ func (f *fakeTidyApply) RevertTidyCandidate(_ context.Context, id string, opts m
return f.revertResult, f.revertErr
}
func (f *fakeTidyApply) RejectTidyCandidatesByAction(_ context.Context, action memory.TidyAction, opts memory.TidyApplyOptions) (memory.TidyRejectBatch, error) {
f.rejectAction, f.rejectOpts = action, opts
return f.rejectResult, f.rejectErr
}
// fakeTidyRunner 同时实现试算(MemoryTidyDryRunner)与真跑(MemoryTidyRunner),
// 使「真跑入口」这条断言能在不碰真实 store 的情况下验投影与错误映射。
type fakeTidyRunner struct {
@@ -453,6 +463,8 @@ func TestAdminTidyApplyRBAC(t *testing.T) {
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/apply"},
{http.MethodPost, "/api/v1/admin/memories/candidates/apply"},
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/revert"},
// 按动作批量拒绝(2026-10-07):门与同族同档(owner|admin)。
{http.MethodPost, "/api/v1/admin/memories/candidates/reject?action=link"},
}
for _, p := range paths {
if resp, _ := doReq(t, p.method, ts.URL+p.path, nil, ""); resp.StatusCode != http.StatusUnauthorized {
@@ -0,0 +1,90 @@
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,
}
}
@@ -0,0 +1,111 @@
package gateway
import (
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定整理候选**按动作批量拒绝**的管理面(admin_memory_tidy_reject.go;
// 2026-10-07 主人「那么多 link 是干什么的?该删就删」)。四条判据:
//
// ① 200 + 参数如实透传(action/limit)+ 回执含 total/rejected/truncated;
// ② 落审计 memory.tidy.reject(只带计数,不带候选正文);
// ③ 动作缺失 / 不在闭集内 ⇒ 400(拼错动作不能与「没有这类候选」混为一谈);
// ④ 未接线 ⇒ 500 tidy_unavailable,绝不回一份「已处理 0 条」的假成功。
// TestAdminTidyCandidatesRejectPassesThroughAndAudits 钉住 ①②。
func TestAdminTidyCandidatesRejectPassesThroughAndAudits(t *testing.T) {
t.Parallel()
audit := &recordingAudit{}
fake := &fakeTidyApply{rejectResult: memory.TidyRejectBatch{
Action: memory.TidyLink, Limit: 1000, Total: 501, Rejected: 501,
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/candidates/reject?action=link&limit=1000", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("reject status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if fake.rejectAction != memory.TidyLink || fake.rejectOpts.Limit != 1000 {
t.Fatalf("透传 action/limit = %q/%d, want link/1000", fake.rejectAction, fake.rejectOpts.Limit)
}
if fake.rejectOpts.Actor == "" {
t.Fatal("决定者标签必须落 decided_by(审计要能回答「谁批的」)")
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
t.Fatalf("解析 data: %v (body=%s)", err, body)
}
for key, want := range map[string]string{
"action": `"link"`, "limit": `1000`, "total": `501`, "rejected": `501`, "truncated": `false`,
} {
if got := string(payload[key]); got != want {
t.Fatalf("%s = %s, want %s", key, got, want)
}
}
if len(audit.entries) != 1 || audit.entries[0].What != tidyRejectAction {
t.Fatalf("审计 = %+v, want 一条 %s", audit.entries, tidyRejectAction)
}
res := audit.entries[0].Result
if !strings.HasPrefix(res, "ok") || !strings.Contains(res, "action=link") ||
!strings.Contains(res, "rejected=501") || !strings.Contains(res, "total=501") {
t.Fatalf("审计 result = %q, want ok + action=link + total/rejected 计数", res)
}
}
// TestAdminTidyCandidatesRejectRejectsBadAction 钉住 ③:动作缺失或不在闭集内 ⇒ 400。
func TestAdminTidyCandidatesRejectRejectsBadAction(t *testing.T) {
t.Parallel()
audit := &recordingAudit{}
fake := &fakeTidyApply{rejectErr: memory.ErrTidyUnknownAction}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/reject", nil, tok)
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
t.Fatalf("缺 action ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/candidates/reject?action=delete-everything", nil, tok)
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
t.Fatalf("未知 action ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/candidates/reject?action=link&limit=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
t.Fatalf("limit=abc ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
}
if len(audit.entries) != 3 {
t.Fatalf("三次失败都该留审计(result=error):%+v", audit.entries)
}
for _, e := range audit.entries {
if e.What != tidyRejectAction || e.Result != "error" {
t.Fatalf("审计 = %+v, want %s/error", e, tidyRejectAction)
}
}
}
// TestAdminTidyCandidatesRejectNotWired 钉住 ④:存储没实现候选视图 ⇒ 500,绝不假装
// 「已拒绝 0 条」。
func TestAdminTidyCandidatesRejectNotWired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/candidates/reject?action=link", nil, tok)
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
t.Fatalf("未接线 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
}
}
+7 -3
View File
@@ -244,16 +244,20 @@ func (s *Server) registerAdminRoutes(
mux.Handle("GET /api/v1/admin/memories/reviews", writeAuth(http.HandlerFunc(s.adminMemoryReviews)))
mux.Handle("POST /api/v1/admin/memories/{id}/revert", writeAuth(http.HandlerFunc(s.adminMemoryRevert)))
// 记忆整理的**应用路径 + 手动真跑**(切片 TIDY-1,design/23 §7/§8/§11;见
// admin_memory_tidy_apply.go)。五条端点补齐「整理真的能用」:此前只有
// admin_memory_tidy_apply.go)。六条端点补齐「整理真的能用」:此前只有
// `POST …/tidy/dry-run`(只读试算),候选落库后**没有任何执行面**,于是 77 条
// 全 pending、两天一条没应用。门与同族的 `GET /admin/memories` /
// `POST …/memories/{id}/revert` 同档(writeAuth = owner|admin):候选正文含隐私。
// 写动作落审计 `memory.tidy.apply` / `memory.tidy.revert`;`…/tidy/run` 的审计
// 由领域侧 TidyService 记 `memory.tidy.run`(一次点击一条,不重复)。
// 写动作落审计 `memory.tidy.apply` / `memory.tidy.revert` / `memory.tidy.reject`;
// `…/tidy/run` 的审计由领域侧 TidyService 记 `memory.tidy.run`(一次点击一条,不重复)。
mux.Handle("GET /api/v1/admin/memories/candidates", writeAuth(http.HandlerFunc(s.adminTidyCandidatesList)))
mux.Handle("POST /api/v1/admin/memories/candidates/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidatesApplyBatch)))
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidateApply)))
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/revert", writeAuth(http.HandlerFunc(s.adminTidyCandidateRevert)))
// 批量拒绝(按动作,2026-10-07):给**没有执行语义**的候选一条干净出路
// (活库积压 501 条 link,永远应用不了,只占待办)。它只改候选状态、不碰记忆,
// 幂等;审计 `memory.tidy.reject`。见 admin_memory_tidy_reject.go。
mux.Handle("POST /api/v1/admin/memories/candidates/reject", writeAuth(http.HandlerFunc(s.adminTidyCandidatesReject)))
mux.Handle("POST /api/v1/admin/memories/tidy/run", writeAuth(http.HandlerFunc(s.adminMemoryTidyRun)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))