mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
媒体记忆四层收尾。方案 A:只做引用,不建媒体实体节点。 ## 为何不把媒体建成图库实体 图库里的实体与关系全部来自**描述文本**的 NLP 提取——描述经 mediaSummaryForEvent 进 L0 事件的 Input,随归档进 L2 文档的 Content, 蒸馏时提取器自然从描述文字里抽出实体和关系。检索能力已经具备。 若再把媒体本身建成节点,节点名只能从描述里取,而描述会被重新生成 (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下 多个语义模糊的节点。代价换不来能力。 所以这一层只做一件事:**反查**。图库句子写着「[image a1b2c3d4e5f6] 一张紫蓝红三色带图」,要能从这条句子取回那份字节。 ## CommitWithMedia:新增方法而非改签名 Commit 有 10 个非测试调用点 + 21 个测试调用点。为一个多数调用方都不需要 的返回值改全部签名不划算。新增 CommitWithMedia 返回 map[句子文本]sentences.id,Commit 内部转调同一份落库逻辑。 ## digest 靠正则从文本反解 三元组由 NLP 提取器从纯文本产出(nlp.ToMemoryTriple 只填 Subject/ Relation/Object/Confidence/SentenceText),提取链路上没有任何位置能塞进 结构化的 digest。要贯通就得改 internal/nlp 的整条数据流。而媒体标记本身 是我们自己按固定格式写进文本的,反解是最省的可靠做法。 配套加 media.ResolvePrefix:文本里是 12 位短 digest(完整 64 位会把一行 撑爆且无助人眼辨认),media_refs 主键要完整 digest。 **前缀歧义视为错误而非"取第一个"**:挂错引用会让 GC 删掉仍被引用的内容。 完整但不存在的 digest 也报错,否则调用方会挂一条孤儿引用。 ## 顺带修掉 L2→L3 的引用泄漏 这是上一层(f855893)留下的缺口:我当时只处理了 L0→L2 的引用转移, 漏了 L2→L3 这一跳。archiveColdDocs 调 docStore.Remove(doc.ID) 时不注销 媒体引用——文档一旦消失就再没有任何东西能告诉我们它引用过哪些 digest, media_refs 里那条记录永久悬空、引用计数永不归零,对应 blob 永远不会被 GC 回收。 新增 releaseDocMedia。L2→L3 这一跳是**释放**而非转移,因为图库存的是从 描述文本抽出的实体与关系,不再持有字节;媒体此时已完成使命。 顺序有讲究:必须在 commitTriplesWithMedia 之后释放。那一步已把引用挂到 graph_sentence owner 上,先销后挂会让引用计数瞬时归零,此时若后台 GC 正在跑就会把内容当孤儿清掉。 ## 顺带修 Pending 的排除逻辑遗漏(承上一提交) ## 测试 graphmedia_test.go 11 例。核心是 TestBindSentenceMedia_RoundTrip: 写入 → 提交 → 从句子 id 反查 digest → 取回字节逐字节比对 → 跑 GC(0) 确认被引用的内容不被清。 其余覆盖:正则不误命中普通方括号([注意]/[TODO] 不能当 digest,否则会拿 假前缀去 ResolvePrefix)、无法补全的 digest 不挂引用、媒体关闭时全链路 静默 no-op、releaseDocMedia 释放后 GC 真能回收、200 个样本的前缀补全 要么唯一命中要么明确报歧义。 TestCommit_StillWorksAfterRefactor 记录一个既有行为:重复提交时 entitiesCreated 不归零,因为 SQLite 的 ON CONFLICT DO UPDATE 也算一行 affected。用 main 分支的 graph.go 单独跑过基线确认与本次重构无关, 该字段只用于日志,故记录现状不改行为。 全仓 go build / go vet / go test 通过,internal/agent/core 与 internal/memory 全部 -race -count=2 通过,SDK 冻结 diff = 0。
512 lines
15 KiB
Go
512 lines
15 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"runtime/debug"
|
||
"strings"
|
||
"time"
|
||
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||
)
|
||
|
||
type ConsolidationTask struct {
|
||
Type string `json:"type"`
|
||
Reason string `json:"reason"`
|
||
Data interface{} `json:"data"`
|
||
}
|
||
|
||
func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
|
||
msg := fmt.Sprintf(
|
||
"【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 memory_merge 合并实体,或使用 memory_block_merge 标记不合并\n2. 不要使用 memory_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
|
||
task.Type, task.Reason,
|
||
)
|
||
a.injectSelf(msg)
|
||
log.Printf("[agent] enqueued consolidation task: %s", task.Reason)
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 四个独立心跳循环,各自拥有独立的 ticker 和配置
|
||
// ──────────────────────────────────────────────
|
||
|
||
// distillLoop 上下文裁剪(L1→L2),使用 distillInterval
|
||
func (a *Agent) distillLoop() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("[agent] distillLoop panic recovered: %v\n%s", r, debug.Stack())
|
||
time.Sleep(time.Second)
|
||
go a.distillLoop()
|
||
}
|
||
}()
|
||
if a.docStore == nil {
|
||
return
|
||
}
|
||
ticker := time.NewTicker(a.distillInterval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
log.Printf("[agent] heartbeat distill tick")
|
||
a.distillContext()
|
||
a.autoReloadPlugins()
|
||
case <-a.ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// archiveLoop 冷文档归档(L2→L3),使用 archiveInterval
|
||
func (a *Agent) archiveLoop() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("[agent] archiveLoop panic recovered: %v\n%s", r, debug.Stack())
|
||
time.Sleep(time.Second)
|
||
go a.archiveLoop()
|
||
}
|
||
}()
|
||
if a.memory == nil {
|
||
return
|
||
}
|
||
ticker := time.NewTicker(a.archiveInterval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
log.Printf("[agent] heartbeat archive tick")
|
||
a.archiveColdDocs()
|
||
case <-a.ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// mergeLoop 实体合并检测(GraphDB → LLM 裁决),使用 mergeInterval
|
||
func (a *Agent) mergeLoop() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("[agent] mergeLoop panic recovered: %v\n%s", r, debug.Stack())
|
||
time.Sleep(time.Second)
|
||
go a.mergeLoop()
|
||
}
|
||
}()
|
||
if a.memory == nil {
|
||
return
|
||
}
|
||
ticker := time.NewTicker(a.mergeInterval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
log.Printf("[agent] heartbeat merge tick")
|
||
a.detectEntityMerge()
|
||
case <-a.ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// reviewLoop 关系复审(GraphDB → ClearSentenceID → CleanupOrphanedSentences),使用 reviewInterval
|
||
func (a *Agent) reviewLoop() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("[agent] reviewLoop panic recovered: %v\n%s", r, debug.Stack())
|
||
time.Sleep(time.Second)
|
||
go a.reviewLoop()
|
||
}
|
||
}()
|
||
if a.memory == nil {
|
||
return
|
||
}
|
||
ticker := time.NewTicker(a.reviewInterval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
log.Printf("[agent] heartbeat review tick")
|
||
a.reviewRelations()
|
||
case <-a.ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 蒸馏逻辑
|
||
// ──────────────────────────────────────────────
|
||
|
||
func (a *Agent) distillContext() {
|
||
if a.docStore == nil {
|
||
return
|
||
}
|
||
n := a.context.Len()
|
||
if n > a.maxContextSize*2 {
|
||
archived := a.context.Prune("", a.maxContextSize, a.docStore)
|
||
if archived > 0 {
|
||
log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 冷文档归档:docStore → GraphDB (L3→L4)
|
||
// ──────────────────────────────────────────────
|
||
|
||
func (a *Agent) archiveColdDocs() {
|
||
if a.memory == nil {
|
||
return
|
||
}
|
||
|
||
log.Printf("[agent] cold doc archival start")
|
||
|
||
if a.indexer != nil {
|
||
if err := a.indexer.Sync(); err != nil {
|
||
log.Printf("[agent] indexer sync error: %v", err)
|
||
}
|
||
}
|
||
|
||
if a.docStore != nil {
|
||
a.docStore.Reindex()
|
||
}
|
||
|
||
if a.docStore != nil {
|
||
coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
|
||
for _, doc := range coldDocs {
|
||
triples := docToTriples(doc, a.embedder)
|
||
if len(triples) > 0 {
|
||
ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0)
|
||
if err != nil {
|
||
log.Printf("[agent] doc→graph archival error: %v", err)
|
||
continue
|
||
}
|
||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
|
||
// 先销媒体引用再删文档:文档一旦从 docStore 消失,
|
||
// 就再没有任何东西能告诉我们它曾经引用过哪些 digest,
|
||
// media_refs 里那条记录就永久悬空、引用计数永不归零,
|
||
// 导致对应 blob 永远不会被 GC 回收。
|
||
//
|
||
// 且必须在 commitTriplesWithMedia 之后:那一步已经把引用
|
||
// 挂到了 graph_sentence owner 上。先销后挂会让引用计数瞬时
|
||
// 归零,此时若后台 GC 正在跑就会把内容当孤儿清掉。
|
||
a.releaseDocMedia(doc.ID)
|
||
a.docStore.Remove(doc.ID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// releaseDocMedia 注销文档持有的全部媒体引用。
|
||
//
|
||
// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述
|
||
// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命:
|
||
// 描述已经进了图库,blob 可以交给容量 GC 决定去留。
|
||
func (a *Agent) releaseDocMedia(docID string) {
|
||
if a.mediaStore == nil || docID == "" {
|
||
return
|
||
}
|
||
n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID)
|
||
if err != nil {
|
||
log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err)
|
||
return
|
||
}
|
||
if n > 0 {
|
||
log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n)
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 实体合并检测:GraphDB → LLM 裁决
|
||
// ──────────────────────────────────────────────
|
||
|
||
func (a *Agent) detectEntityMerge() {
|
||
if a.memory == nil {
|
||
return
|
||
}
|
||
|
||
log.Printf("[agent] entity merge detection start")
|
||
|
||
result, err := a.memory.Recall(nil, nil, 1, "")
|
||
if err != nil || result == nil || len(result.Entities) < 2 {
|
||
return
|
||
}
|
||
|
||
llmCandidates := 0
|
||
maxCandidates := 5
|
||
|
||
for i := 0; i < len(result.Entities) && llmCandidates < maxCandidates; i++ {
|
||
for j := i + 1; j < len(result.Entities) && llmCandidates < maxCandidates; j++ {
|
||
ea, eb := result.Entities[i].Name, result.Entities[j].Name
|
||
if ea > eb {
|
||
ea, eb = eb, ea
|
||
}
|
||
key := ea + "||" + eb
|
||
|
||
// 跳过已标记"不合并"的实体对
|
||
a.noMergeMu.Lock()
|
||
rounds, ok := a.noMergeMarkers[key]
|
||
if ok {
|
||
rounds--
|
||
if rounds <= 0 {
|
||
delete(a.noMergeMarkers, key)
|
||
} else {
|
||
a.noMergeMarkers[key] = rounds
|
||
}
|
||
}
|
||
a.noMergeMu.Unlock()
|
||
if ok {
|
||
continue
|
||
}
|
||
|
||
// 复合相似度:字符二元组 + 语义向量(仅增强检测,不做自动合并)
|
||
sim := entitySimilarity(result.Entities[i].Name, result.Entities[j].Name)
|
||
semSim := entitySemanticSimilarity(result.Entities[i].Name, result.Entities[j].Name, a.embedder)
|
||
if semSim > sim {
|
||
sim = semSim
|
||
}
|
||
|
||
if sim > 0.75 {
|
||
llmCandidates++
|
||
a.enqueueConsolidationTask(ConsolidationTask{
|
||
Type: "entity_merge",
|
||
Reason: fmt.Sprintf(
|
||
"实体「%s」(类型:%s, 提及%d次) 与「%s」(类型:%s, 提及%d次) 相似度 %.0f%%,可能指代同一事物,请判断是否需要合并",
|
||
result.Entities[i].Name, result.Entities[i].Type, result.Entities[i].MentionCount,
|
||
result.Entities[j].Name, result.Entities[j].Type, result.Entities[j].MentionCount,
|
||
sim*100,
|
||
),
|
||
Data: map[string]interface{}{
|
||
"entity_a": result.Entities[i].Name,
|
||
"entity_a_type": result.Entities[i].Type,
|
||
"entity_a_mentions": result.Entities[i].MentionCount,
|
||
"entity_b": result.Entities[j].Name,
|
||
"entity_b_type": result.Entities[j].Type,
|
||
"entity_b_mentions": result.Entities[j].MentionCount,
|
||
"similarity": sim,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
if llmCandidates > 0 {
|
||
log.Printf("[agent] entity merge: %d merge candidates sent for LLM decision", llmCandidates)
|
||
} else {
|
||
log.Printf("[agent] entity merge: no similar entities found")
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 关系复审:GraphDB → ClearSentenceID → CleanupOrphanedSentences
|
||
// ──────────────────────────────────────────────
|
||
|
||
func (a *Agent) reviewRelations() {
|
||
if a.memory == nil {
|
||
return
|
||
}
|
||
|
||
log.Printf("[agent] relation review start")
|
||
|
||
reviewCount := 0
|
||
const maxReviewBatch = 5
|
||
relResult, err := a.memory.Recall(nil, nil, 1, "")
|
||
if err != nil || relResult == nil {
|
||
return
|
||
}
|
||
for _, rel := range relResult.Relations {
|
||
if reviewCount >= maxReviewBatch {
|
||
break
|
||
}
|
||
if rel.SentenceID == 0 || rel.SentenceText == "" {
|
||
continue
|
||
}
|
||
|
||
a.enqueueConsolidationTask(ConsolidationTask{
|
||
Type: "relation_review",
|
||
Reason: fmt.Sprintf(
|
||
"【关系复审】原始句子: '%s'\n当前三元组: (%s → %s → %s) 置信度 %.2f\n请判断是否需要修正(如相对引用未解析、主宾颠倒、噪音三元组等),如需修正请用 memory_edit 工具",
|
||
rel.SentenceText, rel.SourceName, rel.RelationType, rel.TargetName, rel.Confidence,
|
||
),
|
||
Data: map[string]interface{}{
|
||
"relation_id": rel.ID,
|
||
"source": rel.SourceName,
|
||
"relation_type": rel.RelationType,
|
||
"target": rel.TargetName,
|
||
"confidence": rel.Confidence,
|
||
"sentence": rel.SentenceText,
|
||
},
|
||
})
|
||
|
||
// 清除句子引用(复审后解除关联)
|
||
if err := a.memory.ClearSentenceID(rel.ID); err != nil {
|
||
log.Printf("[agent] clear sentence_id for relation %d: %v", rel.ID, err)
|
||
}
|
||
reviewCount++
|
||
}
|
||
|
||
if reviewCount > 0 {
|
||
// 清理无引用的句子
|
||
if deleted, err := a.memory.CleanupOrphanedSentences(); err != nil {
|
||
log.Printf("[agent] cleanup orphaned sentences: %v", err)
|
||
} else if deleted > 0 {
|
||
log.Printf("[agent] cleanup %d orphaned sentences", deleted)
|
||
}
|
||
log.Printf("[agent] relation review: %d relations sent for review", reviewCount)
|
||
}
|
||
}
|
||
|
||
// entitySemanticSimilarity 使用词嵌入向量余弦相似度计算实体名语义相似度
|
||
func entitySemanticSimilarity(a, b string, embedder *memory.StaticEmbedder) float64 {
|
||
if a == "" || b == "" || embedder == nil || !embedder.Loaded() {
|
||
return 0
|
||
}
|
||
va := embedder.Vectorize(a)
|
||
vb := embedder.Vectorize(b)
|
||
if len(va) == 0 || len(vb) == 0 {
|
||
return 0
|
||
}
|
||
return vector.CosineSimilarity(va, vb)
|
||
}
|
||
|
||
func entitySimilarity(a, b string) float64 {
|
||
if a == "" || b == "" {
|
||
return 0
|
||
}
|
||
if a == b {
|
||
return 1.0
|
||
}
|
||
runesA, runesB := []rune(a), []rune(b)
|
||
if len(runesA) < 2 || len(runesB) < 2 {
|
||
if len(runesA) == len(runesB) && len(runesA) == 1 {
|
||
if runesA[0] == runesB[0] {
|
||
return 1.0
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
setA := make(map[string]bool)
|
||
for i := 0; i < len(runesA)-1; i++ {
|
||
setA[string(runesA[i:i+2])] = true
|
||
}
|
||
|
||
setB := make(map[string]bool)
|
||
for i := 0; i < len(runesB)-1; i++ {
|
||
setB[string(runesB[i:i+2])] = true
|
||
}
|
||
|
||
intersect := 0
|
||
for bg := range setA {
|
||
if setB[bg] {
|
||
intersect++
|
||
}
|
||
}
|
||
|
||
union := len(setA) + len(setB) - intersect
|
||
if union <= 0 {
|
||
return 0
|
||
}
|
||
|
||
return float64(intersect) / float64(union)
|
||
}
|
||
|
||
func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||
var triples []memory.Triple
|
||
if doc == nil {
|
||
return triples
|
||
}
|
||
|
||
if doc.Source == "graph" || doc.Source == "" {
|
||
return nil
|
||
}
|
||
|
||
isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true"
|
||
|
||
// 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」
|
||
if !isArchivedContext && doc.Summary != "" && len([]rune(doc.Summary)) < 80 && !isTemplateSummary(doc.Summary) {
|
||
triples = append(triples, memory.Triple{
|
||
Subject: "文档",
|
||
SubjectType: "Concept",
|
||
Relation: "主题",
|
||
Object: doc.Summary,
|
||
ObjectType: "Topic",
|
||
Confidence: 1.0,
|
||
})
|
||
}
|
||
|
||
// NLP 通用提取
|
||
e := nlp.NewExtractor(nil)
|
||
if embedder != nil {
|
||
e.SetEmbedder(embedder)
|
||
}
|
||
result := e.Extract(doc.Content)
|
||
if result != nil {
|
||
for _, nt := range result.Triples {
|
||
mt := nlp.ToMemoryTriple(nt)
|
||
if mt.Subject != "" && mt.Relation != "" && mt.Object != "" {
|
||
triples = append(triples, mt)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 仅当来源非归档上下文且非空时写「来源」——归档文档写死模板三元组属于垃圾
|
||
if doc.Source != "" && doc.Source != "context_archived" {
|
||
triples = append(triples, memory.Triple{
|
||
Subject: "文档",
|
||
SubjectType: "Concept",
|
||
Relation: "来源",
|
||
Object: doc.Source,
|
||
ObjectType: "Source",
|
||
Confidence: 1.0,
|
||
})
|
||
}
|
||
|
||
return triples
|
||
}
|
||
|
||
// isTemplateSummary 识别 summarizeEntries 生成的模板化摘要
|
||
// (形如「来自 N 个来源的 M 条对话 (src1, src2) 涉及: kw1, kw2」),
|
||
// 这类摘要无独立信息量,不应作为「主题」实体写入图库。
|
||
func isTemplateSummary(s string) bool {
|
||
if s == "" {
|
||
return true
|
||
}
|
||
return strings.HasPrefix(s, "来自 ") && strings.Contains(s, "条对话")
|
||
}
|
||
|
||
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
|
||
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
|
||
"source": source,
|
||
"input": input,
|
||
"response": response,
|
||
"tool_results": toolResults,
|
||
"tools_used": toolsUsed,
|
||
"agent_id": string(a.id),
|
||
"timestamp": time.Now().Unix(),
|
||
})
|
||
}
|
||
|
||
func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
||
start := time.Now()
|
||
a.currentOutputChannel = "_consolidation_"
|
||
|
||
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||
a.injectSourceContext(stageCtx, evt)
|
||
|
||
_, toolsUsed, _, err := a.process(input, stageCtx)
|
||
if err != nil {
|
||
log.Printf("[agent] consolidation error: %v", err)
|
||
return
|
||
}
|
||
|
||
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
|
||
}
|