package memory import "strings" // 本文件是记忆整理的 T0 判定(design/23 §5.1/§5.2):对一条被召回的候选对 // 施加**规则否决**与**包含判定**,给出建议动作或否决原因。 // // 判定顺序刻意是「先否决、后包含」: // 1. 硬门(跨作用域/跨角色/跨项目/群聊来源)—— design §5.2-7/8/9; // 2. 时效污染(一次性事件、有期限状态)—— §5.2-1; // 3. 数值不一致 —— §5.2-2; // 4. 低置信 —— §5.2-6; // 5. 包含判定:互不包含 → 判 keep、**不产候选**(2026-10-07 口径:link 无执行 // 语义,见本文件 containNone 分支的说明); // 6. 包含成立但否定不一致 → 冲突,走覆盖而非重复(§5.2-3)。 // // 命中任一条否决规则即踢出候选(design §5.1 T0-4)。 // isolatedSessionPrefix 是渠道隔离会话的 id 前缀(internal/channel/session // 的 sessionIDPrefix)。主人的私聊与 Web 主会话落 "web",群聊/陌生人落 // "sess-…",因此前缀是「这条记忆来自只读上下文」的可判定信号。 const isolatedSessionPrefix = "sess-" // backthinkSessionID 与 backthink.DefaultSessionID 逐字一致:自主后台思考是 // 本进程的系统会话,不是主人的隔离渠道会话,必须放行。 const backthinkSessionID = "sess-backthink" // judgePair 对一条候选对做 T0 判定。score 是召回相似度(只用于证据, // **绝不参与判定**:design/23 §1.2「单阈值双向失效」)。 func judgePair(a, b MemoryItem, score float64) tidyVerdict { if veto, hit := tidyHardGate(a, b); hit { return tidyVerdict{Veto: veto} } if hasAbsoluteTimestamp(a.Content) || hasAbsoluteTimestamp(b.Content) || hasTemporalState(a.Content) || hasTemporalState(b.Content) { return tidyVerdict{Veto: VetoTemporal} } if !sameNumbers(a.Content, b.Content) { return tidyVerdict{Veto: VetoNumeric} } if a.Confidence < tidyLowConfidenceThreshold || b.Confidence < tidyLowConfidenceThreshold { return tidyVerdict{Veto: VetoLowConfidence} } c := judgeContainment(a.Content, b.Content) if c.Kind == containNone { // 相似但互不包含(design §1.3 的 [1]/[3]「红牛 vs 冰美式」):判 **keep**, // 一条候选都不产。 // // 2026-10-07 主人口径(「那么多 link 是干什么的?该删就删」)——**为什么 // 不实现 link**:design/23 §14 把「`link` 关系如何存储与呈现(是否建 // `memory_links` 表;是否只做面板展示而不进检索)」明确列为**待细化** ⇒ // 它既没有落点(§10 的 memory_items 新列里没有链接列、§11 的端点表里没有 // link 端点),也没有数据模型;而执行面只认三种可应用动作 // (tidy_apply.go 的 applyable())。⇒ 产一条 link 候选的**唯一后果**是 // 一条永远应用不了、只能 skip 的待办(活库曾积压 501 条,把面板淹没)。 // 「以设计为准」的结论是:**不产它**,直到 §14 那条被细化出存储形态。 return tidyVerdict{Action: TidyKeep, Containment: c, Reason: tidyReasonSimilarNotContained} } if !sameNegations(a.Content, b.Content) { // 事实集合看似包含,但一方带否定而另一方没有(「X」vs「戒了X」): // 那是新事实覆盖旧事实,不是重复(design §5.2-3)。 return tidyVerdict{Veto: VetoConflict, Containment: c} } target := pickMergeTarget(a, b, c.Kind) return tidyVerdict{ Action: TidyMerge, Target: &target, Containment: c, Reason: tidyReasonFactSubset, } } // tidyHardGate 施加作用域与来源硬门(design/23 §5.2-7/8/9)。 // 整理器本身**逐分区扫描、分区之间永不比较**(§6);这里再判一次是纵深 // 防御:任何经其它路径送进来的跨分区对都必须被否决,而不是侥幸漏过。 func tidyHardGate(a, b MemoryItem) (TidyVetoReason, bool) { if a.Scope != b.Scope { return VetoScope, true } if a.PersonaID != b.PersonaID { return VetoPersona, true } if a.ProjectID != b.ProjectID { return VetoProject, true } if nonOwnerSource(a.SourceConversation) || nonOwnerSource(b.SourceConversation) { return VetoSource, true } return VetoNone, false } // pickMergeTarget 选存活条目:包含关系里**超集**那一方存活,子集并入它 // (design §1.3「把 A 并入 B,content 取 B(超集),保留 B 的 id」)。 // 双向包含(事实集合等价)时取**更早创建**的一方存活 —— 它是 first_seen, // 时间跨度因此落在存活行上(design §8);仍并列时按 id 字典序,保证确定性。 func pickMergeTarget(a, b MemoryItem, kind containmentKind) MemoryItem { switch kind { case containASubsetB: return b case containBSubsetA: return a default: if a.CreatedAt.Equal(b.CreatedAt) { if a.ID <= b.ID { return a } return b } if a.CreatedAt.Before(b.CreatedAt) { return a } return b } } // nonOwnerSource 报告一条记忆的溯源是不是群聊/陌生人这类**只读**来源 // (design/23 §5.2-9、§6)。判据是结构化溯源(design/03 §2 裁决 04.4.3) // 里的会话 id:渠道隔离会话由 channel/session 以 "sess-" 前缀命名, // 而主人私聊与 Web 主会话落 "web"。 // // 这是纵深防御:上游(loop.extractMemory)在受限上下文本就绝不提取, // 所以这类行只可能来自历史遗留。规则在此兜住它,使整理既不合并、 // 也不归档群聊/陌生人来源的条目。非结构化溯源(裁决前的 LLM 自由文本) // 无法机器判定,按「不是受限来源」处理。 func nonOwnerSource(sourceConversation string) bool { ref, ok := ParseSourceConversation(sourceConversation) if !ok { return false } if !strings.HasPrefix(ref.SessionID, isolatedSessionPrefix) { return false } for _, system := range tidySystemSessions() { if ref.SessionID == system { return false } } return true } // tidySystemSessions 返回本进程自己的系统会话(它们不是主人的隔离渠道会话, // 因此不作为「群聊/陌生人来源」)。它是函数而不是包级变量,符合 // 「禁包级全局变量」的项目规范(同 backthink.triggers 的手法)。 func tidySystemSessions() []string { return []string{backthinkSessionID, TidySessionID} } // sameNegations 报告两条内容的否定标记集合是否一致。不一致意味着 // 「X」与「非X」并存 —— 那是覆盖关系,合并会丢掉一次事实变更。 func sameNegations(a, b string) bool { fa, fb := extractTidyFacts(a), extractTidyFacts(b) if len(fa.negs) != len(fb.negs) { return false } for n := range fa.negs { if _, ok := fb.negs[n]; !ok { return false } } return true }