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, } }