Files
Cyrene_v2/internal/runtime/memory/preinsert_judge.go
T
AskaEth 1b4d0a552f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): T1 灰区模型判定 —— D3 入库前审查补上 LLM 那一半
主人 2026-10-07 裁定「可以做 T1」。此前 D3 只有纯规则 T0,凡灰区(相似但
互不包含)一律保守 create,真机上「同一事实换措辞」类重复结构性治不住
(清完 92 条字面级重复后,active 里含「喵喵七号」的仍有 101 条)。

- 插入点:T0 判定内核只多吐一份结构化 Gray(惰性收集,恒零模型调用);
  T1 挂在异步审查层 Review,同步入库闸不碰它 ⇒ 写入路径零模型调用是
  **数据流结构**而不是承诺
- 只许回 merge / modify / keep + 一句理由;坏 JSON、非法枚举、编号越界
  一律回落 create(绝不猜、绝不挑最近的一条)
- 禁产新内容:模型只给「动作 + 候选编号 + 一句理由」,正文一律取自库里
  既有的两行 ⇒ T1 结构性地不可能把幻觉写进记忆库
- 不越硬门与红线:灰区已滤掉跨角色/作用域/项目/只读来源与时效/数值/冲突/
  低置信;「规则否决一票否决」不可走样,T1 不提供翻案入口
- 有界:每次审查至多一次调用(灰区打包,上限 8 条),子超时 8s 且不超过
  审查总预算;失败只改 reason、决定恒 create、零 UPDATE
- 开关 t1Judge(放 $DATA_DIR/config/memory-tidy.json,零新 env 键),默认开;
  模型出口用闭包晚绑定既有模型栈(零新客户端、零新依赖)
- 留痕 reason 结构化(t1_merge / t1_modify / t1_keep / t1_reply_invalid /
  t1_unavailable),rule_version d3.1 → d3.2;模型的自由文本理由一律丢弃
- preinsert_config.go 为纯搬移(为守 500 行上限,无新逻辑)
2026-10-07 14:10:33 +08:00

