feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人实测: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:
AskaEth committed 2026-10-06 22:39:35 +08:00
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,
}
}
+12
View File
@@ -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)))