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

200 lines
10 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"
"net/http"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件承载记忆整理「试算(dry-run)」入口(契约 v0.75;切片 MT1)。
//
// 它回答的是主人的那句「**现在就想看到在 736 条上它会建议合并哪些**」:
// 整理器本身是低峰定时任务(design/23 §4,默认 23:00–07:00 才跑),在它
// 第一次开跑之前,库里既没有候选也没有留痕 —— 面板只能空着。本端点把同一套
// 规则**当场算一遍并把候选直接回给面板**。
//
// 四条不可走样的口径:
//
// 1. **零副作用**:跑的是 `memory.Tidier.DryRun`,它不写 memory_tidy_candidates、
// 不写 memory_tidy_runs、**绝不改 memory_items 的任何列**(单测逐行钉死)。
// 2. **不写第二套规则**:候选由既有 rule engine / tidier 代码路径产出,端点只做
// 线上投影;阈值/上限一律取既有配置(MEMORY_TIDY_RECALL_THRESHOLD /
// SCAN_LIMIT / CANDIDATE_LIMIT),端点自己**不引入任何上限**。
// 3. **诚实失败**:未接线 ⇒ `500 tidy_unavailable`;配置关闭 ⇒ `409 tidy_disabled`;
// 语义召回不可用 ⇒ `500 tidy_recall_unavailable`;超时 ⇒ `503 tidy_timeout`。
// **一律不回空候选数组假装「没有建议」** —— 那是把「没算出来」谎报成「算过了,没有」。
// 4. **只算不写 ⇒ 不审计**:它是只读计算(与 /admin/config-facts 同一档),
// 因此不写审计链,也不进 registerAdminWriteRoutes 那条 owner-only 写路径。
//
// 面板按下才跑,**不做自动轮询**:试算是一次真实规则计算(扫描 + 两两召回 +
// 包含判定),不是看板指标。
// tidyDryRunTimeout 是试算处理的墙钟上限。试算在既有护栏内是有界的
// (每分区扫描 ≤ SCAN_LIMIT、参与比对 ≤ maxTidyCompareItems),显式给一个上限
// 只为让卡住的请求(如 embedding 服务半死不活)不占着连接;超时**如实报错**。
const tidyDryRunTimeout = 3 * time.Minute
// MemoryTidyDryRunner 是记忆整理「试算」的注入出口(契约 v0.75;切片 MT1)。
// 组合根注入的就是低峰心跳跑的那个 `*memory.TidyService` —— 同一套规则、同一份
// 配置,因此试算给出的建议与今晚 23:00 那一轮是同一口径。
//
// 契约 v0.76(切片 MT-A)在同一出口上加了**加性**的两个方法(异步试算的启动与
// 查询):它们不改变 DryRun 的语义,只是把「跑很久的那一轮」挪到后台。
type MemoryTidyDryRunner interface {
// DryRun 跑一轮整理规则并返回候选,零副作用。
DryRun(ctx context.Context) (memory.TidyDryRun, error)
// StartDryRun 启动一次异步试算并立即返回句柄(契约 v0.76 §4.12.1)。
// timeout 是这次运行的墙钟上限(非正取实现侧的兜底值)。
StartDryRun(timeout time.Duration) (memory.TidyDryRunHandle, error)
// DryRunResult 查询一次异步试算的状态;runId 未知(含进程重启后)返回
// memory.ErrTidyRunNotFound。
DryRunResult(runID string) (memory.TidyAsyncDryRun, error)
}
// AdminTidyDryRunCandidate 是试算响应里的一条候选(契约 v0.75 §4.12)。
// 字段与 `memory_tidy_candidates` 的落库形状同构,但**没有 status / decided\***
// —— 试算不落库,因此不存在「已决定」这回事;ID 也不下发(它没有任何后续动作)。
//
// Evidence 复用领域类型(`memory.TidyEvidence`,带 JSON tag):面板的 diff 预览
// 需要 A/B 原文、包含证据与否决原因,字段就是按它设计的。
type AdminTidyDryRunCandidate struct {
Action string `json:"action"`
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"`
ScoreMin float64 `json:"scoreMin"`
ScoreMax float64 `json:"scoreMax"`
Evidence memory.TidyEvidence `json:"evidence"`
}
// AdminTidyDryRunResponse 是 POST /api/v1/admin/memories/tidy/dry-run 的线上结构
// (契约 v0.75 §4.12)。
//
// `dryRun` 恒为 true:这份响应**没有**改动任何记忆,客户端可据此把「仅试算」的
// 文案与结构绑定,而不是只靠一段本地硬编码说明。
type AdminTidyDryRunResponse struct {
DryRun bool `json:"dryRun"`
RecallMode string `json:"recallMode"`
DurationMs int64 `json:"durationMs"`
RuleVersion string `json:"ruleVersion"`
// 观测口径(与真跑一轮同一套计数):扫描分区数 / 扫描条数 / 参与比对条数 /
// 召回对数 / 被否决对数与分布。
Partitions int `json:"partitions"`
Scanned int `json:"scanned"`
Compared int `json:"compared"`
Pairs int `json:"pairs"`
Vetoed int `json:"vetoed"`
VetoReasons map[string]int `json:"vetoReasons"`
// Truncated 列出被护栏截断的分区标签(扫描/比对/候选上限),供运维放大配额。
// 它**不是**候选;缺席表示本轮没有被截断。
Truncated []string `json:"truncated,omitempty"`
// DedupeGroups / DedupeRowsReducible 是 `dedupe_group`(2026-10-24)的**试算口径**:
// 机械可判重复分成了多少簇、这些簇一共能减少多少 active 行。
// 它们是「先看数、后落库」这条要求的落点 —— 试算零写库,数字就是真跑会产出的。
DedupeGroups int `json:"dedupeGroups"`
DedupeRowsReducible int `json:"dedupeRowsReducible"`
// DedupeTruncated 是本轮被每分区候选上限丢掉的 dedupe 簇数(超限绝不静默)。
DedupeTruncated int `json:"dedupeTruncated"`
// Candidates 恒为非 null 数组:空数组 = 「算过了,这一轮没有建议」,与
// 「没算出来」(那种情况是错误响应)**必须能区分**。
Candidates []AdminTidyDryRunCandidate `json:"candidates"`
}
// adminMemoryTidyDryRun 是「试算整理建议」的处理函数。它只读语义、不审计
// (见文件头注释第 4 条),失败一律如实作答。
//
// 带 `?async=1` 时改走**加性异步**(契约 v0.76 §4.12.1,见
// admin_memory_tidy_async.go):立刻回 202 + runId,结果由 B15 轮询取回。
// 不带该参数时这条函数的行为**逐字不变** —— 老客户端(含现有面板)零影响。
func (s *Server) adminMemoryTidyDryRun(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get(tidyAsyncQueryParam) == tidyAsyncTriggerValue {
s.adminMemoryTidyDryRunAsync(w, r)
return
}
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), tidyDryRunTimeout)
defer cancel()
result, err := s.memoryTidy.DryRun(ctx)
if err != nil {
s.writeTidyDryRunError(w, err)
return
}
writeJSON(w, http.StatusOK, adminTidyDryRunResponse(result))
}
// writeTidyDryRunError 把试算失败映射成**诚实**的 HTTP 错误响应。映射表在
// tidyDryRunAppError(同步与异步共用的唯一映射点,见 admin_memory_tidy_async.go)。
func (s *Server) writeTidyDryRunError(w http.ResponseWriter, err error) {
failed := tidyDryRunAppError(err)
// 配置关闭是**显式选择**,不算故障:与既有口径一致,不为它记 ERROR 日志。
// 其余失败(召回不可用 / 超时 / 内部错)都要在日志里留原始错误。
if failed.Code != "tidy_disabled" {
s.log.Error("memory tidy dry-run failed", "code", failed.Code, "err", err)
}
writeError(w, failed)
}
// adminTidyDryRunResponse 把领域结果投影成线上结构。它**不复制**任何阈值/上限:
// 条数与截断标签都来自领域侧已经算好的结果(Candidates 已被每分区上限截断)。
func adminTidyDryRunResponse(result memory.TidyDryRun) AdminTidyDryRunResponse {
out := AdminTidyDryRunResponse{
DryRun: true,
RecallMode: result.RecallMode,
DurationMs: result.DurationMS,
RuleVersion: memory.TidyRuleVersion,
Partitions: result.Stats.Partitions,
Scanned: result.Stats.Scanned,
Compared: result.Stats.Compared,
Pairs: result.Stats.Pairs,
Vetoed: result.Stats.Vetoed,
VetoReasons: result.Stats.VetoReasons,
Truncated: result.Stats.Truncated,
DedupeGroups: result.Stats.DedupeGroups,
DedupeRowsReducible: result.Stats.DedupeRows,
DedupeTruncated: result.Stats.DedupeTruncated,
Candidates: make([]AdminTidyDryRunCandidate, 0, len(result.Candidates)),
}
for _, c := range result.Candidates {
out.Candidates = append(out.Candidates, adminTidyDryRunCandidate(c))
}
return out
}
// adminTidyDryRunCandidate 投影一条候选。SourceIDs 兜成非 null 数组(面板按数组读)。
func adminTidyDryRunCandidate(c memory.TidyCandidate) AdminTidyDryRunCandidate {
ids := c.SourceIDs
if ids == nil {
ids = []string{}
}
return AdminTidyDryRunCandidate{
Action: string(c.Action),
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,
ScoreMin: c.ScoreMin,
ScoreMax: c.ScoreMax,
Evidence: c.Evidence,
}
}