Files
HomeAgent/internal/memory/block.go
JianFeeeee 9d45cd0277 fix(rel): 场景贯穿流水线到块层 + 编辑不再丢置信度/场景 + 构建默认带 onnxruntime
三件事,前两件是上一轮热部署暴露/遗留的真缺陷。

1) 热部署差点静默降级(已修)
   `make build` 之前**不带任何 tags**,而发行构建(deploy/packaging/build.sh)
   默认 HOMED_TAGS=onnxruntime,package-linux.sh 还会直接拒收非 onnxruntime 二进制。
   实测差异:33MB vs 84MB;启动日志里
   「multimodal space active: provider=chineseclip dim=512」整行消失、
   少加载一个插件(chinese-clip/qwen3vl provider 降级)、
   静态词向量退回 fallback。即「随手 make build」与「发行构建」不是同一个东西,
   而部署时无从察觉。
   修:Makefile 的 build 默认 HOMED_TAGS ?= onnxruntime(与打包脚本一致),
   构建后自动校验二进制里有没有 onnxruntime,缺了就打 WARN。
   生产已按此重新构建部署(v1.4.0+hotfix.d98bf51,已核实 provider 行回归)。

2) memory_edit 每跑一次就静默降级一次(新)
   memory_edit 是「按包含匹配 Purge + 写新三元组」,中间那一步把旧关系的
   置信度、原句、**场景引用**全丢了:置信度被重置成默认 1.0,场景钉死的记忆
   被打散成无场景。而关系复审心跳(reviewLoop)走的正是这条路——每轮复审都
   在无声地削记忆质量。
   修:编辑前用 FindRelations 精确取回旧关系,把置信度/原句/场景带到新三元组;
   新增 ScenesOfRelation。Purge(hard/soft)与 PurgeNoise/PurgeOrphans 之后
   统一清理悬空 scene_refs,SceneStats 不再说谎。

3) 场景贯穿流水线到块层(按「rel 应贯穿整条流水线」的设计)
   此前场景只到 relation/entity:块(L0/L3 一等记忆块)没有场景,于是
   「那场 QQ 对话里发过来的那张图」在场面重现时永远取不回来。
   - MemoryBlock.Scene + memory_blocks.scene 列(幂等 ALTER 迁移)。
   - scene_refs 增加 ref_text 承载字符串主键(块/文档 id 不是数值)。
     **不能只 ALTER ADD COLUMN**:唯一约束要从 (scene_id,kind,ref_id) 变成
     含 ref_text 的四元组,而 ALTER 改不了约束——旧约束会让「同场景第 2 个块」
     直接冲突(只在多块场景暴露)。改为按列探测后整表重建并搬运旧数据。
   - PutMemoryBlocks 同事务挂 scene_refs(kind='block');无场景重写不覆盖已有场景
     (否则一次无场景重写就静默抹掉挂载)。
   - RecallByScene 返回块;FormatContext 增「场景素材」段(模态 + 文本/短 digest),
     上限 3 条。
   - 生产者接线:attachBlocksToSentence 让块继承承载它的三元组的场景;
     linkBlocksToDocument 让文档的块继承文档来源场景(QQ 归档的图挂 chan:qq)。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景块(取回/同场景多块/无场景重写不抹场景/悬空引用清理)、
**旧表结构迁移**(降级成旧 scene_refs 后重开,旧数据保留且多块可写)、
场景素材注入、FindRelations+ScenesOfRelation 编辑搬运闭环。

生产:已重建(-tags onnxruntime)并原子替换 /usr/local/bin/homed + 重启,
35 插件全加载、panic/fatal=0、chineseclip 空间 active。
2026-09-15 09:03:36 +08:00

