Files
Cyrene_v2/internal/runtime/memory/tidy_rules.go
T
AskaEth 6d92536276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
2026-10-07 11:07:00 +08:00

163 lines
6.7 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. 包含判定:互不包含 → 判 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
}