主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
298 lines
11 KiB
Go
298 lines
11 KiB
Go
package memory
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// 本文件是记忆整理(design/23 §5.1 T0-3/5、§5.2-1/2/3)的**内容判据**:
|
||
// 把一条记忆正文拆成可判定的「事实要素」集合、判定包含关系、识别一次性事件
|
||
// 与时效性状态、抽取数值与否定标记。
|
||
//
|
||
// 它是纯函数、零 LLM、确定性:同样的输入永远给同样的结论。这样整理才可
|
||
// 幂等(design/23 §4.1 A:两次相同输入的运行必须产出相同候选),测试才
|
||
// 能逐条断言而不是「看起来对」。design/23 §14 把它列为待细化的空白,
|
||
// 这里是本切片按「保守近似」落下的具体算法(见各函数的说明)。
|
||
|
||
// 事实要素抽取用的字符分类与固定词表。
|
||
const (
|
||
// tidyMissingEvidenceCap 是 diff 预览里逐要素证据的条数上限:缺失要素
|
||
// 太多时说明两条内容差得远,列全没有意义,也会把 evidence JSON 撑大。
|
||
tidyMissingEvidenceCap = 8
|
||
// tidySingleRuneTerm 表示单字 CJK 串本身作为一个词项(成词才有意义)。
|
||
tidySingleRuneTerm = 1
|
||
)
|
||
|
||
// 绝对日期/时刻的判定:它们把一条记忆钉在**具体某一天/某一刻**,因此是
|
||
// 一次性事件的信号([8]「2026年9月8日周二晚22:38」)。
|
||
// 刻意**不含**「22点后 / 十点多」这类周期性表述 —— 那是习惯([5]/[13]/[17])。
|
||
var tidyAbsoluteTimeRe = regexp.MustCompile(
|
||
`(?:\d{4}\s*年\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*日)?)` +
|
||
`|(?:\d{1,2}\s*月\s*\d{1,2}\s*日)` +
|
||
`|(?:\d{4}\s*[-/]\s*\d{1,2}\s*[-/]\s*\d{1,2})` +
|
||
`|(?:\d{1,2}\s*/\s*\d{1,2})` +
|
||
`|(?:\d{1,2}\s*[::]\s*\d{1,2})`)
|
||
|
||
// 星期几:只有**不是**「每周X」这类周期表述时才算绝对时刻。
|
||
var tidyWeekdayRe = regexp.MustCompile(`(?:周|星期)[一二三四五六日天]`)
|
||
|
||
// tidyTemporalStateWords 是有期限状态的信号词(design/23 §1.1 的
|
||
// 「时效性污染:一次性事件当长期记忆」里的状态类,如 [12]/[15] 的假期状态)。
|
||
// 命中即否决合并 —— 时效性状态不是长期事实。
|
||
var tidyTemporalStateWords = []string{
|
||
"假期", "放假", "休假", "期间", "暂时", "临时",
|
||
"本周", "本月", "这周", "这个月", "上周", "下周",
|
||
"今天", "明天", "昨天", "前几天", "即将",
|
||
}
|
||
|
||
// tidyNegationMarkers 是否定/负向标记(design/23 §5.2-3:「X vs 非X、戒了X、
|
||
// 改成了Y」是**新事实覆盖旧事实**,不是重复,绝不自动合)。
|
||
//
|
||
// ⚠️ 刻意**不含**光杆「不」:它在中文里无处不在(「不错」「不一定」),
|
||
// 拿它当否定标记会把正常语句误判成冲突。宁可漏判冲突(保守),
|
||
// 也不让「不」把绝大多数合并建议否决掉。
|
||
var tidyNegationMarkers = []string{
|
||
"不再", "没有", "没再", "未", "别", "勿", "戒", "停止", "取消",
|
||
"拒绝", "否认", "无", "非", "不喜欢", "不爱", "不想",
|
||
}
|
||
|
||
// tidyFacts 是一条记忆内容的事实要素集合(「原子断言」的可判定近似)。
|
||
type tidyFacts struct {
|
||
// nums 是数值/时刻/日期 token:数值不同即不同事实(design/23 §5.2-2)。
|
||
nums map[string]struct{}
|
||
// negs 是否定标记(design/23 §5.2-3)。
|
||
negs map[string]struct{}
|
||
// terms 是词项:CJK 二元组、拉丁词、emoji/符号。它保留相邻关系
|
||
// (「熬夜」与「夜熬」不该等价),供**召回相似度**使用。
|
||
terms map[string]struct{}
|
||
// runes / words 是**包含判据**用的关键要素:CJK 内容单字(已去掉功能字)
|
||
// 与拉丁词/emoji 符号。用「关键要素逐一出现」而不是二元组,是因为二元组
|
||
// 在插入词时会整片断裂(「主人喜欢喝红牛提神」与「主人不喜欢喝红牛提神」
|
||
// 会因多了一个「不」而失配),那样会把大量真包含对误判成互不包含。
|
||
r runes
|
||
w words
|
||
}
|
||
|
||
// runes 是 CJK 内容单字集合(键是 rune,避免每次比较做字符串转换)。
|
||
type runes map[rune]struct{}
|
||
|
||
// words 是拉丁词与 emoji/符号集合。
|
||
type words map[string]struct{}
|
||
|
||
// isFunctionRune 报告 r 是否是不承载事实的功能字(的/了/也…)。它是
|
||
// switch 而不是查表:既避免包级全局变量(AGENTS §10),也让判定无分配。
|
||
//
|
||
// 刻意**只排除纯结构字**:像「常」「深」「夜」「习」「惯」这类带意义的字
|
||
// 必须留在关键要素里,否则「常在深夜」与「深夜」会被当成同一事实。
|
||
func isFunctionRune(r rune) bool {
|
||
switch r {
|
||
case '的', '了', '地', '得', '着', '过', '是', '在', '和', '与', '及', '或',
|
||
'也', '都', '很', '就', '还', '而', '但', '吧', '吗', '呢', '啊', '呀',
|
||
'之', '其', '并', '把', '被', '给', '让', '向', '从', '对', '个', '们',
|
||
'这', '那', '我', '你', '他', '她', '它', '有', '会', '能', '要', '又',
|
||
'再', '才', '太', '更', '最', '请', '不', '没', '无', '未', '别', '勿', '非':
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// extractTidyFacts 把正文拆成事实要素集合。它对内容做最小规范化
|
||
// (大小写折叠、压缩空白),不改变任何字符类别。
|
||
func extractTidyFacts(content string) tidyFacts {
|
||
f := tidyFacts{
|
||
nums: make(map[string]struct{}),
|
||
negs: make(map[string]struct{}),
|
||
terms: make(map[string]struct{}),
|
||
r: make(runes),
|
||
w: make(words),
|
||
}
|
||
normalized := strings.ToLower(strings.TrimSpace(content))
|
||
for _, marker := range tidyNegationMarkers {
|
||
if strings.Contains(normalized, marker) {
|
||
f.negs[marker] = struct{}{}
|
||
}
|
||
}
|
||
|
||
var cjk, latin, num []rune
|
||
flush := func() {
|
||
switch {
|
||
case len(cjk) > 0:
|
||
if len(cjk) == tidySingleRuneTerm {
|
||
f.terms[string(cjk)] = struct{}{}
|
||
}
|
||
for i := 0; i+1 < len(cjk); i++ {
|
||
f.terms[string(cjk[i:i+2])] = struct{}{}
|
||
}
|
||
for _, r := range cjk {
|
||
if !isFunctionRune(r) {
|
||
f.r[r] = struct{}{}
|
||
}
|
||
}
|
||
case len(latin) > 0:
|
||
word := string(latin)
|
||
f.terms[word] = struct{}{}
|
||
f.w[word] = struct{}{}
|
||
case len(num) > 0:
|
||
if tok := strings.Trim(string(num), "::/.-"); tok != "" {
|
||
f.nums[tok] = struct{}{}
|
||
}
|
||
}
|
||
cjk, latin, num = nil, nil, nil
|
||
}
|
||
|
||
for _, r := range normalized {
|
||
switch {
|
||
case unicode.Is(unicode.Han, r):
|
||
if len(latin) > 0 || len(num) > 0 {
|
||
flush()
|
||
}
|
||
cjk = append(cjk, r)
|
||
case unicode.IsLetter(r):
|
||
if len(cjk) > 0 || len(num) > 0 {
|
||
flush()
|
||
}
|
||
latin = append(latin, r)
|
||
case unicode.IsDigit(r):
|
||
if len(cjk) > 0 || len(latin) > 0 {
|
||
flush()
|
||
}
|
||
num = append(num, r)
|
||
case (r == ':' || r == ':' || r == '/' || r == '.' || r == '-') && len(num) > 0:
|
||
// 22:38 / 10/3 / 3.5 这类时刻与日期:数字之间的分隔符并入数值
|
||
// token,使「22:38」是一个数值要素,而不是 22 与 38 两个。
|
||
num = append(num, r)
|
||
case unicode.IsSymbol(r):
|
||
// emoji 与符号本身是事实要素:[4] 有 🎁、[20] 有 👀 正是
|
||
// design/23 §1.3「相似度极高但不是包含关系 ⇒ 只应链接」的判据。
|
||
flush()
|
||
sym := string(r)
|
||
f.terms[sym] = struct{}{}
|
||
f.w[sym] = struct{}{}
|
||
default:
|
||
flush()
|
||
}
|
||
}
|
||
flush()
|
||
return f
|
||
}
|
||
|
||
// containmentKind 是包含判定的结论。
|
||
type containmentKind string
|
||
|
||
const (
|
||
containNone containmentKind = "none"
|
||
containASubsetB containmentKind = "a_subset_b"
|
||
containBSubsetA containmentKind = "b_subset_a"
|
||
containBoth containmentKind = "both"
|
||
)
|
||
|
||
// containment 是包含判定的结果与证据。
|
||
type containment struct {
|
||
Kind containmentKind
|
||
// Missing 是「A ⊆ B 缺了哪些要素」的逐条证据(最多 tidyMissingEvidenceCap 条)。
|
||
Missing []string
|
||
}
|
||
|
||
// containsFacts 报告 a 的关键要素是否**逐一**出现在 b 中(a ⊆ b):数值、
|
||
// 否定标记、CJK 内容字、拉丁词/emoji 符号四类都要满足。这就是 design/23
|
||
// §5.1 T0-3 的「保守近似」:A ⊆ B 仅当 A 的关键要素(专有名词、数值、否定词)
|
||
// 逐一出现在 B 中,不满足即视为互不包含 → 判 keep、不产候选(见 tidy_rules.go)。
|
||
func containsFacts(a, b tidyFacts) (bool, []string) {
|
||
var missing []string
|
||
for n := range a.nums {
|
||
if _, ok := b.nums[n]; !ok {
|
||
missing = append(missing, "数值:"+n)
|
||
}
|
||
}
|
||
for n := range a.negs {
|
||
if _, ok := b.negs[n]; !ok {
|
||
missing = append(missing, "否定:"+n)
|
||
}
|
||
}
|
||
for r := range a.r {
|
||
if _, ok := b.r[r]; !ok {
|
||
missing = append(missing, "字:"+string(r))
|
||
}
|
||
}
|
||
for w := range a.w {
|
||
if _, ok := b.w[w]; !ok {
|
||
missing = append(missing, "词:"+w)
|
||
}
|
||
}
|
||
if len(missing) == 0 {
|
||
return true, nil
|
||
}
|
||
if len(missing) > tidyMissingEvidenceCap {
|
||
missing = missing[:tidyMissingEvidenceCap]
|
||
}
|
||
return false, missing
|
||
}
|
||
|
||
// judgeContainment 判定两条内容的事实集合包含关系。互不包含
|
||
// (含「相似但并列」)返回 containNone —— 调用方据此判 keep、**不产候选**
|
||
// (2026-10-07 起 link 候选已不再产出,见 tidy_rules.go 的 containNone 分支)。
|
||
func judgeContainment(aContent, bContent string) containment {
|
||
a := extractTidyFacts(aContent)
|
||
b := extractTidyFacts(bContent)
|
||
aSubsetB, missingA := containsFacts(a, b)
|
||
bSubsetA, _ := containsFacts(b, a)
|
||
switch {
|
||
case aSubsetB && bSubsetA:
|
||
return containment{Kind: containBoth}
|
||
case aSubsetB:
|
||
return containment{Kind: containASubsetB, Missing: missingA}
|
||
case bSubsetA:
|
||
return containment{Kind: containBSubsetA}
|
||
default:
|
||
return containment{Kind: containNone, Missing: missingA}
|
||
}
|
||
}
|
||
|
||
// hasAbsoluteTimestamp 报告正文里有没有**绝对日期/时刻**(一次性事件的信号,
|
||
// design/23 §5.1 T0-5)。「每周二」这类周期表述不算。
|
||
func hasAbsoluteTimestamp(content string) bool {
|
||
normalized := strings.ToLower(strings.TrimSpace(content))
|
||
if tidyAbsoluteTimeRe.MatchString(normalized) {
|
||
return true
|
||
}
|
||
for _, loc := range tidyWeekdayRe.FindAllStringIndex(normalized, -1) {
|
||
if loc[0] == 0 {
|
||
return true
|
||
}
|
||
// 「每」紧邻在星期词之前时是周期表述(每周二),不算绝对时刻。
|
||
if !strings.HasSuffix(normalized[:loc[0]], "每") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// hasTemporalState 报告正文里有没有有期限状态的信号词(假期/期间/本周…)。
|
||
// 命中即否决合并:有期限的状态不是长期事实(design/23 §5.2-1)。
|
||
func hasTemporalState(content string) bool {
|
||
normalized := strings.ToLower(strings.TrimSpace(content))
|
||
for _, w := range tidyTemporalStateWords {
|
||
if strings.Contains(normalized, w) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// sameNumbers 报告两条内容的数值 token 集合是否一致:数值不同即不同事实
|
||
// (design/23 §5.2-2);两边都没有数值时返回 true(无数值可比)。
|
||
func sameNumbers(a, b string) bool {
|
||
fa, fb := extractTidyFacts(a), extractTidyFacts(b)
|
||
if len(fa.nums) != len(fb.nums) {
|
||
return false
|
||
}
|
||
for n := range fa.nums {
|
||
if _, ok := fb.nums[n]; !ok {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|