288 lines
10 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 (
"database/sql"
"encoding/json"
"fmt"
"time"
)
// BlockModality 是一等记忆块的原生模态。
type BlockModality string
const (
BlockText BlockModality = "text"
BlockImage BlockModality = "image"
BlockVideo BlockModality = "video"
BlockAudio BlockModality = "audio"
)
// MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。
//
// 它不携带 Layer、Owner 或 RefCount块当前由哪个层的容器持有哪个层就是
// 唯一事实源。Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。
// PayloadDigest 仅用于定位内容寻址的原始字节,不表示另一条逻辑记忆。
type MemoryBlock struct {
ID string `json:"id"`
Modality BlockModality `json:"modality"`
Text string `json:"text,omitempty"`
PayloadDigest string `json:"payload_digest,omitempty"`
MIME string `json:"mime,omitempty"`
Size int64 `json:"size,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Vector []float64 `json:"vector,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Source string `json:"source,omitempty"`
Tool string `json:"tool,omitempty"`
// Scene 是这个块所属的场景键(可空)。
//
// 块是记忆流水线里最细的「子项目」:一段转写、一张图的描述、一份附件。
// 场景要贯穿到流水线底就得从块开始——否则「QQ 那场对话里发过来的那张图」
// 在场面重现时永远拿不回来。块进 L3 时按 Scene 挂 scene_refs(kind='block')
// 场景召回即可把它取回(见 GraphDB.RecallByScene
Scene string `json:"scene,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
// source/target kind 当前允许 block、entity、sentence、document。
type MemoryBlockEdge struct {
ID int64 `json:"id"`
SourceKind string `json:"source_kind"`
SourceID string `json:"source_id"`
TargetKind string `json:"target_kind"`
TargetID string `json:"target_id"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
}
func validBlockModality(modality BlockModality) bool {
return modality == BlockText || modality == BlockImage || modality == BlockVideo || modality == BlockAudio
}
// PutMemoryBlocks 将完成 L2→L3 迁移的块写成 GraphDB 原生节点。
// 调用方只有在本事务成功后才能从 Document 删除这些块。
func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
if len(blocks) == 0 {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
tx, err := g.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, block := range blocks {
if block.ID == "" {
return fmt.Errorf("memory block id is required")
}
if !validBlockModality(block.Modality) {
return fmt.Errorf("memory block %s has invalid modality %q", block.ID, block.Modality)
}
vectorJSON, err := json.Marshal(block.Vector)
if err != nil {
return fmt.Errorf("marshal memory block %s vector: %w", block.ID, err)
}
now := time.Now()
if block.CreatedAt.IsZero() {
block.CreatedAt = now
}
_, err = tx.Exec(`INSERT INTO memory_blocks (
id, modality, text_content, payload_digest, mime, size, width, height,
vector, fingerprint, source, tool, scene, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
modality = excluded.modality,
text_content = excluded.text_content,
payload_digest = excluded.payload_digest,
mime = excluded.mime,
size = excluded.size,
width = excluded.width,
height = excluded.height,
vector = excluded.vector,
fingerprint = excluded.fingerprint,
source = excluded.source,
tool = excluded.tool,
-- 场景只在本次给了值时才覆盖:块可能先被写入、后被归档路径补挂场景,
-- 反过来「已挂场景的块被一次无场景的重写抹掉」是不可接受的静默降级。
scene = CASE WHEN excluded.scene != '' THEN excluded.scene ELSE memory_blocks.scene END,
updated_at = excluded.updated_at`,
block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME,
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
block.Source, block.Tool, block.Scene, block.CreatedAt, now)
if err != nil {
return fmt.Errorf("put memory block %s: %w", block.ID, err)
}
// 场景引用与块同事务:块写进去了、引用丢了,这个块在场景里就永远取不回。
if block.Scene != "" {
if err := tagSceneRefTx(tx, block.Scene, "block", 0, block.ID, 1.0); err != nil {
return fmt.Errorf("tag scene for block %s: %w", block.ID, err)
}
}
}
return tx.Commit()
}
// PutDocumentNode 在 L3 登记一个文档节点,作为 document --contains--> block
// 结构边的端点。文档正文已蒸馏为实体/关系,这里只保留身份与摘要。
func (g *GraphDB) PutDocumentNode(id, summary string) error {
if id == "" {
return fmt.Errorf("document node id is required")
}
g.mu.Lock()
defer g.mu.Unlock()
_, err := g.db.Exec(`INSERT INTO documents (id, summary) VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET summary = excluded.summary`, id, summary)
return err
}
// MemoryBlocks 查询 Graph 层实际持有的一等记忆节点。
func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT id, modality, text_content, payload_digest, mime,
size, width, height, vector, fingerprint, source, tool, created_at, updated_at
FROM memory_blocks ORDER BY created_at, id`)
if err != nil {
return nil, err
}
defer rows.Close()
var blocks []MemoryBlock
for rows.Next() {
var block MemoryBlock
var vectorJSON string
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
&block.UpdatedAt); err != nil {
return nil, err
}
if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
}
}
blocks = append(blocks, block)
}
return blocks, rows.Err()
}
func validGraphNodeKind(kind string) bool {
return kind == "block" || kind == "entity" || kind == "sentence" || kind == "document"
}
func graphNodeExists(tx *sql.Tx, kind, id string) (bool, error) {
var n int
var err error
switch kind {
case "block":
err = tx.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, id).Scan(&n)
case "entity":
err = tx.QueryRow(`SELECT COUNT(*) FROM entities WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
case "sentence":
err = tx.QueryRow(`SELECT COUNT(*) FROM sentences WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
case "document":
err = tx.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, id).Scan(&n)
default:
return false, fmt.Errorf("invalid graph node kind %q", kind)
}
return n == 1, err
}
// AddMemoryBlockEdge 建立 contains、depicts、derived_from 等原生图边。
// 端点必须是真实 Graph 节点,不能用 owner 字符串伪装关系。
func (g *GraphDB) AddMemoryBlockEdge(sourceKind, sourceID, targetKind, targetID, edgeType string) error {
if !validGraphNodeKind(sourceKind) || !validGraphNodeKind(targetKind) {
return fmt.Errorf("invalid memory block edge kinds %q -> %q", sourceKind, targetKind)
}
if sourceID == "" || targetID == "" || edgeType == "" {
return fmt.Errorf("memory block edge endpoints and type are required")
}
g.mu.Lock()
defer g.mu.Unlock()
tx, err := g.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, endpoint := range []struct{ kind, id string }{{sourceKind, sourceID}, {targetKind, targetID}} {
exists, err := graphNodeExists(tx, endpoint.kind, endpoint.id)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%s graph node %s does not exist", endpoint.kind, endpoint.id)
}
}
_, err = tx.Exec(`INSERT OR IGNORE INTO memory_block_edges
(source_kind, source_id, target_kind, target_id, edge_type)
VALUES (?, ?, ?, ?, ?)`, sourceKind, sourceID, targetKind, targetID, edgeType)
if err != nil {
return err
}
return tx.Commit()
}
func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id,
edge_type, created_at FROM memory_block_edges ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var edges []MemoryBlockEdge
for rows.Next() {
var edge MemoryBlockEdge
if err := rows.Scan(&edge.ID, &edge.SourceKind, &edge.SourceID, &edge.TargetKind,
&edge.TargetID, &edge.Type, &edge.CreatedAt); err != nil {
return nil, err
}
edges = append(edges, edge)
}
return edges, rows.Err()
}
// BlocksForNode 返回与某个图节点通过任意边相连的一等记忆块。
// 例sentence --contains--> blockentity --depicts--> block。
func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content, b.payload_digest,
b.mime, b.size, b.width, b.height, b.vector, b.fingerprint, b.source, b.tool,
b.created_at, b.updated_at
FROM memory_block_edges e
JOIN memory_blocks b ON (
(e.source_kind = 'block' AND e.source_id = b.id AND e.target_kind = ? AND e.target_id = ?)
OR (e.target_kind = 'block' AND e.target_id = b.id AND e.source_kind = ? AND e.source_id = ?))
ORDER BY b.created_at, b.id`, nodeKind, nodeID, nodeKind, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var blocks []MemoryBlock
for rows.Next() {
var block MemoryBlock
var vectorJSON string
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
&block.UpdatedAt); err != nil {
return nil, err
}
if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
}
}
blocks = append(blocks, block)
}
return blocks, rows.Err()
}