153 lines
5.8 KiB
Go
153 lines
5.8 KiB
Go
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
|
||
}
|