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