mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、 再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个 致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上 只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。 本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆: 一、描述链彻底删除(无残留、无兼容分支) - media.Item 去掉 Description/DescribedBy 与对应列; - 删除 Store.Describe / Store.Search / Store.Pending; - 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项 core.memory.media.describe_on_ingest; - SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。 二、marker 机制删除,媒体归属改为结构化块边 - 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/ extractMediaDigests/sentenceWithMediaMarkers/docMediaContext; - memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样, 不再被 marker 污染; - 块以 sentence --contains--> block / document --contains--> block 结构边 挂到承载节点(新增 documents 表与 document 节点种类); - 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。 三、旧数据迁移(幂等) - 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest 还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行; - CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子 连同块边一起删掉。 四、向量融合:媒体按图本身被召回 - 新增 vector.FuseVectors(逐维求和 + L2 归一化); - Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合), 新增 Doc.DenseFP,指纹变化触发重算; - ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在 同一统一空间内比稠密余弦; - 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再 直接充当记忆检索结果。 五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建) - internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧 (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、 sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。 验证:go build ./... 、go vet ./...(含 -tags medialive)均通过; 在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/... 全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造 无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
194 lines
5.9 KiB
Go
194 lines
5.9 KiB
Go
package memory
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 旧媒体实体迁移。
|
||
//
|
||
// 历史上媒体进 L3 的方式是把正文 marker 反解成普通实体三元组:
|
||
//
|
||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||
// → 实体「图片 a1b2c3d4e5f6」(type=Media) -内容-> 「一张紫蓝红三色带图」
|
||
//
|
||
// 这条路径把「媒体」伪装成实体 + 用生成的描述文本当语义索引,正是要废弃的
|
||
// 将就机制。迁移做的事:把每条这类实体还原成原生记忆块,用
|
||
// sentence --contains--> block 结构边挂到它当时所属的句子上,
|
||
// 然后删掉旧实体与它的描述关系。块只按自己的向量被检索。
|
||
//
|
||
// 迁移是幂等的:实体处理完即删除,重复运行不会重复建块。
|
||
|
||
// legacyMediaDigestPattern 从旧媒体实体名尾部取出短 digest。
|
||
// 名字形如「图片 a1b2c3d4e5f6」——旧实现刻意为每种模态加中文前缀。
|
||
var legacyMediaDigestPattern = regexp.MustCompile(`([0-9a-f]{8,64})$`)
|
||
|
||
// LegacyMediaEntityDigest 从旧媒体实体名里取出短 digest,取不到返回空串。
|
||
func LegacyMediaEntityDigest(name string) string {
|
||
name = strings.TrimSpace(name)
|
||
if !strings.Contains(name, " ") {
|
||
return ""
|
||
}
|
||
m := legacyMediaDigestPattern.FindStringSubmatch(name)
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
return m[1]
|
||
}
|
||
|
||
// LegacyMediaResolver 把一个短 digest 解析成可用于 L3 的一等记忆块。
|
||
// 解析失败(内容已不存在)返回 false,该实体将被直接删除而不建块。
|
||
type LegacyMediaResolver func(shortDigest string) (MemoryBlock, bool)
|
||
|
||
// MigrateLegacyMediaEntities 把 marker 反解出来的旧媒体实体迁移成原生块。
|
||
//
|
||
// 返回迁移的块数与删除的旧实体数。任何一步失败都会回滚整个迁移,
|
||
// 因为半途中断会留下既没有块也没有实体的句子——信息静默消失。
|
||
func (g *GraphDB) MigrateLegacyMediaEntities(resolve LegacyMediaResolver) (blocks, entities int, err error) {
|
||
if resolve == nil {
|
||
return 0, 0, nil
|
||
}
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
|
||
rows, err := g.db.Query(`SELECT id, name FROM entities WHERE type = 'Media'`)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
type legacyEntity struct {
|
||
id int64
|
||
name string
|
||
}
|
||
var legacy []legacyEntity
|
||
for rows.Next() {
|
||
var e legacyEntity
|
||
if err := rows.Scan(&e.id, &e.name); err != nil {
|
||
rows.Close()
|
||
return 0, 0, err
|
||
}
|
||
legacy = append(legacy, e)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
rows.Close()
|
||
return 0, 0, err
|
||
}
|
||
rows.Close()
|
||
if len(legacy) == 0 {
|
||
return 0, 0, nil
|
||
}
|
||
|
||
tx, err := g.db.Begin()
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// 同一份字节可能被多个旧实体引用(重复注入的同一张图),
|
||
// 迁移后应指向同一个块:块的身份是内容,不是实体行。
|
||
blockIDForDigest := make(map[string]string)
|
||
|
||
for _, e := range legacy {
|
||
short := LegacyMediaEntityDigest(e.name)
|
||
if short != "" {
|
||
if block, ok := resolve(short); ok && block.PayloadDigest != "" {
|
||
id, seen := blockIDForDigest[block.PayloadDigest]
|
||
if !seen {
|
||
if err := insertMigratedBlock(tx, block); err != nil {
|
||
return 0, 0, fmt.Errorf("migrate legacy media %s: %w", short, err)
|
||
}
|
||
blockIDForDigest[block.PayloadDigest] = block.ID
|
||
id = block.ID
|
||
blocks++
|
||
}
|
||
n, err := attachBlockToLegacySentences(tx, e.id, id)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
_ = n
|
||
}
|
||
}
|
||
// 无论能否解析出内容,旧实体与它的描述关系都必须删除:
|
||
// 留着就等于继续用描述文本当媒体索引。
|
||
if _, err := tx.Exec(`DELETE FROM relations WHERE source_id = ? OR target_id = ?`, e.id, e.id); err != nil {
|
||
return 0, 0, err
|
||
}
|
||
if _, err := tx.Exec(`DELETE FROM entities WHERE id = ?`, e.id); err != nil {
|
||
return 0, 0, err
|
||
}
|
||
entities++
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
return 0, 0, err
|
||
}
|
||
return blocks, entities, nil
|
||
}
|
||
|
||
// insertMigratedBlock 写一条迁移来的块(不经过 PutMemoryBlocks,避免重入锁)。
|
||
func insertMigratedBlock(tx *sql.Tx, block MemoryBlock) error {
|
||
if block.ID == "" {
|
||
return fmt.Errorf("migrated block id is required")
|
||
}
|
||
if !validBlockModality(block.Modality) {
|
||
return fmt.Errorf("migrated block %s has invalid modality %q", block.ID, block.Modality)
|
||
}
|
||
vectorJSON, err := json.Marshal(block.Vector)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
created := block.CreatedAt
|
||
if created.IsZero() {
|
||
created = time.Now()
|
||
}
|
||
_, err = tx.Exec(`INSERT INTO memory_blocks (
|
||
id, modality, text_content, payload_digest, mime, size, width, height,
|
||
vector, fingerprint, source, tool, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(id) DO NOTHING`,
|
||
block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME,
|
||
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
|
||
block.Source, block.Tool, created, time.Now())
|
||
return err
|
||
}
|
||
|
||
// attachBlockToLegacySentences 把迁移出的块挂到该旧实体当时所属的句子上,
|
||
// 并保留那些句子(它们可能只有媒体关系,删实体后就再无关系引用)。
|
||
func attachBlockToLegacySentences(tx *sql.Tx, entityID int64, blockID string) (int, error) {
|
||
rows, err := tx.Query(`SELECT DISTINCT s.id FROM sentences s
|
||
JOIN relations r ON r.sentence_id = s.id
|
||
WHERE r.source_id = ? OR r.target_id = ?`, entityID, entityID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
var sids []int64
|
||
for rows.Next() {
|
||
var sid int64
|
||
if err := rows.Scan(&sid); err != nil {
|
||
rows.Close()
|
||
return 0, err
|
||
}
|
||
sids = append(sids, sid)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
rows.Close()
|
||
return 0, err
|
||
}
|
||
rows.Close()
|
||
|
||
n := 0
|
||
for _, sid := range sids {
|
||
if _, err := tx.Exec(`INSERT OR IGNORE INTO memory_block_edges
|
||
(source_kind, source_id, target_kind, target_id, edge_type)
|
||
VALUES ('sentence', ?, 'block', ?, 'contains')`,
|
||
fmt.Sprintf("%d", sid), blockID); err != nil {
|
||
return n, err
|
||
}
|
||
n++
|
||
}
|
||
return n, nil
|
||
}
|