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 ./... 全绿。
104 lines
3.8 KiB
Go
104 lines
3.8 KiB
Go
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,
|
||
}
|
||
}
|