主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。
① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
`applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
- 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
- `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
- 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
(**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
- `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
- **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
(群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
**可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
(`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。
验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。
⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
163 lines
6.7 KiB
Go
163 lines
6.7 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. 包含判定:互不包含 → 判 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
|
||
}
|