mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
Compare commits
11 Commits
v1.1.0
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b792b91f5 | |||
| 208d39c296 | |||
| eb02f00998 | |||
| f478659b89 | |||
| 743b963dec | |||
| 6b87a1de14 | |||
| 26dc76f1a6 | |||
| 440704cf27 | |||
| c08383dc4b | |||
| e671a8c082 | |||
| dcaea64439 |
@ -193,7 +193,11 @@ internal/
|
|||||||
|
|
||||||
## 项目状态
|
## 项目状态
|
||||||
|
|
||||||
**v1.1.0** — 记忆系统支持二进制多媒体节点。此前四层记忆(L0 活跃上下文 / L1 文本 / L2 文档 / L3 图库)全部只存文字,图片音频经视觉模型转成描述后原始字节即丢弃,"那张紫蓝红三色带图"再也取不回来。本版新增内容寻址媒体存储(CAS,`internal/memory/media`):元数据进 SQLite、blob 按 sha256 落盘去重,L0/L2/L3 各层只记 digest 并通过 `media_refs` 维护引用计数,容量上限由后台 GC 真正兑现(被引用的内容即便超限也永不删除)。**描述文本才是持久语义记忆,blob 只是缓存**——描述随记忆各层一直留存并可检索,原始字节可被容量 GC 淘汰,因此几个月后仍能从图库句子反查到那张图(若尚在则逐字节取回)。描述由后台循环经视觉源生成(默认关闭,开启后每 30s 最多 4 条,不与对话抢配额),放在对话路径上会给每张图的回复加十几秒而收益为零——那一轮模型本来就直接看着图。同时修五个缺陷:`core.New` 漏接 `rc.SetMediaStore` 致 L0→L2 引用转移在生产静默失效;三元组全被实体名校验拒绝时仍释放引用并删除文档(数据丢失,已反向验证);媒体入图库曾依赖 NLP 提取器碰巧提出合规三元组而时好时坏,改为按媒体标记确定性产出;L3 媒体检索一度没有任何调用方(能存进去、agent 拿不出来);`remotedevice` 网关与 `agentcli` 终端各一处数据竞争。配套 `-tags medialive` 自动触发链实测:只注入一个图片事件,落盘/描述/归档/图库绑定/GC 保护/二轮召回七个阶段全由生产代码自行触发,真实视觉模型下 agent 在不给图的第二轮准确答出三条色带的颜色与近似 hex。插件 ABI 未变(`SDKCompatibleVersion` 仍为 1.0.0),存量 `plugin.bin` 无需重编。
|
**v1.0.4** — 两处数据竞争修复(现网 `/api/v1/device/ws` 通道与终端推流)。此前 `-race` 全仓复验即暴露:`remotedevice` 网关对同一连接的 `bufio.Writer` 由两条路径并发写(`handleWS` 主循环回写 hello_ack/绑定回执/pong,与 `PushJSON`/`PushData` 的 agent→设备下发),`bufio.Writer` 非线程安全,`TestWSPushDataAudio` 异步下发即稳定撞车;`agentcli` 终端把共享读缓冲传给 reader goroutine(OS 层持续覆写)又在 `readLoop` 里 `copy(data, buf[:r.n])`,读写并发。修法:连接级写锁(`wconn.wmu`,Push* 与 handleWS 共用同一把锁,`PushData` 整条下发持锁保证协议顺序)与「读结果随 `readResult` 自带切片传递、不再共享缓冲」。全仓 `go test ./... -race` 由 7 处 race / 5 个测试 FAIL 变为 32 包全绿。
|
||||||
|
|
||||||
|
**v1.0.3** — 内核 stage 协调器双重解锁修复。现网 homed 主进程曾一次 `fatal error: sync: unlock of unlocked mutex` 整体死亡(带走全部 27 个子进程插件):`Host.endStage` 把「递减 inflight、判定最后离开者」放在 `coordMu` 临界区之外,而摘除协调器在临界区之内,于是后到插件能挂进一个正在收尾的协调器、被误判成最后离开者,对同一把 `stageMu` 解了两次。**`sync.Mutex` 双重解锁是 runtime fatal 而非 panic,两层 `recover` 结构上拦不住**,这才让「插件崩溃不拖垮内核」的隔离设计整体失效。修法是把计数、判定、摘除收进同一临界区,并把首进者写共享段的 `enter()` 也移入锁内(此前后到者可能读到写一半的段)。配套 5 个回归用例,含把旧实现 stash 回来验证测试确实能复现 fatal 的反向验证。
|
||||||
|
|
||||||
|
**v1.0.1** — 多模态 bugfix。插件 ABI/协议未变,1.0.0 编出的 `plugin.bin` 无需重编。修三类缺陷:(1)**看图假成功**——媒体块挂在 tool message 上不被模型当作可视内容(实测同一张图:tool message 0/3 读到、独立 user message 3/3),改为另起一条紧随其后的 user message 承载,落实插件文案一直在说的「注入后续对话」;(2)**新增多模态能力声明与回退链**——`core.llm.sources.<name>.vision/.audio` 声明源能否真正处理媒体(网关会静默剥离 `image_url` 后仍返回 200,带图与不带图 prompt_tokens 完全相同),不支持时自动走视觉源转写成文字,并落实了 `core.input_processing.image.fallback_provider` 这批早已注册却从未被读取的配置项;(3)**`see_video` 帧数语义反了**——`fps=1/N` 是频率不是数量,20s 视频请求 10 帧只得 2 帧、请求 1 帧反得 20 帧,改为 `ffprobe` 取时长 + `fps=N/时长` + `-frames:v` 硬封顶。
|
||||||
|
|
||||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||||
|
|
||||||
@ -220,7 +224,7 @@ internal/
|
|||||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||||
|
|
||||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
||||||
- Windows:`HomeAgent_v1.1.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
- Windows:`HomeAgent_v1.0.4_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||||
- 校验:`SHA256SUMS`
|
- 校验:`SHA256SUMS`
|
||||||
|
|
||||||
|
|||||||
@ -179,7 +179,11 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**v1.1.0** — Binary/multimedia nodes in the memory system. All four tiers (L0 active context / L1 text / L2 documents / L3 graph) previously stored text only: an image or audio clip was turned into a description by a vision model and the original bytes were dropped, so "that purple-blue-red banded image" could never be retrieved again. This release adds a content-addressed media store (CAS, `internal/memory/media`): metadata in SQLite, blobs deduplicated on disk by sha256, with every tier holding only digests and reference counts maintained through `media_refs`, so the capacity cap is finally enforced by a background GC (referenced content is never deleted, even over the limit). **The description text is the durable semantic memory; the blob is only a cache** — descriptions persist across all tiers and stay searchable while raw bytes may be evicted, so months later a graph sentence still resolves back to that image (byte-for-byte if it survives). Descriptions are generated by a background loop through a vision source (off by default; at most 4 items per 30s when enabled, so it never competes with conversations for quota) — doing it inline would add tens of seconds to every image reply for no gain, since the model is looking at the image in that turn anyway. Five defects fixed as well: `core.New` never called `rc.SetMediaStore`, silently disabling L0→L2 reference transfer in production; references were released and the document deleted even when every triple was rejected by entity-name validation (data loss, reverse-verified); media entering the graph depended on the NLP extractor happening to produce valid triples and was therefore intermittent, now replaced by deterministic triples derived from media markers; L3 media lookup had no callers at all (stored fine, unreachable by the agent); and one data race each in the `remotedevice` gateway and the `agentcli` terminal. Ships with a `-tags medialive` auto-trigger integration test: a single injected image event drives all seven stages — CAS write, description, archival, graph binding, GC protection, second-turn recall — entirely through production code paths, and with a real vision model the agent names all three band colours and their approximate hex values in a second turn that includes no image. Plugin ABI unchanged (`SDKCompatibleVersion` stays 1.0.0); existing `plugin.bin` files need no rebuild.
|
**v1.0.4** — Two data-race fixes (the live `/api/v1/device/ws` gateway and terminal streaming). A full `-race` pass exposed both: `remotedevice` wrote one connection's `bufio.Writer` from two concurrent paths (`handleWS` loop replies hello_ack/bind_ack/pong, plus `PushJSON`/`PushData` agent→device pushes) — `bufio.Writer` is not thread-safe, and `TestWSPushDataAudio` async push hit it reliably; `agentcli` handed the shared read buffer to the reader goroutine (which the OS keeps overwriting) while `readLoop` did `copy(data, buf[:r.n])` — concurrent read/write of the same buffer. Fix: connection-level write lock (`wconn.wmu`, shared by Push* and handleWS; `PushData` holds it across the whole start/chunks/end sequence to preserve protocol order) plus carrying read results in per-result slices instead of a shared buffer. Repo-wide `go test ./... -race` went from 7 races / 5 failing tests to all-clean.
|
||||||
|
|
||||||
|
**v1.0.3** — Kernel stage-coordinator double-unlock fix. The production `homed` main process once died outright with `fatal error: sync: unlock of unlocked mutex`, taking all 27 subprocess plugins with it: `Host.endStage` performed "decrement inflight, decide whether I'm the last leaver" *outside* the `coordMu` critical section while detaching the coordinator *inside* it, so a late-arriving plugin could attach to a coordinator that was already finishing, be misjudged as the last leaver, and unlock the same `stageMu` twice. **A `sync.Mutex` double unlock is a runtime fatal, not a panic, so the two layers of `recover` structurally cannot catch it**—which is exactly why the "a crashing plugin must not take down the kernel" isolation design failed wholesale here. The fix folds counting, decision, and detach into one critical section, and also moves the first arriver's `enter()` (which writes the shared segment) inside the lock—previously a late arriver could read a half-written segment. Ships with 5 regression cases, including a reverse check that stashes the old implementation back to confirm the tests really do reproduce the fatal.
|
||||||
|
|
||||||
|
**v1.0.1** — Multimodal bugfix. The plugin ABI/protocol is unchanged, so `plugin.bin` artifacts built for 1.0.0 need no rebuild. Three defects fixed: (1) **vision silently failing**—media blocks attached to a tool message are not treated as viewable content by the model (measured on one image: 0/3 read from a tool message, 3/3 from a standalone user message); media now rides its own user message placed immediately after, which is what the plugin's own wording ("injected into the following conversation") always claimed; (2) **new multimodal capability declaration + fallback chain**—`core.llm.sources.<name>.vision/.audio` declares whether a source can genuinely process media (a gateway may strip `image_url` and still return 200, with identical prompt_tokens with and without the image); when it cannot, media is transcribed to text via a vision-capable source, finally wiring up the long-registered but never-read `core.input_processing.image.fallback_provider` settings; (3) **`see_video` frame-count semantics were inverted**—`fps=1/N` is a *rate*, not a count, so a 20s video yielded 2 frames when 10 were requested and 20 frames when 1 was requested; now `ffprobe` measures duration and the filter becomes `fps=N/duration` with `-frames:v` as a hard cap.
|
||||||
|
|
||||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||||
|
|
||||||
@ -206,7 +210,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
|||||||
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
||||||
|
|
||||||
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
||||||
- Windows: `HomeAgent_v1.1.0_{Full,Server,Client}_win64.exe` (NSIS installer)
|
- Windows: `HomeAgent_v1.0.4_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||||
- Verification: `SHA256SUMS`
|
- Verification: `SHA256SUMS`
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,6 @@ import (
|
|||||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||||
@ -325,27 +324,6 @@ func main() {
|
|||||||
log.Printf("[homed] warning: document store: %v", err)
|
log.Printf("[homed] warning: document store: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
|
||||||
// L0/L2/L3 只记 digest。开关默认开;关闭后全部媒体接线静默跳过,
|
|
||||||
// 对话行为与本特性上线前完全一致。
|
|
||||||
var mediaStore *media.Store
|
|
||||||
if cfgReg.GetBool("core.memory.media.enabled", true) {
|
|
||||||
mediaDir := cfgReg.GetString("core.memory.media.dir",
|
|
||||||
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
|
|
||||||
maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048)
|
|
||||||
ms, err := media.New(mediaDir, int64(maxMB)*1024*1024)
|
|
||||||
if err != nil {
|
|
||||||
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
|
|
||||||
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
|
|
||||||
} else {
|
|
||||||
mediaStore = ms
|
|
||||||
defer mediaStore.Close()
|
|
||||||
st := mediaStore.Stats()
|
|
||||||
log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)",
|
|
||||||
st["count"], st["total_bytes"], maxMB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||||
if err := ks.Start(); err != nil {
|
if err := ks.Start(); err != nil {
|
||||||
log.Printf("[homed] warning: knowledge store: %v", err)
|
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||||
@ -453,10 +431,6 @@ func main() {
|
|||||||
Knowledge: ks,
|
Knowledge: ks,
|
||||||
SocialStore: socialStore,
|
SocialStore: socialStore,
|
||||||
TextMemory: textMem,
|
TextMemory: textMem,
|
||||||
MediaStore: mediaStore,
|
|
||||||
MediaGCInterval: cfgReg.GetDuration("core.memory.media.gc_interval", 6*time.Hour),
|
|
||||||
MediaGCMinAge: cfgReg.GetDuration("core.memory.media.gc_min_age", time.Hour),
|
|
||||||
MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false),
|
|
||||||
Personality: personality,
|
Personality: personality,
|
||||||
PluginReg: pluginReg,
|
PluginReg: pluginReg,
|
||||||
PluginDir: cfg.Plugin.Dir,
|
PluginDir: cfg.Plugin.Dir,
|
||||||
|
|||||||
@ -153,6 +153,14 @@ build_initconfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---- gui (Electron) ----
|
# ---- gui (Electron) ----
|
||||||
|
#
|
||||||
|
# 输出目录必须用 --config.directories.output,**不能用 -o**:
|
||||||
|
# electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的 Building 段),
|
||||||
|
# 不是 output。此前 `-o "$BUILD_DIR"` 被当成 macOS 的 target 列表,报
|
||||||
|
# ⨯ Unknown target: /home/program/trueagent/build
|
||||||
|
# (路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的,
|
||||||
|
# 这也是它看起来像「路径错」而实际是「参数位置错」的原因)。
|
||||||
|
# v1.0.1 与 v1.0.3 两次发布都因此手工组装过 GUI。
|
||||||
build_gui() {
|
build_gui() {
|
||||||
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
|
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
|
||||||
echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host"
|
echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host"
|
||||||
@ -170,12 +178,21 @@ build_gui() {
|
|||||||
# 不传 --config:electron-builder 默认从 package.json 的 "build" 键读配置。
|
# 不传 --config:electron-builder 默认从 package.json 的 "build" 键读配置。
|
||||||
# 传 --config package.json 会让它把**整个** package.json 当配置校验,
|
# 传 --config package.json 会让它把**整个** package.json 当配置校验,
|
||||||
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
|
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
|
||||||
(cd "$gui_dir" && npx electron-builder \
|
#
|
||||||
--linux --win --mac \
|
# GUI 失败不中断整体构建:homed/waiter/initconfig 是发布的主体,
|
||||||
--x64 --arm64 \
|
# 而 GUI 依赖 electron 运行时下载(离线机器、arm64 缺缓存都会失败)。
|
||||||
-p never \
|
# set -e 下若不接住,一个可选组件会让整轮跨平台构建全废。
|
||||||
-o "$BUILD_DIR")
|
if (cd "$gui_dir" && npx electron-builder \
|
||||||
echo " OK"
|
--linux --win --mac \
|
||||||
|
--x64 --arm64 \
|
||||||
|
-p never \
|
||||||
|
--config.directories.output="$BUILD_DIR"); then
|
||||||
|
echo " OK"
|
||||||
|
else
|
||||||
|
echo " WARN: gui 构建失败(可选组件,不影响 homed/waiter/initconfig)"
|
||||||
|
echo " Linux 包可用 deploy/packaging/package-linux.sh 内置的手工组装路径"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---- dispatch ----
|
# ---- dispatch ----
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
|
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
|
||||||
# 会显示错误版本(DisplayVersion 也取自这个宏)。
|
# 会显示错误版本(DisplayVersion 也取自这个宏)。
|
||||||
!ifndef PRODUCT_VERSION
|
!ifndef PRODUCT_VERSION
|
||||||
!define PRODUCT_VERSION "1.1.0"
|
!define PRODUCT_VERSION "1.0.4"
|
||||||
!endif
|
!endif
|
||||||
|
|
||||||
!if "${VARIANT}" == "full"
|
!if "${VARIANT}" == "full"
|
||||||
|
|||||||
@ -9,14 +9,20 @@ PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux"
|
|||||||
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
|
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
|
||||||
|
|
||||||
ARCH="${1:-amd64}" # amd64 or arm64
|
ARCH="${1:-amd64}" # amd64 or arm64
|
||||||
|
|
||||||
|
# electron 官方发布物用 x64/arm64 命名,而 Debian 用 amd64/arm64。
|
||||||
|
# 两者在 arm64 上恰好同名,amd64 上不同——此前缓存查找统一用 TAR_ARCH
|
||||||
|
# (amd64),于是 electron-v*-linux-x64.zip 永远命中不到,amd64 GUI 只能
|
||||||
|
# 靠"回退到 host node_modules"这条路组装。干净 worktree 里没有完整
|
||||||
|
# node_modules,GUI 就被静默跳过。故单独映射。
|
||||||
ACTION="${2:-all}" # all, build, deb, tar, rpm
|
ACTION="${2:-all}" # all, build, deb, tar, rpm
|
||||||
|
|
||||||
DEB_ARCH="$ARCH"
|
DEB_ARCH="$ARCH"
|
||||||
RPM_ARCH="$ARCH"
|
RPM_ARCH="$ARCH"
|
||||||
TAR_ARCH="$ARCH"
|
TAR_ARCH="$ARCH"
|
||||||
case "$ARCH" in
|
case "$ARCH" in
|
||||||
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64" ;;
|
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64"; ELECTRON_ARCH="x64" ;;
|
||||||
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64" ;;
|
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64"; ELECTRON_ARCH="arm64" ;;
|
||||||
*) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;;
|
*) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@ -142,24 +148,48 @@ build_gui() {
|
|||||||
|
|
||||||
echo ">>> Building GUI directory for linux/$ARCH..."
|
echo ">>> Building GUI directory for linux/$ARCH..."
|
||||||
|
|
||||||
if [ ! -d "$gui_dir/node_modules" ]; then
|
# 判据是 electron 包本身在不在,而不是 node_modules 目录在不在。
|
||||||
|
#
|
||||||
|
# npm install 失败(离线、网络受限)会留下一个只有一两个条目的空壳
|
||||||
|
# node_modules,目录存在但 electron 缺失。只看目录会以为"已安装",
|
||||||
|
# 于是 ever 读不到版本、缓存匹配退化、最后走到"host dist 也没有"而
|
||||||
|
# 静默跳过 GUI——包名和目录名全都正确,只是没有 GUI,没有任何一步报错。
|
||||||
|
if [ ! -f "$gui_dir/node_modules/electron/package.json" ]; then
|
||||||
|
if [ -d "$gui_dir/node_modules" ]; then
|
||||||
|
echo " node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)"
|
||||||
|
fi
|
||||||
echo " npm install..."
|
echo " npm install..."
|
||||||
(cd "$gui_dir" && npm install --production)
|
if ! (cd "$gui_dir" && npm install --production); then
|
||||||
|
echo " WARNING: npm install 失败——离线环境下这是预期的。"
|
||||||
|
echo " GUI 需要 cmd/gui/node_modules/electron 或 ~/.cache/electron 缓存。"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# electron 版本从已安装的包里读,保证运行时与 app 依赖一致
|
# electron 版本优先从已安装的包里读,保证运行时与 app 依赖一致。
|
||||||
|
# 读不到时退而从 package.json 的依赖声明里取数字部分(它可能写成
|
||||||
|
# "^33.0.0" 这类范围,只用于给缓存匹配一个提示,匹配不上仍会走通配)。
|
||||||
local ever
|
local ever
|
||||||
ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true)
|
ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true)
|
||||||
|
if [ -z "$ever" ]; then
|
||||||
|
ever=$(python3 -c "
|
||||||
|
import json, re
|
||||||
|
d = json.load(open('$gui_dir/package.json'))
|
||||||
|
spec = (d.get('devDependencies', {}) or {}).get('electron') or (d.get('dependencies', {}) or {}).get('electron') or ''
|
||||||
|
m = re.search(r'(\\d+(?:\\.\\d+)*)', spec)
|
||||||
|
print(m.group(1) if m else '')
|
||||||
|
" 2>/dev/null || true)
|
||||||
|
[ -n "$ever" ] && echo " electron 版本取自 package.json 依赖声明: $ever(非精确)"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$gui_out"
|
mkdir -p "$gui_out"
|
||||||
|
|
||||||
# 优先:缓存里的目标架构 zip(~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)
|
# 优先:缓存里的目标架构 zip(~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)
|
||||||
local zip=""
|
local zip=""
|
||||||
if [ -n "$ever" ]; then
|
if [ -n "$ever" ]; then
|
||||||
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${TAR_ARCH}.zip" 2>/dev/null | head -1)
|
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
|
||||||
fi
|
fi
|
||||||
if [ -z "$zip" ]; then
|
if [ -z "$zip" ]; then
|
||||||
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${TAR_ARCH}.zip" 2>/dev/null | head -1)
|
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$zip" ]; then
|
if [ -n "$zip" ]; then
|
||||||
@ -174,9 +204,9 @@ build_gui() {
|
|||||||
*) host_arch=unknown ;;
|
*) host_arch=unknown ;;
|
||||||
esac
|
esac
|
||||||
if [ "$TAR_ARCH" != "$host_arch" ]; then
|
if [ "$TAR_ARCH" != "$host_arch" ]; then
|
||||||
echo " WARNING: 缺 electron-v*-linux-${TAR_ARCH}.zip 缓存,且目标架构与 host"
|
echo " WARNING: 缺 electron-v*-linux-${ELECTRON_ARCH}.zip 缓存,且目标架构与 host"
|
||||||
echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。"
|
echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。"
|
||||||
echo " 解法:下载 electron-v${ever:-<ver>}-linux-${TAR_ARCH}.zip 到"
|
echo " 解法:下载 electron-v${ever:-<ver>}-linux-${ELECTRON_ARCH}.zip 到"
|
||||||
echo " ~/.cache/electron/<任意子目录>/ 后重跑。"
|
echo " ~/.cache/electron/<任意子目录>/ 后重跑。"
|
||||||
rm -rf "$gui_out"
|
rm -rf "$gui_out"
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
# Git 分支管理规范
|
# Git 分支管理规范
|
||||||
|
|
||||||
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支)。
|
> 生效:2026-08-31。适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
||||||
> 适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;版本发布走 release 分支 + tag;hotfix 只进 released 分支并 cherry-pick 回 main。**
|
||||||
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;一个中版本一条发布分支,alpha/beta/正式由 tag 区分;hotfix 只进发布分支并 cherry-pick 回 main。**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -12,24 +11,18 @@
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `main` | **唯一长命分支** | — | — | ✅ **永远可部署** |
|
| `main` | **唯一长命分支** | — | — | ✅ **永远可部署** |
|
||||||
| `feature/xxx` | 短命(本次特性完成即删) | main | 合回 main | ❌ 不部署 |
|
| `feature/xxx` | 短命(本次特性完成即删) | main | 合回 main | ❌ 不部署 |
|
||||||
| `release/vX.Y.x` | 中命(**整个中版本生命周期**) | main | 打 tag → 构建发布 | ✅ **发布产物来源** |
|
| `release/vX.Y.Z` | 中命(从切出到下个版本发布) | main | 打 tag → 构建发布 | ✅ **发布产物来源** |
|
||||||
| hotfix(直接提交发布分支) | 随发布分支 | 发布分支 | **cherry-pick 回 main** | ✅ |
|
| hotfix(直接提交 release 分支) | 随 release 分支 | release 分支 | **cherry-pick 回 main** | ✅ |
|
||||||
|
|
||||||
```
|
```
|
||||||
main ──────────────── E ──────────────── G ────────────────(永远可部署)
|
main ──────────────── E ──────────────── G ────────────────(永远可部署)
|
||||||
│ ▲
|
│ ▲
|
||||||
│ feature/xxx │ cherry-pick(修复逐个 pick 回)
|
│ feature/xxx │ cherry-pick(hotfix 逐个 pick 回)
|
||||||
├── A ── B ──(合回)───────────────────┤
|
├── A ── B ──(合回)───────────────────┤
|
||||||
│ │
|
│ │
|
||||||
└── release/v1.0.x ────────────────────────────────────────────────
|
└── release/v1.2.0 release/v1.2.0
|
||||||
│ │ │
|
├─(tag v1.2.0)→ 构建发布 ├─(hotfix) F ← 版本特定严重 bug
|
||||||
├─(tag v1.0.0-alpha.1) 内部验证 │ │
|
└─ 退役(可删可留) └─ F 被 separately cherry-pick 到 main
|
||||||
├─(tag v1.0.0-beta.1) 小范围试用 │ │
|
|
||||||
├─(tag v1.0.0) 正式发布 │ │
|
|
||||||
├─(hotfix) F ─────────────────────┤ │
|
|
||||||
├─(tag v1.0.1) patch 发布 │ │
|
|
||||||
├─(hotfix) H ────────────────────────────────────┤
|
|
||||||
└─(tag v1.0.3) patch 发布
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -39,9 +32,8 @@ main ──────────────── E ────────
|
|||||||
### 1. `main`(唯一长命分支)
|
### 1. `main`(唯一长命分支)
|
||||||
|
|
||||||
- **唯一长期存在且永远可部署**。任何时刻 `git checkout main` 出来都是可构建、可上线的状态。
|
- **唯一长期存在且永远可部署**。任何时刻 `git checkout main` 出来都是可构建、可上线的状态。
|
||||||
- 积攒**下一个中版本**的功能:feature 分支完成即合回,main 持续向前。
|
- 积攒**下一个版本**的功能:feature 分支完成即合回,main 持续向前。
|
||||||
- **main 上不直接开发**。所有改动经 feature 分支合入;hotfix 经 cherry-pick 注入。
|
- **main 上不直接开发**。所有改动经 feature 分支合入;hotfix 经 cherry-pick 注入。
|
||||||
- **main 的 `internal/meta.Version` 始终是下一个未发布版本**,不随 patch 发布变动。
|
|
||||||
- 合入门禁(**单人直推也遵守**,不强制 PR 但强制验证):
|
- 合入门禁(**单人直推也遵守**,不强制 PR 但强制验证):
|
||||||
- `make test` 全绿
|
- `make test` 全绿
|
||||||
- 涉及插件/工具链时:接口冻结检查 `git diff third_party/homeagent-sdk/sdk/` 为空
|
- 涉及插件/工具链时:接口冻结检查 `git diff third_party/homeagent-sdk/sdk/` 为空
|
||||||
@ -49,122 +41,80 @@ main ──────────────── E ────────
|
|||||||
|
|
||||||
### 2. `feature/xxx`(新特性/修复)
|
### 2. `feature/xxx`(新特性/修复)
|
||||||
|
|
||||||
- 命名:`feature/<短横线描述>`,如 `feature/plugin-proc-migration`、`feature/memory-media`。
|
- 命名:`feature/<短横线描述>`,如 `feature/plugin-proc-migration`、`feature/webui-narrow-fix`。
|
||||||
- **从 main 开出**:`git checkout -b feature/xxx main`。
|
- **从 main 开出**:`git checkout -b feature/xxx main`。
|
||||||
- 完成后合回 main:
|
- 完成后合回 main:
|
||||||
- 单人:直推(`git merge --no-ff` 保留特性边界,或 squash 成一个 commit,二选一在团队内固定)。
|
- 单人:直推(`git merge --no-ff` 保留特性边界,或 squash 成一个 commit,二选一在团队内固定)。
|
||||||
- 多人:走 PR(review 后合入)。
|
- 多人:走 PR(review 后合入)。
|
||||||
- 合回后删除 feature 分支(避免累积)。
|
- 合回后删除 feature 分支(避免累积)。
|
||||||
|
|
||||||
### 3. `release/vX.Y.x`(发布分支:一个中版本一条)
|
### 3. `release/vX.Y.Z`(发布)
|
||||||
|
|
||||||
- **命名用 `x` 占位 patch 位**:`release/v1.0.x` 承载 1.0.0 → 1.0.1 → … → 1.0.N 全部发布,
|
- **从 main 的某个可部署点切出**:`git checkout -b release/v1.2.0 main`。
|
||||||
直到 `release/v1.1.x` 切出为止。**不要按 patch 号建分支**(`release/v1.0.1`、`release/v1.0.3` 各一条会把
|
- 切出后**冻结功能**——release 分支上只做:版本号 bump、发布准备、bug 修复、文档。
|
||||||
同一发布线切成互不相连的碎片,追溯时无法用一条分支看完整条线的演进)。
|
- 打 tag → 构建发布安装包 → 上传(附件命名规范见历史记录)。
|
||||||
- **从 main 的某个可部署点切出**:`git checkout -b release/v1.0.x main`。
|
- **现网部署永远用 release tag 的构建产物**,不是 main 头部、更不是 feature。
|
||||||
- 切出后**冻结功能**——发布分支上只做:版本号 bump、发布准备、bug 修复、文档。
|
|
||||||
- **现网部署永远用发布分支上 tag 的构建产物**,不是 main 头部、更不是 feature。
|
|
||||||
|
|
||||||
### 4. 三级发布通道(alpha / beta / 正式)
|
### 4. hotfix(只属于此版本的严重 bug)
|
||||||
|
|
||||||
通道**由 tag 区分,不由分支区分**——三者共用同一条 `release/vX.Y.x`。
|
|
||||||
|
|
||||||
| 通道 | tag 形式 | 含义 | 受众 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| alpha | `vX.Y.Z-alpha.N` | 功能齐了但未充分验证,可能有已知缺陷 | 仅内部/开发者自测 |
|
|
||||||
| beta | `vX.Y.Z-beta.N` | alpha 问题已修,等待真实环境暴露长尾问题 | 小范围试用、愿意承担风险的用户 |
|
|
||||||
| 正式 | `vX.Y.Z` | 通过验证,可上现网 | 所有用户 |
|
|
||||||
|
|
||||||
- **推进顺序**:alpha → beta → 正式,逐级向前,**每级都是同一条分支上的新 tag**。
|
|
||||||
这也是 semver 的标准预发布语义(`1.1.0-alpha.1 < 1.1.0-beta.1 < 1.1.0`),
|
|
||||||
包管理器与版本比较逻辑天然认得,无需额外约定。
|
|
||||||
- **允许跳级**:若改动小、验证充分(如仅一处已定位并有回归测试覆盖的内核修复),
|
|
||||||
可直接打正式 tag。跳级要在发布说明里写明理由。
|
|
||||||
- alpha/beta 的构建产物**可以上传 release 附件**,但必须在 gitcode release 上勾选
|
|
||||||
"预发布"标记,且发布说明首行标注通道与已知风险。
|
|
||||||
- **beta 未清零的严重问题不得进正式**:正式 tag 意味着"我们认为它能上 24/7 现网"。
|
|
||||||
|
|
||||||
### 5. hotfix(发布后发现的严重 bug)
|
|
||||||
|
|
||||||
- **场景**:版本已发布后,发现只存在于该版本(或该发布线)的严重 bug。
|
- **场景**:版本已发布后,发现只存在于该版本(或该发布线)的严重 bug。
|
||||||
- **动作**:直接把修复提交到**发布分支** → 该分支重新构建、打下一个 patch tag(如 `v1.0.4`)发布。
|
- **动作**:直接把修复提交到 **release 分支**(不收进 main 的开发流)→ 该 release 分支重新构建、打 patch tag(如 `v1.2.1`)发布。
|
||||||
- **关键:hotfix 必须 cherry-pick 回 main**:
|
- **关键:hotfix 必须 cherry-pick 回 main**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在发布分支上提交修复(代码部分与版本号 bump 分开提交)
|
# 在 release 分支上提交修复(代码部分与版本号 bump 分开提交)
|
||||||
git checkout release/v1.0.x
|
|
||||||
git commit -m "fix(x): ..." # ① 修复本身
|
git commit -m "fix(x): ..." # ① 修复本身
|
||||||
git commit -m "chore(release): bump v1.0.4" # ② 版本号(此 commit 不 pick 回 main)
|
git commit -m "chore: bump v1.2.1" # ② 版本号(此 commit 不 pick 回 main)
|
||||||
git tag -a v1.0.4 -m "..."
|
|
||||||
|
|
||||||
# 回到 main,只挑修复本身
|
# 回到 main,只挑修复本身
|
||||||
git checkout main
|
git checkout main
|
||||||
git cherry-pick <修复①的sha> # 只 pick ①,不 pick ②
|
git cherry-pick <修复commit的sha> # 只 pick ①,不 pick ②
|
||||||
```
|
```
|
||||||
|
|
||||||
> **为什么 cherry-pick 而不是 merge**:发布分支只承载该版本特有的补丁,merge 会把
|
> **为什么 cherry-pick 而不是 merge**:release 分支只承载该版本特有的补丁,merge 会把 release 分支的版本号/发布相关改动一并带进 main 造成冲突。逐个 cherry-pick 修复 commit 让 main 精确地只获得修复本身。**版本号 bump 不要 pick 回 main**(main 的版本号应始终是下一个未发布版本)。
|
||||||
> 版本号/发布相关改动一并带进 main 造成冲突,并让 main 的 `meta.Version` 变成
|
|
||||||
> 已发布的旧版本号。逐个 cherry-pick 让 main 精确地只获得修复本身。
|
|
||||||
> **版本号 bump 不要 pick 回 main。**
|
|
||||||
|
|
||||||
- **同时存在多个活跃 feature 分支时**:修复也要 pick 到那些分支,否则它们合回 main 时
|
- **hotfix 已逐个 pick 回 main ⇒ main 已含全部修复 ⇒ 无需再合并 release 回 main**。这是本规范刻意为之——除非 release 分支上有 main 想要的**功能级**改动(罕见),否则 release 永不 merge 回 main。
|
||||||
可能带回旧代码。实践做法是修复落地当天就 pick 到全部活跃分支
|
|
||||||
(如 2026-09-04 的 stage 双重解锁修复同时 pick 到 `main` 与 `feature/memory-media`)。
|
|
||||||
|
|
||||||
- **hotfix 已逐个 pick 回 main ⇒ main 已含全部修复 ⇒ 无需再合并发布分支回 main**。
|
### 5. release 分支退役
|
||||||
这是本规范刻意为之——除非发布分支上有 main 想要的**功能级**改动(罕见),
|
|
||||||
否则发布分支永不 merge 回 main。
|
|
||||||
|
|
||||||
### 6. 发布分支退役
|
- **下个版本发布 = 此 release 分支生命周期结束**(不再维护)。
|
||||||
|
|
||||||
- **下个中版本发布 = 上一条发布分支生命周期结束**(`release/v1.1.x` 出现即 `release/v1.0.x` 退役)。
|
|
||||||
- 退役后可删可留:
|
- 退役后可删可留:
|
||||||
- 删除:保持仓库干净(tag 已保留全部历史,删分支不丢东西)。
|
- 删除:保持仓库干净(tag 已保留全部历史,删分支不丢东西)。
|
||||||
- 保留:便于追溯该发布线的历史构建(对 24/7 现网友好)。
|
- 保留:便于追溯该发布线的历史构建(对 24/7 现网友好,推荐与本仓库一样保留已打 tag 的历史分支做对照)。
|
||||||
- **按 patch 号命名的历史发布分支应当合并/删除**:它们是本规范修订前的遗留形态,
|
- 本仓对现网多代版本并行维护时,保留近期 release 分支是合理的。
|
||||||
内容已被对应的 `release/vX.Y.x` 完全包含,保留只会让"哪条才是这条线"变得含糊。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、当前分支对齐(2026-09-04 执行)
|
## 三、当前分支对齐(2026-08-31 执行)
|
||||||
|
|
||||||
### 主仓(TrueAgent)
|
### 主仓(TrueAgent)
|
||||||
|
|
||||||
| 分支 | 状态 | 处理 |
|
| 现存分支 | 状态 | 处理 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `main` | 含全部 hotfix(逐个 cherry-pick),`meta.Version` = 下一个未发布版本 | ✅ 保持 |
|
| `main` | `48b5c24` [origin/main] | ✅ 保持不变(规范基线) |
|
||||||
| `release/v1.0.x` | 承载 v1.0.0 / v1.0.1 / v1.0.3 全部 tag | ✅ **由 `release/v1.0.1` 重命名而来**(2026-09-04) |
|
| `feature/plugin-proc-migration` | 原 `update`,`69a138c`(领先 main 5:文档基线 + Part 0.1/0.2 + 本规范) | ✅ **已对齐重命名**(2026-08-31) |
|
||||||
| `release/v1.0.0` | `9b92a04`,已被 1.0.x 线完全包含(`merge-base --is-ancestor` 验证通过) | 🗑️ **已删除**(本地 + 远端),tag `v1.0.0` 保留全部历史 |
|
| `backup-local`(SDK 仓) | `7092d15`(ahead 3, behind 14,含 `ignore example/recoverydiag` 敏感提交) | ⚠️ 遗留本地分支,功能已合入 main,**保留不删**(无远端,删除即永久丢失) |
|
||||||
| `release/v1.0.1` | 旧 patch 号命名 | 🗑️ **已重命名为 `release/v1.0.x`**(远端旧名删除) |
|
|
||||||
| `feature/memory-media` | 记忆系统媒体(多模态)支持,进行中 | ⏳ 完成后合回 main 并删除 |
|
|
||||||
| `feature/plugin-proc-migration` | 已合入 main(`525aa1f`) | ⏳ 待删(规范要求合回后删除) |
|
|
||||||
|
|
||||||
### 1.0.x 发布线 tag 历史
|
### SDK 仓(homeagent-sdk)
|
||||||
|
|
||||||
| tag | 提交 | 通道 | 说明 |
|
| 现存分支 | 状态 | 处理 |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| `v1.0.0` | `9b92a04` | 正式 | 外部插件从 C ABI 迁移到子进程 + 共享内存 |
|
| `main` | `61f307b` v1.2.0 | ✅ 保持不变 |
|
||||||
| `v1.0.1` | `e671a8c` | 正式 | 多模态 bugfix(假成功、能力声明与回退链、see_video 帧数语义) |
|
| `update` | `5648519`(领先 main 1:Part 0.2 模板修复) | ⚠️ 与主仓 `update` 对齐重命名 |
|
||||||
| `v1.0.3` | `26dc76f` | 正式 | 内核 stage 协调器双重解锁(直接跳正式:单点修复 + 反向验证 + 全类审计) |
|
| `backup-local` | `7092d15`(ahead 3, behind 14,遗留调试分支) | ⚠️ 可选清理 |
|
||||||
|
|
||||||
> `v1.0.2` 未使用:该号从未发布也无 tag,留空以免与任何本地构建混淆。
|
> `update` 整改工作分支按规范应为 `feature/plugin-proc-migration`(多进程插件化整改,8-9 周大特性)。
|
||||||
|
> 是否重命名由执行人确认;不重命名则视为偏离规范的既有分支,须在文档记录其存在。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、现网部署与版本对应(运维纪律)
|
## 四、现网部署与版本对应(运维纪律)
|
||||||
|
|
||||||
- **现网 homed 永远部署 `release/vX.Y.x` 分支上 tag 的构建产物**,路径见 `Makefile`(`make build` → `build/homed`)。
|
- **现网 homed 永远部署 `release/vX.Y.Z` 分支打出的 tag 构建**,路径见 `Makefile`(`make build` → `build/homed`)。
|
||||||
- systemd 服务(`/usr/local/bin/homed`)替换流程:
|
- systemd 服务(`/usr/local/bin/homed`)替换前:备份旧二进制 → 停服 → 替换 → 起服 → 健康检查(`scripts/verify_deploy.sh`)。
|
||||||
1. 备份旧二进制(`homed.bak.pre<版本>.<时间戳>`)
|
- **改造期间(update 整改)现网不得部署 main 或 feature 的中间态**——只有发版才用 release。
|
||||||
2. 备份配置库(**用 `sqlite3 .backup`,不用 `cp`**——WAL 模式下 cp 可能拿到不一致快照)
|
|
||||||
3. 记录当前插件建链清单,供重启后逐项比对
|
|
||||||
4. `install -m 0755` 替换(原子 rename,不会写坏正在运行的进程镜像)
|
|
||||||
5. `systemctl restart homeagent`
|
|
||||||
6. 健康检查:版本号、插件清单无缺失、`/api/v1/status`、一次真实对话、`fatal error` 计数为 0
|
|
||||||
- **改造期间现网不得部署 main 或 feature 的中间态**——只有发版才用发布分支的 tag。
|
|
||||||
- alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。
|
|
||||||
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
|
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
|
||||||
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓版本对齐是第一优先级**)。
|
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(两仓版本对齐是第一优先级)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -178,36 +128,28 @@ git checkout -b feature/xxx
|
|||||||
git checkout main && git merge --no-ff feature/xxx # 或 squash
|
git checkout main && git merge --no-ff feature/xxx # 或 squash
|
||||||
git branch -d feature/xxx
|
git branch -d feature/xxx
|
||||||
|
|
||||||
# 开一条新中版本的发布线
|
# 发布
|
||||||
git checkout -b release/v1.1.x main
|
git checkout -b release/v1.2.0 main
|
||||||
git commit -am "chore(release): bump v1.1.0-alpha.1"
|
git commit -am "chore: bump v1.2.0" # 版本号
|
||||||
git tag -a v1.1.0-alpha.1 -m "..." # alpha:内部验证
|
git tag v1.2.0
|
||||||
# ... 修问题 ...
|
# ... 构建发布 ...
|
||||||
git commit -am "chore(release): bump v1.1.0-beta.1"
|
|
||||||
git tag -a v1.1.0-beta.1 -m "..." # beta:小范围试用
|
|
||||||
# ... 真实环境验证 ...
|
|
||||||
git commit -am "chore(release): bump v1.1.0"
|
|
||||||
git tag -a v1.1.0 -m "..." # 正式
|
|
||||||
|
|
||||||
# hotfix(发布后)——注意是同一条 release/v1.0.x,不新建分支
|
# hotfix(发布后)
|
||||||
git checkout release/v1.0.x
|
git checkout release/v1.2.0
|
||||||
git commit -am "fix(x): 严重 bug" # ① 修复
|
git commit -am "fix(x): 严重 bug" # ① 修复
|
||||||
git commit -am "chore(release): bump v1.0.4" # ② 版本号
|
git commit -am "chore: bump v1.2.1" # ② 版本号
|
||||||
git tag -a v1.0.4 -m "..."
|
git tag v1.2.1
|
||||||
git checkout main
|
git checkout main
|
||||||
git cherry-pick <修复①的sha> # ③ 只挑修复
|
git cherry-pick <修复①的sha> # ③ 只挑修复
|
||||||
# 若有活跃 feature 分支,也 pick 过去
|
|
||||||
git checkout feature/xxx && git cherry-pick <main 上那个 pick 的 sha>
|
|
||||||
|
|
||||||
# 发布分支退役(下个中版本发布后,可选)
|
# release 退役(可选)
|
||||||
git branch -d release/v1.0.x # tag 已保存历史,删分支不丢东西
|
git branch -d release/v1.2.0 # tag 已保存历史,删分支不丢东西
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、本规范与「接口冻结」约束的关系
|
## 六、本规范与「接口冻结」约束的关系
|
||||||
|
|
||||||
- feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
|
- feature 分支合回 main 的门禁(`git diff sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
|
||||||
- `internal/sdk` **不受冻结约束**,可自由扩展;冻结只针对公开 SDK 接口(`third_party/homeagent-sdk/sdk/`)。
|
- 插件多进程化整改(`feature/plugin-proc-migration` 或现 `update`)**不满足接口冻结不等于不能合并**——
|
||||||
- 若整改确需突破公开接口,走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七),
|
接口冻结约束的是「公开 SDK 不变」,整改若突破需走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七)。
|
||||||
并同步 `SDKCompatibleVersion` 与 SDK 仓的 release tag。
|
|
||||||
@ -14,7 +14,6 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
@ -51,17 +50,6 @@ type Agent struct {
|
|||||||
// 文本记忆(原始对话日志)
|
// 文本记忆(原始对话日志)
|
||||||
textMem *text.Memory
|
textMem *text.Memory
|
||||||
|
|
||||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
|
||||||
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过——
|
|
||||||
// 它是记忆增强而非对话必需品,缺了不该让对话失败。
|
|
||||||
mediaStore *media.Store
|
|
||||||
// mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。
|
|
||||||
mediaGCInterval time.Duration
|
|
||||||
// mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。
|
|
||||||
mediaGCMinAge time.Duration
|
|
||||||
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
|
|
||||||
mediaDescribe bool
|
|
||||||
|
|
||||||
// 人格设定
|
// 人格设定
|
||||||
personality *agentPkg.Personality
|
personality *agentPkg.Personality
|
||||||
|
|
||||||
@ -118,13 +106,6 @@ type Agent struct {
|
|||||||
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
|
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
|
||||||
pendingMedia map[string]interface{}
|
pendingMedia map[string]interface{}
|
||||||
|
|
||||||
// pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。
|
|
||||||
//
|
|
||||||
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
|
|
||||||
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
|
||||||
// 与 pendingMedia 同受 a.mu 保护。
|
|
||||||
pendingMediaDigests []string
|
|
||||||
|
|
||||||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||||||
interruptInput bool
|
interruptInput bool
|
||||||
|
|
||||||
@ -138,7 +119,7 @@ type Agent struct {
|
|||||||
|
|
||||||
// 输入去重:防 webui/GUI 断线重连导致的消息重放
|
// 输入去重:防 webui/GUI 断线重连导致的消息重放
|
||||||
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
|
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
|
||||||
lastInput map[string]time.Time
|
lastInput map[string]time.Time
|
||||||
lastInputMu sync.Mutex
|
lastInputMu sync.Mutex
|
||||||
|
|
||||||
// 词嵌入模型,用于实体语义相似度计算
|
// 词嵌入模型,用于实体语义相似度计算
|
||||||
@ -170,20 +151,16 @@ type AgentConfig struct {
|
|||||||
Knowledge *knowledge.Store
|
Knowledge *knowledge.Store
|
||||||
SocialStore *social.SocialStore
|
SocialStore *social.SocialStore
|
||||||
TextMemory *text.Memory
|
TextMemory *text.Memory
|
||||||
MediaStore *media.Store
|
|
||||||
MediaGCInterval time.Duration
|
|
||||||
MediaGCMinAge time.Duration
|
|
||||||
MediaDescribe bool
|
|
||||||
Personality *agentPkg.Personality
|
Personality *agentPkg.Personality
|
||||||
PluginReg *plugin.Registry
|
PluginReg *plugin.Registry
|
||||||
PluginDir string
|
PluginDir string
|
||||||
DistillInterval time.Duration
|
DistillInterval time.Duration
|
||||||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||||||
ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval
|
ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval
|
||||||
MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval
|
MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval
|
||||||
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
||||||
ContextSavePath string // 上下文持久化路径,空则不持久化
|
ContextSavePath string // 上下文持久化路径,空则不持久化
|
||||||
EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用
|
EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用
|
||||||
Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建
|
Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建
|
||||||
StageHost *StageHost
|
StageHost *StageHost
|
||||||
EventBus *events.Bus
|
EventBus *events.Bus
|
||||||
@ -232,12 +209,6 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
if cfg.IO != nil {
|
if cfg.IO != nil {
|
||||||
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
|
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
|
||||||
}
|
}
|
||||||
// 必须把媒体存储也注给 RelevanceContext:L0→L2 归档(Prune)靠
|
|
||||||
// rc.transferMediaRefs 把引用从 context owner 转给 document owner。
|
|
||||||
// 漏了这一行的后果是静默的:rc.mediaStore 为 nil 时转移直接 return,
|
|
||||||
// 而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在
|
|
||||||
// context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。
|
|
||||||
rc.SetMediaStore(cfg.MediaStore)
|
|
||||||
|
|
||||||
return &Agent{
|
return &Agent{
|
||||||
id: cfg.ID,
|
id: cfg.ID,
|
||||||
@ -256,10 +227,6 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
knowledge: cfg.Knowledge,
|
knowledge: cfg.Knowledge,
|
||||||
social: cfg.SocialStore,
|
social: cfg.SocialStore,
|
||||||
textMem: cfg.TextMemory,
|
textMem: cfg.TextMemory,
|
||||||
mediaStore: cfg.MediaStore,
|
|
||||||
mediaGCInterval: cfg.MediaGCInterval,
|
|
||||||
mediaGCMinAge: cfg.MediaGCMinAge,
|
|
||||||
mediaDescribe: cfg.MediaDescribe,
|
|
||||||
personality: cfg.Personality,
|
personality: cfg.Personality,
|
||||||
pluginReg: cfg.PluginReg,
|
pluginReg: cfg.PluginReg,
|
||||||
pluginDir: cfg.PluginDir,
|
pluginDir: cfg.PluginDir,
|
||||||
@ -294,8 +261,6 @@ func (a *Agent) Start() {
|
|||||||
go a.archiveLoop()
|
go a.archiveLoop()
|
||||||
go a.mergeLoop()
|
go a.mergeLoop()
|
||||||
go a.reviewLoop()
|
go a.reviewLoop()
|
||||||
go a.mediaGCLoop()
|
|
||||||
go a.mediaDescribeLoop()
|
|
||||||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package core
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@ -13,7 +12,6 @@ import (
|
|||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
"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/memory/vector"
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
)
|
)
|
||||||
@ -24,25 +22,13 @@ type ToolResultItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ContextEvent struct {
|
type ContextEvent struct {
|
||||||
// ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。
|
Timestamp time.Time `json:"timestamp"`
|
||||||
//
|
Source string `json:"source"`
|
||||||
// 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。
|
Input string `json:"input"`
|
||||||
// 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。
|
Response string `json:"response,omitempty"`
|
||||||
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
|
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||||
ID string `json:"id,omitempty"`
|
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Vector vector.Vector `json:"-"`
|
||||||
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"`
|
|
||||||
// Media 是本轮对话涉及的媒体 digest(sha256 十六进制)。
|
|
||||||
//
|
|
||||||
// 存 digest 而不存路径:路径会失效(/tmp 探针图、下载缓存、别的进程的
|
|
||||||
// 临时产物),digest 是内容本身的身份,配合 internal/memory/media 的 CAS
|
|
||||||
// 永远能取回原始字节——只要它还没被容量 GC 淘汰。
|
|
||||||
Media []string `json:"media,omitempty"`
|
|
||||||
Vector vector.Vector `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextFlushInterval = 5 * time.Second
|
const contextFlushInterval = 5 * time.Second
|
||||||
@ -56,41 +42,6 @@ type RelevanceContext struct {
|
|||||||
dirty bool
|
dirty bool
|
||||||
toolDefLookup func(name string) *sdk.ToolDef
|
toolDefLookup func(name string) *sdk.ToolDef
|
||||||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||||||
|
|
||||||
// mediaStore 只用于 Prune 时把媒体引用从事件转给归档文档。
|
|
||||||
// 为 nil 时引用转移静默跳过(媒体存储未启用)。
|
|
||||||
mediaStore *media.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMediaStore 注入媒体存储,供 L0→L2 归档时转移媒体引用。
|
|
||||||
func (c *RelevanceContext) SetMediaStore(s *media.Store) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
c.mediaStore = s
|
|
||||||
}
|
|
||||||
|
|
||||||
// transferMediaRefs 把被归档事件的媒体引用转给目标文档(调用方已持 c.mu)。
|
|
||||||
//
|
|
||||||
// 先挂后销:若反序,引用计数会瞬时归零,此时若后台 GC 正在跑
|
|
||||||
// 就会把仍被记忆引用的内容当孤儿清掉。
|
|
||||||
func (c *RelevanceContext) transferMediaRefs(archive []scoredEvent, docID string) {
|
|
||||||
if c.mediaStore == nil || docID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, s := range archive {
|
|
||||||
evt := s.event
|
|
||||||
if evt == nil || evt.ID == "" || len(evt.Media) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, d := range evt.Media {
|
|
||||||
if err := c.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
|
|
||||||
log.Printf("[media] 归档转移 AddRef 失败 (%s → doc %s): %v", shortDigest(d), docID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := c.mediaStore.DropOwner(media.OwnerContext, evt.ID); err != nil {
|
|
||||||
log.Printf("[media] 归档转移 DropOwner 失败 (evt %s): %v", evt.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
||||||
@ -305,16 +256,6 @@ func (c *RelevanceContext) flush() {
|
|||||||
c.dirty = false
|
c.dirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// scoredEvent 是 Prune 里按相关度排序的事件。
|
|
||||||
//
|
|
||||||
// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要
|
|
||||||
// 把待归档列表传进去,局部类型无法出现在方法签名上。
|
|
||||||
type scoredEvent struct {
|
|
||||||
event *ContextEvent
|
|
||||||
score float64
|
|
||||||
idx int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
|
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@ -336,10 +277,15 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
|||||||
|
|
||||||
queryVec := c.embedder.VectorizeClean(currentInput)
|
queryVec := c.embedder.VectorizeClean(currentInput)
|
||||||
|
|
||||||
scoredEvents := make([]scoredEvent, len(candidates))
|
type scored struct {
|
||||||
|
event *ContextEvent
|
||||||
|
score float64
|
||||||
|
idx int
|
||||||
|
}
|
||||||
|
scoredEvents := make([]scored, len(candidates))
|
||||||
for i, evt := range candidates {
|
for i, evt := range candidates {
|
||||||
score := vector.CosineSimilarity(queryVec, evt.Vector)
|
score := vector.CosineSimilarity(queryVec, evt.Vector)
|
||||||
scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i}
|
scoredEvents[i] = scored{event: evt, score: score, idx: i}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(scoredEvents, func(i, j int) bool {
|
sort.Slice(scoredEvents, func(i, j int) bool {
|
||||||
@ -381,11 +327,6 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
|||||||
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
||||||
if err == nil && doc != nil {
|
if err == nil && doc != nil {
|
||||||
archived = len(entries)
|
archived = len(entries)
|
||||||
// 媒体引用随事件一起从 L0 转到 L2:先把引用挂到归档文档上,
|
|
||||||
// 再注销原事件的引用。顺序不能反——先销后挂会让引用计数
|
|
||||||
// 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的
|
|
||||||
// 内容当孤儿清掉。
|
|
||||||
c.transferMediaRefs(archive, doc.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,3 +385,5 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import (
|
|||||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
"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/memory/vector"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||||
)
|
)
|
||||||
@ -181,86 +180,19 @@ func (a *Agent) archiveColdDocs() {
|
|||||||
coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
|
coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
|
||||||
for _, doc := range coldDocs {
|
for _, doc := range coldDocs {
|
||||||
triples := docToTriples(doc, a.embedder)
|
triples := docToTriples(doc, a.embedder)
|
||||||
if len(triples) == 0 {
|
if len(triples) > 0 {
|
||||||
continue
|
ec, rc, err := a.memory.Commit(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)
|
||||||
|
a.docStore.Remove(doc.ID)
|
||||||
}
|
}
|
||||||
ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[agent] doc→graph archival error: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 归档的实质是「信息从 L2 搬到 L3」。一条实体、一条关系都没写进
|
|
||||||
// 图库时,信息并没有搬过去,此时删文档等于直接丢数据。
|
|
||||||
//
|
|
||||||
// 这不是理论情形:Commit 会静默跳过实体名不合法的三元组
|
|
||||||
//(validEntityName 要求 2–50 字符),而 LLM 生成的长描述几乎
|
|
||||||
// 提不出合规实体名——实测 456 字图片描述得到 0 entities 0
|
|
||||||
// relations,随后文档被删、媒体引用被释放、blob 被 GC 清掉,
|
|
||||||
// 图片与描述彻底消失。保留文档,下一轮再试。
|
|
||||||
if ec == 0 && rc == 0 {
|
|
||||||
log.Printf("[agent] doc→graph: %s 未写入任何实体/关系,保留文档待下轮重试"+
|
|
||||||
"(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
|
|
||||||
|
|
||||||
// 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何
|
|
||||||
// 东西能告诉我们它曾经引用过哪些 digest,media_refs 里那条记录
|
|
||||||
// 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。
|
|
||||||
//
|
|
||||||
// 但只有在引用**确实**转移到 graph_sentence 之后才能释放:
|
|
||||||
// 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零,
|
|
||||||
// GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由
|
|
||||||
// 后续一致性检查清理),也不能丢内容。
|
|
||||||
refs, refErr := a.docMediaRefs(doc.ID)
|
|
||||||
switch {
|
|
||||||
case refErr != nil:
|
|
||||||
log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr)
|
|
||||||
case len(refs) == 0:
|
|
||||||
// 该文档本就没有媒体引用,无需释放。
|
|
||||||
case mediaBound == 0:
|
|
||||||
log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+
|
|
||||||
"保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest)",
|
|
||||||
doc.ID, len(refs))
|
|
||||||
default:
|
|
||||||
a.releaseDocMedia(doc.ID)
|
|
||||||
}
|
|
||||||
a.docStore.Remove(doc.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// docMediaRefs 返回文档当前持有的媒体引用(nil store 时为空)。
|
|
||||||
//
|
|
||||||
// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」——
|
|
||||||
// 没有引用时不必打日志,有引用但没绑上图库时必须保留。
|
|
||||||
func (a *Agent) docMediaRefs(docID string) ([]string, error) {
|
|
||||||
if a.mediaStore == nil || docID == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return a.mediaStore.Refs(media.OwnerDocument, docID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 裁决
|
// 实体合并检测:GraphDB → LLM 裁决
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@ -480,15 +412,6 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 媒体三元组:确定性产出,先于 NLP 提取。
|
|
||||||
//
|
|
||||||
// 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测
|
|
||||||
// LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片,
|
|
||||||
// obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库
|
|
||||||
//(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的
|
|
||||||
// digest / mime / 描述都是确定的,直接建三元组而不经提取器。
|
|
||||||
triples = append(triples, mediaTriplesFromText(doc.Content)...)
|
|
||||||
|
|
||||||
// NLP 通用提取
|
// NLP 通用提取
|
||||||
e := nlp.NewExtractor(nil)
|
e := nlp.NewExtractor(nil)
|
||||||
if embedder != nil {
|
if embedder != nil {
|
||||||
|
|||||||
@ -174,11 +174,6 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
|
|||||||
|
|
||||||
blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
|
blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
|
||||||
|
|
||||||
// 用户直接发来的媒体:先落进 CAS。
|
|
||||||
// 不存的后果是 ContextEvent.Input 只剩一句 alt 文本
|
|
||||||
//("[从 qq 收到了 image]"),base64 随 message 数组发给模型后就丢了。
|
|
||||||
a.stageMediaDigests(a.captureBlockMedia(blocks, "input_"+evt.Type)...)
|
|
||||||
|
|
||||||
stageCtx := a.stageCtxFromInput(fallback, evt.Source, "")
|
stageCtx := a.stageCtxFromInput(fallback, evt.Source, "")
|
||||||
stageCtx.Extra = map[string]interface{}{
|
stageCtx.Extra = map[string]interface{}{
|
||||||
"media_blocks": blocks,
|
"media_blocks": blocks,
|
||||||
@ -221,22 +216,14 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
|
|||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
|
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||||
|
|
||||||
// 本轮捕获的媒体(用户发的 + 工具注入的)挂到这条事件上。
|
a.context.Append(ContextEvent{
|
||||||
// 媒体描述并进 Input:描述文本才是持久语义记忆,blob 只是缓存。
|
|
||||||
digests := a.drainMediaDigests()
|
|
||||||
mediaEvt := ContextEvent{
|
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Source: "agent",
|
Source: "agent",
|
||||||
Input: fallback,
|
Input: fallback,
|
||||||
Response: response,
|
Response: response,
|
||||||
ToolsUsed: toolsUsed,
|
ToolsUsed: toolsUsed,
|
||||||
ToolResults: toolResults,
|
ToolResults: toolResults,
|
||||||
}
|
})
|
||||||
a.bindEventMedia(&mediaEvt, digests)
|
|
||||||
if s := a.mediaSummaryForEvent(mediaEvt.Media); s != "" {
|
|
||||||
mediaEvt.Input = mediaEvt.Input + "\n" + s
|
|
||||||
}
|
|
||||||
a.context.Append(mediaEvt)
|
|
||||||
|
|
||||||
a.emitResponse(evt, response)
|
a.emitResponse(evt, response)
|
||||||
|
|
||||||
@ -284,12 +271,12 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
|
|||||||
}
|
}
|
||||||
if mediaType == "image" {
|
if mediaType == "image" {
|
||||||
blocks = append(blocks, agentAPI.ContentBlock{
|
blocks = append(blocks, agentAPI.ContentBlock{
|
||||||
Type: "image_url",
|
Type: "image_url",
|
||||||
ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"},
|
ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"},
|
||||||
})
|
})
|
||||||
} else if mediaType == "audio" {
|
} else if mediaType == "audio" {
|
||||||
blocks = append(blocks, agentAPI.ContentBlock{
|
blocks = append(blocks, agentAPI.ContentBlock{
|
||||||
Type: "audio_url",
|
Type: "audio_url",
|
||||||
AudioURL: &agentAPI.AudioURL{URL: imgURL},
|
AudioURL: &agentAPI.AudioURL{URL: imgURL},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -384,21 +371,14 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
|||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
|
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||||
|
|
||||||
// 纯文本输入也可能产生媒体:模型调 multimodal_see_picture / see_video 等工具时,
|
a.context.Append(ContextEvent{
|
||||||
// 插件经 SetToolBlocks 注入的块已在 process() 里被捕获。
|
|
||||||
textEvt := ContextEvent{
|
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Source: "agent",
|
Source: "agent",
|
||||||
Input: cleanInput,
|
Input: cleanInput,
|
||||||
Response: response,
|
Response: response,
|
||||||
ToolsUsed: toolsUsed,
|
ToolsUsed: toolsUsed,
|
||||||
ToolResults: toolResults,
|
ToolResults: toolResults,
|
||||||
}
|
})
|
||||||
a.bindEventMedia(&textEvt, a.drainMediaDigests())
|
|
||||||
if s := a.mediaSummaryForEvent(textEvt.Media); s != "" {
|
|
||||||
textEvt.Input = textEvt.Input + "\n" + s
|
|
||||||
}
|
|
||||||
a.context.Append(textEvt)
|
|
||||||
|
|
||||||
a.emitResponse(evt, response)
|
a.emitResponse(evt, response)
|
||||||
|
|
||||||
|
|||||||
@ -1,382 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
|
||||||
)
|
|
||||||
|
|
||||||
// L3 图库的媒体引用绑定。
|
|
||||||
//
|
|
||||||
// 设计定位(方案 A:只做引用,不建媒体实体节点):
|
|
||||||
// 图库里的实体与关系全部来自**描述文本**的 NLP 提取——媒体描述经
|
|
||||||
// mediaSummaryForEvent 进了 L0 事件的 Input,随归档进 L2 文档的 Content,
|
|
||||||
// 蒸馏时提取器自然会从描述文字里抽出实体和关系。
|
|
||||||
//
|
|
||||||
// 为何不把媒体本身建成实体节点:节点名只能从描述里取,而描述会被重新生成
|
|
||||||
// (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下
|
|
||||||
// 多个语义模糊的节点。检索能力靠描述文本已经具备,多这类节点只是噪声。
|
|
||||||
//
|
|
||||||
// 那么图库侧还需要什么:**反查**。图库里的句子写着「[image a1b2c3d4e5f6]
|
|
||||||
// 一张紫蓝红三色带图」,要能从这条句子找回那份字节。这就是
|
|
||||||
// media_refs 的 graph_sentence owner 的用途,也是这一层唯一要做的事。
|
|
||||||
|
|
||||||
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。
|
|
||||||
//
|
|
||||||
// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制
|
|
||||||
// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度,
|
|
||||||
// 以及有人手写了完整 digest 的情况。
|
|
||||||
var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`)
|
|
||||||
|
|
||||||
// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述,
|
|
||||||
// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。
|
|
||||||
//
|
|
||||||
// 与 mediaSummaryForEvent 的输出格式严格对应:
|
|
||||||
//
|
|
||||||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
|
||||||
//
|
|
||||||
// 描述取到行尾而非贪婪到底:一条事件可能挂多个媒体,各占一行。
|
|
||||||
var mediaMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
|
|
||||||
|
|
||||||
// mediaMarker 是从文档正文里解析出的一条媒体标记。
|
|
||||||
type mediaMarker struct {
|
|
||||||
label string // mime 或 kind,如 image/png
|
|
||||||
shortDigest string
|
|
||||||
description string
|
|
||||||
raw string // 原始整段,用作三元组的 SentenceText
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseMediaMarkers 从文本里解析全部媒体标记。
|
|
||||||
//
|
|
||||||
// 为何需要它而不只是 extractMediaDigests:媒体入 L3 曾完全依赖 NLP 提取器
|
|
||||||
// 碰巧从描述文本里提出合规三元组——实测 LLM 的 477 字图片描述只产出
|
|
||||||
// 「水平 -分割-> 成」这种语法碎片,obj 仅 1 字被 validEntityName 拒掉,
|
|
||||||
// 于是整条媒体记忆进不了图库。而媒体自身的信息(digest / mime / 描述)
|
|
||||||
// 是确定的,不该受提取器运气支配。
|
|
||||||
func parseMediaMarkers(text string) []mediaMarker {
|
|
||||||
if text == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1)
|
|
||||||
if len(ms) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := make(map[string]bool, len(ms))
|
|
||||||
var out []mediaMarker
|
|
||||||
for _, m := range ms {
|
|
||||||
d := m[2]
|
|
||||||
if seen[d] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[d] = true
|
|
||||||
out = append(out, mediaMarker{
|
|
||||||
label: m[1],
|
|
||||||
shortDigest: d,
|
|
||||||
description: strings.TrimSpace(m[3]),
|
|
||||||
raw: strings.TrimSpace(m[0]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaEntityName 是媒体在图库里的实体名。
|
|
||||||
//
|
|
||||||
// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字:
|
|
||||||
// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图
|
|
||||||
// 就会在图谱上留下多个节点。digest 不变则名字不变。
|
|
||||||
// 长度也天然合规(validEntityName 要求 2–50 字符)。
|
|
||||||
func mediaEntityName(label, shortDigest string) string {
|
|
||||||
kind := "媒体"
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(label, "image"):
|
|
||||||
kind = "图片"
|
|
||||||
case strings.HasPrefix(label, "audio"):
|
|
||||||
kind = "音频"
|
|
||||||
case strings.HasPrefix(label, "video"):
|
|
||||||
kind = "视频"
|
|
||||||
}
|
|
||||||
return kind + " " + shortDigest
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaTriplesFromText 为文本里的每条媒体标记产出确定的三元组。
|
|
||||||
//
|
|
||||||
// 这是媒体进 L3 的可靠路径:不经过 NLP 提取器,因此不受它对描述性文本
|
|
||||||
// 提取能力的影响。每条媒体至少产出一条「<媒体实体> -内容-> <描述摘要>」,
|
|
||||||
// 且 SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能
|
|
||||||
// 反解到 digest——绑定从概率事件变成确定行为。
|
|
||||||
//
|
|
||||||
// 描述摘要截到 40 字:validEntityName 上限 50 字符,留出余量;
|
|
||||||
// 图谱节点名过长会让可视化和实体合并都难以处理,完整描述留在
|
|
||||||
// SentenceText 与 media 表里。
|
|
||||||
func mediaTriplesFromText(text string) []memory.Triple {
|
|
||||||
markers := parseMediaMarkers(text)
|
|
||||||
if len(markers) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []memory.Triple
|
|
||||||
for _, m := range markers {
|
|
||||||
name := mediaEntityName(m.label, m.shortDigest)
|
|
||||||
|
|
||||||
// 类型三元组恒可产出,不依赖描述是否存在
|
|
||||||
out = append(out, memory.Triple{
|
|
||||||
Subject: name,
|
|
||||||
SubjectType: "Media",
|
|
||||||
Relation: "类型",
|
|
||||||
Object: m.label,
|
|
||||||
ObjectType: "MimeType",
|
|
||||||
Confidence: 1.0,
|
|
||||||
SentenceText: m.raw,
|
|
||||||
})
|
|
||||||
|
|
||||||
desc := summarizeForEntity(m.description, 40)
|
|
||||||
if desc == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, memory.Triple{
|
|
||||||
Subject: name,
|
|
||||||
SubjectType: "Media",
|
|
||||||
Relation: "内容",
|
|
||||||
Object: desc,
|
|
||||||
ObjectType: "Description",
|
|
||||||
Confidence: 1.0,
|
|
||||||
SentenceText: m.raw,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// summarizeForEntity 把描述压成可作实体名的短串。
|
|
||||||
//
|
|
||||||
// 取首个句子边界之前的内容,再按 rune 截断——直接按字节截会切坏 UTF-8,
|
|
||||||
// 图库里就会出现乱码实体名。空白与 Markdown 强调符号一并清掉,
|
|
||||||
// 否则「**整体构成**」这类标记会进实体名。
|
|
||||||
func summarizeForEntity(s string, maxRunes int) string {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s = strings.NewReplacer("**", "", "*", "", "\n", " ", "\t", " ").Replace(s)
|
|
||||||
for _, sep := range []string{"。", ";", ",", ". ", "; "} {
|
|
||||||
if i := strings.Index(s, sep); i > 0 {
|
|
||||||
s = s[:i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
r := []rune(s)
|
|
||||||
if len(r) > maxRunes {
|
|
||||||
r = r[:maxRunes]
|
|
||||||
}
|
|
||||||
out := strings.TrimSpace(string(r))
|
|
||||||
// 太短的残片(如单字)过不了 validEntityName,直接放弃比写进去更好
|
|
||||||
if len([]rune(out)) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractMediaDigests 从文本里找出所有媒体标记的 digest。
|
|
||||||
//
|
|
||||||
// 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP
|
|
||||||
// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/
|
|
||||||
// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。
|
|
||||||
// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们
|
|
||||||
// 自己按固定格式写进文本的,反解是这里最省的可靠做法。
|
|
||||||
func extractMediaDigests(text string) []string {
|
|
||||||
if text == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
matches := mediaDigestPattern.FindAllStringSubmatch(text, -1)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := make(map[string]bool, len(matches))
|
|
||||||
var out []string
|
|
||||||
for _, m := range matches {
|
|
||||||
d := m[1]
|
|
||||||
if seen[d] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[d] = true
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// bindSentenceMedia 把句子文本里提到的媒体挂到对应的 sentences.id 上。
|
|
||||||
//
|
|
||||||
// sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。
|
|
||||||
// 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用
|
|
||||||
// (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。
|
|
||||||
//
|
|
||||||
// 返回实际绑定成功的引用数,这是调用方的安全依据:归档路径靠它判定
|
|
||||||
// 「引用真的转移到图库了吗」,不能用「Commit 没报错」代替——Commit 会
|
|
||||||
// 静默跳过实体名不合法(validEntityName 要求 2–50 字符)的三元组,
|
|
||||||
// 于是「无错但一条也没写进去」是真实会发生的:LLM 生成的长描述提不出
|
|
||||||
// 合规实体名,实测 456 字描述得到 0 entities 0 relations。
|
|
||||||
func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int {
|
|
||||||
if a.mediaStore == nil || len(sentenceIDs) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
bound := 0
|
|
||||||
for text, sid := range sentenceIDs {
|
|
||||||
if sid == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
digests := extractMediaDigests(text)
|
|
||||||
if len(digests) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ownerID := strconv.FormatInt(sid, 10)
|
|
||||||
for _, short := range digests {
|
|
||||||
// 文本里是短 digest,media_refs 的主键要完整 digest。
|
|
||||||
// 补全失败(内容已被 GC 清掉、或前缀有歧义)就跳过——
|
|
||||||
// 挂一条对不上的引用比不挂更糟:DropOwner 永远匹配不到它。
|
|
||||||
full, err := a.mediaStore.ResolvePrefix(short)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := a.mediaStore.AddRef(full, media.OwnerGraphSentence, ownerID); err != nil {
|
|
||||||
log.Printf("[media] 句子引用绑定失败 (%s → sentence %s): %v", short, ownerID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
bound++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bound > 0 {
|
|
||||||
log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound)
|
|
||||||
}
|
|
||||||
return bound
|
|
||||||
}
|
|
||||||
|
|
||||||
// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。
|
|
||||||
//
|
|
||||||
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定,
|
|
||||||
// 而不必各自记得多调一次 bindSentenceMedia。
|
|
||||||
// mediaBound 是本次实际挂到 graph_sentence owner 上的引用数;归档路径靠它
|
|
||||||
// 判定能否安全释放旧引用。媒体存储关闭时恒为 0(此时也没有引用需要释放)。
|
|
||||||
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (entities, relations, mediaBound int, err error) {
|
|
||||||
if a.memory == nil {
|
|
||||||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
|
||||||
}
|
|
||||||
// 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。
|
|
||||||
if a.mediaStore == nil {
|
|
||||||
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
|
|
||||||
return ec, rc, 0, cErr
|
|
||||||
}
|
|
||||||
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
|
|
||||||
if err != nil {
|
|
||||||
return ec, rc, 0, err
|
|
||||||
}
|
|
||||||
return ec, rc, a.bindSentenceMedia(sentenceIDs), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecallMediaForSentence 反查某条图库句子引用的媒体。
|
|
||||||
//
|
|
||||||
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节
|
|
||||||
// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用
|
|
||||||
// mediaStore.Get 取内容、Stat 取描述与元数据。
|
|
||||||
func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) {
|
|
||||||
if a.mediaStore == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
|
|
||||||
//
|
|
||||||
// 关系行本身不持有媒体,媒体挂在句子上(graph_sentence owner)。
|
|
||||||
// 因此"这次召回涉及哪些媒体"必须经由关系 → 句子 → media_refs 这条路。
|
|
||||||
func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
|
|
||||||
if len(relations) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := make(map[int64]bool, len(relations))
|
|
||||||
var out []int64
|
|
||||||
for _, r := range relations {
|
|
||||||
if r.SentenceID == 0 || seen[r.SentenceID] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[r.SentenceID] = true
|
|
||||||
out = append(out, r.SentenceID)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。
|
|
||||||
//
|
|
||||||
// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的
|
|
||||||
// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。
|
|
||||||
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
|
|
||||||
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
|
|
||||||
//
|
|
||||||
// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系
|
|
||||||
// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。
|
|
||||||
// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。
|
|
||||||
//
|
|
||||||
// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图",
|
|
||||||
// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。
|
|
||||||
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
|
|
||||||
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(injected.Entities))
|
|
||||||
for _, e := range injected.Entities {
|
|
||||||
names = append(names, e.Name)
|
|
||||||
}
|
|
||||||
res, err := a.memory.Recall(nil, names, 1, "")
|
|
||||||
if err != nil || res == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return a.mediaContextForRelations(res.Relations)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。
|
|
||||||
//
|
|
||||||
// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。
|
|
||||||
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息——
|
|
||||||
// 描述永存而字节可能已被淘汰,两者状态不同。
|
|
||||||
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
|
||||||
if a.mediaStore == nil || len(sentenceIDs) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var lines []string
|
|
||||||
for _, sid := range sentenceIDs {
|
|
||||||
digests, err := a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
|
||||||
if err != nil || len(digests) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var parts []string
|
|
||||||
for _, d := range digests {
|
|
||||||
it, err := a.mediaStore.Stat(d)
|
|
||||||
if err != nil || it == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
label := string(it.Kind)
|
|
||||||
if it.MIME != "" {
|
|
||||||
label = it.MIME
|
|
||||||
}
|
|
||||||
desc := it.Description
|
|
||||||
if desc == "" {
|
|
||||||
desc = "(未描述)"
|
|
||||||
}
|
|
||||||
parts = append(parts, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), desc))
|
|
||||||
}
|
|
||||||
if len(parts) > 0 {
|
|
||||||
lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, ";")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
@ -1,705 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
|
||||||
)
|
|
||||||
|
|
||||||
// L3 图库媒体引用测试。
|
|
||||||
//
|
|
||||||
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份字节。
|
|
||||||
// 因此测试的重点是「反查链路是否完整」以及「引用是否会悬空或误删」。
|
|
||||||
|
|
||||||
func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewGraphDB: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { g.Close() })
|
|
||||||
|
|
||||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("media.New: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { ms.Close() })
|
|
||||||
|
|
||||||
return &Agent{memory: g, mediaStore: ms}, g, ms
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractMediaDigests(t *testing.T) {
|
|
||||||
// 与 mediaSummaryForEvent 的输出格式对应
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
text string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}},
|
|
||||||
{"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}},
|
|
||||||
{"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}},
|
|
||||||
{"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}},
|
|
||||||
{"无标记", "普通句子,没有媒体", nil},
|
|
||||||
{"空串", "", nil},
|
|
||||||
// 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix
|
|
||||||
{"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil},
|
|
||||||
{"过短", "[image abc] 太短", nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
got := extractMediaDigests(c.text)
|
|
||||||
if len(got) != len(c.want) {
|
|
||||||
t.Fatalf("%s: 得到 %v,期望 %v", c.name, got, c.want)
|
|
||||||
}
|
|
||||||
for i := range got {
|
|
||||||
if got[i] != c.want[i] {
|
|
||||||
t.Fatalf("%s: 第 %d 个得到 %q,期望 %q", c.name, i, got[i], c.want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) {
|
|
||||||
_, g, _ := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图"
|
|
||||||
triples := []memory.Triple{{
|
|
||||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
|
||||||
SentenceText: sentence,
|
|
||||||
}}
|
|
||||||
|
|
||||||
ids, ec, rc, err := g.CommitWithMedia(triples, "s1", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ec == 0 || rc == 0 {
|
|
||||||
t.Fatalf("应写入实体与关系,实际 ec=%d rc=%d", ec, rc)
|
|
||||||
}
|
|
||||||
if ids[sentence] == 0 {
|
|
||||||
t.Fatalf("应返回句子 id,实际 %v", ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommit_StillWorksAfterRefactor(t *testing.T) {
|
|
||||||
// Commit 有三十多个调用点,内部转调后行为必须完全不变
|
|
||||||
_, g, _ := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
triples := []memory.Triple{
|
|
||||||
{Subject: "张三", Relation: "喜欢", Object: "咖啡", SentenceText: "张三喜欢咖啡"},
|
|
||||||
{Subject: "李四", Relation: "住在", Object: "北京"},
|
|
||||||
}
|
|
||||||
ec, rc, err := g.Commit(triples, "s1", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ec != 4 || rc != 2 {
|
|
||||||
t.Fatalf("期望 4 实体 2 关系,实际 ec=%d rc=%d", ec, rc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重复提交同一批:关系被唯一约束去重。
|
|
||||||
//
|
|
||||||
// 实体计数**不**归零——这是 upsertEntity 的既有行为:SQLite 的
|
|
||||||
// ON CONFLICT DO UPDATE 也算一行 affected,于是 RowsAffected() > 0
|
|
||||||
// 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是
|
|
||||||
// 首次 ec=2 / 重复 ec=2,与 CommitWithMedia 重构无关。
|
|
||||||
// entitiesCreated 只用于日志,故此处记录现状而不改行为。
|
|
||||||
ec2, rc2, err := g.Commit(triples, "s1", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if rc2 != 0 {
|
|
||||||
t.Fatalf("重复提交不该新建关系,实际 rc=%d", rc2)
|
|
||||||
}
|
|
||||||
if ec2 != 4 {
|
|
||||||
t.Fatalf("实体计数应与首次一致(既有 upsert 计数行为),实际 ec=%d", ec2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
|
||||||
// 整层的核心断言:写入 → 提交 → 反查取回原始字节
|
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
content := []byte("\x89PNG\r\n\x1a\n fake image bytes")
|
|
||||||
digest, err := ms.Put(content, media.Item{MIME: "image/png", Kind: media.KindImage})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
short := shortDigest(digest)
|
|
||||||
|
|
||||||
sentence := "[image/png " + short + "] 一张紫蓝红三色带图"
|
|
||||||
triples := []memory.Triple{{
|
|
||||||
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence,
|
|
||||||
}}
|
|
||||||
|
|
||||||
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 找到句子 id
|
|
||||||
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sid := ids[sentence]
|
|
||||||
if sid == 0 {
|
|
||||||
t.Fatal("拿不到句子 id")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 反查:从句子取回 digest,再取回字节
|
|
||||||
digests, err := a.RecallMediaForSentence(sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(digests) != 1 || digests[0] != digest {
|
|
||||||
t.Fatalf("反查应得完整 digest %s,实际 %v", shortDigest(digest), digests)
|
|
||||||
}
|
|
||||||
got, err := ms.Get(digests[0])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("取回内容失败: %v", err)
|
|
||||||
}
|
|
||||||
if string(got) != string(content) {
|
|
||||||
t.Fatal("取回的内容与写入不一致")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 引用计数非零 → GC 不会清它
|
|
||||||
if _, _, err := ms.GC(0); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := ms.Get(digest); err != nil {
|
|
||||||
t.Fatalf("被图库句子引用的内容不该被 GC 清掉: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) {
|
|
||||||
// 文本里的 digest 在库里不存在时必须跳过,不能挂一条对不上的引用——
|
|
||||||
// 那条引用 DropOwner 永远匹配不到,会永久占着计数。
|
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
sentence := "[image/png deadbeefdead] 一张不存在的图"
|
|
||||||
ids := map[string]int64{sentence: 42}
|
|
||||||
a.bindSentenceMedia(ids)
|
|
||||||
|
|
||||||
refs, err := ms.Refs(media.OwnerGraphSentence, "42")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(refs) != 0 {
|
|
||||||
t.Fatalf("无法补全的 digest 不该挂引用,实际 %v", refs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBindSentenceMedia_NilStoreNoop(t *testing.T) {
|
|
||||||
a := &Agent{}
|
|
||||||
a.bindSentenceMedia(map[string]int64{"[image aaaaaaaaaaaa] x": 1})
|
|
||||||
if got, err := a.RecallMediaForSentence(1); err != nil || got != nil {
|
|
||||||
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
|
||||||
// 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致
|
|
||||||
dir := t.TempDir()
|
|
||||||
g, err := memory.NewGraphDB(filepath.Join(dir, "g.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer g.Close()
|
|
||||||
|
|
||||||
a := &Agent{memory: g}
|
|
||||||
ec, rc, _, err := a.commitTriplesWithMedia([]memory.Triple{
|
|
||||||
{Subject: "张三", Relation: "喜欢", Object: "咖啡"},
|
|
||||||
}, "s1", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ec != 2 || rc != 1 {
|
|
||||||
t.Fatalf("期望 2 实体 1 关系,实际 ec=%d rc=%d", ec, rc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReleaseDocMedia_DropsRefsSoGCCanReclaim(t *testing.T) {
|
|
||||||
// L2→L3 那一跳留下的泄漏:文档被 Remove 但引用没销,
|
|
||||||
// 引用计数永不归零,blob 永远不会被 GC 回收。
|
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ms.AddRef(digest, media.OwnerDocument, "doc_1"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 释放前 GC 清不掉
|
|
||||||
if _, _, err := ms.GC(0); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := ms.Stat(digest); err != nil {
|
|
||||||
t.Fatal("有文档引用时不该被清")
|
|
||||||
}
|
|
||||||
|
|
||||||
a.releaseDocMedia("doc_1")
|
|
||||||
|
|
||||||
if refs, _ := ms.Refs(media.OwnerDocument, "doc_1"); len(refs) != 0 {
|
|
||||||
t.Fatalf("释放后不该还有文档引用,实际 %v", refs)
|
|
||||||
}
|
|
||||||
// 现在 GC 能回收了
|
|
||||||
removed, _, err := ms.GC(0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if removed != 1 {
|
|
||||||
t.Fatalf("释放引用后 GC 应能回收,实际清理 %d 条", removed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaContextForSentences(t *testing.T) {
|
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
|
||||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, "7"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := a.mediaContextForSentences([]int64{7, 8})
|
|
||||||
if out == "" {
|
|
||||||
t.Fatal("应产出媒体说明")
|
|
||||||
}
|
|
||||||
if !contains(out, "句子 #7") || !contains(out, "一张紫蓝红三色带图") {
|
|
||||||
t.Fatalf("说明内容不对: %q", out)
|
|
||||||
}
|
|
||||||
// 8 号句子没引用媒体,不该出现
|
|
||||||
if contains(out, "句子 #8") {
|
|
||||||
t.Fatalf("无引用的句子不该出现: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolvePrefix(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer ms.Close()
|
|
||||||
|
|
||||||
digest, _ := ms.Put([]byte("content"), media.Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
// 短前缀补全
|
|
||||||
full, err := ms.ResolvePrefix(digest[:12])
|
|
||||||
if err != nil || full != digest {
|
|
||||||
t.Fatalf("短前缀补全失败: %v / %v", full, err)
|
|
||||||
}
|
|
||||||
// 完整 digest 原样返回
|
|
||||||
full, err = ms.ResolvePrefix(digest)
|
|
||||||
if err != nil || full != digest {
|
|
||||||
t.Fatalf("完整 digest 应原样返回: %v / %v", full, err)
|
|
||||||
}
|
|
||||||
// 过短拒绝
|
|
||||||
if _, err := ms.ResolvePrefix("abc"); err == nil {
|
|
||||||
t.Fatal("过短前缀应报错")
|
|
||||||
}
|
|
||||||
// 不存在
|
|
||||||
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
|
|
||||||
t.Fatal("不存在的前缀应报错")
|
|
||||||
}
|
|
||||||
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用
|
|
||||||
fake := ""
|
|
||||||
for i := 0; i < 64; i++ {
|
|
||||||
fake += "0"
|
|
||||||
}
|
|
||||||
if _, err := ms.ResolvePrefix(fake); err == nil {
|
|
||||||
t.Fatal("不存在的完整 digest 应报错")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
|
||||||
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。
|
|
||||||
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
|
|
||||||
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」:
|
|
||||||
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
|
|
||||||
dir := t.TempDir()
|
|
||||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer ms.Close()
|
|
||||||
|
|
||||||
digests := make([]string, 0, 200)
|
|
||||||
for i := 0; i < 200; i++ {
|
|
||||||
d, err := ms.Put([]byte("content-"+strconv.Itoa(i)), media.Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
digests = append(digests, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, d := range digests {
|
|
||||||
got, err := ms.ResolvePrefix(d[:12])
|
|
||||||
if err != nil {
|
|
||||||
// 报歧义是可接受结果;静默取错才是缺陷
|
|
||||||
if !contains(err.Error(), "歧义") {
|
|
||||||
t.Fatalf("非歧义错误: %v", err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if got != d {
|
|
||||||
t.Fatalf("补全结果错误: 前缀 %s 得到 %s", d[:12], got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
|
||||||
// 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities
|
|
||||||
// 0 relations),文档不能删、媒体引用不能释放。
|
|
||||||
//
|
|
||||||
// 该缺陷曾真实发生:LLM 生成的 456 字图片描述提不出合规实体名
|
|
||||||
//(validEntityName 要求 2–50 字符),archiveColdDocs 只检查
|
|
||||||
// len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。
|
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
ds := document.NewStore(filepath.Join(dir, "docs"))
|
|
||||||
if err := ds.Start(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer ds.Stop()
|
|
||||||
a.docStore = ds
|
|
||||||
a.embedder = memory.NewStaticEmbedder()
|
|
||||||
|
|
||||||
content := []byte("image bytes")
|
|
||||||
digest, err := ms.Put(content, media.Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态。
|
|
||||||
//
|
|
||||||
// 用超长 Source 而不是指望 NLP 提取器:docToTriples 在
|
|
||||||
// Source != "context_archived" 时会写一条 {文档 -来源-> Source},
|
|
||||||
// Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过
|
|
||||||
// → len(triples)==1 但 ec=0 rc=0。构造是确定的,不依赖提取器的
|
|
||||||
// 具体行为(提取器行为随版本变化,测试不该押在它身上)。
|
|
||||||
//
|
|
||||||
// 正文里刻意**不放**媒体标记:mediaTriplesFromText 会为标记产出
|
|
||||||
// 合规的「图片 <digest>」三元组,那样 ec/rc 就不为 0,这个用例
|
|
||||||
// 也就测不到「全被拒绝」这个状态了。媒体引用直接用 AddRef 挂上,
|
|
||||||
// 模拟「文档持有媒体但正文的媒体标记已在清洗中丢失」这一情形——
|
|
||||||
// 那正是最危险的组合:有引用要释放,却没有句子能承载它。
|
|
||||||
longSource := strings.Repeat("超长来源名", 20) // 100 字,远超 50 字符上限
|
|
||||||
// Summary 也必须超长:docToTriples 会为合理 summary 写一条
|
|
||||||
// {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。
|
|
||||||
// 这里要的是「三元组全部被拒」这一个状态。
|
|
||||||
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字,触发长度门槛被跳过
|
|
||||||
doc := &document.Doc{
|
|
||||||
ID: "doc_keep",
|
|
||||||
Summary: longSummary,
|
|
||||||
Content: "一段没有媒体标记的正文",
|
|
||||||
Source: longSource,
|
|
||||||
CreatedAt: time.Now().Add(-200 * time.Hour),
|
|
||||||
LastAccess: time.Now().Add(-200 * time.Hour),
|
|
||||||
AccessCount: 0,
|
|
||||||
}
|
|
||||||
if err := ds.Insert(doc); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Insert 会把 LastAccess 覆写成 now、AccessCount 置 1,
|
|
||||||
// 于是 FindColdDocs(72h, 2) 一篇都找不到。插入后再改回来,
|
|
||||||
// 让文档真正满足"冷"的条件——这是触发归档路径的前提。
|
|
||||||
for _, d := range ds.RecentDocs(10) {
|
|
||||||
if d.ID == doc.ID {
|
|
||||||
d.LastAccess = time.Now().Add(-200 * time.Hour)
|
|
||||||
d.AccessCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ms.AddRef(digest, media.OwnerDocument, doc.ID); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.archiveColdDocs()
|
|
||||||
|
|
||||||
// 关键断言三连:内容在、引用在、文档在
|
|
||||||
if _, err := ms.Get(digest); err != nil {
|
|
||||||
t.Fatalf("图库未写入任何实体/关系,内容却丢了: %v", err)
|
|
||||||
}
|
|
||||||
refs, err := ms.Refs(media.OwnerDocument, doc.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(refs) == 0 {
|
|
||||||
t.Error("引用被释放了——图库没有句子承载它,释放后 GC 会删掉内容")
|
|
||||||
}
|
|
||||||
if removed, _, err := ms.GC(0); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
} else if _, err := ms.Stat(digest); err != nil {
|
|
||||||
t.Fatalf("GC(清 %d 条) 删掉了本该保留的内容", removed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) {
|
|
||||||
// mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用。
|
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
digest, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
short := shortDigest(digest)
|
|
||||||
|
|
||||||
// 句子含可反解的短 digest → 应绑定 1 个
|
|
||||||
_, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{
|
|
||||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
|
||||||
SentenceText: "[image/png " + short + "] 一张三色带图",
|
|
||||||
}}, "s1", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if bound != 1 {
|
|
||||||
t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 句子无 digest → 绑定 0 个
|
|
||||||
_, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{
|
|
||||||
Subject: "张三", Relation: "喜欢", Object: "咖啡",
|
|
||||||
SentenceText: "张三喜欢咖啡",
|
|
||||||
}}, "s2", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if bound2 != 0 {
|
|
||||||
t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSentenceIDsFromRelations(t *testing.T) {
|
|
||||||
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
|
|
||||||
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子,
|
|
||||||
// 拿 0 去查 media_refs 会命中一个不存在的 owner。
|
|
||||||
rels := []memory.Relation{
|
|
||||||
{ID: 1, SentenceID: 5},
|
|
||||||
{ID: 2, SentenceID: 0}, // 无句子
|
|
||||||
{ID: 3, SentenceID: 5}, // 重复
|
|
||||||
{ID: 4, SentenceID: 7},
|
|
||||||
}
|
|
||||||
got := sentenceIDsFromRelations(rels)
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("应得 2 个去重后的句子 id,实际 %v", got)
|
|
||||||
}
|
|
||||||
if got[0] != 5 || got[1] != 7 {
|
|
||||||
t.Fatalf("句子 id 或顺序不对: %v", got)
|
|
||||||
}
|
|
||||||
if n := sentenceIDsFromRelations(nil); n != nil {
|
|
||||||
t.Fatalf("空输入应返回 nil,实际 %v", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
|
|
||||||
// L3 检索接线回归:媒体描述进了图库,agent 必须拿得出来。
|
|
||||||
//
|
|
||||||
// 第四层做完了"存和反查的能力"(RecallMediaForSentence /
|
|
||||||
// mediaContextForSentences),但那两个函数一度没有任何调用方——
|
|
||||||
// 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。
|
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, "5"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 命中的关系挂着 5 号句子 → 应产出媒体说明
|
|
||||||
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: 5}})
|
|
||||||
if out == "" {
|
|
||||||
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
|
|
||||||
}
|
|
||||||
if !contains(out, "一张紫蓝红三色带图") {
|
|
||||||
t.Errorf("媒体说明里应含描述文本: %q", out)
|
|
||||||
}
|
|
||||||
if !contains(out, shortDigest(digest)) {
|
|
||||||
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没挂媒体的关系不该产出噪声
|
|
||||||
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
|
|
||||||
t.Errorf("无媒体的句子不该产出说明: %q", out)
|
|
||||||
}
|
|
||||||
if out := a.mediaContextForRelations(nil); out != "" {
|
|
||||||
t.Errorf("空关系不该产出说明: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
|
|
||||||
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
|
|
||||||
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
|
|
||||||
a, graph, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ms.Describe(digest, "自动注入用的测试图", "visionllm"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sentence := "用户发来的图片 [image/png " + shortDigest(digest) + "] 自动注入用的测试图"
|
|
||||||
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
|
|
||||||
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
|
|
||||||
}}, "auto", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sid := sids[sentence]
|
|
||||||
if sid == 0 {
|
|
||||||
t.Fatal("拿不到句子 id")
|
|
||||||
}
|
|
||||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, strconv.FormatInt(sid, 10)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.indexer = memory.NewIndexer(graph)
|
|
||||||
if err := a.indexer.Sync(); err != nil {
|
|
||||||
t.Fatalf("indexer sync: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := a.buildMemoryContext("测试图片", 0)
|
|
||||||
if out == "" {
|
|
||||||
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
|
|
||||||
}
|
|
||||||
if !contains(out, "【关联媒体】") {
|
|
||||||
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
|
|
||||||
}
|
|
||||||
if !contains(out, "自动注入用的测试图") {
|
|
||||||
t.Errorf("媒体段里应含描述文本: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseMediaMarkers(t *testing.T) {
|
|
||||||
// 与 mediaSummaryForEvent 的输出格式严格对应
|
|
||||||
text := "用户发来图片\n媒体内容:\n" +
|
|
||||||
"[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图\n" +
|
|
||||||
"[audio/wav bbbbccccdddd] 一段三秒的钢琴声\n" +
|
|
||||||
"[image/png a1b2c3d4e5f6] 重复的同一张图"
|
|
||||||
|
|
||||||
ms := parseMediaMarkers(text)
|
|
||||||
if len(ms) != 2 {
|
|
||||||
t.Fatalf("应解析出 2 条去重后的标记,实际 %d: %+v", len(ms), ms)
|
|
||||||
}
|
|
||||||
if ms[0].label != "image/png" || ms[0].shortDigest != "a1b2c3d4e5f6" {
|
|
||||||
t.Errorf("第一条解析错误: %+v", ms[0])
|
|
||||||
}
|
|
||||||
if ms[0].description != "一张紫蓝红三色带图" {
|
|
||||||
t.Errorf("描述应取到行尾且不跨行: %q", ms[0].description)
|
|
||||||
}
|
|
||||||
if ms[1].label != "audio/wav" {
|
|
||||||
t.Errorf("第二条 label 错误: %+v", ms[1])
|
|
||||||
}
|
|
||||||
// raw 用作 SentenceText,必须含 digest 才能被 bindSentenceMedia 反解
|
|
||||||
if !contains(ms[0].raw, "a1b2c3d4e5f6") {
|
|
||||||
t.Errorf("raw 必须含 digest: %q", ms[0].raw)
|
|
||||||
}
|
|
||||||
if n := parseMediaMarkers("没有任何标记的普通文本"); n != nil {
|
|
||||||
t.Errorf("无标记应返回 nil,实际 %+v", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaEntityName(t *testing.T) {
|
|
||||||
// 实体名必须由 digest 而非描述构成:描述会被重新生成,
|
|
||||||
// 若名字取自描述,同一张图会在图谱上留下多个节点。
|
|
||||||
cases := []struct{ label, digest, want string }{
|
|
||||||
{"image/png", "a1b2c3d4e5f6", "图片 a1b2c3d4e5f6"},
|
|
||||||
{"audio/wav", "bbbbccccdddd", "音频 bbbbccccdddd"},
|
|
||||||
{"video/mp4", "ccccddddeeee", "视频 ccccddddeeee"},
|
|
||||||
{"application/octet-stream", "ddddeeeeffff", "媒体 ddddeeeeffff"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := mediaEntityName(c.label, c.digest)
|
|
||||||
if got != c.want {
|
|
||||||
t.Errorf("mediaEntityName(%q,%q) = %q,期望 %q", c.label, c.digest, got, c.want)
|
|
||||||
}
|
|
||||||
// 必须过 validEntityName 的 2–50 字符门槛,否则 Commit 会静默跳过
|
|
||||||
if n := len([]rune(got)); n < 2 || n > 50 {
|
|
||||||
t.Errorf("实体名长度 %d 不在 2–50 之间: %q", n, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSummarizeForEntity(t *testing.T) {
|
|
||||||
cases := []struct{ in, want string }{
|
|
||||||
{"一张紫蓝红三色带图。还有更多内容。", "一张紫蓝红三色带图"},
|
|
||||||
{"**整体构成**:正方形画布", "整体构成:正方形画布"}, // Markdown 强调符被清掉
|
|
||||||
{"", ""},
|
|
||||||
{"短", ""}, // 单字过不了 validEntityName,宁可不写
|
|
||||||
// 无句子边界时按 rune 截到 40(不是按字节,否则切坏 UTF-8 会在图库里留乱码)
|
|
||||||
{"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库里出现乱码实体名字符",
|
|
||||||
"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := summarizeForEntity(c.in, 40)
|
|
||||||
if got != c.want {
|
|
||||||
t.Errorf("summarizeForEntity(%q) = %q,期望 %q", c.in, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaTriplesFromText_DeterministicRegardlessOfNLP(t *testing.T) {
|
|
||||||
// 核心回归:媒体入 L3 不再依赖 NLP 提取器的运气。
|
|
||||||
//
|
|
||||||
// 实测 LLM 的 477 字图片描述经提取器只产出「水平 -分割-> 成」,
|
|
||||||
// obj 仅 1 字被 validEntityName 拒掉 → ec=0 rc=0 → 媒体记忆进不了图库,
|
|
||||||
// 且时好时坏取决于描述文本。这里验证确定性路径。
|
|
||||||
longDesc := "这张图片是一张纯色块构成的抽象图像,不包含任何文字、人物、物体或可识别的场景。" +
|
|
||||||
"整体构成:一个小尺寸的正方形图像,被水平分割成三条颜色条带。"
|
|
||||||
text := "媒体内容:\n[image/png 89e293b42546] " + longDesc
|
|
||||||
|
|
||||||
triples := mediaTriplesFromText(text)
|
|
||||||
if len(triples) < 2 {
|
|
||||||
t.Fatalf("应至少产出类型+内容两条三元组,实际 %d", len(triples))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每条都必须能通过 validEntityName(经 Commit 实证)
|
|
||||||
g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer g.Close()
|
|
||||||
sids, ec, rc, err := g.CommitWithMedia(triples, "det", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ec == 0 || rc == 0 {
|
|
||||||
t.Fatalf("确定性三元组应能写入图库,实际 ec=%d rc=%d", ec, rc)
|
|
||||||
}
|
|
||||||
if len(sids) == 0 {
|
|
||||||
t.Fatal("应返回句子 id 供 bindSentenceMedia 绑定")
|
|
||||||
}
|
|
||||||
// SentenceText 必须含 digest,否则绑定还是断的
|
|
||||||
for st := range sids {
|
|
||||||
if !contains(st, "89e293b42546") {
|
|
||||||
t.Errorf("句子必须含短 digest 供反解: %q", st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 描述为空时仍应产出类型三元组——媒体节点不能因为没描述就不存在
|
|
||||||
bare := mediaTriplesFromText("[image/png 89e293b42546]")
|
|
||||||
if len(bare) != 1 {
|
|
||||||
t.Fatalf("无描述时应只有类型三元组,实际 %d 条", len(bare))
|
|
||||||
}
|
|
||||||
if bare[0].Relation != "类型" {
|
|
||||||
t.Errorf("无描述时那条应是类型三元组: %+v", bare[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,570 +0,0 @@
|
|||||||
//go:build medialive
|
|
||||||
|
|
||||||
// 媒体记忆自动触发链的集成测试。
|
|
||||||
//
|
|
||||||
// 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事——
|
|
||||||
// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走:
|
|
||||||
//
|
|
||||||
// processMediaInput → captureBlockMedia(入 CAS)
|
|
||||||
// → Prune → transferMediaRefs(L0→L2 引用转移)
|
|
||||||
// → describePendingMedia(真实视觉模型生成描述)
|
|
||||||
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3)
|
|
||||||
// → 第二轮提问,验证 agent 真能召回
|
|
||||||
//
|
|
||||||
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。
|
|
||||||
// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore),
|
|
||||||
// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。
|
|
||||||
//
|
|
||||||
// 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑:
|
|
||||||
//
|
|
||||||
// MEDIALIVE_BASE_URL=http://127.0.0.1:8081/v1 \
|
|
||||||
// MEDIALIVE_API_KEY=sk-xxx \
|
|
||||||
// MEDIALIVE_MODEL=claude-opus-5 \
|
|
||||||
// MEDIALIVE_ADAPTER=openai \
|
|
||||||
// go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v -timeout 20m
|
|
||||||
//
|
|
||||||
// 源、模型、密钥全部由调用方显式指定,测试自己不猜任何默认值——
|
|
||||||
// 猜一个默认端点会让测试在别人机器上打到意料之外的服务。
|
|
||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/zlib"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"hash/crc32"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
|
||||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
|
||||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// liveCfg 是调用方通过环境变量显式提供的 LLM 源配置。
|
|
||||||
type liveCfg struct {
|
|
||||||
baseURL string
|
|
||||||
apiKey string
|
|
||||||
model string
|
|
||||||
adapter string
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireLiveCfg 读取环境变量;缺任何一项就 Skip 而非猜默认值。
|
|
||||||
//
|
|
||||||
// 刻意不提供 fallback:一个猜出来的 base_url 可能打到调用者机器上
|
|
||||||
// 完全不相干的服务,而测试会把那次调用的失败报成"媒体记忆有问题"。
|
|
||||||
func requireLiveCfg(t *testing.T) liveCfg {
|
|
||||||
t.Helper()
|
|
||||||
c := liveCfg{
|
|
||||||
baseURL: os.Getenv("MEDIALIVE_BASE_URL"),
|
|
||||||
apiKey: os.Getenv("MEDIALIVE_API_KEY"),
|
|
||||||
model: os.Getenv("MEDIALIVE_MODEL"),
|
|
||||||
adapter: os.Getenv("MEDIALIVE_ADAPTER"),
|
|
||||||
}
|
|
||||||
var missing []string
|
|
||||||
if c.baseURL == "" {
|
|
||||||
missing = append(missing, "MEDIALIVE_BASE_URL")
|
|
||||||
}
|
|
||||||
if c.apiKey == "" {
|
|
||||||
missing = append(missing, "MEDIALIVE_API_KEY")
|
|
||||||
}
|
|
||||||
if c.model == "" {
|
|
||||||
missing = append(missing, "MEDIALIVE_MODEL")
|
|
||||||
}
|
|
||||||
if c.adapter == "" {
|
|
||||||
missing = append(missing, "MEDIALIVE_ADAPTER")
|
|
||||||
}
|
|
||||||
if len(missing) > 0 {
|
|
||||||
t.Skipf("缺少环境变量 %s——本测试要求调用方显式指定源/模型/密钥,不使用任何默认值",
|
|
||||||
strings.Join(missing, ", "))
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// livePNG 造一张横向三色带真 PNG(手工拼 IHDR/IDAT/IEND)。
|
|
||||||
//
|
|
||||||
// 用可辨认的纯色而非随机字节:断言要能检查"模型是否真的看到了内容",
|
|
||||||
// 随机噪声无法产生可验证的描述。
|
|
||||||
func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte {
|
|
||||||
t.Helper()
|
|
||||||
chunk := func(typ string, data []byte) []byte {
|
|
||||||
var b bytes.Buffer
|
|
||||||
if err := binary.Write(&b, binary.BigEndian, uint32(len(data))); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
body := append([]byte(typ), data...)
|
|
||||||
b.Write(body)
|
|
||||||
if err := binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(body)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return b.Bytes()
|
|
||||||
}
|
|
||||||
var raw bytes.Buffer
|
|
||||||
for y := 0; y < h; y++ {
|
|
||||||
raw.WriteByte(0) // filter type: none
|
|
||||||
c := colors[y*len(colors)/h]
|
|
||||||
for x := 0; x < w; x++ {
|
|
||||||
raw.Write(c[:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var comp bytes.Buffer
|
|
||||||
zw := zlib.NewWriter(&comp)
|
|
||||||
if _, err := zw.Write(raw.Bytes()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zw.Close()
|
|
||||||
|
|
||||||
var ihdr bytes.Buffer
|
|
||||||
binary.Write(&ihdr, binary.BigEndian, uint32(w))
|
|
||||||
binary.Write(&ihdr, binary.BigEndian, uint32(h))
|
|
||||||
ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8-bit truecolor
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
|
|
||||||
out.Write(chunk("IHDR", ihdr.Bytes()))
|
|
||||||
out.Write(chunk("IDAT", comp.Bytes()))
|
|
||||||
out.Write(chunk("IEND", nil))
|
|
||||||
return out.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// liveEnv 是一套完整但完全独立的 agent 运行环境。
|
|
||||||
type liveEnv struct {
|
|
||||||
agent *Agent
|
|
||||||
io *agentIO.IOManager
|
|
||||||
mediaSt *media.Store
|
|
||||||
docStore *document.Store
|
|
||||||
graph *memory.GraphDB
|
|
||||||
dir string
|
|
||||||
}
|
|
||||||
|
|
||||||
// newLiveEnv 构造真 Agent:真 provider、真 CAS、真图库、真文档库。
|
|
||||||
//
|
|
||||||
// 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用
|
|
||||||
// (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。
|
|
||||||
func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
vm := luaVM.NewVM(filepath.Join(dir, "adapters"))
|
|
||||||
if err := vm.Start(); err != nil {
|
|
||||||
t.Fatalf("lua vm: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(vm.Stop)
|
|
||||||
|
|
||||||
// Vision: true —— 能力是声明的,不是探测的。网关可能静默剥离
|
|
||||||
// image_url 后仍返回 200,从响应无法推断它到底看见了没有。
|
|
||||||
prov := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
|
||||||
Model: c.model, BaseURL: c.baseURL, APIKey: c.apiKey,
|
|
||||||
MaxTokens: 1200, Temperature: 0.3, Vision: true,
|
|
||||||
}, vm, "medialive", c.adapter)
|
|
||||||
|
|
||||||
pm := agentAPI.NewProviderManager()
|
|
||||||
pm.Register("medialive", prov)
|
|
||||||
if err := pm.SetDefault("medialive"); err != nil {
|
|
||||||
t.Fatalf("set default provider: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ms, err := media.New(filepath.Join(dir, "media"), 256<<20)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("media store: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { ms.Close() })
|
|
||||||
|
|
||||||
graph, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("graph: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { graph.Close() })
|
|
||||||
|
|
||||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
|
||||||
if err := docStore.Start(); err != nil {
|
|
||||||
t.Fatalf("doc store: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(docStore.Stop)
|
|
||||||
|
|
||||||
io := agentIO.NewIOManager()
|
|
||||||
|
|
||||||
a := New(AgentConfig{
|
|
||||||
ID: types.AgentID("medialive"),
|
|
||||||
SystemPrompt: "你是一个有长期记忆的助手。回答简洁准确。",
|
|
||||||
Provider: prov,
|
|
||||||
ProviderManager: pm,
|
|
||||||
IO: io,
|
|
||||||
Memory: graph,
|
|
||||||
DocStore: docStore,
|
|
||||||
MediaStore: ms,
|
|
||||||
MediaGCInterval: 0, // 本测试自己控制 GC 时机
|
|
||||||
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
|
|
||||||
StageHost: NewStageHost(),
|
|
||||||
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
|
|
||||||
InputProcessing: types.InputProcessingConfig{},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 排空 outputCh:容量 256,但长跑不消费会堵住 emitResponse。
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-io.OutputChan():
|
|
||||||
case <-a.ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return &liveEnv{agent: a, io: io, mediaSt: ms, docStore: docStore, graph: graph, dir: dir}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。
|
|
||||||
func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|
||||||
c := requireLiveCfg(t)
|
|
||||||
env := newLiveEnv(t, c)
|
|
||||||
a := env.agent
|
|
||||||
defer a.Stop()
|
|
||||||
|
|
||||||
img := livePNG(t, 96, 96, [][3]byte{{128, 0, 255}, {0, 64, 255}, {255, 0, 0}})
|
|
||||||
t.Logf("测试图片: %d 字节(紫/蓝/红三色带)", len(img))
|
|
||||||
|
|
||||||
// ── 阶段 1:注入 image 事件,验证 CAS 自动落盘 ──
|
|
||||||
//
|
|
||||||
// 直接调 handleInput 而不启 eventLoop:eventLoop 是纯转发(select →
|
|
||||||
// handleInput),走同一条代码路径,但同步调用让断言不必猜时序。
|
|
||||||
evt := &agentIO.InputEvent{
|
|
||||||
RequestID: "live-1",
|
|
||||||
Source: "test_channel",
|
|
||||||
Type: "image",
|
|
||||||
OutputChannel: "test_channel",
|
|
||||||
Payload: map[string]interface{}{
|
|
||||||
"data": mediaB64(img),
|
|
||||||
"mime": "image/png",
|
|
||||||
"alt": "一张测试图片",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
t0 := time.Now()
|
|
||||||
a.handleInput(evt)
|
|
||||||
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
|
|
||||||
|
|
||||||
// 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带
|
|
||||||
// `COALESCE(description,'') != ''`,只返回**已描述**的媒体,
|
|
||||||
// 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。
|
|
||||||
items, err := env.mediaSt.Pending(10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pending: %v", err)
|
|
||||||
}
|
|
||||||
if len(items) != 1 {
|
|
||||||
t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items))
|
|
||||||
}
|
|
||||||
digest := items[0].Digest
|
|
||||||
t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s",
|
|
||||||
digest[:12], items[0].Size, items[0].Tool)
|
|
||||||
|
|
||||||
stored, err := env.mediaSt.Get(digest)
|
|
||||||
if err != nil || !bytes.Equal(stored, img) {
|
|
||||||
t.Fatalf("落盘内容与原图不一致 (err=%v)", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 阶段 2:引用自动挂到 ContextEvent 上 ──
|
|
||||||
//
|
|
||||||
// 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里
|
|
||||||
// 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。
|
|
||||||
var evtID string
|
|
||||||
var summaryOK bool
|
|
||||||
for _, e := range a.context.Recent(0) {
|
|
||||||
if len(e.Media) > 0 {
|
|
||||||
evtID = e.ID
|
|
||||||
summaryOK = strings.Contains(e.Input, digest[:12])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if evtID == "" {
|
|
||||||
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
|
|
||||||
}
|
|
||||||
ctxRefs, err := env.mediaSt.Refs(media.OwnerContext, evtID)
|
|
||||||
if err != nil || len(ctxRefs) != 1 || ctxRefs[0] != digest {
|
|
||||||
t.Fatalf("context owner 引用缺失: refs=%v err=%v", ctxRefs, err)
|
|
||||||
}
|
|
||||||
if !summaryOK {
|
|
||||||
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
|
|
||||||
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
|
|
||||||
}
|
|
||||||
t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK)
|
|
||||||
|
|
||||||
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)──
|
|
||||||
pending, err := env.mediaSt.Pending(5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(pending) != 1 {
|
|
||||||
t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending))
|
|
||||||
}
|
|
||||||
|
|
||||||
t1 := time.Now()
|
|
||||||
a.describePendingMedia()
|
|
||||||
t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds())
|
|
||||||
|
|
||||||
it, err := env.mediaSt.Stat(digest)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if it.Description == "" {
|
|
||||||
t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述")
|
|
||||||
}
|
|
||||||
sawColors := strings.Contains(it.Description, "紫") &&
|
|
||||||
strings.Contains(it.Description, "蓝") &&
|
|
||||||
strings.Contains(it.Description, "红")
|
|
||||||
t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s",
|
|
||||||
len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90))
|
|
||||||
if !sawColors {
|
|
||||||
t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s",
|
|
||||||
truncRunes(it.Description, 200))
|
|
||||||
}
|
|
||||||
if left, _ := env.mediaSt.Pending(5); len(left) != 0 {
|
|
||||||
t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left))
|
|
||||||
}
|
|
||||||
// 有描述之后 Search 才应能命中(它按 description 做 LIKE)
|
|
||||||
if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil {
|
|
||||||
t.Errorf("search: %v", err)
|
|
||||||
} else if len(found) == 0 {
|
|
||||||
t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效")
|
|
||||||
} else {
|
|
||||||
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ──
|
|
||||||
//
|
|
||||||
// MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。
|
|
||||||
// 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它
|
|
||||||
// transferMediaRefs 直接 return,引用永久悬空在 context owner 上。
|
|
||||||
// 填充数量必须 > Prune 内部固定的 10 条保护窗口。
|
|
||||||
//
|
|
||||||
// Prune 无条件保护最后 10 条事件(protected := events[len-10:]),
|
|
||||||
// 只在更早的部分里挑归档对象。填 4 条时总数才 5,全落进保护窗口、
|
|
||||||
// candidates 为空、直接返回 0——这不是缺陷,是"最近的对话不该被归档"
|
|
||||||
// 的设计。带图事件必须被推到第 11 条之前才可能被归档。
|
|
||||||
const fillerCount = 14
|
|
||||||
for i := 0; i < fillerCount; i++ {
|
|
||||||
a.context.Append(ContextEvent{
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Source: "filler",
|
|
||||||
Input: fmt.Sprintf("无关的填充对话 %d,用来把带图事件挤出活跃窗口", i),
|
|
||||||
Response: "好的。",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore)
|
|
||||||
t.Logf("Prune 归档 %d 条事件", archived)
|
|
||||||
if archived == 0 {
|
|
||||||
t.Fatal("Prune 未归档任何事件,无法验证引用转移")
|
|
||||||
}
|
|
||||||
|
|
||||||
docRefsFound := ""
|
|
||||||
for _, d := range env.docStore.RecentDocs(20) {
|
|
||||||
refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID)
|
|
||||||
if err == nil && len(refs) > 0 && refs[0] == digest {
|
|
||||||
docRefsFound = d.ID
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if docRefsFound == "" {
|
|
||||||
t.Fatal("引用未转移到 document owner——" +
|
|
||||||
"core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" +
|
|
||||||
"(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)")
|
|
||||||
}
|
|
||||||
if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 {
|
|
||||||
t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left))
|
|
||||||
}
|
|
||||||
t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound)
|
|
||||||
|
|
||||||
// 转移全程内容必须可读:先挂后销的顺序若反了,
|
|
||||||
// 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。
|
|
||||||
if _, err := env.mediaSt.Get(digest); err != nil {
|
|
||||||
t.Fatalf("转移后内容不可读: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ──
|
|
||||||
//
|
|
||||||
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
|
||||||
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
|
|
||||||
// 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。
|
|
||||||
for _, d := range env.docStore.RecentDocs(20) {
|
|
||||||
if d.ID == docRefsFound {
|
|
||||||
d.LastAccess = time.Now().Add(-100 * time.Hour)
|
|
||||||
d.AccessCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.archiveColdDocs()
|
|
||||||
|
|
||||||
sentRefs := 0
|
|
||||||
var boundSentence int64
|
|
||||||
rows, err := env.graph.Recall(nil, nil, 1, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("graph recall: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("图库实体数 %d", len(rows.Entities))
|
|
||||||
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
|
|
||||||
for sid := int64(1); sid <= 40; sid++ {
|
|
||||||
refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
|
||||||
if err == nil && len(refs) > 0 {
|
|
||||||
sentRefs += len(refs)
|
|
||||||
if boundSentence == 0 {
|
|
||||||
boundSentence = sid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sentRefs == 0 {
|
|
||||||
t.Error("L2→L3 未绑定任何 graph_sentence 引用——" +
|
|
||||||
"bindSentenceMedia 未被 commitTriplesWithMedia 触发," +
|
|
||||||
"或句子正文里没有可反解的短 digest")
|
|
||||||
} else {
|
|
||||||
t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence)
|
|
||||||
|
|
||||||
got, err := env.agent.RecallMediaForSentence(boundSentence)
|
|
||||||
if err != nil || len(got) == 0 || got[0] != digest {
|
|
||||||
t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err)
|
|
||||||
} else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) {
|
|
||||||
t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err)
|
|
||||||
} else {
|
|
||||||
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ──
|
|
||||||
removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := env.mediaSt.Stat(digest); err != nil {
|
|
||||||
t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+
|
|
||||||
"引用计数或 owner 语义有误", removed, freed)
|
|
||||||
}
|
|
||||||
t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed)
|
|
||||||
|
|
||||||
// ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ──
|
|
||||||
//
|
|
||||||
// 不再提供图片,只问"还记得吗"。能答出三色说明记忆链路端到端可用。
|
|
||||||
// L2 文档此刻已被 archiveColdDocs 删除(归档的语义就是搬完删源),
|
|
||||||
// 所以这一轮只能靠 L3 图库召回——而自动注入路径依赖 indexer。
|
|
||||||
// 生产由 main.go 注入并周期 Sync;测试里手工建一个并同步一次。
|
|
||||||
a.indexer = memory.NewIndexer(env.graph)
|
|
||||||
if err := a.indexer.Sync(); err != nil {
|
|
||||||
t.Fatalf("indexer sync: %v", err)
|
|
||||||
}
|
|
||||||
if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" {
|
|
||||||
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
|
|
||||||
if strings.Contains(mc, "【关联媒体】") {
|
|
||||||
t.Logf("✓ 记忆上下文含媒体段")
|
|
||||||
} else {
|
|
||||||
t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Error("图库召回为空,agent 无从得知历史媒体")
|
|
||||||
}
|
|
||||||
|
|
||||||
ask := &agentIO.InputEvent{
|
|
||||||
RequestID: "live-2",
|
|
||||||
Source: "test_channel",
|
|
||||||
Type: "text",
|
|
||||||
OutputChannel: "test_channel",
|
|
||||||
Payload: map[string]interface{}{
|
|
||||||
"content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
respCh := make(chan *agentIO.OutputEvent, 4)
|
|
||||||
ask.ResponseCh = respCh
|
|
||||||
|
|
||||||
t2 := time.Now()
|
|
||||||
a.handleInput(ask)
|
|
||||||
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
|
|
||||||
|
|
||||||
var answer string
|
|
||||||
select {
|
|
||||||
case out := <-respCh:
|
|
||||||
answer, _ = out.Payload["content"].(string)
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("第二轮没有收到回复")
|
|
||||||
}
|
|
||||||
t.Logf("agent 回答: %s", truncRunes(answer, 220))
|
|
||||||
|
|
||||||
recalled := strings.Contains(answer, "紫") &&
|
|
||||||
strings.Contains(answer, "蓝") &&
|
|
||||||
strings.Contains(answer, "红")
|
|
||||||
if !recalled {
|
|
||||||
t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+
|
|
||||||
"也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s",
|
|
||||||
truncRunes(answer, 300))
|
|
||||||
} else {
|
|
||||||
t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红")
|
|
||||||
}
|
|
||||||
|
|
||||||
st := env.mediaSt.Stats()
|
|
||||||
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v",
|
|
||||||
st["count"], st["total_bytes"], st["described"], st["unreferenced"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
|
|
||||||
//
|
|
||||||
// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色,
|
|
||||||
// 无法区分真召回与先验偏好。
|
|
||||||
func TestMediaLive_NegativeControl(t *testing.T) {
|
|
||||||
c := requireLiveCfg(t)
|
|
||||||
env := newLiveEnv(t, c)
|
|
||||||
a := env.agent
|
|
||||||
defer a.Stop()
|
|
||||||
|
|
||||||
ask := &agentIO.InputEvent{
|
|
||||||
RequestID: "neg-1",
|
|
||||||
Source: "test_channel",
|
|
||||||
Type: "text",
|
|
||||||
OutputChannel: "test_channel",
|
|
||||||
Payload: map[string]interface{}{
|
|
||||||
"content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
respCh := make(chan *agentIO.OutputEvent, 4)
|
|
||||||
ask.ResponseCh = respCh
|
|
||||||
|
|
||||||
a.handleInput(ask)
|
|
||||||
|
|
||||||
var answer string
|
|
||||||
select {
|
|
||||||
case out := <-respCh:
|
|
||||||
answer, _ = out.Payload["content"].(string)
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("阴性对照没有收到回复")
|
|
||||||
}
|
|
||||||
t.Logf("无记忆时的回答: %s", truncRunes(answer, 200))
|
|
||||||
|
|
||||||
// 上游不可用时这条对照没有意义:它只能证明"没答出颜色",
|
|
||||||
// 而原因是调用失败而非缺少记忆。据此判 PASS 属于假阳性。
|
|
||||||
if strings.HasPrefix(answer, "处理错误:") {
|
|
||||||
t.Skipf("上游 LLM 调用失败,阴性对照无法判定: %s", truncRunes(answer, 160))
|
|
||||||
}
|
|
||||||
|
|
||||||
guessed := strings.Contains(answer, "紫") &&
|
|
||||||
strings.Contains(answer, "蓝") &&
|
|
||||||
strings.Contains(answer, "红")
|
|
||||||
if guessed {
|
|
||||||
t.Errorf("无任何媒体记忆却猜中紫/蓝/红——"+
|
|
||||||
"说明阳性用例的通过可能只是先验偏好而非真召回: %s", truncRunes(answer, 300))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaB64 返回不带 data URL 前缀的 base64(processMediaInput 自己拼前缀)。
|
|
||||||
func mediaB64(b []byte) string {
|
|
||||||
return media.DataURL("image/png", b)[len("data:image/png;base64,"):]
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncRunes(s string, n int) string {
|
|
||||||
r := []rune(strings.ReplaceAll(s, "\n", " "))
|
|
||||||
if len(r) <= n {
|
|
||||||
return string(r)
|
|
||||||
}
|
|
||||||
return string(r[:n]) + "…"
|
|
||||||
}
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"runtime/debug"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 媒体记忆的两条后台循环。
|
|
||||||
//
|
|
||||||
// mediaGCLoop 清理无人引用的 blob,让容量上限真正生效。
|
|
||||||
// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。
|
|
||||||
//
|
|
||||||
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
|
|
||||||
// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒,
|
|
||||||
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
|
|
||||||
// 这一轮模型本来就直接看着图。
|
|
||||||
|
|
||||||
const (
|
|
||||||
// mediaDescribeBatch 是单轮描述的媒体条数上限。
|
|
||||||
//
|
|
||||||
// 取 4:既有回退链的 modalFallbackMaxBlocks 是 6(一次请求最多带 6 个媒体),
|
|
||||||
// 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字
|
|
||||||
// 无法可靠切分回各自的 digest。
|
|
||||||
mediaDescribeBatch = 4
|
|
||||||
|
|
||||||
// mediaDescribeMinInterval 是两轮描述之间的最小间隔。
|
|
||||||
//
|
|
||||||
// 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它
|
|
||||||
// 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。
|
|
||||||
mediaDescribeMinInterval = 30 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// mediaGCLoop 周期清理无引用的媒体内容。
|
|
||||||
//
|
|
||||||
// 不做这件事的后果:容量上限形同虚设。CAS 的 GC 只在被显式调用时执行,
|
|
||||||
// 而 Put 路径不触发它——一次 see_video 抽 10 帧,帧本身没人引用(工具
|
|
||||||
// 结果被 Prune 掉之后),若无人清理就会一直堆在磁盘上。
|
|
||||||
func (a *Agent) mediaGCLoop() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("[agent] mediaGCLoop panic recovered: %v\n%s", r, debug.Stack())
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
go a.mediaGCLoop()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if a.mediaStore == nil || a.mediaGCInterval <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ticker := time.NewTicker(a.mediaGCInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
removed, freed, err := a.mediaStore.GC(a.mediaGCMinAge)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[media] GC 失败: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if removed > 0 {
|
|
||||||
st := a.mediaStore.Stats()
|
|
||||||
log.Printf("[media] GC 清理 %d 条(释放 %d 字节),剩余 %v 条 / %v 字节",
|
|
||||||
removed, freed, st["count"], st["total_bytes"])
|
|
||||||
}
|
|
||||||
case <-a.ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaDescribeLoop 给未描述的媒体补文字描述。
|
|
||||||
//
|
|
||||||
// 描述文本才是持久语义记忆:blob 会被容量 GC 淘汰,而描述留在 media 表里,
|
|
||||||
// 并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。
|
|
||||||
// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。
|
|
||||||
func (a *Agent) mediaDescribeLoop() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack())
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
go a.mediaDescribeLoop()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if a.mediaStore == nil || !a.mediaDescribe {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ticker := time.NewTicker(mediaDescribeMinInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
a.describePendingMedia()
|
|
||||||
case <-a.ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// describePendingMedia 取一批未描述的媒体逐条描述。
|
|
||||||
//
|
|
||||||
// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest
|
|
||||||
// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返
|
|
||||||
// 也要保证「描述 ↔ digest」的对应关系是确定的。
|
|
||||||
func (a *Agent) describePendingMedia() {
|
|
||||||
pending, err := a.mediaStore.Pending(mediaDescribeBatch)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[media] 取待描述项失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(pending) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, it := range pending {
|
|
||||||
select {
|
|
||||||
case <-a.ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
kind := "image"
|
|
||||||
if it.Kind == media.KindAudio {
|
|
||||||
kind = "audio"
|
|
||||||
} else if it.Kind != media.KindImage {
|
|
||||||
// 视频帧以 image 入库;其余大类没有可用的描述通道,
|
|
||||||
// 标记成"不可描述"以免每轮都被 Pending 取出来重试。
|
|
||||||
if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil {
|
|
||||||
log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
p, srcName := a.resolveModalFallback(kind)
|
|
||||||
if p == nil {
|
|
||||||
// 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。
|
|
||||||
// 配置好之后自然会被下一轮捡起来。
|
|
||||||
log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := a.mediaStore.Get(it.Digest)
|
|
||||||
if err != nil {
|
|
||||||
// blob 已被 GC 清掉但元数据还在(GC 会同删,此处属异常路径):
|
|
||||||
// 标记一下避免死循环。
|
|
||||||
log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err)
|
|
||||||
if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil {
|
|
||||||
log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mime := it.MIME
|
|
||||||
if mime == "" {
|
|
||||||
mime = "image/png"
|
|
||||||
}
|
|
||||||
url := media.DataURL(mime, data)
|
|
||||||
|
|
||||||
desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"})
|
|
||||||
if err != nil {
|
|
||||||
// 失败不标记:可能是网络抖动或配额,下一轮该重试。
|
|
||||||
log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if desc == "" {
|
|
||||||
// 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断
|
|
||||||
// 同一个道理,视作失败而非"没什么可说的"。
|
|
||||||
log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil {
|
|
||||||
log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 媒体后台循环测试。
|
|
||||||
//
|
|
||||||
// 两条循环都要能在「未启用」时干净退出——它们随 Agent.Start() 无条件启动,
|
|
||||||
// 若不早退就会在每个没配媒体存储的部署上空转一个 goroutine。
|
|
||||||
|
|
||||||
func newMediaLoopAgent(t *testing.T, gcInterval, minAge time.Duration, describe bool) (*Agent, *media.Store) {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("media.New: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { ms.Close() })
|
|
||||||
|
|
||||||
a := &Agent{
|
|
||||||
mediaStore: ms,
|
|
||||||
mediaGCInterval: gcInterval,
|
|
||||||
mediaGCMinAge: minAge,
|
|
||||||
mediaDescribe: describe,
|
|
||||||
}
|
|
||||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
|
||||||
t.Cleanup(a.cancel)
|
|
||||||
return a, ms
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaGCLoop_ExitsWhenDisabled(t *testing.T) {
|
|
||||||
// 两种禁用形态都必须立刻返回,不留空转 goroutine:
|
|
||||||
// 1. mediaStore 为 nil(媒体记忆整体关闭)
|
|
||||||
// 2. gcInterval 为 0(显式不自动清理)
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
agent *Agent
|
|
||||||
}{
|
|
||||||
{"nil store", func() *Agent {
|
|
||||||
a := &Agent{mediaGCInterval: time.Hour}
|
|
||||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
|
||||||
return a
|
|
||||||
}()},
|
|
||||||
{"zero interval", func() *Agent {
|
|
||||||
dir := t.TempDir()
|
|
||||||
ms, _ := media.New(filepath.Join(dir, "m"), 0)
|
|
||||||
t.Cleanup(func() { ms.Close() })
|
|
||||||
a := &Agent{mediaStore: ms, mediaGCInterval: 0}
|
|
||||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
|
||||||
return a
|
|
||||||
}()},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func(a *Agent) { a.mediaGCLoop(); close(done) }(c.agent)
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatalf("%s: mediaGCLoop 未立即返回(会空转 goroutine)", c.name)
|
|
||||||
}
|
|
||||||
c.agent.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaGCLoop_ClearsOrphansKeepsReferenced(t *testing.T) {
|
|
||||||
a, ms := newMediaLoopAgent(t, 50*time.Millisecond, 0, false)
|
|
||||||
|
|
||||||
kept, _ := ms.Put([]byte("referenced"), media.Item{MIME: "image/png"})
|
|
||||||
if err := ms.AddRef(kept, media.OwnerContext, "evt-1"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
orphan, _ := ms.Put([]byte("orphaned"), media.Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
go a.mediaGCLoop()
|
|
||||||
|
|
||||||
deadline := time.Now().Add(3 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if _, err := ms.Stat(orphan); err != nil {
|
|
||||||
break // 孤儿已被清
|
|
||||||
}
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
a.cancel()
|
|
||||||
|
|
||||||
if _, err := ms.Stat(orphan); err == nil {
|
|
||||||
t.Fatal("无引用项应被 GC 清理")
|
|
||||||
}
|
|
||||||
// 关键不变量:有引用的内容永不被删,否则记忆里的 digest 成悬空指针
|
|
||||||
if _, err := ms.Get(kept); err != nil {
|
|
||||||
t.Fatalf("被引用的内容不该被清: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaGCLoop_MinAgeProtectsFresh(t *testing.T) {
|
|
||||||
// minAge 保护刚 Put 还没来得及 AddRef 的项——它们 refcount 也是 0
|
|
||||||
a, ms := newMediaLoopAgent(t, 30*time.Millisecond, time.Hour, false)
|
|
||||||
|
|
||||||
d, _ := ms.Put([]byte("just-arrived"), media.Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
go a.mediaGCLoop()
|
|
||||||
time.Sleep(400 * time.Millisecond) // 足够跑十几轮 GC
|
|
||||||
a.cancel()
|
|
||||||
|
|
||||||
if _, err := ms.Get(d); err != nil {
|
|
||||||
t.Fatalf("minAge 内的新项不该被清: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
|
|
||||||
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
|
|
||||||
a, _ := newMediaLoopAgent(t, 0, 0, false)
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() { a.mediaDescribeLoop(); close(done) }()
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) {
|
|
||||||
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
|
|
||||||
// 配置好之后必须还能被捡起来。
|
|
||||||
a, ms := newMediaLoopAgent(t, 0, 0, true)
|
|
||||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
// providerManager 为 nil → resolveModalFallback 返回 nil
|
|
||||||
a.describePendingMedia()
|
|
||||||
|
|
||||||
it, err := ms.Stat(d)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if it.Description != "" || it.DescribedBy != "" {
|
|
||||||
t.Fatalf("无可用源时不该写描述: %+v", it)
|
|
||||||
}
|
|
||||||
pending, _ := ms.Pending(10)
|
|
||||||
if len(pending) != 1 {
|
|
||||||
t.Fatalf("项应仍在待描述队列里,实际 %d 条", len(pending))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
|
|
||||||
// video/other 大类没有可用的描述通道,必须标记掉,
|
|
||||||
// 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。
|
|
||||||
a, ms := newMediaLoopAgent(t, 0, 0, true)
|
|
||||||
|
|
||||||
other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"})
|
|
||||||
a.describePendingMedia()
|
|
||||||
|
|
||||||
it, err := ms.Stat(other)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if it.DescribedBy != "unsupported" {
|
|
||||||
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
|
|
||||||
}
|
|
||||||
// 标记后必须退出待描述队列,否则每轮都被取出来重试、永久占着
|
|
||||||
// LIMIT 的名额,真正需要描述的新项永远轮不到。
|
|
||||||
pending, _ := ms.Pending(10)
|
|
||||||
if len(pending) != 0 {
|
|
||||||
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
|
|
||||||
a, _ := newMediaLoopAgent(t, 0, 0, true)
|
|
||||||
a.describePendingMedia() // 不该 panic
|
|
||||||
}
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
|
||||||
// 并让 L0 的 ContextEvent 记住它们的 digest。
|
|
||||||
//
|
|
||||||
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
|
|
||||||
//
|
|
||||||
// 1. 用户直接发图 → processMediaInput → mediaToBlocks
|
|
||||||
// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
|
|
||||||
// base64 随 message 数组发给模型后就丢了。
|
|
||||||
// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
|
|
||||||
// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。
|
|
||||||
//
|
|
||||||
// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、
|
|
||||||
// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。
|
|
||||||
//
|
|
||||||
// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS,
|
|
||||||
// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。
|
|
||||||
|
|
||||||
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。
|
|
||||||
//
|
|
||||||
// 只处理 data URL:http(s) URL 拿不到字节就无法做内容寻址,
|
|
||||||
// 而"下载它再存"会把一次对话变成一次网络请求(超时、鉴权、SSRF 全来了),
|
|
||||||
// 不在本层解决。
|
|
||||||
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string {
|
|
||||||
if a.mediaStore == nil || len(blocks) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var digests []string
|
|
||||||
for _, b := range blocks {
|
|
||||||
var url string
|
|
||||||
switch {
|
|
||||||
case b.ImageURL != nil && b.ImageURL.URL != "":
|
|
||||||
url = b.ImageURL.URL
|
|
||||||
case b.AudioURL != nil && b.AudioURL.URL != "":
|
|
||||||
url = b.AudioURL.URL
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mime, data, ok := media.ParseDataURL(url)
|
|
||||||
if !ok {
|
|
||||||
continue // http(s) URL 或格式不认,跳过
|
|
||||||
}
|
|
||||||
|
|
||||||
d, err := a.mediaStore.Put(data, media.Item{
|
|
||||||
MIME: mime,
|
|
||||||
Tool: tool,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品
|
|
||||||
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
digests = append(digests, d)
|
|
||||||
}
|
|
||||||
return digests
|
|
||||||
}
|
|
||||||
|
|
||||||
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
|
|
||||||
//
|
|
||||||
// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的
|
|
||||||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
|
||||||
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
|
|
||||||
func (a *Agent) stageMediaDigests(digests ...string) {
|
|
||||||
if len(digests) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.pendingMediaDigests = append(a.pendingMediaDigests, digests...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// drainMediaDigests 取出并清空本轮累积的 digest。
|
|
||||||
func (a *Agent) drainMediaDigests() []string {
|
|
||||||
if len(a.pendingMediaDigests) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := a.pendingMediaDigests
|
|
||||||
a.pendingMediaDigests = nil
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。
|
|
||||||
//
|
|
||||||
// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化),
|
|
||||||
// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。
|
|
||||||
// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容,
|
|
||||||
// 要么孤儿永远清不掉。
|
|
||||||
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
|
||||||
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if evt.ID == "" {
|
|
||||||
evt.ID = newEventID()
|
|
||||||
}
|
|
||||||
for _, d := range digests {
|
|
||||||
if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil {
|
|
||||||
log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
evt.Media = append(evt.Media, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。
|
|
||||||
//
|
|
||||||
// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。
|
|
||||||
// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,
|
|
||||||
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。
|
|
||||||
func (a *Agent) mediaSummaryForEvent(digests []string) string {
|
|
||||||
if a.mediaStore == nil || len(digests) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var lines []string
|
|
||||||
for _, d := range digests {
|
|
||||||
it, err := a.mediaStore.Stat(d)
|
|
||||||
if err != nil || it == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
label := string(it.Kind)
|
|
||||||
if it.MIME != "" {
|
|
||||||
label = it.MIME
|
|
||||||
}
|
|
||||||
if it.Description != "" {
|
|
||||||
lines = append(lines, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), it.Description))
|
|
||||||
} else {
|
|
||||||
lines = append(lines, fmt.Sprintf("[%s %s] (未描述)", label, shortDigest(d)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "媒体内容:\n" + strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// newEventID 生成 ContextEvent 的稳定标识。
|
|
||||||
//
|
|
||||||
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
|
|
||||||
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
|
|
||||||
// a.mu 串行化 Append,不存在同纳秒两条。
|
|
||||||
func newEventID() string {
|
|
||||||
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
|
|
||||||
func shortDigest(d string) string {
|
|
||||||
if len(d) > 12 {
|
|
||||||
return d[:12]
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
@ -1,355 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 媒体记忆接线测试:验证媒体从对话进入 CAS、挂到 L0 事件、
|
|
||||||
// 随归档转到 L2 文档的完整链路。
|
|
||||||
//
|
|
||||||
// 核心断言不是"函数被调用了",而是不变量:
|
|
||||||
// 1. 媒体存不进去时对话照常(它是记忆增强,不是对话必需品)
|
|
||||||
// 2. 引用转移期间内容始终可读(先挂后销,不留归零窗口)
|
|
||||||
// 3. mediaStore 为 nil 时全链路静默跳过,行为与本特性上线前一致
|
|
||||||
|
|
||||||
func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("media.New: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { ms.Close() })
|
|
||||||
|
|
||||||
emb := memory.NewStaticEmbedder()
|
|
||||||
a := &Agent{
|
|
||||||
mediaStore: ms,
|
|
||||||
context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb),
|
|
||||||
}
|
|
||||||
a.context.SetMediaStore(ms)
|
|
||||||
return a, ms
|
|
||||||
}
|
|
||||||
|
|
||||||
// imageBlockURL 造一个带指定 URL 的图片块。
|
|
||||||
// 名字带 URL 后缀是为了不与 modalfallback_test.go 里固定用 testPNG 的
|
|
||||||
// imageBlock() 撞名——两者用途不同:那个验回退链,这个验入库。
|
|
||||||
func imageBlockURL(dataURL string) agentAPI.ContentBlock {
|
|
||||||
return agentAPI.ContentBlock{
|
|
||||||
Type: "image_url",
|
|
||||||
ImageURL: &agentAPI.ImageURL{URL: dataURL, Detail: "auto"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCaptureBlockMedia_StoresDataURL(t *testing.T) {
|
|
||||||
a, ms := newTestAgentWithMedia(t)
|
|
||||||
|
|
||||||
raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3}
|
|
||||||
blocks := []agentAPI.ContentBlock{
|
|
||||||
{Type: "text", Text: "看这张图"},
|
|
||||||
imageBlockURL(media.DataURL("image/png", raw)),
|
|
||||||
}
|
|
||||||
|
|
||||||
digests := a.captureBlockMedia(blocks, "multimodal_see_picture")
|
|
||||||
if len(digests) != 1 {
|
|
||||||
t.Fatalf("应捕获 1 个媒体,实际 %d", len(digests))
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := ms.Get(digests[0])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("回读失败: %v", err)
|
|
||||||
}
|
|
||||||
if string(got) != string(raw) {
|
|
||||||
t.Fatal("内容不一致")
|
|
||||||
}
|
|
||||||
it, _ := ms.Stat(digests[0])
|
|
||||||
if it.MIME != "image/png" || it.Tool != "multimodal_see_picture" || it.Kind != media.KindImage {
|
|
||||||
t.Fatalf("元数据不对: %+v", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) {
|
|
||||||
// http(s) URL 拿不到字节就无法内容寻址;"下载它再存"会把一次对话
|
|
||||||
// 变成一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。
|
|
||||||
a, _ := newTestAgentWithMedia(t)
|
|
||||||
|
|
||||||
blocks := []agentAPI.ContentBlock{
|
|
||||||
imageBlockURL("https://example.com/x.png"),
|
|
||||||
}
|
|
||||||
if d := a.captureBlockMedia(blocks, "t"); len(d) != 0 {
|
|
||||||
t.Fatalf("http URL 不该被捕获,实际 %d 个", len(d))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
|
|
||||||
// mediaStore 未启用时全链路静默跳过,不能 panic 也不能报错——
|
|
||||||
// 行为必须与本特性上线前完全一致。
|
|
||||||
a := &Agent{}
|
|
||||||
blocks := []agentAPI.ContentBlock{imageBlockURL(media.DataURL("image/png", []byte("x")))}
|
|
||||||
if d := a.captureBlockMedia(blocks, "t"); d != nil {
|
|
||||||
t.Fatalf("nil store 应返回 nil,实际 %v", d)
|
|
||||||
}
|
|
||||||
a.stageMediaDigests("deadbeef")
|
|
||||||
if got := a.drainMediaDigests(); len(got) != 1 {
|
|
||||||
t.Fatal("stage/drain 不依赖 store,应正常工作")
|
|
||||||
}
|
|
||||||
// bindEventMedia 对 nil store 也必须安全
|
|
||||||
evt := &ContextEvent{}
|
|
||||||
a.bindEventMedia(evt, []string{"deadbeef"})
|
|
||||||
if len(evt.Media) != 0 || evt.ID != "" {
|
|
||||||
t.Fatalf("nil store 时不该改动事件: %+v", evt)
|
|
||||||
}
|
|
||||||
if s := a.mediaSummaryForEvent([]string{"deadbeef"}); s != "" {
|
|
||||||
t.Fatalf("nil store 时摘要应为空,得到 %q", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) {
|
|
||||||
a, ms := newTestAgentWithMedia(t)
|
|
||||||
|
|
||||||
blocks := []agentAPI.ContentBlock{
|
|
||||||
imageBlockURL(media.DataURL("image/jpeg", []byte("frame"))),
|
|
||||||
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: media.DataURL("audio/wav", []byte("sound"))}},
|
|
||||||
}
|
|
||||||
digests := a.captureBlockMedia(blocks, "multimodal_see_video")
|
|
||||||
if len(digests) != 2 {
|
|
||||||
t.Fatalf("应捕获 2 个,实际 %d", len(digests))
|
|
||||||
}
|
|
||||||
|
|
||||||
kinds := map[media.Kind]int{}
|
|
||||||
for _, d := range digests {
|
|
||||||
it, err := ms.Stat(d)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
kinds[it.Kind]++
|
|
||||||
}
|
|
||||||
if kinds[media.KindImage] != 1 || kinds[media.KindAudio] != 1 {
|
|
||||||
t.Fatalf("大类归属不对: %v", kinds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStageDrainMediaDigests(t *testing.T) {
|
|
||||||
a, _ := newTestAgentWithMedia(t)
|
|
||||||
|
|
||||||
a.stageMediaDigests("a", "b")
|
|
||||||
a.stageMediaDigests("c")
|
|
||||||
got := a.drainMediaDigests()
|
|
||||||
if len(got) != 3 {
|
|
||||||
t.Fatalf("应累积 3 个,实际 %d", len(got))
|
|
||||||
}
|
|
||||||
// drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍
|
|
||||||
if again := a.drainMediaDigests(); again != nil {
|
|
||||||
t.Fatalf("drain 后应为空,实际 %v", again)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) {
|
|
||||||
a, ms := newTestAgentWithMedia(t)
|
|
||||||
|
|
||||||
d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"}
|
|
||||||
a.bindEventMedia(evt, []string{d})
|
|
||||||
|
|
||||||
if evt.ID == "" {
|
|
||||||
t.Fatal("应懒生成事件 ID")
|
|
||||||
}
|
|
||||||
if len(evt.Media) != 1 || evt.Media[0] != d {
|
|
||||||
t.Fatalf("事件应记住 digest: %+v", evt.Media)
|
|
||||||
}
|
|
||||||
// 双向落地:CAS 侧也要知道谁在引用,否则 GC 会误删
|
|
||||||
it, _ := ms.Stat(d)
|
|
||||||
if it.RefCount != 1 {
|
|
||||||
t.Fatalf("引用计数应为 1,实际 %d", it.RefCount)
|
|
||||||
}
|
|
||||||
refs, _ := ms.Refs(media.OwnerContext, evt.ID)
|
|
||||||
if len(refs) != 1 {
|
|
||||||
t.Fatalf("media_refs 应有 1 条,实际 %d", len(refs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
|
|
||||||
// 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json
|
|
||||||
a, _ := newTestAgentWithMedia(t)
|
|
||||||
evt := &ContextEvent{Input: "纯文本"}
|
|
||||||
a.bindEventMedia(evt, nil)
|
|
||||||
if evt.ID != "" {
|
|
||||||
t.Fatalf("无媒体时不该生成 ID,得到 %q", evt.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) {
|
|
||||||
// 方案 C 的核心:描述文本才是持久语义记忆,blob 只是缓存。
|
|
||||||
// blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。
|
|
||||||
a, ms := newTestAgentWithMedia(t)
|
|
||||||
|
|
||||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
|
||||||
if s := a.mediaSummaryForEvent([]string{d}); s == "" {
|
|
||||||
t.Fatal("未描述项也应产出一行(标注未描述)")
|
|
||||||
}
|
|
||||||
|
|
||||||
ms.Describe(d, "一张紫蓝红三色带图", "visionllm")
|
|
||||||
s := a.mediaSummaryForEvent([]string{d})
|
|
||||||
if s == "" {
|
|
||||||
t.Fatal("应产出摘要")
|
|
||||||
}
|
|
||||||
if !strings.Contains(s, "紫蓝红三色带图") {
|
|
||||||
t.Fatalf("摘要应含描述文本: %q", s)
|
|
||||||
}
|
|
||||||
if !strings.Contains(s, "image/png") {
|
|
||||||
t.Fatalf("摘要应含 MIME 标注: %q", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrune_TransfersMediaRefsToDocument(t *testing.T) {
|
|
||||||
// L0→L2 归档:媒体引用从 context 事件转到归档文档,
|
|
||||||
// 且转移期间内容必须始终可读(先挂后销,不留归零窗口)。
|
|
||||||
dir := t.TempDir()
|
|
||||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer ms.Close()
|
|
||||||
|
|
||||||
emb := memory.NewStaticEmbedder()
|
|
||||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
|
||||||
if err := docStore.Start(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
|
|
||||||
rc.SetMediaStore(ms)
|
|
||||||
|
|
||||||
a := &Agent{mediaStore: ms, context: rc}
|
|
||||||
|
|
||||||
// 造一张被引用的图,挂到一条会被淘汰的老事件上
|
|
||||||
payload := []byte("archived-image")
|
|
||||||
d, _ := ms.Put(payload, media.Item{MIME: "image/png"})
|
|
||||||
oldEvt := ContextEvent{
|
|
||||||
Timestamp: time.Now().Add(-time.Hour),
|
|
||||||
Source: "qq",
|
|
||||||
Input: "很久以前的一张图",
|
|
||||||
}
|
|
||||||
a.bindEventMedia(&oldEvt, []string{d})
|
|
||||||
oldEvtID := oldEvt.ID
|
|
||||||
rc.Append(oldEvt)
|
|
||||||
|
|
||||||
// 再塞满 12 条新事件,逼 Prune 把老事件淘汰
|
|
||||||
// (Prune 保护最近 10 条,topK 传 5 使候选全部进归档)
|
|
||||||
for i := 0; i < 12; i++ {
|
|
||||||
rc.Append(ContextEvent{
|
|
||||||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
|
||||||
Source: "qq",
|
|
||||||
Input: "无关内容",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
archived := rc.Prune("完全不相关的查询", 5, docStore)
|
|
||||||
if archived == 0 {
|
|
||||||
t.Fatal("应有事件被归档")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关键断言:内容仍可读(引用被转走而非归零后被清)
|
|
||||||
got, err := ms.Get(d)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("归档后内容应仍可读: %v", err)
|
|
||||||
}
|
|
||||||
if string(got) != string(payload) {
|
|
||||||
t.Fatal("内容被改")
|
|
||||||
}
|
|
||||||
|
|
||||||
it, err := ms.Stat(d)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if it.RefCount < 1 {
|
|
||||||
t.Fatalf("引用应转移而非归零,实际 refcount=%d", it.RefCount)
|
|
||||||
}
|
|
||||||
// 原 context 引用应已注销
|
|
||||||
if refs, _ := ms.Refs(media.OwnerContext, oldEvtID); len(refs) != 0 {
|
|
||||||
t.Fatalf("原事件引用应已注销,仍有 %d 条", len(refs))
|
|
||||||
}
|
|
||||||
// 应挂到某个 document owner 上
|
|
||||||
var docOwned bool
|
|
||||||
docs := docStore.RecentDocs(10)
|
|
||||||
for _, doc := range docs {
|
|
||||||
if refs, _ := ms.Refs(media.OwnerDocument, doc.ID); len(refs) > 0 {
|
|
||||||
docOwned = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !docOwned {
|
|
||||||
t.Fatal("引用应已挂到归档文档上")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
|
|
||||||
// 媒体存储未启用时归档链路必须照常工作
|
|
||||||
dir := t.TempDir()
|
|
||||||
emb := memory.NewStaticEmbedder()
|
|
||||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
|
||||||
if err := docStore.Start(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
|
|
||||||
// 刻意不 SetMediaStore
|
|
||||||
|
|
||||||
for i := 0; i < 15; i++ {
|
|
||||||
rc.Append(ContextEvent{
|
|
||||||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
|
||||||
Source: "qq",
|
|
||||||
Input: "内容",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if n := rc.Prune("查询", 5, docStore); n == 0 {
|
|
||||||
t.Fatal("无媒体存储时归档也应正常")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContextEvent_MediaFieldRoundTrip(t *testing.T) {
|
|
||||||
// context.json 加字段必须向后兼容:存量文件读回来 Media 为空、ID 为空,
|
|
||||||
// 不影响任何既有行为。
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "context.json")
|
|
||||||
|
|
||||||
// 写一份"存量格式"(无 id / media 字段)
|
|
||||||
legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]`
|
|
||||||
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
emb := memory.NewStaticEmbedder()
|
|
||||||
rc := NewRelevanceContext(path, emb)
|
|
||||||
if rc.Len() != 1 {
|
|
||||||
t.Fatalf("应读回 1 条,实际 %d", rc.Len())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新写入带媒体的事件,再读回
|
|
||||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer ms.Close()
|
|
||||||
a := &Agent{mediaStore: ms, context: rc}
|
|
||||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
|
||||||
evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"}
|
|
||||||
a.bindEventMedia(&evt, []string{d})
|
|
||||||
rc.Append(evt)
|
|
||||||
rc.flush()
|
|
||||||
|
|
||||||
rc2 := NewRelevanceContext(path, emb)
|
|
||||||
if rc2.Len() != 2 {
|
|
||||||
t.Fatalf("应有 2 条,实际 %d", rc2.Len())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -344,11 +344,6 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(blocks) > 0 {
|
if len(blocks) > 0 {
|
||||||
// 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。
|
|
||||||
// 不存的后果是 ToolResultItem.Output 只剩那句
|
|
||||||
// "[已将图片注入后续对话] /tmp/x.png",文件一删线索就断了。
|
|
||||||
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
|
|
||||||
|
|
||||||
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
|
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
|
||||||
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
|
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
|
||||||
// 否则模型会把它当成用户新发的图而不是工具拉回来的。
|
// 否则模型会把它当成用户新发的图而不是工具拉回来的。
|
||||||
|
|||||||
@ -150,14 +150,6 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
}
|
}
|
||||||
parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
|
parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
|
||||||
}
|
}
|
||||||
// 命中的关系若挂着媒体,把媒体说明附在结果末尾。
|
|
||||||
//
|
|
||||||
// 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。
|
|
||||||
// 媒体挂在句子上(graph_sentence owner),需经关系→句子→media_refs
|
|
||||||
// 反查。不附上的后果:agent 显式查了图记忆,却仍然不知道有图。
|
|
||||||
if mc := a.mediaContextForRelations(result.Relations); mc != "" {
|
|
||||||
parts = append(parts, "", "关联媒体:", mc)
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
|
|
||||||
case "memory_block_merge":
|
case "memory_block_merge":
|
||||||
@ -197,9 +189,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
if len(triples) == 0 {
|
if len(triples) == 0 {
|
||||||
return "没有有效的三元组"
|
return "没有有效的三元组"
|
||||||
}
|
}
|
||||||
// remember 工具是用户/模型显式写入,不涉及归档删除,
|
ec, rc, err := a.memory.Commit(triples, string(a.id), 0)
|
||||||
// 因此不需要 mediaBound——没有旧引用要释放。
|
|
||||||
ec, rc, _, err := a.commitTriplesWithMedia(triples, string(a.id), 0)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("记忆写入失败: %v", err)
|
return fmt.Sprintf("记忆写入失败: %v", err)
|
||||||
}
|
}
|
||||||
@ -551,10 +541,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
doc := &document.Doc{
|
doc := &document.Doc{
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Content: content,
|
Content: content,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Source: "manual",
|
Source: "manual",
|
||||||
}
|
}
|
||||||
if err := a.docStore.Insert(doc); err != nil {
|
if err := a.docStore.Insert(doc); err != nil {
|
||||||
return fmt.Sprintf("文档写入失败: %v", err)
|
return fmt.Sprintf("文档写入失败: %v", err)
|
||||||
|
|||||||
@ -13,24 +13,6 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
|
|||||||
}
|
}
|
||||||
injected := a.indexer.BuildContext(input)
|
injected := a.indexer.BuildContext(input)
|
||||||
s := a.indexer.FormatContext(injected)
|
s := a.indexer.FormatContext(injected)
|
||||||
|
|
||||||
// 图库召回命中的实体若关联着带媒体的句子,把媒体说明一并注入。
|
|
||||||
//
|
|
||||||
// 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里
|
|
||||||
// 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉
|
|
||||||
// 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被
|
|
||||||
// 容量 GC 淘汰,两者状态不同,必须显式告知。
|
|
||||||
//
|
|
||||||
// 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil
|
|
||||||
//(自动注入只给实体索引以省 token,细节留给 memory_recall)。
|
|
||||||
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
|
|
||||||
if mc := a.mediaContextForInjectedEntities(injected); mc != "" {
|
|
||||||
if s != "" {
|
|
||||||
s += "\n"
|
|
||||||
}
|
|
||||||
s += "【关联媒体】\n" + mc
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxTokens > 0 {
|
if maxTokens > 0 {
|
||||||
s = TruncateByTokens(s, maxTokens)
|
s = TruncateByTokens(s, maxTokens)
|
||||||
}
|
}
|
||||||
@ -126,8 +108,8 @@ func (a *Agent) buildToolCatalog() string {
|
|||||||
}
|
}
|
||||||
// 仅注入插件/通道能力摘要,避免全量工具定义污染 system prompt。
|
// 仅注入插件/通道能力摘要,避免全量工具定义污染 system prompt。
|
||||||
// 每个插件列:名称 + 能力描述 + 工具数。完整工具定义由 get_plugin_tools 按需拉取。
|
// 每个插件列:名称 + 能力描述 + 工具数。完整工具定义由 get_plugin_tools 按需拉取。
|
||||||
byPlugin := map[string]int{} // plugin -> 工具数
|
byPlugin := map[string]int{} // plugin -> 工具数
|
||||||
pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览)
|
pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览)
|
||||||
var order []string
|
var order []string
|
||||||
for _, t := range defs {
|
for _, t := range defs {
|
||||||
fn, ok := t.(map[string]interface{})["function"].(map[string]interface{})
|
fn, ok := t.(map[string]interface{})["function"].(map[string]interface{})
|
||||||
@ -320,7 +302,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
|||||||
"parameters": map[string]interface{}{
|
"parameters": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"},
|
"name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"},
|
||||||
"content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"},
|
"content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"},
|
||||||
},
|
},
|
||||||
"required": []string{"name", "content"},
|
"required": []string{"name", "content"},
|
||||||
|
|||||||
@ -544,7 +544,6 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
|||||||
set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db"))
|
set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db"))
|
||||||
set("core.memory.text", filepath.Join(dataDir, "memory", "text"))
|
set("core.memory.text", filepath.Join(dataDir, "memory", "text"))
|
||||||
set("core.memory.documents", filepath.Join(dataDir, "memory", "documents"))
|
set("core.memory.documents", filepath.Join(dataDir, "memory", "documents"))
|
||||||
set("core.memory.media.dir", filepath.Join(dataDir, "memory", "media"))
|
|
||||||
set("core.knowledge.path", filepath.Join(dataDir, "knowledge"))
|
set("core.knowledge.path", filepath.Join(dataDir, "knowledge"))
|
||||||
set("core.log.path", filepath.Join(dataDir, "log"))
|
set("core.log.path", filepath.Join(dataDir, "log"))
|
||||||
|
|
||||||
@ -649,12 +648,6 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
|||||||
reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"})
|
reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"})
|
||||||
reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"})
|
reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"})
|
||||||
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
|
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
|
||||||
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要(sha256)落盘去重,记忆各层只记 digest。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
|
|
||||||
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"})
|
|
||||||
reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"})
|
|
||||||
reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"})
|
|
||||||
reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"})
|
|
||||||
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
|
|
||||||
reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"})
|
reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"})
|
||||||
reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"})
|
reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"})
|
||||||
|
|
||||||
|
|||||||
@ -37,13 +37,13 @@ type Relation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Triple struct {
|
type Triple struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Relation string `json:"relation"`
|
Relation string `json:"relation"`
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
Confidence float64 `json:"confidence,omitempty"`
|
Confidence float64 `json:"confidence,omitempty"`
|
||||||
SubjectType string `json:"subject_type,omitempty"`
|
SubjectType string `json:"subject_type,omitempty"`
|
||||||
ObjectType string `json:"object_type,omitempty"`
|
ObjectType string `json:"object_type,omitempty"`
|
||||||
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
||||||
}
|
}
|
||||||
|
|
||||||
type GraphDB struct {
|
type GraphDB struct {
|
||||||
@ -192,37 +192,13 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit 把三元组写入图库,返回新建的实体数与关系数。
|
|
||||||
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
||||||
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
|
|
||||||
return ec, rc, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommitWithMedia 与 Commit 相同,但额外返回每条句子文本对应的 sentences.id。
|
|
||||||
//
|
|
||||||
// 为何单独开一个方法而不改 Commit 的签名:Commit 有十个非测试调用点
|
|
||||||
// 加二十多个测试调用点,为了一个多数调用方都不需要的返回值去改全部签名
|
|
||||||
// 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。
|
|
||||||
//
|
|
||||||
// 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体
|
|
||||||
// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点,
|
|
||||||
// 关系行本身不持有媒体。
|
|
||||||
func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) {
|
|
||||||
return g.commit(triples, sessionID, turnID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSentences bool) (map[string]int64, int, int, error) {
|
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
var sentenceIDs map[string]int64
|
|
||||||
if trackSentences {
|
|
||||||
sentenceIDs = make(map[string]int64)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := g.db.Begin()
|
tx, err := g.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
@ -253,24 +229,24 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
|||||||
|
|
||||||
ec, err := g.upsertEntity(tx, t.Subject, subjType)
|
ec, err := g.upsertEntity(tx, t.Subject, subjType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
entitiesCreated += ec
|
entitiesCreated += ec
|
||||||
|
|
||||||
ec, err = g.upsertEntity(tx, t.Object, objType)
|
ec, err = g.upsertEntity(tx, t.Object, objType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
entitiesCreated += ec
|
entitiesCreated += ec
|
||||||
|
|
||||||
var sourceID, targetID int64
|
var sourceID, targetID int64
|
||||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
|
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
|
return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
|
||||||
}
|
}
|
||||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
|
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
|
return 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入/查找句子
|
// 写入/查找句子
|
||||||
@ -279,13 +255,11 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
|||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
`INSERT OR IGNORE INTO sentences (text) VALUES (?)`, t.SentenceText)
|
`INSERT OR IGNORE INTO sentences (text) VALUES (?)`, t.SentenceText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, fmt.Errorf("insert sentence: %w", err)
|
return 0, 0, fmt.Errorf("insert sentence: %w", err)
|
||||||
}
|
}
|
||||||
err = tx.QueryRow("SELECT id FROM sentences WHERE text = ?", t.SentenceText).Scan(&sentenceID)
|
err = tx.QueryRow("SELECT id FROM sentences WHERE text = ?", t.SentenceText).Scan(&sentenceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sentenceID = 0
|
sentenceID = 0
|
||||||
} else if sentenceIDs != nil {
|
|
||||||
sentenceIDs[t.SentenceText] = sentenceID
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -301,11 +275,11 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
|||||||
sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID,
|
sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
relationsCreated++
|
relationsCreated++
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, 0, 0, err
|
return 0, 0, err
|
||||||
} else {
|
} else {
|
||||||
// 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数
|
// 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
@ -314,16 +288,16 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
|||||||
confidence, sourceID, targetID, t.Relation, sessionID,
|
confidence, sourceID, targetID, t.Relation, sessionID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return sentenceIDs, entitiesCreated, relationsCreated, nil
|
return entitiesCreated, relationsCreated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validEntityName(name string) bool {
|
func validEntityName(name string) bool {
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
package media
|
|
||||||
|
|
||||||
import "encoding/base64"
|
|
||||||
|
|
||||||
// base64 编解码单独抽出来,让 media.go 的 import 块只留业务依赖。
|
|
||||||
// 用 StdEncoding:data URL 规范用的是标准表(含 + / =),不是 URL-safe 表。
|
|
||||||
|
|
||||||
func base64Decode(s string) ([]byte, error) {
|
|
||||||
return base64.StdEncoding.DecodeString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func base64Encode(b []byte) string {
|
|
||||||
return base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
@ -1,780 +0,0 @@
|
|||||||
// Package media 是记忆系统的内容寻址媒体存储(CAS)。
|
|
||||||
//
|
|
||||||
// 为何需要它:此前四层记忆全是纯文本载体——L0 `ContextEvent`、L1 `text.Event`、
|
|
||||||
// L2 `document.Doc`、L3 图库的 `sentences.text TEXT UNIQUE`——没有任何一层能
|
|
||||||
// 存二进制。multimodal 插件注入的图片在本轮对话内可见(走 message 数组,不经
|
|
||||||
// 记忆),下一轮起就只剩 `ToolResultItem.Output` 里那句
|
|
||||||
// "[已将图片注入后续对话] /tmp/x.png",即一条路径字符串。那个文件被删或被
|
|
||||||
// 覆盖之后连线索都断了。
|
|
||||||
//
|
|
||||||
// 为何是内容寻址而不是存路径:
|
|
||||||
// - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个
|
|
||||||
// 路径等于留个悬空指针。
|
|
||||||
// - 同一张图往往被反复注入(用户连问几轮同一张截图、see_video 相邻帧高度
|
|
||||||
// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次。
|
|
||||||
// - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用
|
|
||||||
// 文本本身做身份,媒体节点用内容摘要做身份。
|
|
||||||
package media
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。
|
|
||||||
//
|
|
||||||
// 定义为常量而不是让调用方写字符串:owner_kind 进了主键,
|
|
||||||
// 拼错一个字符就是一条永远对不上的孤立引用(AddRef 不会报错,
|
|
||||||
// DropOwner 也永远匹配不到)。
|
|
||||||
const (
|
|
||||||
// OwnerContext 是 L0 对话上下文事件(ContextEvent.ID)。
|
|
||||||
OwnerContext = "context"
|
|
||||||
// OwnerDocument 是 L2 文档记忆(Doc.ID)。
|
|
||||||
OwnerDocument = "document"
|
|
||||||
// OwnerGraphSentence 是 L3 图库句子节点(sentences.id)。
|
|
||||||
OwnerGraphSentence = "graph_sentence"
|
|
||||||
)
|
|
||||||
|
|
||||||
// digestHexLen 是 sha256 的十六进制串长度。
|
|
||||||
const digestHexLen = sha256.Size * 2
|
|
||||||
|
|
||||||
// Kind 是媒体大类。刻意只分三类而不细分具体格式:
|
|
||||||
// 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。
|
|
||||||
type Kind string
|
|
||||||
|
|
||||||
const (
|
|
||||||
KindImage Kind = "image"
|
|
||||||
KindAudio Kind = "audio"
|
|
||||||
KindVideo Kind = "video"
|
|
||||||
KindOther Kind = "other"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Item 是一条媒体记录。
|
|
||||||
//
|
|
||||||
// Digest 既是主键也是磁盘文件名,所以没有单独的 Path 字段——路径可由
|
|
||||||
// Store 根据 Digest 推导,不落库(落了就又是个会失效的引用)。
|
|
||||||
type Item struct {
|
|
||||||
// Digest 是内容 sha256 的十六进制串(64 字符),媒体的唯一身份。
|
|
||||||
Digest string `json:"digest"`
|
|
||||||
// Kind 是大类,供检索时按模态筛选。
|
|
||||||
Kind Kind `json:"kind"`
|
|
||||||
// MIME 是原始 MIME 类型,如 image/png。
|
|
||||||
MIME string `json:"mime"`
|
|
||||||
// Size 是字节数。
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
// Width/Height 是像素尺寸,未知或不适用时为 0。
|
|
||||||
Width int `json:"width,omitempty"`
|
|
||||||
Height int `json:"height,omitempty"`
|
|
||||||
// OriginPath 是首次入库时的来源路径,仅供人类溯源与调试。
|
|
||||||
// **不可用于读取内容**——它随时可能失效,这正是本包存在的理由。
|
|
||||||
OriginPath string `json:"origin_path,omitempty"`
|
|
||||||
// Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。
|
|
||||||
Tool string `json:"tool,omitempty"`
|
|
||||||
// Description 是视觉/音频模型生成的文字描述,供 L2/L3 检索。
|
|
||||||
// 空表示未描述(未开启描述、模型不可用或描述失败)。
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
|
|
||||||
DescribedBy string `json:"described_by,omitempty"`
|
|
||||||
// RefCount 是引用计数。GC 只清理归零的项。
|
|
||||||
RefCount int `json:"ref_count"`
|
|
||||||
// FirstSeen/LastSeen 是首末次入库时间。
|
|
||||||
FirstSeen time.Time `json:"first_seen"`
|
|
||||||
LastSeen time.Time `json:"last_seen"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store 管理媒体的元数据(SQLite)与内容(磁盘 CAS 目录)。
|
|
||||||
//
|
|
||||||
// 元数据与内容分离而不是把 blob 塞进 SQLite:单张图动辄几 MB,塞进库会让
|
|
||||||
// 每次 VACUUM/备份都拖着几百 MB 走,也让 WAL 迅速膨胀。CAS 目录用两级
|
|
||||||
// 前缀分桶(ab/cdef...)避免单目录几万文件。
|
|
||||||
type Store struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
db *sql.DB
|
|
||||||
blobDir string
|
|
||||||
|
|
||||||
// maxBytes 是内容目录的容量上限,0 表示不限。
|
|
||||||
// 超限时 GC 按 LastSeen 从旧到新淘汰 RefCount=0 的项。
|
|
||||||
maxBytes int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// New 打开(或初始化)媒体存储。
|
|
||||||
// dir 下会建 media.db 与 blobs/ 两个条目。
|
|
||||||
func New(dir string, maxBytes int64) (*Store, error) {
|
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("media: create blob dir: %w", err)
|
|
||||||
}
|
|
||||||
dbPath := filepath.Join(dir, "media.db")
|
|
||||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("media: open db: %w", err)
|
|
||||||
}
|
|
||||||
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes}
|
|
||||||
if err := s.initSchema(); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) initSchema() error {
|
|
||||||
stmts := []string{
|
|
||||||
// digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count。
|
|
||||||
`CREATE TABLE IF NOT EXISTS media (
|
|
||||||
digest TEXT PRIMARY KEY,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
mime TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
width INTEGER DEFAULT 0,
|
|
||||||
height INTEGER DEFAULT 0,
|
|
||||||
origin_path TEXT,
|
|
||||||
tool TEXT,
|
|
||||||
description TEXT,
|
|
||||||
described_by TEXT,
|
|
||||||
ref_count INTEGER DEFAULT 0,
|
|
||||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_media_kind ON media(kind)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_media_refcount ON media(ref_count)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_media_last_seen ON media(last_seen)`,
|
|
||||||
// 反向索引:哪条记忆引用了哪个媒体。
|
|
||||||
// owner_kind 取 context / document / graph_sentence,owner_id 是各层自己的标识。
|
|
||||||
// 主键含三列,同一 owner 重复挂同一媒体是幂等的。
|
|
||||||
`CREATE TABLE IF NOT EXISTS media_refs (
|
|
||||||
digest TEXT NOT NULL,
|
|
||||||
owner_kind TEXT NOT NULL,
|
|
||||||
owner_id TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (digest, owner_kind, owner_id)
|
|
||||||
)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_refs_owner ON media_refs(owner_kind, owner_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_refs_digest ON media_refs(digest)`,
|
|
||||||
}
|
|
||||||
for _, q := range stmts {
|
|
||||||
if _, err := s.db.Exec(q); err != nil {
|
|
||||||
return fmt.Errorf("media: schema %q: %w", truncate(q, 60), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// blobPath 按两级前缀分桶推导内容路径。
|
|
||||||
func (s *Store) blobPath(digest string) string {
|
|
||||||
if len(digest) < 4 {
|
|
||||||
return filepath.Join(s.blobDir, digest)
|
|
||||||
}
|
|
||||||
return filepath.Join(s.blobDir, digest[:2], digest[2:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put 落盘并登记一段媒体内容,返回其 digest。
|
|
||||||
//
|
|
||||||
// 幂等:同一内容重复 Put 不重复落盘,只更新 last_seen 与可选的新元数据
|
|
||||||
// (描述、尺寸等——后来者可能带着前一次没有的信息)。
|
|
||||||
func (s *Store) Put(data []byte, meta Item) (string, error) {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return "", fmt.Errorf("media: empty content")
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256(data)
|
|
||||||
digest := hex.EncodeToString(sum[:])
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
path := s.blobPath(digest)
|
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
||||||
return "", fmt.Errorf("media: mkdir: %w", err)
|
|
||||||
}
|
|
||||||
// 先写临时文件再 rename:中途崩溃不会留下半个 blob 被后续
|
|
||||||
// 当成完整内容读走(digest 校验能发现,但那时已经把坏数据喂给模型了)。
|
|
||||||
tmp := path + ".tmp"
|
|
||||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
|
||||||
return "", fmt.Errorf("media: write blob: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, path); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return "", fmt.Errorf("media: commit blob: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if meta.Kind == "" {
|
|
||||||
meta.Kind = KindFromMIME(meta.MIME)
|
|
||||||
}
|
|
||||||
_, err := s.db.Exec(`
|
|
||||||
INSERT INTO media (digest, kind, mime, size, width, height,
|
|
||||||
origin_path, tool, description, described_by,
|
|
||||||
ref_count, first_seen, last_seen)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
||||||
ON CONFLICT(digest) DO UPDATE SET
|
|
||||||
last_seen = excluded.last_seen,
|
|
||||||
-- 只在原值为空时补写:先到的描述可能来自更强的模型,
|
|
||||||
-- 后到的空值不该把它冲掉。
|
|
||||||
description = CASE WHEN COALESCE(media.description,'') = '' THEN excluded.description ELSE media.description END,
|
|
||||||
described_by = CASE WHEN COALESCE(media.described_by,'') = '' THEN excluded.described_by ELSE media.described_by END,
|
|
||||||
width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END,
|
|
||||||
height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END,
|
|
||||||
tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END
|
|
||||||
`, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height,
|
|
||||||
meta.OriginPath, meta.Tool, meta.Description, meta.DescribedBy, now, now)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("media: upsert meta: %w", err)
|
|
||||||
}
|
|
||||||
return digest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 读取内容并校验 digest。
|
|
||||||
//
|
|
||||||
// 校验不是多余的:CAS 的全部保证建立在"文件名 == 内容摘要"上,磁盘位翻转
|
|
||||||
// 或外部误改会让这条保证失效,而把损坏的图喂给模型只会得到无从追溯的幻觉。
|
|
||||||
func (s *Store) Get(digest string) ([]byte, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
path := s.blobPath(digest)
|
|
||||||
s.mu.RUnlock()
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("media: read %s: %w", shortDigest(digest), err)
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256(data)
|
|
||||||
if got := hex.EncodeToString(sum[:]); got != digest {
|
|
||||||
return nil, fmt.Errorf("media: digest mismatch for %s (content corrupted)", shortDigest(digest))
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stat 返回元数据,不读内容。
|
|
||||||
func (s *Store) Stat(digest string) (*Item, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
return s.scanOne(s.db.QueryRow(`
|
|
||||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
|
||||||
description, described_by, ref_count, first_seen, last_seen
|
|
||||||
FROM media WHERE digest = ?`, digest))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Describe 写入(或覆盖)文字描述。
|
|
||||||
//
|
|
||||||
// 与 Put 的"只在空时补写"不同:Describe 是显式操作,调用方明确想要这份
|
|
||||||
// 描述生效(例如换了更强的视觉模型重新描述)。
|
|
||||||
func (s *Store) Describe(digest, description, describedBy string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
res, err := s.db.Exec(`UPDATE media SET description = ?, described_by = ? WHERE digest = ?`,
|
|
||||||
description, describedBy, digest)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("media: describe: %w", err)
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return fmt.Errorf("media: describe: unknown digest %s", shortDigest(digest))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddRef 登记一条引用并递增计数。幂等:同一 (digest, owner) 重复调用不重复计数。
|
|
||||||
func (s *Store) AddRef(digest, ownerKind, ownerID string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
res, err := tx.Exec(`INSERT OR IGNORE INTO media_refs (digest, owner_kind, owner_id) VALUES (?, ?, ?)`,
|
|
||||||
digest, ownerKind, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("media: add ref: %w", err)
|
|
||||||
}
|
|
||||||
// 只有真的插进去才递增:否则重复调用会让计数虚高,GC 永远不敢清。
|
|
||||||
if n, _ := res.RowsAffected(); n > 0 {
|
|
||||||
if _, err := tx.Exec(`UPDATE media SET ref_count = ref_count + 1 WHERE digest = ?`, digest); err != nil {
|
|
||||||
return fmt.Errorf("media: bump refcount: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DropRef 注销一条引用并递减计数。内容不立即删除,留给 GC。
|
|
||||||
func (s *Store) DropRef(digest, ownerKind, ownerID string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
res, err := tx.Exec(`DELETE FROM media_refs WHERE digest = ? AND owner_kind = ? AND owner_id = ?`,
|
|
||||||
digest, ownerKind, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("media: drop ref: %w", err)
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n > 0 {
|
|
||||||
// MAX(0, ...) 兜底:历史数据或并发意外让计数与 refs 表不一致时,
|
|
||||||
// 不让它掉成负数(负数会让容量 GC 的排序失去意义)。
|
|
||||||
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, digest); err != nil {
|
|
||||||
return fmt.Errorf("media: lower refcount: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DropOwner 注销某个 owner 的全部引用(该条记忆被删/被归档替换时用)。
|
|
||||||
func (s *Store) DropOwner(ownerKind, ownerID string) (int, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ?`,
|
|
||||||
ownerKind, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
var digests []string
|
|
||||||
for rows.Next() {
|
|
||||||
var d string
|
|
||||||
if err := rows.Scan(&d); err == nil {
|
|
||||||
digests = append(digests, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if len(digests) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
if _, err := tx.Exec(`DELETE FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, ownerKind, ownerID); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
for _, d := range digests {
|
|
||||||
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, d); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return len(digests), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refs 返回某个 owner 引用的全部 digest。
|
|
||||||
func (s *Store) Refs(ownerKind, ownerID string) ([]string, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at`,
|
|
||||||
ownerKind, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []string
|
|
||||||
for rows.Next() {
|
|
||||||
var d string
|
|
||||||
if err := rows.Scan(&d); err == nil {
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search 按描述文本做 LIKE 匹配,返回最近的若干条。
|
|
||||||
//
|
|
||||||
// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引
|
|
||||||
// (描述文字随记忆条目一起进 Doc.Content,复用那套 TF-IDF/embedding),
|
|
||||||
// 本方法只是"按关键词直接翻媒体库"的补充入口。
|
|
||||||
func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
|
||||||
description, described_by, ref_count, first_seen, last_seen
|
|
||||||
FROM media WHERE COALESCE(description,'') != ''`
|
|
||||||
args := []interface{}{}
|
|
||||||
if strings.TrimSpace(query) != "" {
|
|
||||||
q += ` AND description LIKE ?`
|
|
||||||
args = append(args, "%"+query+"%")
|
|
||||||
}
|
|
||||||
if kind != "" {
|
|
||||||
q += ` AND kind = ?`
|
|
||||||
args = append(args, string(kind))
|
|
||||||
}
|
|
||||||
q += ` ORDER BY last_seen DESC LIMIT ?`
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := s.db.Query(q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []*Item
|
|
||||||
for rows.Next() {
|
|
||||||
it, err := s.scanRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, it)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending 返回尚无描述的媒体,供后台描述任务消费。
|
|
||||||
// Pending 返回尚无描述的媒体,供后台描述任务消费。
|
|
||||||
//
|
|
||||||
// 不只看 description 为空,还要求 described_by 也为空。
|
|
||||||
// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失)
|
|
||||||
// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空——
|
|
||||||
// 若只看 description,这些项每轮都会被取出来重试,永远卡在队列头部,
|
|
||||||
// 真正需要描述的新项永远轮不到(LIMIT 只取前 N 条)。
|
|
||||||
func (s *Store) Pending(limit int) ([]*Item, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
|
||||||
description, described_by, ref_count, first_seen, last_seen
|
|
||||||
FROM media
|
|
||||||
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
|
|
||||||
ORDER BY last_seen DESC LIMIT ?`, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []*Item
|
|
||||||
for rows.Next() {
|
|
||||||
it, err := s.scanRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, it)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GC 清理无人引用的内容。
|
|
||||||
//
|
|
||||||
// 两段策略:
|
|
||||||
// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及
|
|
||||||
// AddRef 的项 refcount 也是 0,minAge 保护它们不被立刻清掉。
|
|
||||||
// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项。
|
|
||||||
//
|
|
||||||
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。
|
|
||||||
func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
cutoff := time.Now().Add(-minAge)
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT digest, size FROM media
|
|
||||||
WHERE ref_count <= 0 AND last_seen < ?
|
|
||||||
ORDER BY last_seen`, cutoff)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
type cand struct {
|
|
||||||
digest string
|
|
||||||
size int64
|
|
||||||
}
|
|
||||||
var cands []cand
|
|
||||||
for rows.Next() {
|
|
||||||
var c cand
|
|
||||||
if err := rows.Scan(&c.digest, &c.size); err == nil {
|
|
||||||
cands = append(cands, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
|
|
||||||
for _, c := range cands {
|
|
||||||
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
|
|
||||||
continue // 删不掉就留着元数据,下轮再试;不制造"元数据没了文件还在"的孤儿
|
|
||||||
}
|
|
||||||
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
removed++
|
|
||||||
freed += c.size
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.maxBytes > 0 {
|
|
||||||
r2, f2 := s.enforceCapacityLocked()
|
|
||||||
removed += r2
|
|
||||||
freed += f2
|
|
||||||
}
|
|
||||||
return removed, freed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// enforceCapacityLocked 在超出 maxBytes 时继续淘汰无引用项(调用方已持锁)。
|
|
||||||
func (s *Store) enforceCapacityLocked() (removed int, freed int64) {
|
|
||||||
var total int64
|
|
||||||
if err := s.db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM media`).Scan(&total); err != nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
if total <= s.maxBytes {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
need := total - s.maxBytes
|
|
||||||
|
|
||||||
rows, err := s.db.Query(`SELECT digest, size FROM media WHERE ref_count <= 0 ORDER BY last_seen`)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
type cand struct {
|
|
||||||
digest string
|
|
||||||
size int64
|
|
||||||
}
|
|
||||||
var cands []cand
|
|
||||||
for rows.Next() {
|
|
||||||
var c cand
|
|
||||||
if err := rows.Scan(&c.digest, &c.size); err == nil {
|
|
||||||
cands = append(cands, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
|
|
||||||
for _, c := range cands {
|
|
||||||
if freed >= need {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
removed++
|
|
||||||
freed += c.size
|
|
||||||
}
|
|
||||||
return removed, freed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats 返回容量与条目统计,供 WebUI / healthcheck 展示。
|
|
||||||
func (s *Store) Stats() map[string]interface{} {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
out := map[string]interface{}{"blob_dir": s.blobDir, "max_bytes": s.maxBytes}
|
|
||||||
var count, described, orphan int
|
|
||||||
var total int64
|
|
||||||
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total)
|
|
||||||
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described)
|
|
||||||
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE ref_count <= 0`).Scan(&orphan)
|
|
||||||
out["count"] = count
|
|
||||||
out["total_bytes"] = total
|
|
||||||
out["described"] = described
|
|
||||||
out["unreferenced"] = orphan
|
|
||||||
|
|
||||||
byKind := map[string]int{}
|
|
||||||
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
|
|
||||||
if err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var k string
|
|
||||||
var n int
|
|
||||||
if rows.Scan(&k, &n) == nil {
|
|
||||||
byKind[k] = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out["by_kind"] = byKind
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) Close() error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.db.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 扫描辅助 ----
|
|
||||||
|
|
||||||
type rowScanner interface {
|
|
||||||
Scan(dest ...interface{}) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) scanOne(r rowScanner) (*Item, error) {
|
|
||||||
it, err := scanItem(r)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, fmt.Errorf("media: unknown digest")
|
|
||||||
}
|
|
||||||
return it, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) scanRows(r rowScanner) (*Item, error) { return scanItem(r) }
|
|
||||||
|
|
||||||
func scanItem(r rowScanner) (*Item, error) {
|
|
||||||
var it Item
|
|
||||||
var kind string
|
|
||||||
var origin, tool, desc, by sql.NullString
|
|
||||||
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
|
||||||
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
it.Kind = Kind(kind)
|
|
||||||
it.OriginPath = origin.String
|
|
||||||
it.Tool = tool.String
|
|
||||||
it.Description = desc.String
|
|
||||||
it.DescribedBy = by.String
|
|
||||||
return &it, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 工具函数 ----
|
|
||||||
|
|
||||||
// KindFromMIME 把 MIME 归到大类。
|
|
||||||
func KindFromMIME(mime string) Kind {
|
|
||||||
m := strings.ToLower(strings.TrimSpace(mime))
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(m, "image/"):
|
|
||||||
return KindImage
|
|
||||||
case strings.HasPrefix(m, "audio/"):
|
|
||||||
return KindAudio
|
|
||||||
case strings.HasPrefix(m, "video/"):
|
|
||||||
return KindVideo
|
|
||||||
default:
|
|
||||||
return KindOther
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseDataURL 从 data:<mime>;base64,<data> 提取 MIME 与原始字节。
|
|
||||||
//
|
|
||||||
// 与 agent/api 里的 parseAudioDataURL 分开实现:那个只认音频且只回 base64
|
|
||||||
// 串(它要把串塞回 OpenAI 的 input_audio 字段),这里要的是解码后的字节。
|
|
||||||
func ParseDataURL(url string) (mime string, data []byte, ok bool) {
|
|
||||||
const prefix = "data:"
|
|
||||||
if !strings.HasPrefix(url, prefix) {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
rest := url[len(prefix):]
|
|
||||||
comma := strings.IndexByte(rest, ',')
|
|
||||||
if comma < 0 {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
head := rest[:comma]
|
|
||||||
payload := rest[comma+1:]
|
|
||||||
if !strings.HasSuffix(strings.ToLower(head), ";base64") {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
mime = head[:len(head)-len(";base64")]
|
|
||||||
if mime == "" || payload == "" {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
decoded, err := base64Decode(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
return mime, decoded, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataURL 把内容编回 data URL,供重新注入模型对话。
|
|
||||||
func DataURL(mime string, data []byte) string {
|
|
||||||
return "data:" + mime + ";base64," + base64Encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CopyFrom 从 reader 读全部内容后 Put,用于大文件不便一次性构造 []byte 的场合。
|
|
||||||
func (s *Store) CopyFrom(r io.Reader, meta Item) (string, error) {
|
|
||||||
data, err := io.ReadAll(r)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("media: read source: %w", err)
|
|
||||||
}
|
|
||||||
return s.Put(data, meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalItems 序列化条目列表,供工具返回给模型。
|
|
||||||
func MarshalItems(items []*Item) string {
|
|
||||||
b, err := json.Marshal(items)
|
|
||||||
if err != nil {
|
|
||||||
return "[]"
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func shortDigest(d string) string {
|
|
||||||
if len(d) > 12 {
|
|
||||||
return d[:12]
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncate(s string, n int) string {
|
|
||||||
s = strings.Join(strings.Fields(s), " ")
|
|
||||||
if len(s) <= n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:n] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolvePrefix 把 digest 前缀补全为完整 digest。
|
|
||||||
//
|
|
||||||
// 日志、事件摘要与图库句子里出现的都是 shortDigest(前 12 位),
|
|
||||||
// 因为完整的 64 位 sha256 会把一行文字撑爆、也无助于人眼辨认。
|
|
||||||
// 反查时需要这个补全,否则那些短标记只能看不能用。
|
|
||||||
//
|
|
||||||
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容,
|
|
||||||
// 宁可这次绑定失败。12 位十六进制的碰撞概率极低,真撞上说明该用更长前缀。
|
|
||||||
func (s *Store) ResolvePrefix(prefix string) (string, error) {
|
|
||||||
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
|
||||||
if len(prefix) < 8 {
|
|
||||||
return "", fmt.Errorf("digest 前缀过短(至少 8 位): %q", prefix)
|
|
||||||
}
|
|
||||||
if len(prefix) == digestHexLen {
|
|
||||||
// 已是完整 digest:仍要确认存在,否则调用方会挂一条孤儿引用
|
|
||||||
if _, err := s.Stat(prefix); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return prefix, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT digest FROM media WHERE digest LIKE ? || '%' LIMIT 2`, prefix)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var found []string
|
|
||||||
for rows.Next() {
|
|
||||||
var d string
|
|
||||||
if err := rows.Scan(&d); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
found = append(found, d)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch len(found) {
|
|
||||||
case 0:
|
|
||||||
return "", fmt.Errorf("digest 前缀 %q 未匹配到媒体", prefix)
|
|
||||||
case 1:
|
|
||||||
return found[0], nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("digest 前缀 %q 有歧义(至少匹配 %s 和 %s)",
|
|
||||||
prefix, found[0][:16], found[1][:16])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,513 +0,0 @@
|
|||||||
package media
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestStore(t *testing.T, maxBytes int64) *Store {
|
|
||||||
t.Helper()
|
|
||||||
s, err := New(t.TempDir(), maxBytes)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { s.Close() })
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPut_ContentAddressedDedup(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
data := []byte("fake-png-bytes")
|
|
||||||
|
|
||||||
d1, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/a.png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
d2, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/b.png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d1 != d2 {
|
|
||||||
t.Fatalf("同一内容应得同一 digest:%s vs %s", d1, d2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去重的意义:同一张图反复注入(连问几轮同一截图、see_video 相邻帧)
|
|
||||||
// 只占一份磁盘。
|
|
||||||
st := s.Stats()
|
|
||||||
if st["count"].(int) != 1 {
|
|
||||||
t.Fatalf("同一内容应只有 1 条记录,实际 %v", st["count"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPut_KindInferredFromMIME(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
cases := map[string]Kind{
|
|
||||||
"image/png": KindImage,
|
|
||||||
"image/jpeg": KindImage,
|
|
||||||
"audio/wav": KindAudio,
|
|
||||||
"video/mp4": KindVideo,
|
|
||||||
"text/plain": KindOther,
|
|
||||||
}
|
|
||||||
for mime, want := range cases {
|
|
||||||
d, err := s.Put([]byte("payload-"+mime), Item{MIME: mime})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
it, err := s.Stat(d)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if it.Kind != want {
|
|
||||||
t.Fatalf("%s 应归为 %s,实际 %s", mime, want, it.Kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGet_DetectsCorruption(t *testing.T) {
|
|
||||||
// CAS 的全部保证建立在「文件名 == 内容摘要」上。外部误改或位翻转必须
|
|
||||||
// 被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
d, err := s.Put([]byte("original-content"), Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := s.Get(d); err != nil {
|
|
||||||
t.Fatalf("正常读取应成功: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(s.blobPath(d), []byte("tampered!"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = s.Get(d)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("内容被改后应报 digest 不匹配,却读成功了")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "digest mismatch") {
|
|
||||||
t.Fatalf("错误应指明 digest 不匹配,得到: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPut_NoPartialBlobOnDisk(t *testing.T) {
|
|
||||||
// 先写 .tmp 再 rename:确认落地后目录里不留临时文件。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
d, err := s.Put([]byte("some-bytes"), Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
dir := filepath.Dir(s.blobPath(d))
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
|
||||||
t.Fatalf("落地后不该留临时文件: %s", e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefCount_AddIsIdempotent(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if err := s.AddRef(d, "context", "evt-1"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
it, _ := s.Stat(d)
|
|
||||||
// 重复 AddRef 若都递增,计数会虚高,GC 永远不敢清。
|
|
||||||
if it.RefCount != 1 {
|
|
||||||
t.Fatalf("同一 owner 重复 AddRef 应只计 1,实际 %d", it.RefCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.AddRef(d, "document", "doc-9"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
it, _ = s.Stat(d)
|
|
||||||
if it.RefCount != 2 {
|
|
||||||
t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefCount_DropAndNeverNegative(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
|
||||||
s.AddRef(d, "context", "e1")
|
|
||||||
|
|
||||||
if err := s.DropRef(d, "context", "e1"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
it, _ := s.Stat(d)
|
|
||||||
if it.RefCount != 0 {
|
|
||||||
t.Fatalf("应归零,实际 %d", it.RefCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 多余的 DropRef 不该把计数压成负数(负数会让容量 GC 的排序失去意义)
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
s.DropRef(d, "context", "e1")
|
|
||||||
}
|
|
||||||
it, _ = s.Stat(d)
|
|
||||||
if it.RefCount != 0 {
|
|
||||||
t.Fatalf("重复 DropRef 后仍应为 0,实际 %d", it.RefCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDropOwner_RemovesAllItsRefs(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
d1, _ := s.Put([]byte("frame1"), Item{MIME: "image/jpeg"})
|
|
||||||
d2, _ := s.Put([]byte("frame2"), Item{MIME: "image/jpeg"})
|
|
||||||
s.AddRef(d1, "context", "evt-x")
|
|
||||||
s.AddRef(d2, "context", "evt-x")
|
|
||||||
s.AddRef(d1, "document", "doc-y") // 别的 owner 也引了 d1
|
|
||||||
|
|
||||||
n, err := s.DropOwner("context", "evt-x")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n != 2 {
|
|
||||||
t.Fatalf("应注销 2 条引用,实际 %d", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
it1, _ := s.Stat(d1)
|
|
||||||
it2, _ := s.Stat(d2)
|
|
||||||
if it1.RefCount != 1 {
|
|
||||||
t.Fatalf("d1 仍被 document 引用,应剩 1,实际 %d", it1.RefCount)
|
|
||||||
}
|
|
||||||
if it2.RefCount != 0 {
|
|
||||||
t.Fatalf("d2 应归零,实际 %d", it2.RefCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefs_ListsOwnerDigests(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
|
|
||||||
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
|
|
||||||
s.AddRef(d1, "context", "e1")
|
|
||||||
s.AddRef(d2, "context", "e1")
|
|
||||||
|
|
||||||
got, err := s.Refs("context", "e1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("应返回 2 个 digest,实际 %d", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGC_KeepsReferencedContent(t *testing.T) {
|
|
||||||
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,
|
|
||||||
// 正是本包要避免的。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
kept, _ := s.Put([]byte("referenced"), Item{MIME: "image/png"})
|
|
||||||
orphan, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
|
|
||||||
s.AddRef(kept, "context", "e1")
|
|
||||||
|
|
||||||
// minAge=0 让刚 Put 的都算超龄
|
|
||||||
removed, _, err := s.GC(0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if removed != 1 {
|
|
||||||
t.Fatalf("应只清 1 条无引用项,实际 %d", removed)
|
|
||||||
}
|
|
||||||
if _, err := s.Get(kept); err != nil {
|
|
||||||
t.Fatalf("被引用的内容不该被清: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.Stat(orphan); err == nil {
|
|
||||||
t.Fatal("无引用项的元数据应已删除")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGC_MinAgeProtectsFreshUnreferenced(t *testing.T) {
|
|
||||||
// 刚 Put 还没来得及 AddRef 的项 refcount 也是 0;
|
|
||||||
// minAge 必须保护它们,否则「Put 完还没挂上就被 GC 清掉」。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
d, _ := s.Put([]byte("just-arrived"), Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
removed, _, err := s.GC(time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if removed != 0 {
|
|
||||||
t.Fatalf("新入库项应被 minAge 保护,却清掉了 %d 条", removed)
|
|
||||||
}
|
|
||||||
if _, err := s.Get(d); err != nil {
|
|
||||||
t.Fatalf("内容应还在: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGC_EnforcesCapacity(t *testing.T) {
|
|
||||||
// 容量上限:清完超龄项后仍超限,继续按 last_seen 从旧到新淘汰无引用项。
|
|
||||||
blob := make([]byte, 1024)
|
|
||||||
s := newTestStore(t, 2048) // 只容 2KB
|
|
||||||
|
|
||||||
var digests []string
|
|
||||||
for i := 0; i < 4; i++ {
|
|
||||||
b := append([]byte{byte(i)}, blob...) // 内容各异,避免去重
|
|
||||||
d, err := s.Put(b, Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
digests = append(digests, d)
|
|
||||||
time.Sleep(2 * time.Millisecond) // 拉开 last_seen
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保护最后一个,确认容量 GC 也不碰有引用的
|
|
||||||
s.AddRef(digests[3], "context", "e1")
|
|
||||||
|
|
||||||
removed, freed, err := s.GC(0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if removed == 0 {
|
|
||||||
t.Fatal("超限应触发淘汰")
|
|
||||||
}
|
|
||||||
if _, err := s.Get(digests[3]); err != nil {
|
|
||||||
t.Fatalf("有引用项即使超限也不该删: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("removed=%d freed=%d", removed, freed)
|
|
||||||
|
|
||||||
st := s.Stats()
|
|
||||||
if total := st["total_bytes"].(int64); total > 2048 {
|
|
||||||
// 有引用项可能让总量降不到线下,这是刻意的(宁可超限也不断引用)
|
|
||||||
t.Logf("总量 %d 仍超 2048,因有引用项不可删(预期行为)", total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribe_OverwritesExplicitly(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
it, _ := s.Stat(d)
|
|
||||||
if it.Description != "一只橘猫" || it.DescribedBy != "vis-a" {
|
|
||||||
t.Fatalf("描述未写入: %+v", it)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Describe 是显式操作,允许覆盖(换更强模型重描述)
|
|
||||||
if err := s.Describe(d, "一只橘色虎斑猫坐在窗台", "vis-b"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
it, _ = s.Stat(d)
|
|
||||||
if !strings.Contains(it.Description, "虎斑") || it.DescribedBy != "vis-b" {
|
|
||||||
t.Fatalf("Describe 应覆盖旧描述: %+v", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribe_UnknownDigestErrors(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
err := s.Describe("deadbeef", "x", "y")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("未知 digest 应报错而非静默成功")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPut_DoesNotClobberExistingDescription(t *testing.T) {
|
|
||||||
// 先到的描述可能来自更强的模型;后到的空值不该把它冲掉。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
data := []byte("img")
|
|
||||||
d, _ := s.Put(data, Item{MIME: "image/png", Description: "详细描述", DescribedBy: "strong-model"})
|
|
||||||
|
|
||||||
// 第二次 Put 同内容但不带描述
|
|
||||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
it, _ := s.Stat(d)
|
|
||||||
if it.Description != "详细描述" || it.DescribedBy != "strong-model" {
|
|
||||||
t.Fatalf("重复 Put 的空描述不该冲掉已有描述: %+v", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
|
||||||
// 后来者可能带着前一次没有的信息(尺寸、工具名)
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
data := []byte("img")
|
|
||||||
d, _ := s.Put(data, Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
if _, err := s.Put(data, Item{MIME: "image/png", Width: 640, Height: 480, Tool: "multimodal_see_picture"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
it, _ := s.Stat(d)
|
|
||||||
if it.Width != 640 || it.Height != 480 {
|
|
||||||
t.Fatalf("尺寸应被补写: %dx%d", it.Width, it.Height)
|
|
||||||
}
|
|
||||||
if it.Tool != "multimodal_see_picture" {
|
|
||||||
t.Fatalf("工具名应被补写: %q", it.Tool)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearch_FiltersByDescriptionAndKind(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"})
|
|
||||||
da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"})
|
|
||||||
dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"})
|
|
||||||
s.Describe(di, "一张蓝色的柱状图表", "vis")
|
|
||||||
s.Describe(da, "一段关于图表的讲解录音", "aud")
|
|
||||||
|
|
||||||
all, err := s.Search("图表", "", 10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(all) != 2 {
|
|
||||||
t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all))
|
|
||||||
}
|
|
||||||
|
|
||||||
imgs, _ := s.Search("图表", KindImage, 10)
|
|
||||||
if len(imgs) != 1 || imgs[0].Digest != di {
|
|
||||||
t.Fatalf("按 image 过滤应只剩图片,实际 %d 条", len(imgs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无描述的项不该出现在语义检索结果里
|
|
||||||
for _, it := range all {
|
|
||||||
if it.Digest == dn {
|
|
||||||
t.Fatal("无描述的项不该被 Search 返回")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPending_ReturnsUndescribed(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
described, _ := s.Put([]byte("has-desc"), Item{MIME: "image/png"})
|
|
||||||
undescribed, _ := s.Put([]byte("needs-desc"), Item{MIME: "image/png"})
|
|
||||||
s.Describe(described, "已有描述", "vis")
|
|
||||||
|
|
||||||
pending, err := s.Pending(10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(pending) != 1 || pending[0].Digest != undescribed {
|
|
||||||
t.Fatalf("应只返回未描述项,实际 %d 条", len(pending))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDataURL(t *testing.T) {
|
|
||||||
// 与 agent/api 的 parseAudioDataURL 不同:这里要解码后的字节。
|
|
||||||
raw := []byte{0x89, 'P', 'N', 'G'}
|
|
||||||
url := DataURL("image/png", raw)
|
|
||||||
|
|
||||||
mime, data, ok := ParseDataURL(url)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("应解析成功")
|
|
||||||
}
|
|
||||||
if mime != "image/png" {
|
|
||||||
t.Fatalf("MIME 应为 image/png,得到 %q", mime)
|
|
||||||
}
|
|
||||||
if string(data) != string(raw) {
|
|
||||||
t.Fatalf("字节应还原,得到 %v", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, bad := range []string{
|
|
||||||
"http://example.com/x.png", // 不是 data URL
|
|
||||||
"data:image/png,notbase64", // 缺 ;base64
|
|
||||||
"data:;base64,", // 空 MIME 与空载荷
|
|
||||||
"data:image/png;base64,!!!", // 非法 base64
|
|
||||||
} {
|
|
||||||
if _, _, ok := ParseDataURL(bad); ok {
|
|
||||||
t.Fatalf("%q 应解析失败", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStats_CountsByKindAndDescription(t *testing.T) {
|
|
||||||
s := newTestStore(t, 4096)
|
|
||||||
d1, _ := s.Put([]byte("i1"), Item{MIME: "image/png"})
|
|
||||||
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
|
||||||
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
|
||||||
s.Describe(d1, "描述", "vis")
|
|
||||||
s.AddRef(d1, "context", "e1")
|
|
||||||
|
|
||||||
st := s.Stats()
|
|
||||||
if st["count"].(int) != 3 {
|
|
||||||
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
|
||||||
}
|
|
||||||
if st["described"].(int) != 1 {
|
|
||||||
t.Fatalf("described 应为 1,实际 %v", st["described"])
|
|
||||||
}
|
|
||||||
if st["unreferenced"].(int) != 2 {
|
|
||||||
t.Fatalf("unreferenced 应为 2,实际 %v", st["unreferenced"])
|
|
||||||
}
|
|
||||||
byKind := st["by_kind"].(map[string]int)
|
|
||||||
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
|
||||||
t.Fatalf("by_kind 不对: %v", byKind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPut_RejectsEmpty(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil {
|
|
||||||
t.Fatal("空内容应报错")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
|
||||||
// 记忆的意义就在于跨重启还在。
|
|
||||||
dir := t.TempDir()
|
|
||||||
s1, err := New(dir, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
|
|
||||||
s1.Describe(d, "跨重启的描述", "vis")
|
|
||||||
s1.AddRef(d, "context", "e1")
|
|
||||||
s1.Close()
|
|
||||||
|
|
||||||
s2, err := New(dir, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer s2.Close()
|
|
||||||
|
|
||||||
it, err := s2.Stat(d)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("重开后应能查到: %v", err)
|
|
||||||
}
|
|
||||||
if it.Description != "跨重启的描述" || it.RefCount != 1 {
|
|
||||||
t.Fatalf("元数据应持久化: %+v", it)
|
|
||||||
}
|
|
||||||
data, err := s2.Get(d)
|
|
||||||
if err != nil || string(data) != "persistent-img" {
|
|
||||||
t.Fatalf("内容应持久化: %v / %q", err, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) {
|
|
||||||
// 「已尝试但无法描述」的项必须退出待描述队列。
|
|
||||||
//
|
|
||||||
// 这些项被标记为 described_by=unsupported/content-missing 而 description
|
|
||||||
// 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、
|
|
||||||
// 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
|
|
||||||
fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"})
|
|
||||||
unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"})
|
|
||||||
described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
// 标记「尝试过但不支持」:description 空,described_by 非空
|
|
||||||
if err := s.Describe(unsupported, "", "unsupported"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.Describe(described, "一张图", "visionllm"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pending, err := s.Pending(10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(pending) != 1 {
|
|
||||||
var names []string
|
|
||||||
for _, p := range pending {
|
|
||||||
names = append(names, shortDigest(p.Digest))
|
|
||||||
}
|
|
||||||
t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names)
|
|
||||||
}
|
|
||||||
if pending[0].Digest != fresh {
|
|
||||||
t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,316 +0,0 @@
|
|||||||
package media
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/zlib"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"hash/crc32"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。
|
|
||||||
//
|
|
||||||
// 之前这套场景是 internal/memory/media/smoke/ 下一个带 //go:build smoke 的
|
|
||||||
// 独立 main,得记着加 -tags smoke 才跑得到——那种早晚会被忘掉。搬成普通
|
|
||||||
// 测试后它随 go test ./... 一起跑,冒烟的意义(每次改动都过一遍真实链路)
|
|
||||||
// 才真正成立。
|
|
||||||
|
|
||||||
// makePNG 生成一张 w×h 的条带 PNG,用真 PNG 而不是随机字节,
|
|
||||||
// 让入库/回读/digest 走的是与生产一致的数据形态。
|
|
||||||
//
|
|
||||||
// variant 注入到像素而不只用于选色:最初写的是
|
|
||||||
// palette[(variant+y*3/h)%5],调色盘只 5 色,于是 variant=0 与 5 产出
|
|
||||||
// 逐字节相同的 PNG——冒烟跑出「6 帧只得 5 条」,看着像存储丢了一帧,
|
|
||||||
// 实际是 CAS 正确去重了两张真同图。冒烟要验的是「不同帧各存一份」,
|
|
||||||
// 夹具就必须保证帧间真的不同。
|
|
||||||
func makePNG(w, h, variant int) []byte {
|
|
||||||
palette := [][3]byte{
|
|
||||||
{255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200},
|
|
||||||
}
|
|
||||||
var raw bytes.Buffer
|
|
||||||
for y := 0; y < h; y++ {
|
|
||||||
raw.WriteByte(0) // 每行的滤波器字节
|
|
||||||
c := palette[(variant+y*3/h)%len(palette)]
|
|
||||||
for x := 0; x < w; x++ {
|
|
||||||
raw.Write(c[:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 把 variant 写进首行头几个像素,确保不同 variant 字节必然不同。
|
|
||||||
b := raw.Bytes()
|
|
||||||
if len(b) > 8 {
|
|
||||||
b[1] = byte(variant)
|
|
||||||
b[2] = byte(variant >> 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
var z bytes.Buffer
|
|
||||||
zw := zlib.NewWriter(&z)
|
|
||||||
zw.Write(b)
|
|
||||||
zw.Close()
|
|
||||||
|
|
||||||
chunk := func(typ string, data []byte) []byte {
|
|
||||||
var out bytes.Buffer
|
|
||||||
binary.Write(&out, binary.BigEndian, uint32(len(data)))
|
|
||||||
out.WriteString(typ)
|
|
||||||
out.Write(data)
|
|
||||||
binary.Write(&out, binary.BigEndian, crc32.ChecksumIEEE(append([]byte(typ), data...)))
|
|
||||||
return out.Bytes()
|
|
||||||
}
|
|
||||||
var ihdr bytes.Buffer
|
|
||||||
binary.Write(&ihdr, binary.BigEndian, uint32(w))
|
|
||||||
binary.Write(&ihdr, binary.BigEndian, uint32(h))
|
|
||||||
ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8bit 深度、truecolor
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
|
|
||||||
out.Write(chunk("IHDR", ihdr.Bytes()))
|
|
||||||
out.Write(chunk("IDAT", z.Bytes()))
|
|
||||||
out.Write(chunk("IEND", nil))
|
|
||||||
return out.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
|
|
||||||
// 场景:用户连问几轮同一张截图。multimodal 每轮都会重新注入,
|
|
||||||
// 磁盘上应该只有一份,但每轮的 context 事件各持一个引用。
|
|
||||||
s := newTestStore(t, 50*1024*1024)
|
|
||||||
png := makePNG(400, 400, 0)
|
|
||||||
|
|
||||||
var d0 string
|
|
||||||
for turn := 1; turn <= 5; turn++ {
|
|
||||||
// 走 data URL:这是 SetToolBlocks 实际给出的形态
|
|
||||||
url := DataURL("image/png", png)
|
|
||||||
mime, data, ok := ParseDataURL(url)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("第 %d 轮 data URL 解析失败", turn)
|
|
||||||
}
|
|
||||||
d, err := s.Put(data, Item{
|
|
||||||
MIME: mime, Width: 400, Height: 400,
|
|
||||||
OriginPath: fmt.Sprintf("/tmp/probe_%d.png", turn),
|
|
||||||
Tool: "multimodal_see_picture",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("第 %d 轮 Put: %v", turn, err)
|
|
||||||
}
|
|
||||||
if d0 == "" {
|
|
||||||
d0 = d
|
|
||||||
} else if d != d0 {
|
|
||||||
t.Fatalf("同一张图第 %d 轮 digest 变了", turn)
|
|
||||||
}
|
|
||||||
if err := s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn)); err != nil {
|
|
||||||
t.Fatalf("第 %d 轮 AddRef: %v", turn, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
st := s.Stats()
|
|
||||||
if st["count"].(int) != 1 {
|
|
||||||
t.Fatalf("5 轮同图应只存 1 份,实际 %v 条", st["count"])
|
|
||||||
}
|
|
||||||
if total := st["total_bytes"].(int64); total != int64(len(png)) {
|
|
||||||
t.Fatalf("字节数应等于单张原图 %d,实际 %d", len(png), total)
|
|
||||||
}
|
|
||||||
it, _ := s.Stat(d0)
|
|
||||||
if it.RefCount != 5 {
|
|
||||||
t.Fatalf("应有 5 个引用,实际 %d", it.RefCount)
|
|
||||||
}
|
|
||||||
t.Logf("同图 5 轮:条目=1 字节=%d refcount=%d", len(png), it.RefCount)
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmoke_VideoFramesDistinct(t *testing.T) {
|
|
||||||
// 场景:see_video 抽 6 帧,帧间内容不同,应各存一份并共享一个 owner。
|
|
||||||
s := newTestStore(t, 50*1024*1024)
|
|
||||||
var frames []string
|
|
||||||
for i := 0; i < 6; i++ {
|
|
||||||
d, err := s.Put(makePNG(320, 240, i), Item{
|
|
||||||
MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("第 %d 帧: %v", i, err)
|
|
||||||
}
|
|
||||||
frames = append(frames, d)
|
|
||||||
if err := s.AddRef(d, "context", "evt-video"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
st := s.Stats()
|
|
||||||
if st["count"].(int) != 6 {
|
|
||||||
t.Fatalf("6 帧应各存一份,实际 %v 条", st["count"])
|
|
||||||
}
|
|
||||||
refs, err := s.Refs("context", "evt-video")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(refs) != 6 {
|
|
||||||
t.Fatalf("evt-video 应引用 6 帧,实际 %d", len(refs))
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmoke_DescribeThenRetrieve(t *testing.T) {
|
|
||||||
// 场景 C:视觉模型描述落库后,描述文字成为可检索的语义入口。
|
|
||||||
// 这是本方案最关键的一环——blob 可能被淘汰,描述会长期留在记忆里。
|
|
||||||
s := newTestStore(t, 50*1024*1024)
|
|
||||||
|
|
||||||
pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
|
||||||
if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var frames []string
|
|
||||||
for i := 0; i < 6; i++ {
|
|
||||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
|
||||||
frames = append(frames, d)
|
|
||||||
if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hits, _ := s.Search("三色带", KindImage, 10); len(hits) != 1 {
|
|
||||||
t.Fatalf("搜「三色带」应命中 1 条,实际 %d", len(hits))
|
|
||||||
}
|
|
||||||
if hits, _ := s.Search("计数器", KindImage, 10); len(hits) != 6 {
|
|
||||||
t.Fatalf("搜「计数器」应命中 6 帧,实际 %d", len(hits))
|
|
||||||
}
|
|
||||||
pend, _ := s.Pending(100)
|
|
||||||
if len(pend) != 0 {
|
|
||||||
t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend))
|
|
||||||
}
|
|
||||||
_ = frames
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmoke_ArchiveTransfersOwnership(t *testing.T) {
|
|
||||||
// 场景:L0 的 context 事件被 Prune 归档进 L2 文档,
|
|
||||||
// 媒体引用需从 context owner 转到 document owner,期间内容不能被 GC 掉。
|
|
||||||
s := newTestStore(t, 50*1024*1024)
|
|
||||||
png := makePNG(400, 400, 0)
|
|
||||||
d, _ := s.Put(png, Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
|
||||||
for turn := 1; turn <= 5; turn++ {
|
|
||||||
s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn))
|
|
||||||
}
|
|
||||||
|
|
||||||
// evt-1 被淘汰,其内容归档为一篇文档
|
|
||||||
n, err := s.DropOwner("context", "evt-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n != 1 {
|
|
||||||
t.Fatalf("应注销 1 条引用,实际 %d", n)
|
|
||||||
}
|
|
||||||
if err := s.AddRef(d, "document", "doc_archived_001"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
it, _ := s.Stat(d)
|
|
||||||
if it.RefCount != 5 {
|
|
||||||
t.Fatalf("引用转移后总数应仍为 5(4 context + 1 document),实际 %d", it.RefCount)
|
|
||||||
}
|
|
||||||
// 归档过程中内容必须始终可读
|
|
||||||
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
|
|
||||||
t.Fatalf("归档后内容应完好: %v", err)
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmoke_GCSweepsToolLeftovers(t *testing.T) {
|
|
||||||
// 场景:别的工具(cmd_run 之类)产出的一次性图片没人引用,
|
|
||||||
// 应被 GC 清掉;而被记忆引用的媒体一个都不能少。
|
|
||||||
s := newTestStore(t, 50*1024*1024)
|
|
||||||
|
|
||||||
keep, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
|
|
||||||
s.AddRef(keep, "document", "doc-1")
|
|
||||||
var frames []string
|
|
||||||
for i := 0; i < 6; i++ {
|
|
||||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"})
|
|
||||||
s.AddRef(d, "context", "evt-video")
|
|
||||||
frames = append(frames, d)
|
|
||||||
}
|
|
||||||
// 1000+i 保证与上面的帧、以及彼此都不重复
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
|
||||||
}
|
|
||||||
|
|
||||||
before := s.Stats()["count"].(int)
|
|
||||||
removed, freed, err := s.GC(0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
after := s.Stats()["count"].(int)
|
|
||||||
if removed != 20 {
|
|
||||||
t.Fatalf("应清 20 条孤儿,实际 %d", removed)
|
|
||||||
}
|
|
||||||
if after != before-20 {
|
|
||||||
t.Fatalf("条目数应从 %d 降到 %d,实际 %d", before, before-20, after)
|
|
||||||
}
|
|
||||||
if _, err := s.Get(keep); err != nil {
|
|
||||||
t.Fatalf("被文档引用的图被误删: %v", err)
|
|
||||||
}
|
|
||||||
for i, f := range frames {
|
|
||||||
if _, err := s.Get(f); err != nil {
|
|
||||||
t.Fatalf("第 %d 帧被误删: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("GC: %d 条 → 清 %d 条(%d 字节)→ %d 条", before, removed, freed, after)
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
|
||||||
// 端到端:入库 → 描述 → 引用 → GC → 重启 → 检索,
|
|
||||||
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
|
|
||||||
dir := t.TempDir()
|
|
||||||
s, err := New(dir, 50*1024*1024)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
png := makePNG(400, 400, 0)
|
|
||||||
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
|
|
||||||
s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm")
|
|
||||||
s.AddRef(pic, "graph_sentence", "sent-42")
|
|
||||||
for i := 0; i < 6; i++ {
|
|
||||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
|
||||||
s.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm")
|
|
||||||
s.AddRef(d, "context", "evt-video")
|
|
||||||
}
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
|
||||||
}
|
|
||||||
if _, _, err := s.GC(0); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
beforeCount := s.Stats()["count"].(int)
|
|
||||||
s.Close()
|
|
||||||
|
|
||||||
s2, err := New(dir, 50*1024*1024)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("重开失败: %v", err)
|
|
||||||
}
|
|
||||||
defer s2.Close()
|
|
||||||
|
|
||||||
if got := s2.Stats()["count"].(int); got != beforeCount {
|
|
||||||
t.Fatalf("重开后条目数变了: %d → %d", beforeCount, got)
|
|
||||||
}
|
|
||||||
it, err := s2.Stat(pic)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("重开后查不到: %v", err)
|
|
||||||
}
|
|
||||||
if it.Description == "" || it.RefCount != 1 {
|
|
||||||
t.Fatalf("元数据未持久化: %+v", it)
|
|
||||||
}
|
|
||||||
data, err := s2.Get(pic)
|
|
||||||
if err != nil || !bytes.Equal(data, png) {
|
|
||||||
t.Fatalf("重开后内容不一致: %v", err)
|
|
||||||
}
|
|
||||||
if refs, _ := s2.Refs("context", "evt-video"); len(refs) != 6 {
|
|
||||||
t.Fatalf("重开后视频帧引用应为 6,实际 %d", len(refs))
|
|
||||||
}
|
|
||||||
if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 {
|
|
||||||
t.Fatal("重开后描述应仍可检索")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」
|
|
||||||
if n := blobFileCount(t, s2); n != beforeCount {
|
|
||||||
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s2)
|
|
||||||
t.Logf("跨重启:%d 条目、描述与引用全部完好", beforeCount)
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
package media
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestSoak_SustainedMixedLoad 长稳测试:持续混合负载下不变量不破。
|
|
||||||
// 用 -run TestSoak -timeout 300s 单独跑,默认 short 模式跳过。
|
|
||||||
func TestSoak_SustainedMixedLoad(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("long soak test; run with -run TestSoak")
|
|
||||||
}
|
|
||||||
dur := 60 * time.Second
|
|
||||||
s := newTestStore(t, 8*1024*1024) // 8MB 上限,逼 GC 频繁工作
|
|
||||||
|
|
||||||
// 常驻受保护集
|
|
||||||
const keepN = 20
|
|
||||||
keep := make([]string, keepN)
|
|
||||||
keepData := make([][]byte, keepN)
|
|
||||||
for i := range keep {
|
|
||||||
d := make([]byte, 4096)
|
|
||||||
rand.Read(d)
|
|
||||||
d = append([]byte(fmt.Sprintf("keep-%d-", i)), d...)
|
|
||||||
dg, err := s.Put(d, Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.AddRef(dg, "graph_sentence", fmt.Sprintf("s-%d", i)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
keep[i] = dg
|
|
||||||
keepData[i] = d
|
|
||||||
}
|
|
||||||
|
|
||||||
stop := make(chan struct{})
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var puts, gets, gcs, describes, searches, refOps atomic.Int64
|
|
||||||
var fatal atomic.Int64
|
|
||||||
|
|
||||||
worker := func(name string, fn func(iter int) error) {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if err := fn(i); err != nil {
|
|
||||||
fatal.Add(1)
|
|
||||||
t.Errorf("%s 第 %d 次失败: %v", name, i, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入者 ×3
|
|
||||||
for w := 0; w < 3; w++ {
|
|
||||||
wid := w
|
|
||||||
worker(fmt.Sprintf("put-%d", wid), func(i int) error {
|
|
||||||
b := make([]byte, 2048)
|
|
||||||
rand.Read(b)
|
|
||||||
b = append([]byte(fmt.Sprintf("eph-%d-%d-", wid, i)), b...)
|
|
||||||
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
puts.Add(1)
|
|
||||||
// 三分之一挂上引用再立刻注销,模拟短命引用
|
|
||||||
if i%3 == 0 {
|
|
||||||
own := fmt.Sprintf("tmp-%d-%d", wid, i)
|
|
||||||
if err := s.AddRef(d, "context", own); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.DropRef(d, "context", own); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
refOps.Add(2)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取者 ×3:受保护集必须始终完好
|
|
||||||
for r := 0; r < 3; r++ {
|
|
||||||
worker("get", func(i int) error {
|
|
||||||
idx := i % keepN
|
|
||||||
got, err := s.Get(keep[idx])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !bytes.Equal(got, keepData[idx]) {
|
|
||||||
return fmt.Errorf("内容被改 %s", shortDigest(keep[idx]))
|
|
||||||
}
|
|
||||||
gets.Add(1)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GC 者
|
|
||||||
worker("gc", func(i int) error {
|
|
||||||
if _, _, err := s.GC(0); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
gcs.Add(1)
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// 描述者
|
|
||||||
worker("describe", func(i int) error {
|
|
||||||
pend, err := s.Pending(5)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, it := range pend {
|
|
||||||
// 忽略 unknown digest:GC 可能在 Pending 与 Describe 之间清掉它,
|
|
||||||
// 这是正常竞态而非缺陷。
|
|
||||||
_ = s.Describe(it.Digest, fmt.Sprintf("描述 %d 含图表与文字", i), "vis")
|
|
||||||
describes.Add(1)
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检索者
|
|
||||||
worker("search", func(i int) error {
|
|
||||||
if _, err := s.Search("图表", KindImage, 20); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := s.Stat(keep[i%keepN]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
searches.Add(1)
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(dur)
|
|
||||||
close(stop)
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if n := fatal.Load(); n > 0 {
|
|
||||||
t.Fatalf("%d 个 worker 报致命错误", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("%v 内: put=%d get=%d gc=%d describe=%d search=%d refOps=%d",
|
|
||||||
dur, puts.Load(), gets.Load(), gcs.Load(), describes.Load(), searches.Load(), refOps.Load())
|
|
||||||
|
|
||||||
// 收尾断言
|
|
||||||
for i, d := range keep {
|
|
||||||
got, err := s.Get(d)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("受保护项丢失 %s: %v", shortDigest(d), err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(got, keepData[i]) {
|
|
||||||
t.Fatalf("受保护项内容变了 %s", shortDigest(d))
|
|
||||||
}
|
|
||||||
it, err := s.Stat(d)
|
|
||||||
if err != nil || it.RefCount != 1 {
|
|
||||||
t.Fatalf("受保护项引用计数应为 1: %+v", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
|
|
||||||
st := s.Stats()
|
|
||||||
t.Logf("收尾: 条目=%v 字节=%v 未引用=%v 已描述=%v",
|
|
||||||
st["count"], st["total_bytes"], st["unreferenced"], st["described"])
|
|
||||||
if total := st["total_bytes"].(int64); total > 8*1024*1024*3 {
|
|
||||||
t.Fatalf("容量失控: %d 远超上限", total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,601 +0,0 @@
|
|||||||
package media
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 压力测试与冒烟测试。
|
|
||||||
//
|
|
||||||
// 关注点不是吞吐数字,而是并发下的不变量是否被破坏:
|
|
||||||
// 1. ref_count 与 media_refs 表的行数必须始终一致(错位会让 GC 误删或永不清)
|
|
||||||
// 2. GC 与读写并发时,有引用的内容绝不能被删
|
|
||||||
// 3. 同内容并发 Put 只落一份磁盘、digest 一致
|
|
||||||
// 4. SQLite 在多 goroutine 下不出现 "database is locked"
|
|
||||||
|
|
||||||
func randBytes(t *testing.T, n int) []byte {
|
|
||||||
t.Helper()
|
|
||||||
b := make([]byte, n)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
t.Fatalf("rand: %v", err)
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkRefIntegrity 校验核心不变量:每个 digest 的 ref_count 等于
|
|
||||||
// media_refs 里指向它的行数。这条对不上就意味着 GC 的判断依据是错的。
|
|
||||||
func checkRefIntegrity(t *testing.T, s *Store) {
|
|
||||||
t.Helper()
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT m.digest, m.ref_count, COUNT(r.digest)
|
|
||||||
FROM media m LEFT JOIN media_refs r ON m.digest = r.digest
|
|
||||||
GROUP BY m.digest, m.ref_count`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("integrity query: %v", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var bad int
|
|
||||||
for rows.Next() {
|
|
||||||
var d string
|
|
||||||
var stored, actual int
|
|
||||||
if err := rows.Scan(&d, &stored, &actual); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if stored != actual {
|
|
||||||
bad++
|
|
||||||
if bad <= 5 {
|
|
||||||
t.Errorf("ref 计数错位 %s: ref_count=%d 实际引用行=%d", shortDigest(d), stored, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bad > 0 {
|
|
||||||
t.Fatalf("共 %d 条 digest 的 ref_count 与 media_refs 不一致", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// blobFileCount 统计 CAS 目录下的实际文件数(不含 .tmp)。
|
|
||||||
func blobFileCount(t *testing.T, s *Store) int {
|
|
||||||
t.Helper()
|
|
||||||
n := 0
|
|
||||||
filepath.Walk(s.blobDir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil || info == nil || info.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(path, ".tmp") {
|
|
||||||
t.Errorf("残留临时文件: %s", path)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
n++
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStress_ConcurrentPutSameContent(t *testing.T) {
|
|
||||||
// 同一内容被 N 个 goroutine 同时 Put:digest 必须一致,磁盘只一份。
|
|
||||||
// 现实对应:see_video 抽出的相邻帧、用户连发同一张图。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
data := randBytes(t, 64*1024)
|
|
||||||
|
|
||||||
const workers = 32
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
digests := make([]string, workers)
|
|
||||||
errs := make([]error, workers)
|
|
||||||
for i := 0; i < workers; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(idx int) {
|
|
||||||
defer wg.Done()
|
|
||||||
d, err := s.Put(data, Item{MIME: "image/png", OriginPath: fmt.Sprintf("/tmp/%d.png", idx)})
|
|
||||||
digests[idx] = d
|
|
||||||
errs[idx] = err
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
for i, err := range errs {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("worker %d Put 失败: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
first := digests[0]
|
|
||||||
for i, d := range digests {
|
|
||||||
if d != first {
|
|
||||||
t.Fatalf("worker %d digest 不一致: %s vs %s", i, d, first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n := blobFileCount(t, s); n != 1 {
|
|
||||||
t.Fatalf("同一内容应只落一份 blob,实际 %d 个文件", n)
|
|
||||||
}
|
|
||||||
if got, err := s.Get(first); err != nil || !bytes.Equal(got, data) {
|
|
||||||
t.Fatalf("内容应可完整读回: err=%v len=%d", err, len(got))
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStress_ConcurrentPutDistinctContent(t *testing.T) {
|
|
||||||
// 大量不同内容并发入库:不丢条目、不串内容。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
const workers = 16
|
|
||||||
const perWorker = 25
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var failed atomic.Int64
|
|
||||||
type rec struct {
|
|
||||||
digest string
|
|
||||||
data []byte
|
|
||||||
}
|
|
||||||
recCh := make(chan rec, workers*perWorker)
|
|
||||||
|
|
||||||
for w := 0; w < workers; w++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(wid int) {
|
|
||||||
defer wg.Done()
|
|
||||||
for i := 0; i < perWorker; i++ {
|
|
||||||
data := []byte(fmt.Sprintf("w%d-i%d-", wid, i))
|
|
||||||
data = append(data, randBytes(t, 512)...)
|
|
||||||
d, err := s.Put(data, Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
failed.Add(1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
recCh <- rec{digest: d, data: data}
|
|
||||||
}
|
|
||||||
}(w)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
close(recCh)
|
|
||||||
|
|
||||||
if n := failed.Load(); n > 0 {
|
|
||||||
t.Fatalf("%d 次 Put 失败", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
var records []rec
|
|
||||||
for r := range recCh {
|
|
||||||
records = append(records, r)
|
|
||||||
}
|
|
||||||
if len(records) != workers*perWorker {
|
|
||||||
t.Fatalf("应有 %d 条记录,实际 %d", workers*perWorker, len(records))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 逐条回读校验内容没串
|
|
||||||
for _, r := range records {
|
|
||||||
got, err := s.Get(r.digest)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("读 %s 失败: %v", shortDigest(r.digest), err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(got, r.data) {
|
|
||||||
t.Fatalf("内容串了: %s", shortDigest(r.digest))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
st := s.Stats()
|
|
||||||
if st["count"].(int) != len(records) {
|
|
||||||
t.Fatalf("库内条目应为 %d,实际 %v", len(records), st["count"])
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStress_ConcurrentRefChurn(t *testing.T) {
|
|
||||||
// 引用增删风暴:多 owner 对少量 digest 反复 AddRef/DropRef。
|
|
||||||
// 核心断言是最终 ref_count 与 media_refs 行数一致——错位就意味着
|
|
||||||
// GC 会误删(计数偏低)或永不清(计数虚高)。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
|
|
||||||
const digestCount = 8
|
|
||||||
digests := make([]string, digestCount)
|
|
||||||
for i := range digests {
|
|
||||||
d, err := s.Put([]byte(fmt.Sprintf("payload-%d", i)), Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
digests[i] = d
|
|
||||||
}
|
|
||||||
|
|
||||||
const workers = 24
|
|
||||||
const rounds = 40
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var addErr, dropErr atomic.Int64
|
|
||||||
|
|
||||||
for w := 0; w < workers; w++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(wid int) {
|
|
||||||
defer wg.Done()
|
|
||||||
owner := fmt.Sprintf("evt-%d", wid)
|
|
||||||
for r := 0; r < rounds; r++ {
|
|
||||||
d := digests[(wid+r)%digestCount]
|
|
||||||
if err := s.AddRef(d, "context", owner); err != nil {
|
|
||||||
addErr.Add(1)
|
|
||||||
}
|
|
||||||
// 故意重复 AddRef:幂等性在并发下也必须成立
|
|
||||||
if err := s.AddRef(d, "context", owner); err != nil {
|
|
||||||
addErr.Add(1)
|
|
||||||
}
|
|
||||||
if r%2 == 0 {
|
|
||||||
if err := s.DropRef(d, "context", owner); err != nil {
|
|
||||||
dropErr.Add(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(w)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if n := addErr.Load(); n > 0 {
|
|
||||||
t.Fatalf("AddRef 失败 %d 次", n)
|
|
||||||
}
|
|
||||||
if n := dropErr.Load(); n > 0 {
|
|
||||||
t.Fatalf("DropRef 失败 %d 次", n)
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStress_GCConcurrentWithWrites(t *testing.T) {
|
|
||||||
// GC 与读写并发。最重要的断言:有引用的内容在整个过程中始终可读。
|
|
||||||
// 这条一旦破,记忆里的 digest 就成了悬空指针。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
|
|
||||||
// 一批"受保护"的内容,全程持有引用
|
|
||||||
const protectedCount = 10
|
|
||||||
protected := make([]string, protectedCount)
|
|
||||||
protectedData := make([][]byte, protectedCount)
|
|
||||||
for i := range protected {
|
|
||||||
data := append([]byte(fmt.Sprintf("protected-%d-", i)), randBytes(t, 256)...)
|
|
||||||
d, err := s.Put(data, Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.AddRef(d, "document", fmt.Sprintf("doc-%d", i)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
protected[i] = d
|
|
||||||
protectedData[i] = data
|
|
||||||
}
|
|
||||||
|
|
||||||
stop := make(chan struct{})
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var readErr atomic.Int64
|
|
||||||
var gcRuns atomic.Int64
|
|
||||||
var putCount atomic.Int64
|
|
||||||
|
|
||||||
// 写入者:持续 Put 一次性内容(不加引用,是 GC 的正常目标)
|
|
||||||
for w := 0; w < 4; w++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(wid int) {
|
|
||||||
defer wg.Done()
|
|
||||||
i := 0
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
data := append([]byte(fmt.Sprintf("ephemeral-%d-%d-", wid, i)), randBytes(t, 128)...)
|
|
||||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err == nil {
|
|
||||||
putCount.Add(1)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取者:反复读受保护内容,任何一次失败都是致命的
|
|
||||||
for r := 0; r < 4; r++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
for i, d := range protected {
|
|
||||||
got, err := s.Get(d)
|
|
||||||
if err != nil {
|
|
||||||
readErr.Add(1)
|
|
||||||
t.Errorf("受保护内容读失败 %s: %v", shortDigest(d), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !bytes.Equal(got, protectedData[i]) {
|
|
||||||
readErr.Add(1)
|
|
||||||
t.Errorf("受保护内容被改 %s", shortDigest(d))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GC 者:minAge=0 让所有无引用项立刻可清,最大化与写入的冲突
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if _, _, err := s.GC(0); err != nil {
|
|
||||||
t.Errorf("GC 报错: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gcRuns.Add(1)
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
time.Sleep(1500 * time.Millisecond)
|
|
||||||
close(stop)
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if n := readErr.Load(); n > 0 {
|
|
||||||
t.Fatalf("受保护内容读取失败 %d 次——GC 误删了有引用的项", n)
|
|
||||||
}
|
|
||||||
t.Logf("并发窗口内: Put=%d GC=%d 轮", putCount.Load(), gcRuns.Load())
|
|
||||||
|
|
||||||
// 收尾确认:受保护的一个都没少
|
|
||||||
for i, d := range protected {
|
|
||||||
got, err := s.Get(d)
|
|
||||||
if err != nil || !bytes.Equal(got, protectedData[i]) {
|
|
||||||
t.Fatalf("收尾检查失败 %s: %v", shortDigest(d), err)
|
|
||||||
}
|
|
||||||
it, err := s.Stat(d)
|
|
||||||
if err != nil || it.RefCount != 1 {
|
|
||||||
t.Fatalf("受保护项引用计数应为 1: %+v err=%v", it, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
|
|
||||||
// 描述写入与检索并发。C 部分的后台描述任务会长期这样跑。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
const n = 60
|
|
||||||
digests := make([]string, n)
|
|
||||||
for i := range digests {
|
|
||||||
d, err := s.Put([]byte(fmt.Sprintf("img-%d", i)), Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
digests[i] = d
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var descErr, searchErr atomic.Int64
|
|
||||||
|
|
||||||
// 描述写入者
|
|
||||||
for w := 0; w < 4; w++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(wid int) {
|
|
||||||
defer wg.Done()
|
|
||||||
for i := wid; i < n; i += 4 {
|
|
||||||
desc := fmt.Sprintf("第 %d 张图,含蓝色图表与文字", i)
|
|
||||||
if err := s.Describe(digests[i], desc, "vis-src"); err != nil {
|
|
||||||
descErr.Add(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检索者 + Pending 消费者
|
|
||||||
for r := 0; r < 3; r++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for i := 0; i < 50; i++ {
|
|
||||||
if _, err := s.Search("图表", KindImage, 20); err != nil {
|
|
||||||
searchErr.Add(1)
|
|
||||||
}
|
|
||||||
if _, err := s.Pending(10); err != nil {
|
|
||||||
searchErr.Add(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if v := descErr.Load(); v > 0 {
|
|
||||||
t.Fatalf("Describe 失败 %d 次", v)
|
|
||||||
}
|
|
||||||
if v := searchErr.Load(); v > 0 {
|
|
||||||
t.Fatalf("Search/Pending 失败 %d 次", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全部应已描述完
|
|
||||||
pending, err := s.Pending(1000)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(pending) != 0 {
|
|
||||||
t.Fatalf("应全部描述完,仍有 %d 条未描述", len(pending))
|
|
||||||
}
|
|
||||||
got, err := s.Search("图表", KindImage, 1000)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got) != n {
|
|
||||||
t.Fatalf("应检索到 %d 条,实际 %d", n, len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStress_CapacityGCUnderLoad(t *testing.T) {
|
|
||||||
// 容量上限在持续写入下必须真正生效,且不碰有引用的项。
|
|
||||||
const cap = 256 * 1024 // 256KB
|
|
||||||
s := newTestStore(t, cap)
|
|
||||||
|
|
||||||
// 先放 3 个有引用的大项(合计约 96KB),它们永不可删
|
|
||||||
const keepN = 3
|
|
||||||
keep := make([]string, keepN)
|
|
||||||
for i := range keep {
|
|
||||||
data := append([]byte(fmt.Sprintf("keep-%d-", i)), randBytes(t, 32*1024)...)
|
|
||||||
d, err := s.Put(data, Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.AddRef(d, "graph_sentence", fmt.Sprintf("sent-%d", i)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
keep[i] = d
|
|
||||||
}
|
|
||||||
|
|
||||||
// 持续写入无引用内容,交替 GC
|
|
||||||
for round := 0; round < 30; round++ {
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
data := append([]byte(fmt.Sprintf("tmp-%d-%d-", round, i)), randBytes(t, 16*1024)...)
|
|
||||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
|
|
||||||
t.Fatalf("round %d Put: %v", round, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, _, err := s.GC(0); err != nil {
|
|
||||||
t.Fatalf("round %d GC: %v", round, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
st := s.Stats()
|
|
||||||
total := st["total_bytes"].(int64)
|
|
||||||
t.Logf("上限 %d,收尾总量 %d,条目 %v", cap, total, st["count"])
|
|
||||||
|
|
||||||
// 有引用的项必须都在
|
|
||||||
for _, d := range keep {
|
|
||||||
if _, err := s.Get(d); err != nil {
|
|
||||||
t.Fatalf("有引用项被容量 GC 删了 %s: %v", shortDigest(d), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 无引用项应被压到上限附近:允许略超(有引用项本身可能就占了大头),
|
|
||||||
// 但不该无界增长——30 轮 × 3 × 16KB = 1.4MB 若全留下就是失控。
|
|
||||||
if total > cap*2 {
|
|
||||||
t.Fatalf("容量 GC 未生效:总量 %d 远超上限 %d", total, cap)
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
|
|
||||||
// 大量写入 + GC 之后重开:元数据与磁盘不该出现互相不认的孤儿。
|
|
||||||
dir := t.TempDir()
|
|
||||||
s1, err := New(dir, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var kept []string
|
|
||||||
for i := 0; i < 100; i++ {
|
|
||||||
data := append([]byte(fmt.Sprintf("churn-%d-", i)), randBytes(t, 256)...)
|
|
||||||
d, err := s1.Put(data, Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if i%5 == 0 {
|
|
||||||
if err := s1.AddRef(d, "context", fmt.Sprintf("e-%d", i)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
kept = append(kept, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, _, err := s1.GC(0); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
beforeStats := s1.Stats()
|
|
||||||
s1.Close()
|
|
||||||
|
|
||||||
s2, err := New(dir, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("重开失败: %v", err)
|
|
||||||
}
|
|
||||||
defer s2.Close()
|
|
||||||
|
|
||||||
afterStats := s2.Stats()
|
|
||||||
if beforeStats["count"] != afterStats["count"] {
|
|
||||||
t.Fatalf("重开后条目数变了: %v → %v", beforeStats["count"], afterStats["count"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每条元数据都应有对应磁盘文件(无「元数据在文件没了」的孤儿)
|
|
||||||
rows, err := s2.db.Query(`SELECT digest FROM media`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var missing int
|
|
||||||
for rows.Next() {
|
|
||||||
var d string
|
|
||||||
if rows.Scan(&d) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(s2.blobPath(d)); err != nil {
|
|
||||||
missing++
|
|
||||||
if missing <= 3 {
|
|
||||||
t.Errorf("元数据存在但 blob 缺失: %s", shortDigest(d))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if missing > 0 {
|
|
||||||
t.Fatalf("%d 条元数据没有对应文件", missing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 磁盘文件数应等于元数据条数(无「文件在元数据没了」的孤儿)
|
|
||||||
if n := blobFileCount(t, s2); n != afterStats["count"].(int) {
|
|
||||||
t.Fatalf("磁盘文件 %d 与元数据 %v 不一致", n, afterStats["count"])
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, d := range kept {
|
|
||||||
if _, err := s2.Get(d); err != nil {
|
|
||||||
t.Fatalf("有引用项重开后读不到 %s: %v", shortDigest(d), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStress_LargeBlob(t *testing.T) {
|
|
||||||
// 单个大文件:see_video 10 帧 × 2MB 是现实上限附近。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
data := randBytes(t, 4*1024*1024) // 4MB
|
|
||||||
|
|
||||||
d, err := s.Put(data, Item{MIME: "image/jpeg", Width: 1920, Height: 1080})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("4MB Put 失败: %v", err)
|
|
||||||
}
|
|
||||||
got, err := s.Get(d)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("4MB Get 失败: %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(got, data) {
|
|
||||||
t.Fatal("4MB 内容回读不一致")
|
|
||||||
}
|
|
||||||
it, _ := s.Stat(d)
|
|
||||||
if it.Size != int64(len(data)) {
|
|
||||||
t.Fatalf("Size 记录错: %d vs %d", it.Size, len(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStress_DataURLRoundTripAtScale(t *testing.T) {
|
|
||||||
// data URL 往返是插件注入的实际路径(SetToolBlocks 给的就是 data URL)。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
for i := 0; i < 50; i++ {
|
|
||||||
raw := randBytes(t, 2048)
|
|
||||||
url := DataURL("image/png", raw)
|
|
||||||
mime, decoded, ok := ParseDataURL(url)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("第 %d 次解析失败", i)
|
|
||||||
}
|
|
||||||
if mime != "image/png" || !bytes.Equal(decoded, raw) {
|
|
||||||
t.Fatalf("第 %d 次往返不一致", i)
|
|
||||||
}
|
|
||||||
d, err := s.Put(decoded, Item{MIME: mime})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
back, err := s.Get(d)
|
|
||||||
if err != nil || !bytes.Equal(back, raw) {
|
|
||||||
t.Fatalf("第 %d 次入库回读不一致: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkRefIntegrity(t, s)
|
|
||||||
}
|
|
||||||
@ -10,7 +10,10 @@ var (
|
|||||||
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
|
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
|
||||||
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
|
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
|
||||||
// 用新版 plugindev 重编),故跃到主版本号。
|
// 用新版 plugindev 重编),故跃到主版本号。
|
||||||
Version = "1.1.0"
|
//
|
||||||
|
// 1.0.1:多模态修复。仅内核与内置插件改动,插件 ABI/协议未变,
|
||||||
|
// 1.0.0 编出的 plugin.bin 无需重编。
|
||||||
|
Version = "1.0.4"
|
||||||
|
|
||||||
// Commit 是构建时的 Git commit hash。
|
// Commit 是构建时的 Git commit hash。
|
||||||
Commit = "unknown"
|
Commit = "unknown"
|
||||||
|
|||||||
Reference in New Issue
Block a user