Files
Cyrene_v2/internal/runtime/memory/tidy_rules.go
T
AskaEth 91a3209c9b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
2026-10-05 11:29:36 +08:00

153 lines
5.8 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 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. 包含判定:互不包含 → 只建链接(§1.3);
// 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 冰美式」):
// 只建链接/分组,绝不合并。
return tidyVerdict{Action: TidyLink, Containment: c, Reason: "similar_but_not_contained"}
}
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: "fact_subset",
}
}
// 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
}