mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +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 等)。
470 lines
12 KiB
Go
470 lines
12 KiB
Go
package core
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
type ToolResultItem struct {
|
||
Name string `json:"name"`
|
||
Output string `json:"output"`
|
||
}
|
||
|
||
type ContextEvent struct {
|
||
// ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。
|
||
//
|
||
// 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。
|
||
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
|
||
ID string `json:"id,omitempty"`
|
||
Timestamp time.Time `json:"timestamp"`
|
||
Source string `json:"source"`
|
||
Input string `json:"input"`
|
||
Response string `json:"response,omitempty"`
|
||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||
// --- 原生多模态记忆 ---
|
||
// 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。
|
||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
|
||
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
|
||
DenseFP string `json:"-"` // DenseVec 所属统一空间指纹(缓存字段,不持久化)
|
||
}
|
||
|
||
const contextFlushInterval = 5 * time.Second
|
||
|
||
type RelevanceContext struct {
|
||
mu sync.Mutex
|
||
events []*ContextEvent
|
||
embedder *memory.StaticEmbedder
|
||
denseSpace vector.MultimodalEmbedder
|
||
savePath string
|
||
saveTimer *time.Timer
|
||
dirty bool
|
||
toolDefLookup func(name string) *sdk.ToolDef
|
||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||
}
|
||
|
||
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
||
rc := &RelevanceContext{
|
||
embedder: embedder,
|
||
savePath: savePath,
|
||
}
|
||
if savePath != "" {
|
||
rc.load()
|
||
}
|
||
return rc
|
||
}
|
||
|
||
// SetDenseSpace 注入稠密多模态向量空间。配置后 L0 相关性裁剪可用稠密向量
|
||
// 余弦(与媒体检索、文档检索共享同一空间),未配置时退化到稀疏词向量。
|
||
func (c *RelevanceContext) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.denseSpace = ds
|
||
}
|
||
|
||
func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.toolDefLookup = fn
|
||
}
|
||
|
||
func (c *RelevanceContext) SetChannelDefLookup(fn func(name string) (sdk.ChannelDef, bool)) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.channelDefLookup = fn
|
||
}
|
||
|
||
func (c *RelevanceContext) load() {
|
||
data, err := os.ReadFile(c.savePath)
|
||
if err != nil {
|
||
return
|
||
}
|
||
var events []*ContextEvent
|
||
if err := json.Unmarshal(data, &events); err != nil {
|
||
return
|
||
}
|
||
for _, evt := range events {
|
||
c.computeVector(evt)
|
||
}
|
||
c.events = events
|
||
}
|
||
|
||
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef, channelDefLookup func(name string) (sdk.ChannelDef, bool)) string {
|
||
var text string
|
||
switch {
|
||
case evt.Source == "agent" && evt.Response != "":
|
||
text = evt.Response
|
||
case evt.Source == "cold_storage":
|
||
text = evt.Input + " " + evt.Response
|
||
default:
|
||
text = evt.Input
|
||
}
|
||
|
||
// 计算层:应用输入通道的 Cleaner(不改原文,仅在计算层清洗)
|
||
if channelDefLookup != nil {
|
||
if chDef, ok := channelDefLookup(evt.Source); ok && chDef.Cleaner != nil {
|
||
text = chDef.Cleaner(text)
|
||
}
|
||
if chDef, ok := channelDefLookup(evt.Source); ok && chDef.NoMemory {
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// 计算层:附加工具输出,NoMemory 跳过,其余经 Cleaner 过滤
|
||
if toolDefLookup != nil {
|
||
noMemory := make(map[string]bool)
|
||
for _, tr := range evt.ToolResults {
|
||
def := toolDefLookup(tr.Name)
|
||
if def != nil && def.NoMemory {
|
||
noMemory[tr.Name] = true
|
||
}
|
||
}
|
||
for _, tr := range evt.ToolResults {
|
||
if noMemory[tr.Name] {
|
||
continue
|
||
}
|
||
cleaned := tr.Output
|
||
def := toolDefLookup(tr.Name)
|
||
if def != nil && def.Cleaner != nil {
|
||
cleaned = def.Cleaner(cleaned)
|
||
}
|
||
text += " " + cleaned
|
||
}
|
||
}
|
||
|
||
return memory.CleanText(text)
|
||
}
|
||
|
||
// toolOutputClean 根据工具定义的 NoMemory/Cleaner 清洗输出,用于计算层。
|
||
// 返回 "" 表示跳过(NoMemory),否则返回清洗后文本(Cleaner 或原文)。
|
||
func (c *RelevanceContext) toolOutputClean(name, output string) string {
|
||
if c.toolDefLookup == nil {
|
||
return output
|
||
}
|
||
def := c.toolDefLookup(name)
|
||
if def == nil {
|
||
return output
|
||
}
|
||
if def.NoMemory {
|
||
return ""
|
||
}
|
||
if def.Cleaner != nil {
|
||
return def.Cleaner(output)
|
||
}
|
||
return output
|
||
}
|
||
|
||
// inputChannelClean 根据输入通道的 Def 清洗输入文本,用于计算层。
|
||
func (c *RelevanceContext) inputChannelClean(source, input string) string {
|
||
if c.channelDefLookup == nil {
|
||
return input
|
||
}
|
||
chDef, ok := c.channelDefLookup(source)
|
||
if !ok {
|
||
return input
|
||
}
|
||
if chDef.Cleaner != nil {
|
||
return chDef.Cleaner(input)
|
||
}
|
||
return input
|
||
}
|
||
|
||
// channelCleanerForDoc 返回 ChannelCleaner,使 document 包在存档时能按来源查找 Cleaner。
|
||
func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
|
||
if c.channelDefLookup == nil {
|
||
return nil
|
||
}
|
||
return func(source string) func(string) string {
|
||
chDef, ok := c.channelDefLookup(source)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return chDef.Cleaner
|
||
}
|
||
}
|
||
|
||
func (c *RelevanceContext) computeVector(evt *ContextEvent) {
|
||
text := textForVector(evt, c.toolDefLookup, c.channelDefLookup)
|
||
// 稀疏向量始终计算(TF-IDF/fastText,退化时仍可用)
|
||
if text != "" {
|
||
evt.Vector = c.embedder.Vectorize(text)
|
||
}
|
||
// 稠密向量:文本向量 ⊕ 本事件持有的一等记忆块媒体向量(同一统一空间)。
|
||
// 只有媒体的输入(无文本)也要有可比较的坐标,因此不再按 text=="" 提前返回。
|
||
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
||
fp := c.denseSpace.Fingerprint()
|
||
var parts [][]float64
|
||
if text != "" {
|
||
if dv, err := c.denseSpace.VectorizeDense(text); err == nil && len(dv) > 0 {
|
||
parts = append(parts, dv)
|
||
}
|
||
}
|
||
for _, b := range evt.Blocks {
|
||
// 只融合同指纹的块向量:另一套坐标系的向量混进来会算出
|
||
// 两边都不像的方向。
|
||
if len(b.Vector) > 0 && b.Fingerprint == fp {
|
||
parts = append(parts, b.Vector)
|
||
}
|
||
}
|
||
evt.DenseVec = vector.FuseVectors(parts...)
|
||
evt.DenseFP = fp
|
||
}
|
||
}
|
||
|
||
func (c *RelevanceContext) Save() error {
|
||
if c.savePath == "" {
|
||
return nil
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(c.savePath), 0755); err != nil {
|
||
return err
|
||
}
|
||
data, err := json.Marshal(c.events)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(c.savePath, data, 0644)
|
||
}
|
||
|
||
func (c *RelevanceContext) Append(evt ContextEvent) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
c.computeVector(&evt)
|
||
c.events = append(c.events, &evt)
|
||
|
||
c.save()
|
||
}
|
||
|
||
func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
c.computeVector(&evt)
|
||
|
||
idx := sort.Search(len(c.events), func(i int) bool {
|
||
return c.events[i].Timestamp.After(evt.Timestamp)
|
||
})
|
||
|
||
c.events = append(c.events, nil)
|
||
copy(c.events[idx+1:], c.events[idx:])
|
||
c.events[idx] = &evt
|
||
|
||
c.save()
|
||
}
|
||
|
||
func (c *RelevanceContext) save() error {
|
||
if c.savePath == "" {
|
||
return nil
|
||
}
|
||
if !c.dirty {
|
||
c.dirty = true
|
||
if c.saveTimer == nil {
|
||
c.saveTimer = time.AfterFunc(contextFlushInterval, c.flush)
|
||
} else {
|
||
c.saveTimer.Reset(contextFlushInterval)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c *RelevanceContext) flush() {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if !c.dirty {
|
||
return
|
||
}
|
||
data, err := json.Marshal(c.events)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if err := os.WriteFile(c.savePath, data, 0644); err != nil {
|
||
return
|
||
}
|
||
c.dirty = false
|
||
}
|
||
|
||
// scoredEvent 是 Prune 里按相关度排序的事件。
|
||
//
|
||
// 提为包级类型:Prune 需要把待归档列表传给后续处理。
|
||
type scoredEvent struct {
|
||
event *ContextEvent
|
||
score float64
|
||
idx int
|
||
}
|
||
|
||
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
if len(c.events) <= topK {
|
||
return 0
|
||
}
|
||
|
||
pCount := 10
|
||
if pCount > len(c.events) {
|
||
pCount = len(c.events)
|
||
}
|
||
protected := c.events[len(c.events)-pCount:]
|
||
candidates := c.events[:len(c.events)-pCount]
|
||
|
||
if len(candidates) == 0 {
|
||
return 0
|
||
}
|
||
|
||
// 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。
|
||
var queryDense []float64
|
||
useDense := false
|
||
queryFP := ""
|
||
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
||
if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil {
|
||
queryDense = dv
|
||
queryFP = c.denseSpace.Fingerprint()
|
||
useDense = true
|
||
}
|
||
}
|
||
queryVec := c.embedder.VectorizeClean(currentInput)
|
||
|
||
scoredEvents := make([]scoredEvent, len(candidates))
|
||
for i, evt := range candidates {
|
||
var score float64
|
||
// 只在同一统一空间内比稠密余弦:换了模型/维度后旧事件的向量
|
||
// 属于另一个坐标系,拿来比会得到无意义的分数。
|
||
if useDense && evt.DenseFP == queryFP && len(evt.DenseVec) == len(queryDense) {
|
||
score = vector.DenseCosine(queryDense, evt.DenseVec)
|
||
} else {
|
||
score = vector.CosineSimilarity(queryVec, evt.Vector)
|
||
}
|
||
scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i}
|
||
}
|
||
|
||
sort.Slice(scoredEvents, func(i, j int) bool {
|
||
return scoredEvents[i].score > scoredEvents[j].score
|
||
})
|
||
|
||
keepCount := topK - len(protected)
|
||
if keepCount < 0 {
|
||
keepCount = 0
|
||
}
|
||
keep := scoredEvents
|
||
if len(keep) > keepCount {
|
||
keep = keep[:keepCount]
|
||
}
|
||
archive := scoredEvents[keepCount:]
|
||
|
||
c.events = make([]*ContextEvent, 0, len(keep)+len(protected))
|
||
for _, s := range keep {
|
||
c.events = append(c.events, s.event)
|
||
}
|
||
c.events = append(c.events, protected...)
|
||
|
||
sort.Slice(c.events, func(i, j int) bool {
|
||
return c.events[i].Timestamp.Before(c.events[j].Timestamp)
|
||
})
|
||
|
||
archived := 0
|
||
if docStore != nil && len(archive) > 0 {
|
||
entries := make([]document.ContextEntry, len(archive))
|
||
for i, s := range archive {
|
||
entries[i] = document.ContextEntry{
|
||
Timestamp: s.event.Timestamp,
|
||
Source: s.event.Source,
|
||
Content: s.event.Input,
|
||
Response: s.event.Response,
|
||
ToolResults: convertToolResults(s.event.ToolResults),
|
||
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
|
||
}
|
||
}
|
||
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
||
if err == nil && doc != nil {
|
||
archived = len(entries)
|
||
// 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。
|
||
// 迁移的是块本身(ID 不变、只换持有层),不是复制也不是保活引用;
|
||
// 因此归档后清空源事件的块,确保同一块不同时留在两层。
|
||
for _, s := range archive {
|
||
if s.event != nil {
|
||
s.event.Blocks = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
c.save()
|
||
|
||
return archived
|
||
}
|
||
|
||
func (c *RelevanceContext) Format() string {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
if len(c.events) == 0 {
|
||
return ""
|
||
}
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString("【近期事件】\n")
|
||
for _, e := range c.events {
|
||
sb.WriteString(fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04:05"), e.Source, e.Input))
|
||
if e.Response != "" {
|
||
sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 80)))
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
func (c *RelevanceContext) Recent(n int) []ContextEvent {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
if n <= 0 || n > len(c.events) {
|
||
n = len(c.events)
|
||
}
|
||
result := make([]ContextEvent, n)
|
||
for i, evt := range c.events[len(c.events)-n:] {
|
||
result[i] = *evt
|
||
}
|
||
return result
|
||
}
|
||
|
||
// Blocks 返回当前上下文持有的一等记忆块(供跨层存活判定)。
|
||
// 迁移后源事件已被清空,因此这里只会拿到真正属于 L0 的块。
|
||
func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
var out []memory.MemoryBlock
|
||
for _, e := range c.events {
|
||
out = append(out, e.Blocks...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (c *RelevanceContext) Len() int {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return len(c.events)
|
||
}
|
||
|
||
func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
|
||
if items == nil {
|
||
return nil
|
||
}
|
||
result := make([]document.ToolResultItem, len(items))
|
||
for i, item := range items {
|
||
result[i] = document.ToolResultItem{Name: item.Name, Output: item.Output}
|
||
}
|
||
return result
|
||
}
|