Files
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

298 lines
11 KiB
Go
Raw Permalink 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 (
"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
}