mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
块 union 化后媒体字节归块所有(memory_blocks.data),但存量库里
data 为空、payload_digest 仍指向旧 CAS。不回填就删 CAS ⇒ BlockData
取不到字节,图永久丢失。本提交补上这一步。
生产数据实测(/home/newqqagent/memory):
- graph.db 112 个媒体块,引 106 个唯一 digest
- media.db 156 条 + blobs 87M,112/112 digest 命中
内核(internal/memory/migrate_blockdata.go)
- BackfillBlockData(resolver):单事务、全回滚,按 payload_digest
把字节写进块自身 data 列。保留原块 ID —— 边/场景/向量都挂在它上面。
- CountBlockDataBackfillCandidates:报告模式,不写库。
- ProbeBackfillAvailability:dry-run 看 CAS 命中率。
- ensurePayloadDigestColumn:★ 关键。block union 化后新库的 CREATE
已删该列,旧库还留着。迁移必须两者都能跑:旧库忽略 duplicate
column 错误,新库补列后得到 0 候选(安全空操作),而不是崩在
"no such column"。
- CAS 取不到字节的块计 Missing 并跳过(既有数据损失,不阻断其余)。
命令(cmd/homed-media-backfill)
- 默认只报告,-apply 才写;写前自动 sqlite3 .backup 快照。
- 回填后立刻 BlockData 抽样自检。
判据(4 条单元 + 1 条真实数据 e2e,全部做过变异测试)
- RoundTrip:BlockData 取回字节 == 来源逐字节一致
- MissingDigestSkipped:缺失内容计 Missing 且不阻断
- IdempotentThreeRuns:连跑三次只真回填一次
- FreshDBSafeNoOp:新库(无该列、无历史数据)上是安全空操作
- E2E(-tags e2e,读真 graph.db):112 块全部可取回、sha256 与
payload_digest 全等、抽样 20 块用自己的向量自召回 score=1.0
变异测试(判据不是装饰):
- 回填写空字节 → RoundTrip 挂
- Missing 分支改为也写入 → MissingDigestSkipped 挂
- 候选查询不排除已回填 → IdempotentThreeRuns 挂
- 移除 ensure 调用 → FreshDBSafeNoOp 挂(no such column)
生产数据 e2e 已在 /tmp/e2e-backfill 副本上完成(sqlite3 .backup 快照 +
CAS 拷贝):报告 112/112 命中 → apply 回填 112 块 55.03MB → 独立
交叉验证(python hashlib 全量 112/112 sha256 吻合 + sha256sum 对照
磁盘 blob 一致)→ 幂等复跑 0 候选 → 读路径召回 20/20 命中。
133 lines
3.8 KiB
Go
133 lines
3.8 KiB
Go
//go:build e2e
|
||
|
||
// e2e-media-backfill:在**真实生产数据副本**上验证块 union 化的读路径。
|
||
//
|
||
// 与单元测试的区别:单元测试用注入的假 resolver,这里读真的 graph.db +
|
||
// 真的 CAS 字节,走 RecallBlocks(向量召回)与 BlockData(负载按需取回)
|
||
// 两条真实读路径。
|
||
//
|
||
// 用法:
|
||
//
|
||
// E2E_DB=/tmp/e2e-backfill/graph.db go test -tags 'onnxruntime e2e' -run TestE2EMediaBackfill -v ./internal/memory/
|
||
package memory
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"os"
|
||
"testing"
|
||
)
|
||
|
||
// 真实数据 e2e:回填后的 112 个媒体块必须
|
||
// 1. BlockData(id) 能取回非空字节(负载真的在块里)
|
||
// 2. 字节 sha256 与 payload_digest 一致(身份没变)
|
||
// 3. 用自己的向量召回自己 —— 命中且 Score≈1(读路径端到端可用)
|
||
// 4. 媒体块的 modality / mime 没被回填改写(只改 data 列)
|
||
func TestE2EMediaBackfill(t *testing.T) {
|
||
dbPath := os.Getenv("E2E_DB")
|
||
if dbPath == "" {
|
||
t.Skip("E2E_DB 未设置;跳过真实数据 e2e")
|
||
}
|
||
g, err := NewGraphDB(dbPath)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", dbPath, err)
|
||
}
|
||
t.Cleanup(func() { g.Close() })
|
||
|
||
// 找出所有带 payload_digest 的媒体块
|
||
rows, err := g.db.Query(`SELECT id, payload_digest, modality, mime
|
||
FROM memory_blocks WHERE payload_digest != '' AND modality != 'text' ORDER BY id`)
|
||
if err != nil {
|
||
t.Fatalf("query: %v", err)
|
||
}
|
||
type row struct{ id, digest, modality, mime string }
|
||
var all []row
|
||
for rows.Next() {
|
||
var r row
|
||
if err := rows.Scan(&r.id, &r.digest, &r.modality, &r.mime); err != nil {
|
||
rows.Close()
|
||
t.Fatalf("scan: %v", err)
|
||
}
|
||
all = append(all, r)
|
||
}
|
||
rows.Close()
|
||
if len(all) == 0 {
|
||
t.Skip("库里没有带 payload_digest 的媒体块;跳过")
|
||
}
|
||
t.Logf("媒体块总数:%d", len(all))
|
||
|
||
// 判据 1 + 2:BlockData 取回非空,且 sha256 与 payload_digest 一致
|
||
empty, mismatch := 0, 0
|
||
for _, r := range all {
|
||
data, err := g.BlockData(r.id)
|
||
if err != nil {
|
||
t.Errorf("BlockData(%s): %v", r.id, err)
|
||
continue
|
||
}
|
||
if len(data) == 0 {
|
||
empty++
|
||
continue
|
||
}
|
||
if got := sha256Hex(data); got != r.digest {
|
||
mismatch++
|
||
if mismatch <= 3 {
|
||
t.Errorf("块 %s 字节 sha256=%s ≠ payload_digest=%s", r.id, got[:16], r.digest[:16])
|
||
}
|
||
}
|
||
}
|
||
if empty > 0 || mismatch > 0 {
|
||
t.Fatalf("判据 1/2 失败:空 data=%d,sha256 不吻合=%d", empty, mismatch)
|
||
}
|
||
t.Logf("判据 1/2 通过:%d 块全部可取回且字节身份吻合", len(all))
|
||
|
||
// 判据 3:用媒体块自己的向量召回自己(读路径端到端)
|
||
sampled, recalled := 0, 0
|
||
for _, r := range all {
|
||
b, ok, err := g.BlockByIDPrefix(r.id)
|
||
if err != nil || !ok || len(b.Vector) == 0 {
|
||
continue // 无向量的块跳过(回填不负责算向量)
|
||
}
|
||
sampled++
|
||
hits, err := g.RecallBlocks(BlockRecallQuery{
|
||
Vector: b.Vector,
|
||
Fingerprint: b.Fingerprint,
|
||
TopK: 5,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("RecallBlocks(%s): %v", r.id, err)
|
||
}
|
||
for _, h := range hits {
|
||
if h.Block.ID == r.id {
|
||
recalled++
|
||
if sampled <= 3 {
|
||
t.Logf(" 召回命中:%s score=%.4f", r.id, h.Score)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
if sampled >= 20 {
|
||
break // 抽 20 个够证明读路径
|
||
}
|
||
}
|
||
if sampled > 0 && recalled < sampled {
|
||
t.Errorf("判据 3 失败:抽样 %d 个块,只有 %d 个能被自己的向量召回", sampled, recalled)
|
||
}
|
||
t.Logf("判据 3 通过:抽样 %d 个有向量的块,%d 个自召回命中", sampled, recalled)
|
||
|
||
// 判据 4:modality/mime 未被回填改写
|
||
for _, r := range all {
|
||
if r.modality == "" {
|
||
t.Errorf("块 %s modality 被清空", r.id)
|
||
}
|
||
if r.mime == "" {
|
||
t.Errorf("块 %s mime 被清空", r.id)
|
||
}
|
||
}
|
||
t.Logf("判据 4 通过:modality/mime 原样保留")
|
||
}
|
||
|
||
func sha256Hex(b []byte) string {
|
||
sum := sha256.Sum256(b)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|