feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 11:29:36 +08:00
1 parent 012e9f3ad4
commit 91a3209c9b
25 files changed
+3418 -13

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
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
}