Files
HomeAgent/internal/agent/core/crossmodal.go
JianFeeeee 77f596fb07 chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。

## 范围

- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
  致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
  错误 Reason)——那是产品语言,不属于日志

## 连带修改(必须同步,否则静默失效)

| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |

## 顺带修正一处提示词超限(真实回归)

新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。

处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。

【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。

## 验证

- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
  config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用

## 运维提示

日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
  内核装配完成        -> kernel assembly complete
  插件调度完成        -> plugin scheduling done
  已建链              -> link established
  已加载 N 个         -> N loaded
2026-10-25 15:55:26 +08:00

377 lines
12 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 core
import (
"fmt"
"log"
"sort"
"strings"
"unicode"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
)
// CrossModalHit 是跨模态检索融合后的一条候选。
//
// 统一的检索单元是**记忆块**(文本块与媒体块同构)。文本路命中文档;
// 视觉路命中媒体块——两者都是块,向量在同一统一空间里可直接比较。
//
// ★ 2026-10-11:不再有「CAS 全库」这个检索面。视觉路直接走
// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的
// 内容」这回事——能召回的必然是被某层持有的块。
type CrossModalHit struct {
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
MediaScore float64 // 视觉路原始 cosine(无则 0)
DocScore float64 // 文本路原始 cosine(无则 0)
Fused float64 // 归一化加权融合分,供最终排序
// 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被
// 一路命中的候选之前。
DoubleHit bool
}
// CrossModalFusionConfig 控制文本路与视觉路的融合行为。
type CrossModalFusionConfig struct {
WeightText float64 // 文本路融合权重(默认 0.5)
WeightVisual float64 // 视觉路融合权重(默认 0.5)
DoubleHitBonus float64 // 双命中额外加分(默认 0.15)
MinMaxEps float64 // min-max 归一化除零保护(默认 1e-12)
}
var defaultFusionConfig = CrossModalFusionConfig{
WeightText: 0.5,
WeightVisual: 0.5,
DoubleHitBonus: 0.15,
MinMaxEps: 1e-12,
}
func (c CrossModalFusionConfig) textWeight() float64 {
if c.WeightText <= 0 {
return defaultFusionConfig.WeightText
}
return c.WeightText
}
func (c CrossModalFusionConfig) visualWeight() float64 {
if c.WeightVisual <= 0 {
return defaultFusionConfig.WeightVisual
}
return c.WeightVisual
}
func (c CrossModalFusionConfig) doubleHitBonus() float64 { return c.DoubleHitBonus }
func (c CrossModalFusionConfig) minMaxEps() float64 {
if c.MinMaxEps <= 0 {
return defaultFusionConfig.MinMaxEps
}
return c.MinMaxEps
}
// retrieveCrossModal 是跨模态并行检索的统一入口。
//
// 策略(两路并行,召回真正最相似的):
// 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
// 命中文档若持有媒体块,直接带上该块。
// 2. 视觉路:query 经多模态模型文本编码 → 与**媒体块自己的向量**比余弦
// (memory.RecallBlocks),覆盖文本向量没写到的视觉内容。
// 用的就是块向量——没有第二套向量、没有第二个检索面。
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
//
// 多模态空间未配置时视觉路为空,退化为纯文本路。
func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
if topK <= 0 {
topK = 5
}
per := topK * 2
if per < 8 {
per = 8
}
// ---- 文本路 ----
var textHits []CrossModalHit
if a.docStore != nil {
for _, dh := range a.docStore.QueryScored(query, per) {
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
// 命中文档若持有一等记忆块,把首个媒体块一并带上(用于双命中标记)。
for i := range dh.Doc.Blocks {
if dh.Doc.Blocks[i].Modality != memory.BlockText {
b := dh.Doc.Blocks[i]
hit.Media = &b
break
}
}
textHits = append(textHits, hit)
}
}
// ---- 视觉路(多模态文本编码 → 媒体块自己的向量)----
var visualHits []CrossModalHit
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.memory != nil {
qv, err := a.multimodalSpace.VectorizeDense(query)
if err != nil {
log.Printf("[crossmodal] multimodal text encoding failed: %v", err)
} else if hits, err := a.memory.RecallBlocks(memory.BlockRecallQuery{
Vector: qv,
Fingerprint: a.multimodalSpace.Fingerprint(),
TopK: per,
// ★ ExcludeText 必须传:视觉路要的是**媒体块**。
//
// 不传的后果(实测回归,块 union 化引入):CAS 时代这里查的是
// `FROM media`(只含媒体),结构上不可能被文本块挤掉。
// 换成全局 RecallBlocks 后,同一个 chineseclip 空间里图像块的
// 绝对 cosine(0.36~0.41)天然低于文本块(0.65~1.00),
// per=200 时返回 1000 条里图像块 **0 条** —— 视觉路被饿死。
// 下面的 `continue` 是防御,不是过滤手段:筛选必须发生在
// 截断**之前**,否则名额已被文本块占满。
ExcludeText: true,
}); err != nil {
log.Printf("[crossmodal] media block retrieval failed: %v", err)
} else {
for _, h := range hits {
if h.Block.Modality == memory.BlockText {
continue // 文本块由文本路负责,避免同一内容两路重复
}
b := h.Block
visualHits = append(visualHits, CrossModalHit{
Media: &b, MediaScore: h.Score,
})
}
}
}
return fuseCrossModal(textHits, visualHits, topK, cfg)
}
// fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。
func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
norm := func(hits []CrossModalHit, pick func(CrossModalHit) float64) []float64 {
out := make([]float64, len(hits))
if len(hits) == 0 {
return out
}
maxV, minV := pick(hits[0]), pick(hits[0])
for _, h := range hits[1:] {
v := pick(h)
if v > maxV {
maxV = v
}
if v < minV {
minV = v
}
}
for i, h := range hits {
v := pick(h)
if maxV-minV < cfg.minMaxEps() {
out[i] = 1
continue
}
out[i] = (v - minV) / (maxV - minV)
}
return out
}
textN := norm(textHits, func(h CrossModalHit) float64 { return h.DocScore })
visualN := norm(visualHits, func(h CrossModalHit) float64 { return h.MediaScore })
byKey := make(map[string]*CrossModalHit)
var keys []string
key := func(h CrossModalHit) string {
if h.Doc != nil {
return "doc:" + h.Doc.ID
}
if h.Media != nil {
return "media:" + h.Media.ID
}
return ""
}
for i, h := range visualHits {
k := key(h)
if k == "" {
continue
}
clone := h
clone.Fused = cfg.visualWeight() * visualN[i]
byKey[k] = &clone
keys = append(keys, k)
}
for i, h := range textHits {
if h.Doc == nil {
continue
}
if h.Media != nil {
if ex, ok := byKey["media:"+h.Media.ID]; ok {
ex.DoubleHit = true
ex.Doc = h.Doc
ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus()
continue
}
}
k := "doc:" + h.Doc.ID
if ex, ok := byKey[k]; ok {
ex.Doc = h.Doc
ex.DoubleHit = false
ex.Fused += cfg.textWeight() * textN[i]
continue
}
clone := h
clone.Fused = cfg.textWeight() * textN[i]
byKey[k] = &clone
keys = append(keys, k)
}
var merged []CrossModalHit
for _, k := range keys {
if c := byKey[k]; c != nil {
merged = append(merged, *c)
}
}
sort.SliceStable(merged, func(i, j int) bool {
if merged[i].DoubleHit != merged[j].DoubleHit {
return merged[i].DoubleHit
}
return merged[i].Fused > merged[j].Fused
})
if len(merged) > topK {
merged = merged[:topK]
}
return merged
}
// crossModalMarkdown 把融合候选渲染成注入提示词的文本。
//
// ## 为什么改
//
// 旧输出是这样的:
//
// 【跨模态相关记忆】
// [1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中)
//
// 三个问题,都被生产日志证实:
//
// 1. 只有**统计描述**(几个来源几条对话),不是内容 —— 模型无法判断相关性;
// 2. **没有一个字**说"你可以把这些原文取回来" —— 模型看完只能忽略;
// 3. 来源写了 `context_archived` 这种内部标记,对模型无意义。
//
// 现在每条给出「时间 · 来源 · 正文开头」,并在块尾**明确告知 doc_query 的作用**:
// 这才是让预填充真正被用起来的必要条件。
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
if len(hits) == 0 {
return ""
}
var lines []string
for i, h := range hits {
var parts []string
parts = append(parts, fmt.Sprintf("[%d]", i+1))
// 时间 + 来源:让模型知道"这是什么时候、谁的事"
if h.Doc != nil {
if h.Doc.HasEvents() {
first := h.Doc.Events[0].Timestamp
last := h.Doc.Events[len(h.Doc.Events)-1].Timestamp
if first.Equal(last) {
parts = append(parts, first.Format("2006-01-02 15:04"))
} else {
parts = append(parts, fmt.Sprintf("%s→%s",
first.Format("01-02 15:04"), last.Format("01-02 15:04")))
}
// 真实来源(跳过内部归档标记,它对模型无意义)
if src := firstRealSource(h.Doc.Events); src != "" {
parts = append(parts, src)
}
} else {
parts = append(parts, h.Doc.CreatedAt.Format("2006-01-02 15:04"))
}
}
// 正文开头:真实内容,模型可据此判断相关性
if h.Doc != nil {
if head := docHeadlineForPrompt(h.Doc); head != "" {
parts = append(parts, head)
}
}
// 媒体只报类型与数量,不编描述(描述式索引已在 v1.2.0 拆除)
if h.Media != nil {
label := string(h.Media.Modality)
if h.Media.MIME != "" {
label = h.Media.MIME
}
parts = append(parts, "["+label+"]")
}
if h.DoubleHit {
parts = append(parts, "(图文双命中)")
}
parts = append(parts, fmt.Sprintf("相关度:%.2f", h.Fused))
lines = append(lines, strings.Join(parts, " · "))
}
var b strings.Builder
b.WriteString("【历史记忆(L2,自动预填充)】\n")
b.WriteString("下面是按语义相似度从历史层挑出的相关片段 —— **不是当前对话**。\n")
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n要其中某条的完整原文与当时过程:用 doc_query,它会把那些事件" +
"按原始时间戳插回你的对话时序。")
return b.String()
}
// firstRealSource 取事件里第一个非内部标记的来源。
//
// `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。
func firstRealSource(events []document.DocEvent) string {
for _, ev := range events {
if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" {
return ev.Source
}
}
if len(events) > 0 {
return events[0].Source
}
return ""
}
// docHeadlineForPrompt 取文档正文的第一句,作为注入行里的内容提示。
//
// 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 doc_query 取。
func docHeadlineForPrompt(d *document.Doc) string {
if d == nil {
return ""
}
if d.HasEvents() {
for _, ev := range d.Events {
in, resp, _ := ev.InOut()
for _, cand := range []string{in, resp} {
if line := firstMeaningfulLineForPrompt(cand); line != "" {
return truncateRunesForPrompt(line, 60)
}
}
}
return ""
}
return truncateRunesForPrompt(firstMeaningfulLineForPrompt(d.Content), 60)
}
func firstMeaningfulLineForPrompt(s string) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
hasWord := false
for _, r := range line {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
hasWord = true
break
}
}
if hasWord {
return strings.Join(strings.Fields(line), " ")
}
}
return ""
}
func truncateRunesForPrompt(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}