【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变
【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)
【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。
【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。
.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
104 lines
4.7 KiB
Go
104 lines
4.7 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// 本文件是入库前审查的**候选召回**(design/23 §15 D3-3:用既有 recall 路径,
|
||
// 不许另造一套相似度实现)。
|
||
//
|
||
// 复用面(逐条,都是整理侧已经存在的实现):
|
||
// - 阈值:与整理同一个召回门(config.MemoryTidy.RecallThreshold,§13 开放点 2);
|
||
// - 相似度:语义 embedding 的余弦(clampUnit + cosineSim)或零依赖的词项余弦
|
||
// (lexicalSimilarity);
|
||
// - 收集与排序:collectTidyPairs —— 因此「阈值过滤 + 分数降序 + id 兜底」
|
||
// 与整理侧逐字一致,截断也可复现。
|
||
//
|
||
// 与整理侧**唯一**的差异:候选只保留「新条目 vs 既有条目」的对(整理侧是
|
||
// 分区内两两),因为入库前审查永远以刚落库的那一条为中心。
|
||
|
||
// recallCandidates 在同一分区内召回候选(分区键 = persona_id + scope +
|
||
// project_id,design §6 硬约束:分区之间永不比较)。
|
||
//
|
||
// 返回的 degraded 表示本轮相似度是从语义路径**降级**到词项余弦的(embedder
|
||
// 报错或向量条数错位):判定规则一字不改,只把「这一轮的依据不够好」如实
|
||
// 带进留痕(§15 D3-8 的「是否降级」)。
|
||
func (r *PreInsertReviewer) recallCandidates(ctx context.Context, item MemoryItem) ([]ingestCandidate, bool, error) {
|
||
partition := tidyPartition{personaID: item.PersonaID, scope: item.Scope, projectID: item.ProjectID}
|
||
existing, err := r.deps.Store.listReviewCandidates(ctx, partition, item.ID, r.cfg.ScanLimit)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
if len(existing) == 0 {
|
||
return nil, false, nil
|
||
}
|
||
// 新条目固定在首位:reviewPairs 只认它为中心。
|
||
items := make([]MemoryItem, 0, len(existing)+1)
|
||
items = append(items, item)
|
||
items = append(items, existing...)
|
||
|
||
if r.deps.Embedder == nil {
|
||
// 未配置语义 embedding:与整理生产轮同一口径 —— 用零依赖词项余弦,
|
||
// 不是降级(这是配置形态,不是故障)。
|
||
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), false, nil
|
||
}
|
||
vecs, err := embedReviewTexts(ctx, r.deps.Embedder, items)
|
||
if err != nil {
|
||
// 语义召回不可用 ⇒ 降级到与整理生产轮**同一个**词项余弦,并如实标记
|
||
// degraded(design §4.1 生产轮回退口径)。降级绝不改变判定规则,
|
||
// 也绝不改写任何一行。
|
||
r.log.Warn("memory ingest review embedding recall unavailable, falling back to lexical",
|
||
"item_id", item.ID, "scope", string(item.Scope), "error", err)
|
||
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), true, nil
|
||
}
|
||
return reviewPairs(items, r.cfg.Threshold, vectorSimFunc(vecs), tidyRecallEmbedding), false, nil
|
||
}
|
||
|
||
// reviewPairs 用整理的收集内核(collectTidyPairs)产出候选对,再只保留以
|
||
// items[0](新条目)为中心的那些,转成审查候选。首位的 id 为空时无法区分
|
||
// 中心,直接返回空 —— 宁可少审一条,也绝不把两条既有记忆当成候选。
|
||
func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string) []ingestCandidate {
|
||
if len(items) < 2 || items[0].ID == "" {
|
||
return nil
|
||
}
|
||
var sink TidyStats
|
||
// 零值预算 = 无时间预算:入库前审查的对数由它自己的 ScanLimit 收口
|
||
// (那是 §15 D3 切片的护栏),本切片不改它的行为。
|
||
pairs, _ := collectTidyPairs(items, threshold, sim, mode, &sink, tidyBudget{})
|
||
out := make([]ingestCandidate, 0, len(pairs))
|
||
for _, p := range pairs {
|
||
switch {
|
||
case p.a.ID == items[0].ID:
|
||
out = append(out, ingestCandidate{item: p.b, score: p.score, mode: p.mode})
|
||
case p.b.ID == items[0].ID:
|
||
out = append(out, ingestCandidate{item: p.a, score: p.score, mode: p.mode})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// embedReviewTexts 分批把条目向量化(批次上限复用整理侧的 tidyEmbedBatch:
|
||
// 不把整个分区的文本一次性交给远端 embedding API)。单批失败即整体失败,
|
||
// 由调用方决定降级还是上报,绝不半途改用混合表示。
|
||
func embedReviewTexts(ctx context.Context, emb embedding.Embedder, items []MemoryItem) ([]embedding.Vector, error) {
|
||
out := make([]embedding.Vector, 0, len(items))
|
||
for start := 0; start < len(items); start += tidyEmbedBatch {
|
||
end := min(start+tidyEmbedBatch, len(items))
|
||
texts := make([]string, 0, end-start)
|
||
for _, it := range items[start:end] {
|
||
texts = append(texts, it.Content)
|
||
}
|
||
vecs, err := emb.Embed(ctx, texts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(vecs) != len(texts) {
|
||
return nil, ErrTidyEmbedCount
|
||
}
|
||
out = append(out, vecs...)
|
||
}
|
||
return out, nil
|
||
}
|