Files
Cyrene_v2/internal/services/gateway/admin_memory_tidy_run.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

104 lines
3.8 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"
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,
DedupeGroups: stats.DedupeGroups,
DedupeRowsReducible: stats.DedupeRows,
DedupeTruncated: stats.DedupeTruncated,
}
}