diff --git a/internal/agent/core/crossmodal.go b/internal/agent/core/crossmodal.go index 637d0f54..66ab396f 100644 --- a/internal/agent/core/crossmodal.go +++ b/internal/agent/core/crossmodal.go @@ -1,6 +1,7 @@ package core import ( + "encoding/json" "fmt" "log" "sort" @@ -286,6 +287,13 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string { if head := docHeadlineForPrompt(h.Doc); head != "" { parts = append(parts, head) } + // ★ 摘要行常常只是"我回复过什么",不含被谈论的东西本身 + // (实测:召回到「已回复老大:原来是「魔芋爽」这个谐音梗」—— + // 模型读得懂"我回复过",但不知道梗是什么,只好答不知道)。 + // 所以补一段**正文/工具回执**摘录:真实内容就在那里。 + if body := docBodyExcerptForPrompt(h.Doc, 80); body != "" { + parts = append(parts, "正文:"+body) + } } // 媒体只报类型与数量,不编描述(描述式索引已在 v1.2.0 拆除) if h.Media != nil { @@ -329,6 +337,107 @@ func firstRealSource(events []document.DocEvent) string { // docHeadlineForPrompt 取文档正文的第一句,作为注入行里的内容提示。 // // 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 coldcontext_query 取。 +// docBodyExcerptForPrompt 取文档里**真实内容**的首段摘录。 +// +// 顺序:事件正文(块里的文本)→ 工具回执 → 文档 Content。 +// 摘要行("已回复…")不在这里出现——它由 docHeadlineForPrompt 负责, +// 两者互补:一个说"发生了什么",一个说"内容是什么"。 +func docBodyExcerptForPrompt(d *document.Doc, n int) string { + if d == nil { + return "" + } + // 最多取两段:事件正文(可能是用户原话)与工具回执(可能是被谈到的内容)。 + // 只取一段时,遇到"已回复…"这类复述就会把真实内容挤掉——实测正是这样: + // 模型看到"原来是这个谐音梗",却看不到梗是什么。 + var cands []string + for _, ev := range d.Events { + for _, b := range ev.Blocks { + if b.Modality != "" && b.Modality != "text" { + continue + } + if line := firstMeaningfulLineForPrompt(b.Text); line != "" { + cands = append(cands, line) + } + } + for _, tr := range ev.ToolResults { + if line := toolResultExcerptForPrompt(tr.Output); line != "" { + cands = append(cands, line) + } + } + if len(cands) >= 2 { + break + } + } + if len(cands) == 0 { + cands = append(cands, firstMeaningfulLineForPrompt(d.Content)) + } + var uniq []string + for _, c := range cands { + if strings.TrimSpace(c) == "" { + continue + } + dup := false + for _, u := range uniq { + if u == c { + dup = true + break + } + } + if !dup { + uniq = append(uniq, c) + } + if len(uniq) >= 2 { + break + } + } + return truncateRunesForPrompt(strings.Join(uniq, " | "), n) +} + +// toolResultExcerptForPrompt 把工具回执压成一行可读正文。 +// +// qq_get_message 这类工具返回 JSON({"content":"因为魔芋爽"}),直接截断会 +// 让模型看到一堆花括号;这里优先取其中的字符串值,取不到才退回原文。 +func toolResultExcerptForPrompt(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "{") || strings.HasPrefix(raw, "[") { + var v interface{} + if err := json.Unmarshal([]byte(raw), &v); err == nil { + if vals := collectJSONStrings(v, 0); len(vals) > 0 { + return strings.Join(vals, " ") + } + } + } + return firstMeaningfulLineForPrompt(raw) +} + +// collectJSONStrings 收集 JSON 里的字符串值(跳过键名与内部字段),深度受限。 +func collectJSONStrings(v interface{}, depth int) []string { + if depth > 3 { + return nil + } + var out []string + switch t := v.(type) { + case map[string]interface{}: + for _, k := range []string{"content", "text", "message", "msg", "summary", "data", "result"} { + if sub, ok := t[k]; ok { + out = append(out, collectJSONStrings(sub, depth+1)...) + } + } + case []interface{}: + for _, sub := range t { + out = append(out, collectJSONStrings(sub, depth+1)...) + } + case string: + if s := strings.TrimSpace(t); s != "" { + out = append(out, s) + } + } + return out +} + func docHeadlineForPrompt(d *document.Doc) string { if d == nil { return "" diff --git a/internal/agent/core/crossmodal_body_test.go b/internal/agent/core/crossmodal_body_test.go new file mode 100644 index 00000000..8da5b3e2 --- /dev/null +++ b/internal/agent/core/crossmodal_body_test.go @@ -0,0 +1,46 @@ +package core + +// L2 预填充必须同时给出「发生了什么」与「内容是什么」。 +// +// 现场:召回命中「已回复老大:原来是「魔芋爽」这个谐音梗」——模型读得懂 +// "我回复过",却不知道梗本身,于是答"不知道"。真实内容在事件的文本块与 +// 工具回执里,摘要行永远不含它。 + +import ( + "strings" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/memory/document" +) + +func TestDocBodyExcerptPicksToolResult(t *testing.T) { + d := &document.Doc{ + Events: []document.DocEvent{{ + Timestamp: time.Now(), + Blocks: []memory.MemoryBlock{ + {Modality: "text", Text: "已回复老大:原来是这个谐音梗。"}, + }, + ToolResults: []document.ToolResultItem{ + {Name: "qq_get_message", Output: `{"content":"因为魔芋爽","message_id":123}`}, + }, + }}, + } + got := docBodyExcerptForPrompt(d, 80) + if !strings.Contains(got, "魔芋爽") { + t.Fatalf("正文摘录应来自工具回执并含真实内容,实际: %q", got) + } + if strings.Contains(got, "{") { + t.Fatalf("JSON 回执应被压成可读文本,实际: %q", got) + } +} + +func TestToolResultExcerptPlainText(t *testing.T) { + if got := toolResultExcerptForPrompt("第一行\n第二行"); got != "第一行" { + t.Fatalf("非 JSON 回执应取首行,实际: %q", got) + } + if got := toolResultExcerptForPrompt(""); got != "" { + t.Fatalf("空回执应为空,实际: %q", got) + } +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index a8d67707..d4ffa41e 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -778,7 +778,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { Tracker: deps.trk, Config: sdk.NewConfig(deps.cfg), Tool: sdk.NewTool(deps.stageHost, deps.iom), - Indexer: sdk.NewIndexer(deps.idx), + Indexer: sdk.NewIndexerWithDocs(deps.idx, deps.docStore), }) } diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 04138d28..b64a1636 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -4166,8 +4166,39 @@ async function queryMemoryContext() { var ctx = data?.context || __("无上下文", "No context"); var summary = data?.summary || ""; var entities = data?.entities || []; + var docs = data?.docs || []; var tk = data?.token_estimate || 0; var html = '
'; + // ★ 召回是 L2+L3 **双路**(2026-10-27):这里必须两路都显示。 + // 此前只显示 L3 图记忆,而 L3 有 72h 冷化延迟 —— 冷化没跑起来时 + // 面板会一直显示"无上下文",让人以为召回彻底坏了(实际 L2 一直有命中)。 + if (docs.length) { + html += + '
' + + __("L2 冷上下文", "L2 cold context") + + '' + + docs + .map((d) => { + var when = String(d.when || "").slice(5, 16); + var score = Number(d.score || 0).toFixed(2); + return ( + '
' + + '' + escHtml(when) + " · " + score + " " + + escHtml(d.text || "") + + "
" + ); + }) + .join("") + + "
"; + } + if (entities.length) { + html += + '
' + + __("L3 图记忆实体", "L3 graph entities") + + '' + + entities.length + + "
"; + } if (summary) html += '
' + diff --git a/internal/sdk/indexer.go b/internal/sdk/indexer.go index 9fd8abdf..697e99bf 100644 --- a/internal/sdk/indexer.go +++ b/internal/sdk/indexer.go @@ -14,4 +14,18 @@ type IndexContext struct { Relations []Relation `json:"relations"` Summary string `json:"summary"` TokenEstimate int `json:"token_estimate"` + // Docs 是 **L2 冷上下文**那一路的结果(文档/媒体片段)。 + // + // 为什么放在同一个结构里:真实召回是 L2 + L3 **双路**,插件若只看到 L3 + // 就会出现「图里没东西 ⇒ 面板显示无上下文」的假象(L3 有 72h 冷化延迟, + // 而 L2 一直是活的)。诊断面板必须复现模型真实收到的两路。 + Docs []DocSnippet `json:"docs"` +} + +// DocSnippet 是 L2 命中的一条片段。 +type DocSnippet struct { + ID string `json:"id"` + When string `json:"when"` + Text string `json:"text"` + Score float64 `json:"score"` } diff --git a/internal/sdk/indexer_impl.go b/internal/sdk/indexer_impl.go index 55fe126b..ba2c17c5 100644 --- a/internal/sdk/indexer_impl.go +++ b/internal/sdk/indexer_impl.go @@ -2,19 +2,28 @@ package sdk import ( "errors" + "strings" + "time" "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/memory/document" ) // indexerImpl 桥接 memory.Indexer 到中立的 IndexerAPI。 type indexerImpl struct { - idx *memory.Indexer + idx *memory.Indexer + docs *document.Store // 可选:给「上下文」诊断面提供 L2 那一路 } func NewIndexer(idx *memory.Indexer) IndexerAPI { return &indexerImpl{idx: idx} } +// NewIndexerWithDocs 让诊断面同时覆盖 L2 冷上下文与 L3 图记忆(双路召回)。 +func NewIndexerWithDocs(idx *memory.Indexer, docs *document.Store) IndexerAPI { + return &indexerImpl{idx: idx, docs: docs} +} + func (i *indexerImpl) BuildContext(q string) (*IndexContext, error) { if i.idx == nil { return nil, errors.New("indexer not available") @@ -31,12 +40,14 @@ func (i *indexerImpl) BuildContext(q string) (*IndexContext, error) { for j, r := range ctx.Relations { relations[j] = Relation{SourceName: r.SourceName, TargetName: r.TargetName, RelationType: r.RelationType} } - return &IndexContext{ + out := &IndexContext{ Entities: entities, Relations: relations, Summary: ctx.Summary, TokenEstimate: ctx.TokenEstimate, - }, nil + } + out.Docs = i.queryDocs(q, 8) + return out, nil } func (i *indexerImpl) FormatContext(ctx *IndexContext) string { @@ -54,6 +65,55 @@ func (i *indexerImpl) FormatContext(ctx *IndexContext) string { }) } +// queryDocs 取 L2 那一路的命中片段(与模型预填充同一检索路径)。 +func (i *indexerImpl) queryDocs(q string, limit int) []DocSnippet { + if i.docs == nil || strings.TrimSpace(q) == "" { + return nil + } + hits := i.docs.QueryScored(q, limit) + out := make([]DocSnippet, 0, len(hits)) + for _, h := range hits { + if h.Doc == nil { + continue + } + when := h.Doc.CreatedAt.Format(time.DateTime) + if h.Doc.HasEvents() { + when = h.Doc.Events[0].Timestamp.Format(time.DateTime) + } + // 正文在事件的**文本块**里(DocEvent 只有 Blocks,没有 Input/Response)。 + text := docFirstText(h.Doc) + out = append(out, DocSnippet{ID: h.Doc.ID, When: when, Text: truncateRunes(text, 120), Score: h.Score}) + } + return out +} + +// docFirstText 取文档可用于展示的首段正文(跳过媒体块)。 +func docFirstText(d *document.Doc) string { + if d == nil { + return "" + } + for _, ev := range d.Events { + for _, b := range ev.Blocks { + if b.Modality != "" && b.Modality != "text" { + continue + } + if t := strings.TrimSpace(b.Text); t != "" { + return t + } + } + } + return d.Content +} + +func truncateRunes(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + func (i *indexerImpl) GetToolDefinitions() []map[string]interface{} { if i.idx == nil { return nil