178 lines
8.1 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
// 本文件是 D3「入库前审查」的 T0 判定内核(design/23 §15.1 D3-2):
// 对**一条新落库的记忆**与同分区既有的召回候选,给出 merge / modify /
// create / skip 四态之一,并留下可审计的依据。
//
// 判定顺序刻意与设计稿逐条对齐:
// 1. **归一化后完全等** ⇒ skip(§15 D3-2 T0-1:已有等价条目,只更新既有行的
// updated_at / confidence,绝不新建第二条);
// 2. **真包含** ⇒ merge 或 modify(§15 D3-2 T0-2 + §8 包含式判据):
// 新条目是既有条目的子集 ⇒ merge(并入既有,既有行存活);
// 既有条目是新条目的子集 ⇒ modify(**新行承载更全的内容**,旧行置为被合并,
// §15 D3-6「改写必须是新行 + 旧行置为被合并」);
// 3. 其余(互不包含 / 被规则否决 / 没有召回)⇒ create(normal 新建)——
// 其中「互不包含**且**未命中任何规则否决」的那些候选另行登记进
// `ingestVerdict.Gray`,交**异步审查层**的 T1 再问一次
// (preinsert_t1.go,design/23 §15.1 D3-2)。**本文件恒为零模型调用**。
//
// 两条不可走样:
// - **相似度只用来召回候选**,永不参与判定(design/23 §1.2「单阈值双向失效」);
// - **规则否决一票否决**,且**绝不跨作用域/跨角色/跨项目**(§5.2-7/8/9 是硬门,
// 不是权衡)—— 这里对每条候选再判一次,是纵深防御(分区过滤已在 SQL 里)。
// ⇒ 被否决的候选**不进灰区**:T1 只能在**已通过硬门、且未被任何规则否决**的
// 候选里选(§5.2 的「绝不合并清单」是主人红线,T1 同样不得越过)。
// ingestCandidate 是一条被召回的既有记忆及其召回分数。
type ingestCandidate struct {
item MemoryItem
score float64
// mode 记录本轮相似度来源(embedding / lexical),进审查留痕(§15 D3-8)。
mode string
}
// ingestVerdict 是入库前审查的判定结果。Decision 为空表示「这条候选没有给出
// 决定性结论」(互不包含或被否决),调用方继续看下一条候选。
type ingestVerdict struct {
Decision IngestDecision
// Existing 是命中的既有条目(create 时为零值)—— 留痕里的「命中的既有 id」。
Existing MemoryItem
// Absorbed 是被软合并的那一行:merge/skip 时是**新落库的那一行**,
// modify 时是既有行(§15 D3-6 的「改写」)。
Absorbed MemoryItem
Score float64
Mode string
Reason string
// Veto 是命中的否决规则(未命中时为空)。它只影响依据,绝不改判决定。
Veto TidyVetoReason
// Gray 是**灰区候选**:相似度召回到了、互不包含、且**未命中任何规则否决**
// (含硬门)的那些既有条目,按召回分数降序(与 cands 同序)。
//
// 它是「T0 判不出结论」的结构化输出,只由**异步审查层**消费(T1,
// design/23 §15.1 D3-2);同步入库闸 `planFromVerdict` 刻意不看它 ——
// 于是「写入路径零模型调用」不是一句承诺,而是这条数据流的结构性结果。
// 被规则否决的候选**永不出现**在这里(§5.2 的红线对 T1 一样有效)。
Gray []ingestCandidate
}
// judgeIngest 对一条新落库的记忆做四态判定。cands 已按召回分数降序
// (collectTidyPairs 的确定性排序),因此同分时的选择可复现。
//
// 无论结论是什么,留痕都要带上**本轮召回来源**(§15 D3-8 的「依据」):
// 「没有命中任何包含关系」与「根本没召回到东西」是两件不同的事。
func judgeIngest(item MemoryItem, cands []ingestCandidate) ingestVerdict {
mode := ""
if len(cands) > 0 {
mode = cands[0].mode
}
if v, ok := judgeExactDuplicate(item, cands); ok {
return v
}
vetoed, vetoScore := VetoNone, 0.0
// 灰区惰性收集:只有真的出现「互不包含且未被否决」的候选才分配,
// 同步写入路径上的常见情形(无候选 / 一眼真新)因此零额外分配。
var gray []ingestCandidate
for _, c := range cands {
if veto, hit := tidyHardGate(item, c.item); hit {
vetoed, vetoScore = keepStrongerVeto(vetoed, vetoScore, veto, c.score)
continue
}
v := judgeContainedIngest(item, c)
if v.Decision != "" {
return v
}
if v.Veto != VetoNone {
vetoed, vetoScore = keepStrongerVeto(vetoed, vetoScore, v.Veto, c.score)
continue
}
gray = append(gray, c)
}
return ingestVerdict{
Decision: IngestCreate, Reason: createReason(vetoed), Veto: vetoed, Mode: mode, Gray: gray,
}
}
// judgeExactDuplicate 施加 §15 D3-2 T0-1:归一化后完全等 ⇒ skip。
// 它排在否决规则**之前**:完全相同的两条就是同一条事实,即便它带时效词或
// 低置信,把它折回既有行也绝不会丢信息(而「否决 ⇒ create」会让主人报的
// 「反复新建」原样复发)。作用域/来源硬门仍然先判 —— 跨分区与群聊来源
// 凭什么都不参与。
func judgeExactDuplicate(item MemoryItem, cands []ingestCandidate) (ingestVerdict, bool) {
key := dedupeKey(item.Content)
for _, c := range cands {
if _, hit := tidyHardGate(item, c.item); hit {
continue
}
if key != dedupeKey(c.item.Content) {
continue
}
// 存活方恒为**既有行**:新行是刚落库的那一条,把它折回既有行正是
// 「不新建」的含义(design §15 D3-2 T0-1)。
return ingestVerdict{
Decision: IngestSkip,
Existing: c.item,
Absorbed: item,
Score: c.score,
Mode: c.mode,
Reason: "exact_duplicate",
}, true
}
return ingestVerdict{}, false
}
// judgeContainedIngest 判定一条候选与新条目的事实关系。Decision 为空表示
// 「无决定性结论」:互不包含(相似但并列)或被规则否决。
func judgeContainedIngest(item MemoryItem, c ingestCandidate) ingestVerdict {
if hasAbsoluteTimestamp(item.Content) || hasAbsoluteTimestamp(c.item.Content) ||
hasTemporalState(item.Content) || hasTemporalState(c.item.Content) {
return ingestVerdict{Veto: VetoTemporal}
}
if !sameNumbers(item.Content, c.item.Content) {
return ingestVerdict{Veto: VetoNumeric}
}
if item.Confidence < tidyLowConfidenceThreshold || c.item.Confidence < tidyLowConfidenceThreshold {
return ingestVerdict{Veto: VetoLowConfidence}
}
cont := judgeContainment(item.Content, c.item.Content)
if cont.Kind == containNone {
// 相似但互不包含(design §1.3 的 [1]/[3]「红牛 vs 冰美式」):
// 绝不合并 —— 对入库而言等价于「正常新建」。
return ingestVerdict{Score: c.score, Mode: c.mode}
}
if !sameNegations(item.Content, c.item.Content) {
// 事实集合看似包含,但一方带否定而另一方没有(「X」vs「戒了X」):
// 那是新事实覆盖旧事实,不是重复(design §5.2-3)。
return ingestVerdict{Veto: VetoConflict}
}
if cont.Kind == containBSubsetA {
// 既有 ⊆ 新:新行承载更全的内容,旧行置为被合并(§15 D3-6)。
return ingestVerdict{
Decision: IngestModify, Existing: c.item, Absorbed: c.item,
Score: c.score, Mode: c.mode, Reason: "fact_superset",
}
}
// 新 ⊆ 既有(含双向等价但措辞不同):并入既有,既有行存活(§15 D3-2 T0-2)。
return ingestVerdict{
Decision: IngestMerge, Existing: c.item, Absorbed: item,
Score: c.score, Mode: c.mode, Reason: "fact_subset",
}
}
// keepStrongerVeto 在两条否决之间保留「分数更高那条候选」的否决原因,
// 让留痕里的依据对应最相关的候选,而不是随手一条。
func keepStrongerVeto(cur TidyVetoReason, curScore float64, next TidyVetoReason, nextScore float64) (TidyVetoReason, float64) {
if cur == VetoNone || nextScore > curScore {
return next, nextScore
}
return cur, curScore
}
// createReason 渲染 create 的依据:没有被否决就是「没有包含关系」,
// 否则如实写出命中的否决规则(留痕可查,design §15 D3-8)。
func createReason(veto TidyVetoReason) string {
if veto == VetoNone {
return "no_contained_match"
}
return "veto:" + string(veto)
}