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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
Reference in new issue
Block a user