Files
Cyrene_v2/internal/runtime/memory/preinsert_judge.go
T
AskaEth 21b906ffb0 feat(memory): D3 第五态 defer(入库审查)+ 提取留存规则闸 + 临时 TTL 配置面
主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都
往记忆里写」「.env 只做兜底用 …这些键优先从面板配置上取」「临时记忆到期删除」。
按 design/23 §18(D3 第五态 defer)、§19(到期处置)、design/03 §9.6
(tempTtlSec 默认 7 天 + 坏值口径)、§10(提取留存规则)落地。

- IngestDecision 闭集加第五态 defer:照常 active 落库 + 写短 expires_at 落
  临时层(不删行、不改正文,回滚 = 清空该列);IngestRuleVersion d3.2 → d3.3。
- 落点:ingestGatePlan 增 ExpiresAt + expiresAt()(走 expiryText/formatTime 的
  定宽 30 字节 UTC,与读侧时效闸同一套文本口径),由 finishIngest 在 defer 一态
  补写 expires_at(INSERT 与补写之间的短暂窗口如实登记,方向 = 宁可多留)。
  非 defer 三态一笔都不多写。
- T0 提取留存规则闸(extract_retention.go):design/03 §10.5 的 N1~N5 纯规则,
  接进 judgeIngest 的候选比对之前,命中即 defer + reason=t0_low_value,不进 T1;
  两道护栏(显式「记住」/ 持久信号)一票放行,fail-safe 方向 = 宁可多留。
- T1 闭集扩为 merge/modify/keep/defer(同一个 prompt、同一次调用,绝不新增
  第二次模型调用):只有关系判为真新时才问价值;defer 要求 target=0,否则判
  非法回落 create(reason=t1_reply_invalid)。
- 提取器提示词加固(design/03 §10.3 B):正向清单 + 反例清单 + 「允许返回 []」。
- 临时记忆 TTL:代码默认 tempTtlSec = 604800(7 天);面板
  $DATA_DIR/config/memory-tidy.json 的 tempTtlSec 优先(ApplyReviewTuning 逐键
  解析,坏值回落默认 + WARN,绝不写进 cfg)。该键在 .env 里没有出处且不新增
  (.env 已冻结)⇒ 实际生效链是「面板 > 代码默认」。启动播种同步该键(*int +
  omitempty,nil 省略、绝不写 null),保证播种文件与生效值逐字一致。
- preinsert_store.go 的 listReviewCandidatesSQL 原位注释:这条内部召回 SQL
  刻意不过时效闸的裁定与理由(design/03 §11.2 第 3 条)。
- 测试:defer 落库带短 expires_at(闸 + T1 两条路径)、写入口径与时效闸同源、
  TTL 坏值与回落、规则版本 d3.3、整理候选幂等键不含 IngestRuleVersion、
  提取规则正例 11 条 / 反例 11 条。

验收:go build ./... + go vet ./... + go test ./... 全绿;windows/darwin 交叉
编译通过(GOOS=darwin GOARCH=arm64 / GOOS=windows GOARCH=amd64)。
2026-10-13 20:22:48 +08:00

195 lines
9.2 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)。**本文件恒为零模型调用**。
//
// 另外在**候选比对之前**插一道**提取留存规则闸**(extract_retention.go,
// design/03 §10.5 的 N1~N5):新条目自己就「显然不值得长期留存」时直接给第五态
// `defer`(`reason=t0_low_value`),**不进候选比对、不进 T1**。它是纯规则、零模型
// 调用,因此不改变上面那句「本文件恒为零模型调用」。
//
// 两条不可走样:
// - **相似度只用来召回候选**,永不参与判定(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
}
// 提取留存规则闸(design/03 §10.5 的 N1~N5):新条目**自己**就「显然不值得
// 长期留存」时直接给第五态 `defer`,**不进候选比对、不进 T1**
// (design/23 §10.6:规则闸拦下的不进 T1,避免「模型推翻规则」)。
//
// 它排在**精确重复之后**是刻意的:已有等价条目时 `skip` 只更新既有行、
// **一行都不新增**,比 `defer`(新增一行临时记忆)更省也更干净。
if rule := judgeExtractionRetention(item.Content); rule != retentionNone {
return ingestVerdict{
Decision: IngestDefer, Reason: retentionLowValueReason, Mode: mode,
Veto: VetoNone, Gray: nil,
}
}
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)
}