feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。 根因两条(都在这片里修掉): ① **以前根本没有执行面** —— 只有只读试算,候选永远 pending ② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完 - 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并** (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计 (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道) - 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run), 「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死** - 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时) - 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column) - 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false) ⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行) ⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
This commit is contained in:
1 parent
57d0e8c543
commit
454ddb0b70
18 files changed
+3181
-71
No files matched your search
@@ -0,0 +1,336 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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"
|
||||
// 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,
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理应用路径的**线上契约形状**(DTO 与投影;切片 TIDY-1)。
|
||||
// 端点与鉴权/审计在 admin_memory_tidy_apply.go,手动真跑在 admin_memory_tidy_run.go。
|
||||
// 拆开只为守 AGENTS §10 的文件行数上限(≤500 行),不改变任何口径。
|
||||
|
||||
// AdminTidyCandidate 是一条落库候选的线上投影(契约里新增的读侧形状)。
|
||||
// 与试算候选(AdminTidyDryRunCandidate)的区别:这里**有 id / status / runId /
|
||||
// createdAt** —— 它们是「拿这条去应用/回滚」所必需的键。
|
||||
type AdminTidyCandidate struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
SourceIDs []string `json:"sourceIds"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
PersonaID string `json:"personaId"`
|
||||
Scope string `json:"scope"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ProposedContent string `json:"proposedContent,omitempty"`
|
||||
MergedContent string `json:"mergedContent,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
ScoreMin float64 `json:"scoreMin"`
|
||||
ScoreMax float64 `json:"scoreMax"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Evidence memory.TidyEvidence `json:"evidence"`
|
||||
}
|
||||
|
||||
// AdminTidyCandidatesResponse 是候选列表的线上结构。`candidates` 恒为非 null 数组。
|
||||
type AdminTidyCandidatesResponse struct {
|
||||
Candidates []AdminTidyCandidate `json:"candidates"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// AdminTidyApplyResult 是一条候选的应用/回滚回执(领域结构直接投影:它是
|
||||
// 纯数据、无隐私之外的派生逻辑)。
|
||||
type AdminTidyApplyResult struct {
|
||||
CandidateID string `json:"candidateId"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
AffectedIDs []string `json:"affectedIds"`
|
||||
}
|
||||
|
||||
// AdminTidyApplyBatchResponse 是批量应用回执。`total` 是命中筛选的**真**待定候选数,
|
||||
// `applied+skipped+failed` 只覆盖 `limit` 之内处理过的那些 —— 两者不相等时
|
||||
// `truncated=true`(超限绝不静默)。
|
||||
type AdminTidyApplyBatchResponse struct {
|
||||
RunID string `json:"runId,omitempty"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Applied int `json:"applied"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Merged int `json:"merged"`
|
||||
Archived int `json:"archived"`
|
||||
Reclassified int `json:"reclassified"`
|
||||
Results []AdminTidyApplyResult `json:"results"`
|
||||
}
|
||||
|
||||
// AdminTidyRunResponse 是手动**真跑**一轮的回执(契约里新增的写面形状):
|
||||
// 「新增 / 合并 / 软删 各多少 + 耗时」就是 `candidatesCreated` /
|
||||
// `candidatesByAction{merge|archive}` / `durationMs`。
|
||||
//
|
||||
// 注意它是**建议侧**计数:真跑只产候选、写运行留痕,**不自动应用**(design §7 的
|
||||
// 人工在环)。要落地改动,接着调 candidates 的 apply / 批量 apply。
|
||||
type AdminTidyRunResponse struct {
|
||||
RunID string `json:"runId"`
|
||||
Trigger string `json:"trigger"`
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Partitions int `json:"partitions"`
|
||||
Scanned int `json:"scanned"`
|
||||
Compared int `json:"compared"`
|
||||
Pairs int `json:"pairs"`
|
||||
Vetoed int `json:"vetoed"`
|
||||
// Errors 是本轮的失败计数(分区失败 + 候选写入失败):非 0 时面板要如实显示。
|
||||
Errors int `json:"errors"`
|
||||
// Truncated 列出被护栏(扫描/比对/候选上限或时间预算)截断的分区标签。
|
||||
Truncated []string `json:"truncated,omitempty"`
|
||||
// VetoReasons 是本轮否决原因分布。
|
||||
VetoReasons map[string]int `json:"vetoReasons"`
|
||||
// CandidatesCreated 是本次**新建**的候选数;CandidatesDuplicate 是被幂等键
|
||||
// 挡下(同一批数据重复跑)的数量。
|
||||
CandidatesCreated int `json:"candidatesCreated"`
|
||||
CandidatesDuplicate int `json:"candidatesDuplicate"`
|
||||
// CandidatesByAction 是新建候选按动作的分布(merge / link / archive / reclassify)。
|
||||
CandidatesByAction map[string]int `json:"candidatesByAction"`
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理**应用路径 + 手动真跑**的管理面(切片 TIDY-1)。五条判据:
|
||||
//
|
||||
// ① 候选列表:投影带 id/status/runId/createdAt(应用所需的键)、`candidates`
|
||||
// 恒为非 null 数组、limit 默认/钳位/非法值三态;
|
||||
// ② 单条应用:applied/skipped 都 200(幂等不是错误)、候选不存在 ⇒ 404、
|
||||
// 存储报错 ⇒ 500,**成功与失败都落审计** `memory.tidy.apply`;
|
||||
// ③ 批量应用:runId/limit 如实透传、超限回执带 truncated+total、未知 runId ⇒
|
||||
// 404 `tidy_run_not_found`;
|
||||
// ④ 回滚:200 + 落审计 `memory.tidy.revert`;
|
||||
// ⑤ 手动真跑:回执含「新增/合并/软删 各多少 + 耗时」;未接线真跑 ⇒ 500
|
||||
// `tidy_run_unavailable`(只读试算の替身不得谎报跑过);关闭 ⇒ 409 `tidy_disabled`;
|
||||
// ⑥ 门:匿名 401 / viewer 403 / owner|admin 200。
|
||||
|
||||
// fakeTidyApply 是整理候选写面的替身:既满足 Deps.Memories(MemoryStore),
|
||||
// 又实现管理面要的窄视图(tidyApplyStore)。
|
||||
type fakeTidyApply struct {
|
||||
cands []memory.TidyCandidate
|
||||
listErr error
|
||||
listStatus string
|
||||
listLimit int
|
||||
|
||||
applyResult memory.TidyApplyResult
|
||||
applyErr error
|
||||
applyID string
|
||||
applyOpts memory.TidyApplyOptions
|
||||
|
||||
batchResult memory.TidyApplyBatch
|
||||
batchErr error
|
||||
batchRunID string
|
||||
batchOpts memory.TidyApplyOptions
|
||||
|
||||
revertResult memory.TidyApplyResult
|
||||
revertErr error
|
||||
revertID string
|
||||
revertOpts memory.TidyApplyOptions
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) List(context.Context) ([]memory.MemoryItem, error) {
|
||||
return []memory.MemoryItem{{ID: "m1", Scope: memory.ScopeUser, Content: "一条记忆"}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) ListTidyCandidates(_ context.Context, status string, limit int) ([]memory.TidyCandidate, error) {
|
||||
f.listStatus, f.listLimit = status, limit
|
||||
return f.cands, f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) ApplyTidyCandidate(_ context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error) {
|
||||
f.applyID, f.applyOpts = id, opts
|
||||
return f.applyResult, f.applyErr
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) ApplyTidyCandidates(_ context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error) {
|
||||
f.batchRunID, f.batchOpts = runID, opts
|
||||
return f.batchResult, f.batchErr
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) RevertTidyCandidate(_ context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error) {
|
||||
f.revertID, f.revertOpts = id, opts
|
||||
return f.revertResult, f.revertErr
|
||||
}
|
||||
|
||||
// fakeTidyRunner 同时实现试算(MemoryTidyDryRunner)与真跑(MemoryTidyRunner),
|
||||
// 使「真跑入口」这条断言能在不碰真实 store 的情况下验投影与错误映射。
|
||||
type fakeTidyRunner struct {
|
||||
stats memory.TidyStats
|
||||
err error
|
||||
calls int
|
||||
dryRuns int
|
||||
}
|
||||
|
||||
func (f *fakeTidyRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
|
||||
f.dryRuns++
|
||||
return memory.TidyDryRun{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTidyRunner) StartDryRun(time.Duration) (memory.TidyDryRunHandle, error) {
|
||||
return memory.TidyDryRunHandle{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTidyRunner) DryRunResult(string) (memory.TidyAsyncDryRun, error) {
|
||||
return memory.TidyAsyncDryRun{}, memory.ErrTidyRunNotFound
|
||||
}
|
||||
|
||||
func (f *fakeTidyRunner) ManualRun(context.Context) (memory.TidyStats, error) {
|
||||
f.calls++
|
||||
return f.stats, f.err
|
||||
}
|
||||
|
||||
// dryRunOnlyRunner 是**只做试算**的替身:真跑入口必须如实回「未接线」,
|
||||
// 不许因为试算可用就假装跑过。
|
||||
type dryRunOnlyRunner struct{}
|
||||
|
||||
func (dryRunOnlyRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
|
||||
return memory.TidyDryRun{}, nil
|
||||
}
|
||||
|
||||
func (dryRunOnlyRunner) StartDryRun(time.Duration) (memory.TidyDryRunHandle, error) {
|
||||
return memory.TidyDryRunHandle{}, nil
|
||||
}
|
||||
|
||||
func (dryRunOnlyRunner) DryRunResult(string) (memory.TidyAsyncDryRun, error) {
|
||||
return memory.TidyAsyncDryRun{}, memory.ErrTidyRunNotFound
|
||||
}
|
||||
|
||||
// fixtureTidyApplyCandidate 是一条落库候选(merge:把 mem-a 软并入 mem-b)。
|
||||
func fixtureTidyApplyCandidate() memory.TidyCandidate {
|
||||
return memory.TidyCandidate{
|
||||
ID: "cand-1",
|
||||
PersonaID: "cyrene",
|
||||
Scope: memory.ScopePersona,
|
||||
Kind: memory.KindRelationship,
|
||||
Action: memory.TidyMerge,
|
||||
SourceIDs: []string{"mem-a"},
|
||||
TargetID: "mem-b",
|
||||
RunID: "run-1",
|
||||
ProposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||
MergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
Reason: "fact_subset",
|
||||
RuleVersion: memory.TidyRuleVersion,
|
||||
ScoreMin: 0.95,
|
||||
ScoreMax: 0.95,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Date(2026, 10, 7, 23, 5, 0, 0, time.UTC),
|
||||
Evidence: memory.TidyEvidence{
|
||||
Similarity: 0.95, RecallMode: "embedding", Containment: "a_subset_b",
|
||||
ContentA: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||
ContentB: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidatesListProjection 钉住①。
|
||||
func TestAdminTidyCandidatesListProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeTidyApply{cands: []memory.TidyCandidate{fixtureTidyApplyCandidate()}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/candidates", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if fake.listStatus != "" || fake.listLimit != memoryReviewDefaultLimit {
|
||||
t.Fatalf("透传 status/limit = %q/%d, want \"\"/%d", fake.listStatus, fake.listLimit, memoryReviewDefaultLimit)
|
||||
}
|
||||
var payload struct {
|
||||
Candidates []map[string]json.RawMessage `json:"candidates"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
|
||||
t.Fatalf("解析 data: %v (body=%s)", err, body)
|
||||
}
|
||||
if payload.Count != 1 || len(payload.Candidates) != 1 {
|
||||
t.Fatalf("count/candidates = %d/%d, want 1/1", payload.Count, len(payload.Candidates))
|
||||
}
|
||||
want := map[string]string{
|
||||
"id": `"cand-1"`,
|
||||
"action": `"merge"`,
|
||||
"status": `"pending"`,
|
||||
"targetId": `"mem-b"`,
|
||||
"personaId": `"cyrene"`,
|
||||
"scope": `"persona"`,
|
||||
"reason": `"fact_subset"`,
|
||||
"ruleVersion": `"` + memory.TidyRuleVersion + `"`,
|
||||
"runId": `"run-1"`,
|
||||
"createdAt": `"2026-10-07T23:05:00Z"`,
|
||||
"scoreMin": `0.95`,
|
||||
"mergedContent": `"主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"`,
|
||||
}
|
||||
for key, val := range want {
|
||||
raw, ok := payload.Candidates[0][key]
|
||||
if !ok {
|
||||
t.Fatalf("缺键 %q(row=%v)", key, payload.Candidates[0])
|
||||
}
|
||||
if string(raw) != val {
|
||||
t.Fatalf("%s = %s, want %s", key, raw, val)
|
||||
}
|
||||
}
|
||||
if string(payload.Candidates[0]["sourceIds"]) != `["mem-a"]` {
|
||||
t.Fatalf("sourceIds = %s, want [\"mem-a\"]", payload.Candidates[0]["sourceIds"])
|
||||
}
|
||||
|
||||
// 空候选 ⇒ 200 + 非 null 空数组(与「未接线」必须可区分)。
|
||||
empty := &fakeTidyApply{}
|
||||
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{Memories: empty})
|
||||
resp, body = doReq(t, http.MethodGet, ts2.URL+"/api/v1/admin/memories/candidates", nil, signAny(t, jwt2, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("空候选 status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var emptyPayload struct {
|
||||
Candidates []map[string]json.RawMessage `json:"candidates"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &emptyPayload); err != nil {
|
||||
t.Fatalf("解析空 data: %v", err)
|
||||
}
|
||||
if emptyPayload.Candidates == nil {
|
||||
t.Fatal("空候选必须是 [],不能是 null")
|
||||
}
|
||||
|
||||
// 非法 limit ⇒ 400。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/candidates?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)
|
||||
}
|
||||
// 未接线(存储没有候选视图)⇒ 500 tidy_unavailable,绝不回空数组。
|
||||
_, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
|
||||
resp, body = doReq(t, http.MethodGet, bare.URL+"/api/v1/admin/memories/candidates", nil, signAny(t, bareJWT, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
|
||||
t.Fatalf("未接线列表 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidateApplyOutcomesAndAudit 钉住②。
|
||||
func TestAdminTidyCandidateApplyOutcomesAndAudit(t *testing.T) {
|
||||
t.Parallel()
|
||||
audit := &recordingAudit{}
|
||||
fake := &fakeTidyApply{applyResult: memory.TidyApplyResult{
|
||||
CandidateID: "cand-1", Action: memory.TidyMerge, Status: memory.TidyApplyApplied,
|
||||
TargetID: "mem-b", AffectedIDs: []string{"mem-a"},
|
||||
}}
|
||||
_, 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/cand-1/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("apply status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if fake.applyID != "cand-1" || fake.applyOpts.Actor != "acct:owner" {
|
||||
t.Fatalf("透传 id/Actor = %q/%q, want cand-1/acct:owner", fake.applyID, fake.applyOpts.Actor)
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
|
||||
t.Fatalf("解析 data: %v", err)
|
||||
}
|
||||
if string(payload["status"]) != `"applied"` || string(payload["affectedIds"]) != `["mem-a"]` {
|
||||
t.Fatalf("回执 = %s, want status=applied affectedIds=[mem-a]", decodeBody(t, body)["data"])
|
||||
}
|
||||
if len(audit.entries) != 1 || audit.entries[0].What != tidyApplyAction || !strings.HasPrefix(audit.entries[0].Result, "ok") {
|
||||
t.Fatalf("审计 = %+v, want 一条 %s/ok", audit.entries, tidyApplyAction)
|
||||
}
|
||||
// 审计不得带记忆正文。
|
||||
if strings.Contains(audit.entries[0].Result, "熬夜") || strings.Contains(audit.entries[0].Result, "mem-a") {
|
||||
t.Fatalf("审计 result 带了正文/行 id:%q", audit.entries[0].Result)
|
||||
}
|
||||
|
||||
// skipped(幂等命中)也是 200:它不是错误。
|
||||
fake.applyResult = memory.TidyApplyResult{
|
||||
CandidateID: "cand-1", Action: memory.TidyMerge, Status: memory.TidyApplySkipped,
|
||||
Reason: "already_decided", AffectedIDs: []string{},
|
||||
}
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("skipped ⇒ %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(decodeBody(t, body)["data"]), `"reason":"already_decided"`) {
|
||||
t.Fatalf("skipped 回执缺 reason:%s", decodeBody(t, body)["data"])
|
||||
}
|
||||
|
||||
// 候选不存在 ⇒ 404 not_found,且**失败也落审计**。
|
||||
fake.applyResult = memory.TidyApplyResult{}
|
||||
fake.applyErr = memory.ErrTidyCandidateNotFound
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/nope/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusNotFound || mediaErrorCode(t, body) != "not_found" {
|
||||
t.Fatalf("不存在的候选 ⇒ %d/%s, want 404/not_found", resp.StatusCode, body)
|
||||
}
|
||||
if len(audit.entries) != 3 || audit.entries[2].Result != "error" {
|
||||
t.Fatalf("失败后的审计 = %+v, want 一条 error", audit.entries)
|
||||
}
|
||||
|
||||
// 存储报错 ⇒ 500(不谎报成功)。
|
||||
fake.applyErr = errors.New("db exploded")
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != "tidy_apply_failed" {
|
||||
t.Fatalf("存储报错 ⇒ %d/%s, want 500/tidy_apply_failed", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidatesApplyBatch 钉住③。
|
||||
func TestAdminTidyCandidatesApplyBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
audit := &recordingAudit{}
|
||||
fake := &fakeTidyApply{batchResult: memory.TidyApplyBatch{
|
||||
RunID: "run-1", Limit: 2, Total: 5, Truncated: true,
|
||||
Applied: 2, Archived: 2,
|
||||
Results: []memory.TidyApplyResult{{
|
||||
CandidateID: "c1", Action: memory.TidyArchive, Status: memory.TidyApplyApplied,
|
||||
AffectedIDs: []string{"m1"},
|
||||
}},
|
||||
}}
|
||||
_, 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/apply?runId=run-1&limit=2", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("batch status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if fake.batchRunID != "run-1" || fake.batchOpts.Limit != 2 || fake.batchOpts.Actor != "acct:owner" {
|
||||
t.Fatalf("透传 runId/limit/Actor = %q/%d/%q", fake.batchRunID, fake.batchOpts.Limit, fake.batchOpts.Actor)
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
|
||||
t.Fatalf("解析 data: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"runId": `"run-1"`, "limit": `2`, "total": `5`, "truncated": `true`,
|
||||
"applied": `2`, "skipped": `0`, "archived": `2`, "merged": `0`,
|
||||
} {
|
||||
if string(payload[key]) != want {
|
||||
t.Fatalf("%s = %s, want %s", key, payload[key], want)
|
||||
}
|
||||
}
|
||||
if len(audit.entries) != 1 || audit.entries[0].What != tidyApplyAction || !strings.HasPrefix(audit.entries[0].Result, "ok") {
|
||||
t.Fatalf("批量审计 = %+v, want 一条 %s/ok", audit.entries, tidyApplyAction)
|
||||
}
|
||||
|
||||
// 省略 runId ⇒ 「全部待定应用」这一档(runId 透传空串)。
|
||||
if _, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply", nil, tok); fake.batchRunID != "" {
|
||||
t.Fatalf("省略 runId 时透传 = %q, want 空串(全部待定)(body=%s)", fake.batchRunID, body)
|
||||
}
|
||||
// 未知 runId ⇒ 404 tidy_run_not_found(不是「这一轮没有候选」)。
|
||||
fake.batchErr = memory.ErrTidyRunRecordNotFound
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply?runId=nope", nil, tok)
|
||||
fake.batchErr = nil
|
||||
if resp.StatusCode != http.StatusNotFound || mediaErrorCode(t, body) != "tidy_run_not_found" {
|
||||
t.Fatalf("未知 runId ⇒ %d/%s, want 404/tidy_run_not_found", resp.StatusCode, body)
|
||||
}
|
||||
// 非法 limit ⇒ 400。
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply?limit=-3", nil, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
|
||||
t.Fatalf("limit=-3 ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidateRevertOutcomes 钉住④。
|
||||
func TestAdminTidyCandidateRevertOutcomes(t *testing.T) {
|
||||
t.Parallel()
|
||||
audit := &recordingAudit{}
|
||||
fake := &fakeTidyApply{revertResult: memory.TidyApplyResult{
|
||||
CandidateID: "cand-1", Action: memory.TidyMerge, Status: memory.TidyApplyApplied,
|
||||
AffectedIDs: []string{"mem-a"},
|
||||
}}
|
||||
_, 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/cand-1/revert", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("revert status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if fake.revertID != "cand-1" {
|
||||
t.Fatalf("透传 id = %q, want cand-1", fake.revertID)
|
||||
}
|
||||
if len(audit.entries) != 1 || audit.entries[0].What != tidyRevertAction || !strings.HasPrefix(audit.entries[0].Result, "ok") {
|
||||
t.Fatalf("审计 = %+v, want 一条 %s/ok", audit.entries, tidyRevertAction)
|
||||
}
|
||||
// 未接线 ⇒ 500 tidy_unavailable。
|
||||
_, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
|
||||
resp, body = doReq(t, http.MethodPost, bare.URL+"/api/v1/admin/memories/candidates/cand-1/revert", nil,
|
||||
signAny(t, bareJWT, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
|
||||
t.Fatalf("未接线 revert ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyRunReceipt 钉住⑤:真跑回执含「新增/合并/软删 + 耗时」。
|
||||
func TestAdminMemoryTidyRunReceipt(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &fakeTidyRunner{stats: memory.TidyStats{
|
||||
RunID: "run-9", Partitions: 4, Scanned: 1191, Compared: 1191, Pairs: 77,
|
||||
Candidates: 77, Duplicates: 3, Vetoed: 12, DurationMS: 89900,
|
||||
VetoReasons: map[string]int{"temporal": 7, "scope": 5},
|
||||
ByAction: map[string]int{"merge": 30, "link": 40, "archive": 5, "reclassify": 2},
|
||||
Truncated: []string{"cyrene/persona/(compare)"},
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: runner})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/run", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tidy run status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("真跑调用次数 = %d, want 1", runner.calls)
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
|
||||
t.Fatalf("解析 data: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"runId": `"run-9"`, "trigger": `"manual"`, "durationMs": `89900`,
|
||||
"scanned": `1191`, "candidatesCreated": `77`, "candidatesDuplicate": `3`,
|
||||
"errors": `0`, "partitions": `4`,
|
||||
} {
|
||||
if string(payload[key]) != want {
|
||||
t.Fatalf("%s = %s, want %s", key, payload[key], want)
|
||||
}
|
||||
}
|
||||
if got := string(payload["candidatesByAction"]); got != `{"archive":5,"link":40,"merge":30,"reclassify":2}` {
|
||||
t.Fatalf("candidatesByAction = %s", got)
|
||||
}
|
||||
if got := string(payload["truncated"]); got != `["cyrene/persona/(compare)"]` {
|
||||
t.Fatalf("truncated = %s(截断必须如实上线)", got)
|
||||
}
|
||||
|
||||
// 关闭 ⇒ 409 tidy_disabled(显式选择,不是故障)。
|
||||
runner.err = memory.ErrTidyDisabled
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/run", nil, tok)
|
||||
runner.err = nil
|
||||
if resp.StatusCode != http.StatusConflict || mediaErrorCode(t, body) != "tidy_disabled" {
|
||||
t.Fatalf("关闭时 ⇒ %d/%s, want 409/tidy_disabled", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 只做试算的替身(未实现真跑)⇒ 500 tidy_run_unavailable,绝不假装跑过。
|
||||
_, only, onlyJWT := buildEnv(t, cfgDefault(), Deps{MemoryTidy: dryRunOnlyRunner{}})
|
||||
resp, body = doReq(t, http.MethodPost, only.URL+"/api/v1/admin/memories/tidy/run", nil,
|
||||
signAny(t, onlyJWT, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != "tidy_run_unavailable" {
|
||||
t.Fatalf("真跑未接线 ⇒ %d/%s, want 500/tidy_run_unavailable", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 整理服务完全未接线 ⇒ 500 tidy_unavailable。
|
||||
_, none, noneJWT := buildEnv(t, cfgDefault(), Deps{})
|
||||
resp, body = doReq(t, http.MethodPost, none.URL+"/api/v1/admin/memories/tidy/run", nil,
|
||||
signAny(t, noneJWT, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
|
||||
t.Fatalf("未接线 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyApplyRBAC 钉住⑥:门与同族管理面一致(匿名 401 / viewer 403 /
|
||||
// owner|admin 200)。
|
||||
func TestAdminTidyApplyRBAC(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeTidyApply{}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake})
|
||||
paths := []struct{ method, path string }{
|
||||
{http.MethodGet, "/api/v1/admin/memories/candidates"},
|
||||
{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"},
|
||||
}
|
||||
for _, p := range paths {
|
||||
if resp, _ := doReq(t, p.method, ts.URL+p.path, nil, ""); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s 匿名 status = %d, want 401", p.method, p.path, resp.StatusCode)
|
||||
}
|
||||
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
|
||||
if resp, _ := doReq(t, p.method, ts.URL+p.path, nil, viewer); resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("%s %s viewer status = %d, want 403", p.method, p.path, resp.StatusCode)
|
||||
}
|
||||
admin := signAny(t, jwt, "acct", auth.RoleAdmin)
|
||||
if resp, body := doReq(t, p.method, ts.URL+p.path, nil, admin); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s %s admin status = %d, want 200 (body=%s)", p.method, p.path, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// TestAdminTidyApplyEndToEndWithRealStore 是**该切片的端到端验收**:真
|
||||
// *memory.Store + 真 memory.TidyService + 真 HTTP 入口,走完整条主人的使用路径 ——
|
||||
//
|
||||
// 手动真跑 → 候选落库 → 列候选 → 单条应用(软合并)→ 再按 run 批量应用 → 回滚。
|
||||
//
|
||||
// 断言全部落在**真库**上(行数不变、状态列、merge_count),不是替身转述。
|
||||
func TestAdminTidyApplyEndToEndWithRealStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
store, err := memory.NewStore(ctx, conn, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("memory.NewStore: %v", err)
|
||||
}
|
||||
// 两条同分区记忆:短句是长句的事实子集 ⇒ 规则判 merge(design/23 §12.1 P1)。
|
||||
const (
|
||||
shortText = "主人常在深夜与助手聊天,有熬夜习惯"
|
||||
longText = "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"
|
||||
)
|
||||
short, err := store.Add(ctx, memory.MemoryItem{
|
||||
PersonaID: "cyrene", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
||||
Content: shortText, Confidence: 0.9,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed short: %v", err)
|
||||
}
|
||||
long, err := store.Add(ctx, memory.MemoryItem{
|
||||
PersonaID: "cyrene", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
||||
Content: longText, Confidence: 0.9,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed long: %v", err)
|
||||
}
|
||||
|
||||
// 无 embedder ⇒ 词项余弦召回(生产环境不可用时的降级路径,正好一并走一遍)。
|
||||
svc, err := memory.NewTidyService(memory.TidyConfig{
|
||||
Enabled: true, RecallThreshold: 0.8, ScanLimit: 200,
|
||||
}, memory.TidyDeps{Store: store})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidyService: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Memories: store, MemoryTidy: svc})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// ① 手动真跑:写候选 + 留痕,并回回执(新增/合并/软删/耗时)。
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/run", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tidy/run status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var run AdminTidyRunResponse
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &run); err != nil {
|
||||
t.Fatalf("解析真跑回执: %v (%s)", err, body)
|
||||
}
|
||||
if run.RunID == "" || run.Trigger != "manual" {
|
||||
t.Fatalf("真跑回执 = %+v, want 有 runId 且 trigger=manual", run)
|
||||
}
|
||||
if run.CandidatesByAction["merge"] != 1 {
|
||||
t.Fatalf("merge 候选 = %d, want 1(回执=%+v)", run.CandidatesByAction["merge"], run)
|
||||
}
|
||||
|
||||
// ② 列候选:拿 id(apply 的唯一钥匙)。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/candidates", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("candidates status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var list AdminTidyCandidatesResponse
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil {
|
||||
t.Fatalf("解析候选列表: %v", err)
|
||||
}
|
||||
var mergeID string
|
||||
for _, c := range list.Candidates {
|
||||
if c.Action == "merge" && c.TargetID == long.ID && c.RunID == run.RunID {
|
||||
mergeID = c.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if mergeID == "" {
|
||||
t.Fatalf("列表里没有指向超集的 merge 候选:%s", body)
|
||||
}
|
||||
|
||||
// ③ 单条应用:软合并(被吸收行 merged,存活行 merge_count+1,content 不变,行数不变)。
|
||||
rowsBefore := countRealMemoryRows(t, conn)
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/"+mergeID+"/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("apply status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var applied AdminTidyApplyResult
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &applied); err != nil {
|
||||
t.Fatalf("解析应用回执: %v", err)
|
||||
}
|
||||
if applied.Status != string(memory.TidyApplyApplied) || applied.AffectedIDs[0] != short.ID {
|
||||
t.Fatalf("应用回执 = %+v, want applied/affected=%s", applied, short.ID)
|
||||
}
|
||||
assertRealMemoryRow(t, conn, short.ID, "merged", long.ID, shortText)
|
||||
if got := countRealMemoryRows(t, conn); got != rowsBefore {
|
||||
t.Fatalf("软合并改变了行数:%d → %d", rowsBefore, got)
|
||||
}
|
||||
// 重复应用 ⇒ 幂等(skipped),且不产生第二次副作用。
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/"+mergeID+"/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("重复应用 status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var again AdminTidyApplyResult
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &again); err != nil {
|
||||
t.Fatalf("解析重复应用回执: %v", err)
|
||||
}
|
||||
if again.Status != "skipped" || again.Reason != "already_decided" {
|
||||
t.Fatalf("重复应用 = %+v, want skipped/already_decided", again)
|
||||
}
|
||||
assertRealMergeCount(t, conn, long.ID, 2)
|
||||
|
||||
// ④ 按 run 批量应用:已应用的候选被幂等挡下(skipped),不产生第二次副作用。
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply?runId="+run.RunID, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("批量应用 status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
assertRealMergeCount(t, conn, long.ID, 2)
|
||||
|
||||
// ⑤ 回滚:字段复原、行数不变。
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/"+mergeID+"/revert", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("revert status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
assertRealMemoryRow(t, conn, short.ID, "active", "", shortText)
|
||||
assertRealMergeCount(t, conn, long.ID, 1)
|
||||
if got := countRealMemoryRows(t, conn); got != rowsBefore {
|
||||
t.Fatalf("回滚改变了行数:%d → %d", rowsBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// countRealMemoryRows 数真库里的 memory_items 行数。
|
||||
func countRealMemoryRows(t *testing.T, conn *sql.DB) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM memory_items`).Scan(&n); err != nil {
|
||||
t.Fatalf("数行数: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// assertRealMemoryRow 断言一行记忆的 status/merged_into/content。
|
||||
func assertRealMemoryRow(t *testing.T, conn *sql.DB, id, wantStatus, wantMergedInto, wantContent string) {
|
||||
t.Helper()
|
||||
var status, mergedInto, content string
|
||||
if err := conn.QueryRowContext(context.Background(),
|
||||
`SELECT status, merged_into, content FROM memory_items WHERE id = ?`, id).
|
||||
Scan(&status, &mergedInto, &content); err != nil {
|
||||
t.Fatalf("读记忆行 %s: %v", id, err)
|
||||
}
|
||||
if status != wantStatus || mergedInto != wantMergedInto {
|
||||
t.Fatalf("行 %s = status=%q merged_into=%q, want %q/%q", id, status, mergedInto, wantStatus, wantMergedInto)
|
||||
}
|
||||
if content != wantContent {
|
||||
t.Fatalf("行 %s 的 content 被改了:%q → %q", id, wantContent, content)
|
||||
}
|
||||
}
|
||||
|
||||
// assertRealMergeCount 断言存活行的 merge_count。
|
||||
func assertRealMergeCount(t *testing.T, conn *sql.DB, id string, want int) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := conn.QueryRowContext(context.Background(),
|
||||
`SELECT merge_count FROM memory_items WHERE id = ?`, id).Scan(&got); err != nil {
|
||||
t.Fatalf("读记忆行 %s: %v", id, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("行 %s merge_count = %d, want %d", id, got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理**手动真跑**入口(切片 TIDY-1):
|
||||
// POST /api/v1/admin/memories/tidy/run —— 立刻按同一套规则跑一轮,写候选 + 写运行
|
||||
// 留痕,并把「新增/合并/软删各多少 + 耗时」如实回执。审计由领域侧 TidyService 记
|
||||
// `memory.tidy.run`(一次点击一条,本文件不重复)。
|
||||
|
||||
// adminMemoryTidyRun 处理 POST /api/v1/admin/memories/tidy/run:手动**真跑**一轮
|
||||
// (与心跳、试算走同一套规则与配置;写候选 + 运行留痕)。它是「panic button」:
|
||||
// 主人不必等到 23:00 就能让整理真的产出一批待定候选,再决定应用。
|
||||
//
|
||||
// 审计不在这里写 —— 领域侧 TidyService 已按 design §8 记 `memory.tidy.run`
|
||||
// (result 里带 trigger=manual / run_id / 计数)。一次点击一条审计。
|
||||
func (s *Server) adminMemoryTidyRun(w http.ResponseWriter, r *http.Request) {
|
||||
if s.memoryTidy == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理服务未接线"))
|
||||
return
|
||||
}
|
||||
runner, ok := s.memoryTidy.(MemoryTidyRunner)
|
||||
if !ok {
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "tidy_run_unavailable",
|
||||
Message: "记忆整理真跑入口未接线(只有只读试算可用)",
|
||||
HTTPCode: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), tidyRunTimeout)
|
||||
defer cancel()
|
||||
|
||||
stats, err := runner.ManualRun(ctx)
|
||||
if err != nil {
|
||||
writeError(w, tidyRunAppError(err))
|
||||
return
|
||||
}
|
||||
s.log.Info("memory tidy manual run finished",
|
||||
"run_id", stats.RunID, "scanned", stats.Scanned, "candidates", stats.Candidates,
|
||||
"duplicates", stats.Duplicates, "duration_ms", stats.DurationMS)
|
||||
writeJSON(w, http.StatusOK, adminTidyRunResponse(stats))
|
||||
}
|
||||
|
||||
// tidyRunAppError 把「真跑」失败映射成诚实的错误码。映射表与试算那张
|
||||
// (tidyDryRunAppError)**分开**:试算的 500/409 文案讲的是「没产出候选」,
|
||||
// 真跑讲的是「没跑成」—— 同一类别、不同事实,混用会让排障读到错的那句。
|
||||
func tidyRunAppError(err error) *apperr.AppError {
|
||||
switch {
|
||||
case errors.Is(err, memory.ErrTidyDisabled):
|
||||
return &apperr.AppError{
|
||||
Code: "tidy_disabled",
|
||||
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false)",
|
||||
HTTPCode: http.StatusConflict,
|
||||
}
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return &apperr.AppError{
|
||||
Code: "tidy_timeout",
|
||||
Message: "记忆整理真跑超时(本轮未完整跑完,已跑部分仍写入了留痕)",
|
||||
HTTPCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
default:
|
||||
return apperr.Wrap(apperr.ErrInternal, "tidy_run_failed", "记忆整理真跑失败")
|
||||
}
|
||||
}
|
||||
|
||||
// adminTidyRunResponse 投影真跑回执。
|
||||
func adminTidyRunResponse(stats memory.TidyStats) AdminTidyRunResponse {
|
||||
byAction := stats.ByAction
|
||||
if byAction == nil {
|
||||
byAction = map[string]int{}
|
||||
}
|
||||
reasons := stats.VetoReasons
|
||||
if reasons == nil {
|
||||
reasons = map[string]int{}
|
||||
}
|
||||
return AdminTidyRunResponse{
|
||||
RunID: stats.RunID,
|
||||
Trigger: "manual",
|
||||
RuleVersion: memory.TidyRuleVersion,
|
||||
DurationMs: stats.DurationMS,
|
||||
Partitions: stats.Partitions,
|
||||
Scanned: stats.Scanned,
|
||||
Compared: stats.Compared,
|
||||
Pairs: stats.Pairs,
|
||||
Vetoed: stats.Vetoed,
|
||||
Errors: stats.Errors,
|
||||
Truncated: stats.Truncated,
|
||||
VetoReasons: reasons,
|
||||
CandidatesCreated: stats.Candidates,
|
||||
CandidatesDuplicate: stats.Duplicates,
|
||||
CandidatesByAction: byAction,
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,18 @@ func (s *Server) registerAdminRoutes(
|
||||
// 它是**写**,因此落审计(动作名 memory.review.revert,见 admin_memory_review.go)。
|
||||
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)。五条端点补齐「整理真的能用」:此前只有
|
||||
// `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`(一次点击一条,不重复)。
|
||||
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)))
|
||||
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)))
|
||||
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
|
||||
|
||||
Reference in new issue
Block a user