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)。
This commit is contained in:
1 parent
68ba85389b
commit
21b906ffb0
18 files changed
+1352
-35
No files matched your search
@@ -53,6 +53,11 @@ const (
|
||||
|
||||
// gateTraceAbsorbed 是闸真的做了决定(落 merged)的留痕原因码。
|
||||
gateTraceAbsorbed = "gate:absorbed"
|
||||
// gateTraceDeferred 是**第五态**在闸侧做决定的留痕原因码(`expires_at` 已写)。
|
||||
// 与 gateTraceAbsorbed 分开:前者写的是 status/merged_into,后者只写
|
||||
// expires_at 一列 —— 「闸当场折回了多少重复」与「闸当场放进了多少临时记忆」
|
||||
// 是两条不同的观测线,加成一个数会让面板读不出分型。
|
||||
gateTraceDeferred = "gate:deferred"
|
||||
// gateTraceBudgetExceeded 是闸被墙钟预算截断的 fail-open 原因码。
|
||||
gateTraceBudgetExceeded = "gate:budget_exceeded"
|
||||
// gateTraceUnavailable 是候选源不可用(读取失败)的 fail-open 原因码。
|
||||
@@ -72,6 +77,14 @@ const (
|
||||
// ingestPlanAbsorbOld 是确定性判据命中且**既有行是子集**(modify):新行正常
|
||||
// insert(它承载更全的内容),既有行置为被合并(design §15 D3-6 的「改写」)。
|
||||
ingestPlanAbsorbOld ingestPlanAction = "absorb_old"
|
||||
// ingestPlanDefer 是**第五态**(design/23 §18.1):真新、但不值得长期留存 ⇒
|
||||
// 行**照常 active 落库**(一个字不丢),只是紧接着把 `expires_at` 写成
|
||||
// 「现在 + 临时 TTL」,使它落进临时层 —— 到期由读侧时效闸隐藏
|
||||
// (design/03 §9.6)、再由后台清扫物理删除(design/23 §19)。
|
||||
//
|
||||
// 它**不投异步审查队列**(Async=false):defer 已经是这条记忆的**终局判断**
|
||||
// (「不值得长期留」),再送语义审查只会把它并进一条长期记忆、把临时性抹掉。
|
||||
ingestPlanDefer ingestPlanAction = "defer"
|
||||
)
|
||||
|
||||
// ingestGatePlan 是同步入库闸的落库计划。零值(Action="")不存在:planIngest
|
||||
@@ -97,6 +110,23 @@ type ingestGatePlan struct {
|
||||
// Elapsed 是闸在写路径上花掉的墙钟。它只进日志与留痕,**不参与任何判定**
|
||||
// (否则同一批数据会有两个结论,幂等性就没了)。
|
||||
Elapsed time.Duration
|
||||
// ExpiresAt 是第五态(defer)要写进新行 `expires_at` 的时刻:**零值 = 不设
|
||||
// 过期**(长期 / 日常记忆,落库为空串,四条列表读路径恒可见)。
|
||||
//
|
||||
// 它是**时刻**而不是「TTL 秒数」:判定的那一刻就把 TTL 换算掉,落库侧因此
|
||||
// 不需要认识任何配置,也不会出现「写的时候又算一次 TTL」的双口径。
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// expiresAt 返回新行落库时的 `expires_at` 文本,与 `insertStatus()` /
|
||||
// `absorbedID()` 是同族:把「计划 → 要写进 INSERT 的值」这一步收在计划自己身上。
|
||||
//
|
||||
// 🔴 **编码必须走 expiryText → formatTime**(定宽 30 字节 UTC),不许另写一种
|
||||
// 布局:读侧时效闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite
|
||||
// 时间函数),布局一旦分叉就会**静默判错**(本仓已有历史事故,timegate_test.go
|
||||
// 钉着那条反证)。零值 ⇒ 空串 ⇒ 该行恒可见。
|
||||
func (p ingestGatePlan) expiresAt() string {
|
||||
return expiryText(p.ExpiresAt)
|
||||
}
|
||||
|
||||
// absorbedID 返回应该写进新行 `merged_into` 的存活行 id。
|
||||
@@ -181,6 +211,13 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
|
||||
plan.Survivor = item
|
||||
plan.Absorbed = v.Existing
|
||||
plan.Trace = gateTraceAbsorbed
|
||||
case IngestDefer:
|
||||
// 第五态(design/23 §18.1):行照常 active 落库(一个字不丢、可 revert),
|
||||
// 只把 expires_at 写成「现在 + 临时 TTL」;**不投异步队列**(defer 已是
|
||||
// 终局判断,再送语义审查只会把临时性抹掉)。
|
||||
plan.Action = ingestPlanDefer
|
||||
plan.ExpiresAt = r.deferDeadline(r.now().UTC())
|
||||
plan.Trace = gateTraceDeferred
|
||||
default:
|
||||
// 真新 / 拿不准:正常 insert,并投既有异步队列(用语义召回再判一次)。
|
||||
plan.Action = ingestPlanInsert
|
||||
@@ -189,6 +226,20 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
|
||||
return plan
|
||||
}
|
||||
|
||||
// deferDeadline 把「现在」换算成第五态要写进 `expires_at` 的时刻(现在 + 临时
|
||||
// TTL,design/03 §9.6)。
|
||||
//
|
||||
// TTL 由 `cfg.TempTTL` 决定,**它已经过 normalize**(`NewPreInsertReviewer` 里
|
||||
// normalizeReviewConfig 先跑),因此这里拿到的恒为正 —— 「≤0 的 TTL 让每条记忆
|
||||
// 落库即过期」这条坏配置形态在构造期就被回落掉了(normalizeReviewConfig 兜底 +
|
||||
// ApplyReviewTuning 的 WARN)。
|
||||
//
|
||||
// 闸(同步)与异步审查层**共用这一个函数**:同一份配置、同一种时刻语义,因此
|
||||
// 同一条记忆无论被哪一层判成 defer,拿到的有效期都来自同一个算式。
|
||||
func (r *PreInsertReviewer) deferDeadline(now time.Time) time.Time {
|
||||
return now.UTC().Add(r.cfg.TempTTL)
|
||||
}
|
||||
|
||||
// failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。
|
||||
// 「绝不静默丢记忆」不是一句承诺,而是这条路径的结构性结果。
|
||||
func (r *PreInsertReviewer) failOpenPlan(reason string, start time.Time) ingestGatePlan {
|
||||
@@ -298,6 +349,35 @@ func (s *Store) finishIngest(ctx context.Context, item MemoryItem, plan ingestGa
|
||||
if plan.Action == ingestPlanInsert {
|
||||
return
|
||||
}
|
||||
if plan.Action == ingestPlanDefer {
|
||||
// 第五态(design/23 §18.1):新行已经在 Add 里照常插好了(active、正文
|
||||
// 一字不差),这里只把 `expires_at` 补成计划给出的时刻 —— 与
|
||||
// absorb_new / absorb_old 是**同一范式**:INSERT 落行、finishIngest 落
|
||||
// 「那一行的派生列」。
|
||||
//
|
||||
// 为什么不是「INSERT 直接带上 expires_at」:store.go 的 INSERT 读的是
|
||||
// `expiryText(item.ExpiresAt)`(由**调用方**置位),而 defer 的判断发生在
|
||||
// 闸里(`planIngest` 拿的是 item 的**副本**),计划的值够不到那条 INSERT。
|
||||
//
|
||||
// 🔴 **代价与短暂窗口(如实登记,不静默)**:这一态因此多出一笔按主键的
|
||||
// UPDATE(微秒级),且 INSERT 与这笔补写之间有一个**极短的窗口** ——
|
||||
// 若进程恰在此刻中断,该行会以 `expires_at=''`(**永不过期**)留在库里。
|
||||
// 方向是刻意的:它正是 fail-safe 的「**宁可多留,不可误弃**」
|
||||
// (design/23 §18.3)—— 停在长期层只是多占一条记忆,而反向失败
|
||||
// (把该留的判成临时)会丢信息。写失败同样只记 WARN、**不让 Add 失败**。
|
||||
//
|
||||
// 非 defer 的三态**一笔都不多写**:absorb_new / absorb_old 本来就有它们的
|
||||
// 第二笔(合并登记 / 置为被合并),insert 在函数开头就返回了。
|
||||
if err := s.setIngestExpiry(ctx, item.ID, plan.expiresAt()); err != nil {
|
||||
s.log.Warn("memory ingest gate defer failed, item left long-lived",
|
||||
"id", item.ID, "scope", string(item.Scope), "error", err)
|
||||
return
|
||||
}
|
||||
if r := s.ingestReviewer(); r != nil {
|
||||
r.bump(func(st *ReviewStats) { st.GateDeferred++ })
|
||||
}
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var err error
|
||||
switch plan.Action {
|
||||
|
||||
Reference in new issue
Block a user