Files
Cyrene_v2/internal/services/gateway/admin_memory_tidy_apply.go
T
AskaEth 6a3f3a329a feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)
M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:

1) 新增 `dedupe_group` 候选动作(不放大 containNone):
   - 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
     (后者附加词项余弦 ≥0.70 与同 kind 收窄);
   - 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
   - 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
     数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
     两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
   - 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
   - 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
     回滚复用 revertTidyMerge(逐条复原);
   - 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
   - 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。

2) 留存闸改组合口径(治根)+ N6/N7:
   - 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
     (数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
   - N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
     这类偏好不再因寒暄词被判临时);
   - 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
     硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
   - 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
     已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
     存量一条不补写(用例钉住)。

契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。

实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
  89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
  这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
  簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。

验收:go build ./... + go vet ./... + go test ./... 全绿。
2026-10-25 00:08:46 +08:00

343 lines
15 KiB
Go
Raw Blame History

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