package gateway import ( "context" "errors" "net/http" "strconv" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" ) // 本文件承载记忆整理**应用路径**的管理面(切片 TIDY-1;design/23 §7/§8/§11)。 // 动因是主人的实测口径:整理只产候选、两天一条没应用(77 条全 pending),而现有 // `POST …/tidy/dry-run` 是**只读试算**,改不了任何东西。 // // 端点分工(DTO 在 admin_memory_tidy_apply_dto.go,手动真跑在 // admin_memory_tidy_run.go —— 拆开只为守 AGENTS §10 的文件行数上限): // // GET /api/v1/admin/memories/candidates 候选列表(含 id/runId/证据) // POST /api/v1/admin/memories/candidates/{id}/apply 按候选 id 应用(写记忆) // POST /api/v1/admin/memories/candidates/apply?runId=&limit= 批量应用(按 run / 全部待定) // POST /api/v1/admin/memories/candidates/{id}/revert 回滚一条已应用候选 // POST /api/v1/admin/memories/tidy/run 手动**真跑**一轮(写候选) // // 口径: // // 1. **门同族**:全部走 `writeAuth`(owner|admin),与同族的 `GET /admin/memories`、 // `POST …/tidy/dry-run`、`POST …/memories/{id}/revert` 同档 —— 候选正文含隐私, // design/23 §11 的既有取向是「含隐私正文的读侧不给 viewer」。⚠️ §11 表格把 // candidates 写回滚标为 owner-only:那只需把 routes.go 里对应行的 `writeAuth` // 换成 `ownerAuth`(一行一处,本文件不用改)。 // 2. **写动作落审计**:动作名 `memory.tidy.apply` / `memory.tidy.revert`(design/23 // §8 的 `memory.tidy.*` 族),who 来自请求身份;审计只带计数/动作,**不带记忆正文**。 // `POST …/tidy/run` 的审计由**领域侧**(TidyService 的 Audit 出口,design §8 的 // `memory.tidy.run`,result 里带 trigger=manual + run_id + 计数)写,本文件不重复记 // —— 一次手动点击只该有一条 memory.tidy.run。 // 3. **诚实失败**:未接线 ⇒ 500 `tidy_unavailable`;候选不存在 ⇒ 404; // runId 查不到 ⇒ 404 `tidy_run_not_found`;配置关闭 ⇒ 409 `tidy_disabled`; // 超时 ⇒ 503 `tidy_timeout`。**绝不**把「没做」回成一份空结果。 // 4. **不写第二套语义**:应用/回滚的真实动作全在 `memory.Store` 里(候选状态条件更新 // 是幂等闸),端点只做鉴权、取参、投影与审计。 // 5. **不加任何上限**:批量上限由领域侧(memory.DefaultTidyApplyBatchLimit 等代码 // 默认值)决定,端点只解析 `limit` 并交给领域侧钳位(`.env` 冻结,不新增键)。 // MemoryTidyRunner 是记忆整理「真跑」的注入出口(切片 TIDY-1)。组合根注入的 // 就是低峰心跳跑的那个 `*memory.TidyService`(与试算同一份配置、同一套规则)。 // // 它与 `MemoryTidyDryRunner` 分开两个接口是**刻意**的:试算只读、真跑写候选 + 留痕, // 两者可分别接线;未实现本接口的替身(例如只做试算的测试桩)得到的是诚实的 // 「真跑未接线」,而不是一条静默成功的假回执。 type MemoryTidyRunner interface { // ManualRun 立即跑一轮**真跑**(写候选 + 写运行留痕 + 审计)。配置关闭时 // 返回 memory.ErrTidyDisabled。 ManualRun(ctx context.Context) (memory.TidyStats, error) } // tidyApplyStore 是整理候选写面的窄视图,由 `*memory.Store` 实现。未实现它的 // `Deps.Memories` 让本组端点回 500 `tidy_unavailable`(「没接线」与「没有候选」 // 必须能分辨,与同族的 ingestReviewStore 同一手法)。 type tidyApplyStore interface { ListTidyCandidates(ctx context.Context, status string, limit int) ([]memory.TidyCandidate, error) 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 ( // tidyUnavailableCode 是「整理写面未接线」的线上错误码(与试算组的 // tidy_unavailable 同名同义:它们背后是同一件事)。 tidyUnavailableCode = "tidy_unavailable" // tidyApplyAction 是应用动作的审计动作名(design/23 §8)。 tidyApplyAction = "memory.tidy.apply" // tidyRevertAction 是候选回滚动作的审计动作名。它与 D3 的 // `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 ) // adminTidyCandidatesList 处理 GET /api/v1/admin/memories/candidates。 // 只读、不审计(与同族只读管理端点同一档)。 func (s *Server) adminTidyCandidatesList(w http.ResponseWriter, r *http.Request) { store := s.tidyCandidates() if store == nil { writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线")) return } // limit 复用同族读侧那一个解析器(默认 100 / 钳到 500 / 非法 400)—— // 本切片不复制第三份 limit 解析(AGENTS §10「三次复制必须抽取」,此处是第二次)。 limit, ok := parseMemoryReviewLimit(r) if !ok { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit")) return } cands, err := store.ListTidyCandidates(r.Context(), r.URL.Query().Get("status"), limit) if err != nil { s.log.Error("list tidy candidates", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_candidates_failed", "读取整理候选失败")) return } out := AdminTidyCandidatesResponse{ Candidates: make([]AdminTidyCandidate, 0, len(cands)), Count: len(cands), } for _, c := range cands { out.Candidates = append(out.Candidates, adminTidyCandidate(c)) } writeJSON(w, http.StatusOK, out) } // adminTidyCandidateApply 处理 POST /api/v1/admin/memories/candidates/{id}/apply: // 应用**一条**候选(唯一真正改记忆的粒度之一)。三种结果都如实作答: // 不存在 ⇒ 404;已决定/不可应用 ⇒ 200 + `status:skipped`(幂等不是错误); // 真的改了 ⇒ 200 + `status:applied` + `affectedIds`。 func (s *Server) adminTidyCandidateApply(w http.ResponseWriter, r *http.Request) { store := s.tidyCandidates() if store == nil { s.auditClaims(r, tidyApplyAction, "error") writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线")) return } id := r.PathValue("id") if id == "" { s.auditClaims(r, tidyApplyAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required")) return } res, err := store.ApplyTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)}) if err != nil { s.auditClaims(r, tidyApplyAction, "error") if errors.Is(err, memory.ErrTidyCandidateNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id)) return } s.log.Error("apply tidy candidate", "err", err, "candidate", id) writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "应用整理候选失败")) return } s.auditClaims(r, tidyApplyAction, tidyApplyAuditResult(res)) writeJSON(w, http.StatusOK, adminTidyApplyResult(res)) } // adminTidyCandidatesApplyBatch 处理 POST /api/v1/admin/memories/candidates/apply: // **批量**应用。`runId` 给出时只应用该轮产出的候选(按 run);省略时应用全部待定 // (「全部待定应用」这一档)。`limit` 省略 ⇒ 领域侧默认值。 // // 逐条结果都在 `results` 里(失败隔离),超限如实回报 `truncated` + `total`。 func (s *Server) adminTidyCandidatesApplyBatch(w http.ResponseWriter, r *http.Request) { store := s.tidyCandidates() if store == nil { s.auditClaims(r, tidyApplyAction, "error") writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线")) return } limit, ok := parseTidyApplyLimit(r) if !ok { s.auditClaims(r, tidyApplyAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit")) return } runID := r.URL.Query().Get("runId") batch, err := store.ApplyTidyCandidates(r.Context(), runID, memory.TidyApplyOptions{ Actor: s.tidyActor(r), Limit: limit, }) if err != nil { s.auditClaims(r, tidyApplyAction, "error") if errors.Is(err, memory.ErrTidyRunRecordNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "tidy_run_not_found", "整理运行 %q 不存在", runID)) return } s.log.Error("apply tidy candidates", "err", err, "run_id", runID) writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "批量应用整理候选失败")) return } s.auditClaims(r, tidyApplyAction, tidyBatchAuditResult(batch)) writeJSON(w, http.StatusOK, adminTidyApplyBatch(batch)) } // adminTidyCandidateRevert 处理 POST /api/v1/admin/memories/candidates/{id}/revert: // 回滚一条已应用候选(记忆行复原 + 候选退回 pending,行永不物理删除,design §8)。 func (s *Server) adminTidyCandidateRevert(w http.ResponseWriter, r *http.Request) { store := s.tidyCandidates() if store == nil { s.auditClaims(r, tidyRevertAction, "error") writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线")) return } id := r.PathValue("id") if id == "" { s.auditClaims(r, tidyRevertAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required")) return } res, err := store.RevertTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)}) if err != nil { s.auditClaims(r, tidyRevertAction, "error") if errors.Is(err, memory.ErrTidyCandidateNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id)) return } s.log.Error("revert tidy candidate", "err", err, "candidate", id) writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_revert_failed", "回滚整理候选失败")) return } s.auditClaims(r, tidyRevertAction, tidyApplyAuditResult(res)) writeJSON(w, http.StatusOK, adminTidyApplyResult(res)) } // tidyCandidates 返回当前接入的整理候选写面;`Memories` 未实现该视图时为 nil。 func (s *Server) tidyCandidates() tidyApplyStore { if s.memories == nil { return nil } store, ok := s.memories.(tidyApplyStore) if !ok { return nil } return store } // tidyActor 返回决定者的审计标签(落 memory_tidy_candidates.decided_by)。 // 与 auditClaimsAccount 的 who 同口径,使面板「谁批的」与审计链能对上。 func (s *Server) tidyActor(r *http.Request) string { if claims, ok := auth.ClaimsFromContext(r.Context()); ok { return claims.Sub + ":" + string(claims.Role) } return "anonymous" } // parseTidyApplyLimit 解析批量的 limit:缺省/空 ⇒ 0(交给领域侧默认值), // 非整数或负数 ⇒ ok=false(调用方回 400)。上限由**领域侧**钳位,端点不复制那个常量。 func parseTidyApplyLimit(r *http.Request) (int, bool) { raw := r.URL.Query().Get("limit") if raw == "" { return 0, true } v, err := strconv.Atoi(raw) if err != nil || v < 0 { return 0, false } return v, true } // adminTidyCandidate 投影一条落库候选。SourceIDs 兜成非 null 数组; // 时间为 UTC RFC3339(与同族管理 DTO 同一口径)。 func adminTidyCandidate(c memory.TidyCandidate) AdminTidyCandidate { ids := c.SourceIDs if ids == nil { ids = []string{} } return AdminTidyCandidate{ ID: c.ID, Action: string(c.Action), Status: c.Status, SourceIDs: ids, TargetID: c.TargetID, PersonaID: c.PersonaID, Scope: string(c.Scope), ProjectID: c.ProjectID, Kind: string(c.Kind), ProposedContent: c.ProposedContent, MergedContent: c.MergedContent, Reason: c.Reason, RuleVersion: c.RuleVersion, RunID: c.RunID, ScoreMin: c.ScoreMin, ScoreMax: c.ScoreMax, CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339), Evidence: c.Evidence, } } // adminTidyApplyResult 投影一条应用/回滚结果。 func adminTidyApplyResult(res memory.TidyApplyResult) AdminTidyApplyResult { ids := res.AffectedIDs if ids == nil { ids = []string{} } return AdminTidyApplyResult{ CandidateID: res.CandidateID, Action: string(res.Action), Status: string(res.Status), Reason: res.Reason, TargetID: res.TargetID, AffectedIDs: ids, } } // adminTidyApplyBatch 投影批量回执(results 恒为非 null 数组)。 func adminTidyApplyBatch(batch memory.TidyApplyBatch) AdminTidyApplyBatchResponse { out := AdminTidyApplyBatchResponse{ RunID: batch.RunID, Limit: batch.Limit, Total: batch.Total, Truncated: batch.Truncated, Applied: batch.Applied, Skipped: batch.Skipped, Failed: batch.Failed, Merged: batch.Merged, Archived: batch.Archived, Reclassified: batch.Reclassified, Deduped: batch.Deduped, RowsAffected: batch.RowsAffected, Results: make([]AdminTidyApplyResult, 0, len(batch.Results)), } for _, res := range batch.Results { out.Results = append(out.Results, adminTidyApplyResult(res)) } return out } // tidyApplyAuditResult 渲染单条应用/回滚的审计 result。它**以 ok 开头**(与 // 同族「result=ok/error」的既有口径一致,面板按前缀就能分桶),其后才挂动作与 // 结果类别、受影响行数 —— 绝不带记忆正文或候选正文(design §8)。 func tidyApplyAuditResult(res memory.TidyApplyResult) string { return "ok action=" + string(res.Action) + " status=" + string(res.Status) + " reason=" + res.Reason + " affected=" + strconv.Itoa(len(res.AffectedIDs)) } // tidyBatchAuditResult 渲染批量应用的审计 result(同样以 ok 开头):带真总数与 // 超限标记,因此审计能回答「这一轮到底批了多少、有没有被上限截住」。 func tidyBatchAuditResult(batch memory.TidyApplyBatch) string { return "ok run_id=" + batch.RunID + " limit=" + strconv.Itoa(batch.Limit) + " total=" + strconv.Itoa(batch.Total) + " truncated=" + strconv.FormatBool(batch.Truncated) + " applied=" + strconv.Itoa(batch.Applied) + " skipped=" + strconv.Itoa(batch.Skipped) + " failed=" + strconv.Itoa(batch.Failed) }