Files
Cyrene_v2/internal/runtime/memory/preinsert_stats.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

102 lines
4.7 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
import "fmt"
// 本文件是入库前审查(design/23 §15.1 D3)的**观测面**:进程内计数与审计出口。
// 它从 preinsert.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)与
// 「模块按职责拆源文件」那条规范 —— 口径逐字未变。
//
// 为什么要**分列**闸与异步队列的计数:同步入库闸(写路径上当场拍板)与异步
// 审查队列(后台补审)是两件事,把它们加成一个数会让「闸拦下了多少重复」与
// 「事后补审了多少」不可分辨。分列之后,面板/日志能分别读到两条路径的量。
// ReviewStats 是审查的观测计数(进程内累计,供日志/测试断言)。
type ReviewStats struct {
Reviewed int
Skipped int
Merged int
Modified int
Created int
// Deferred 是**异步审查层**判出第五态 defer 的条数(T0 规则闸 / T1 同一次
// 调用都可能给出它)。它与 Created 分列:两者都留下新行,但只有 defer 带
// 短 `expires_at`,它是「临时层新增」的观测量。
Deferred int
Dropped int
Failed int
// GateChecked 是**同步入库闸**实际做了一次判定的条数(跳过闸的条目不计)。
GateChecked int
// GateAbsorbed 是闸当场拦下并落成 `merged` 的条数(真重复,含 modify 的
// 「旧行被吸收」)。
GateAbsorbed int
// GateFailed 是闸因预算/候选源不可用而 fail-open 的次数(照常插入 + 留痕)。
GateFailed int
// GateDeferred 是闸当场判定为**第五态 defer** 并**真的写成了** `expires_at`
// 的次数(design/23 §18.1)。它与 GateAbsorbed 分列:「当场折回重复」与
// 「当场放进临时层」是两条不同的观测线,加成一个数面板就读不出分型。
// 只统计写成功的(写失败时那行停在长期层,不是临时记忆)。
GateDeferred int
// T1Called 是 T1 实际发出过**模型调用**的次数。每次审查至多 +1
// (defaultT1MaxGray 只是把多条灰区候选打包进同一次 prompt,不多发请求)。
T1Called int
// T1Decided 是 T1 真的定下结论(merge/modify/keep)的次数。它与 T1Called
// 的差值就是「调了但没用上」(非法回复 / 超时 / 报错回落 create)——
// 这个差值本身是可观测信号,因此两个数分开计。
T1Decided int
// T1Throttled 是**全局限速**命中、因而跳过 T1 的次数(design/23 §15.4,
// reason=`t1_throttled`)。它**不**计入 T1Called(一次模型调用都没发),
// 也不计入 T1Decided(没定任何结论)——「想调但被闸拦下」是与「调了没用上」
// 不同的一件事,分开计才看得出额度是不是成了瓶颈。
T1Throttled int
}
// bump 在互斥下修改计数(工作协程、手动 Review 与同步闸都可能调用)。
func (r *PreInsertReviewer) bump(apply func(*ReviewStats)) {
r.statsMu.Lock()
defer r.statsMu.Unlock()
apply(&r.stats)
}
// recordDecision 累计观测计数(含降级计数,便于运维判读)。
func (r *PreInsertReviewer) recordDecision(rev IngestReview) {
r.bump(func(s *ReviewStats) {
s.Reviewed++
switch rev.Decision {
case IngestSkip:
s.Skipped++
case IngestMerge:
s.Merged++
case IngestModify:
s.Modified++
case IngestDefer:
// 第五态单独一档:它与 create 都「留下了新行」,但一个有 TTL、
// 一个没有 —— 混进 Created 就看不出「有多少记忆是临时的」。
s.Deferred++
default:
s.Created++
}
})
}
// auditWith 写一条审计。verb 让「同步闸当场拦下的」与「异步审查事后合并的」
// 在审计链上可分(前者 memory.ingest.gate,后者 memory.review.ingest)。
// 它**只带决定与 id,绝不带任何记忆正文**。
func (r *PreInsertReviewer) auditWith(verb string, rev IngestReview) {
result := fmt.Sprintf("decision=%s reason=%s item=%s target=%s degraded=%t score=%.3f",
rev.Decision, rev.Reason, rev.ItemID, rev.TargetID, rev.Degraded, rev.Score)
if r.deps.Audit == nil {
r.log.Debug("memory ingest review audit (no sink configured)", "what", verb, "result", result)
return
}
r.deps.Audit(reviewAuditWho, verb, result)
}
// reviewStatsLabel 把计数渲染成一条稳定日志串。
func reviewStatsLabel(s ReviewStats) string {
return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d defer=%d dropped=%d failed=%d "+
"gate_checked=%d gate_absorbed=%d gate_deferred=%d gate_failed=%d "+
"t1_called=%d t1_decided=%d t1_throttled=%d",
s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Deferred, s.Dropped, s.Failed,
s.GateChecked, s.GateAbsorbed, s.GateDeferred, s.GateFailed,
s.T1Called, s.T1Decided, s.T1Throttled)
}