mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 背景 仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑), gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。 但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源 不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。 ## 关键区分(这是本次改动安全的前提) 两个 module 是**不同**的,影响面差一个数量级: | module | 谁依赖 | 改它的后果 | |---|---|---| | `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 | | `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 | ★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0), 只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串** (`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。 ⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。 SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配, 否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。 ## 改动 - `go.mod` / `go.sum`:两个 module 路径 + require/replace - 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径 - SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板 - 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等) ## 明确**保留** gitcode 的地方(镜像机制,不能动) - `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job) - `gitcode.com/api/v5/...`(release 附件上传) - git remote `gitcode`(镜像拉取/推送) - `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维) ## 验证 - `go build ./...` / `go vet ./...` 绿 - `go test ./... -count=1` → 48 包 ok / 0 FAIL - hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`) - 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0 - 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好 ## 顺带修正 `hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」—— module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**: 离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
360 lines
10 KiB
Go
360 lines
10 KiB
Go
package memory
|
||
|
||
import (
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||
"github.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||
_ "github.com/JianFeeeee/HomeAgent/providers/chineseclip"
|
||
)
|
||
|
||
// ★ 端到端召回探针:对比「仲裁前 / 仲裁后」的真实召回差异。
|
||
//
|
||
// 判据全部来自 order-gw 运维叙事里的**真事实**,不从 LLM 输出生成。
|
||
//
|
||
// 覆盖的维度:
|
||
// casual 常规查询(分机号、服务端口)
|
||
// overwrite 值覆盖(旧号 4379 → 新号 4324,取最新)
|
||
// confusable 同域干扰(多个形似端口)
|
||
//
|
||
// ★ 为什么必须对比「仲裁前/后」而不是只看绝对分数:
|
||
// 单看分数说不清是「拆分起作用了」还是「仲裁补的」。两组一起看才知道
|
||
// 每一层各贡献了多少。
|
||
|
||
type probeCase struct {
|
||
name string
|
||
// query 是探针问句。
|
||
query string
|
||
// want 是答案里必须出现的值。
|
||
want string
|
||
// notWant 是答案里不该出现的值(用于 overwrite 维度)。
|
||
notWant string
|
||
// dim 是维度名。
|
||
dim string
|
||
why string
|
||
}
|
||
|
||
// probeCases 是端到端探针集。
|
||
//
|
||
// 用例全部来自真库 /var/tmp/ha-c 的实际块文本与 order-gw 叙事。
|
||
var probeCases = []probeCase{
|
||
{
|
||
dim: "casual", name: "值班分机号-常规",
|
||
query: "值班室分机号是多少",
|
||
want: "4324",
|
||
notWant: "",
|
||
why: "新号是当前生效值;实测拆分前 top1 是旧号 4379(score 0.8127)",
|
||
},
|
||
{
|
||
dim: "casual", name: "admin服务端口",
|
||
query: "admin 服务的端口是多少",
|
||
want: "8861",
|
||
notWant: "",
|
||
why: "三个服务各自的端口,考的是「值归对了主语」",
|
||
},
|
||
{
|
||
dim: "casual", name: "billing服务端口",
|
||
query: "billing 服务监听哪个端口",
|
||
want: "8499",
|
||
notWant: "",
|
||
why: "同上,但更依赖主语归属(billing ≠ admin)",
|
||
},
|
||
{
|
||
dim: "overwrite", name: "值班分机号-覆盖",
|
||
query: "现在的值班分机号是多少",
|
||
want: "4324",
|
||
notWant: "4379",
|
||
why: "★ 核心用例:新号必须在结果里,旧号不能压过它",
|
||
},
|
||
{
|
||
dim: "overwrite", name: "旧号-历史查询",
|
||
query: "以前的值班分机号是哪个",
|
||
want: "4379",
|
||
notWant: "",
|
||
why: "旧号仍要能查到(历史信息不是噪声)—— 与上一条方向相反",
|
||
},
|
||
{
|
||
dim: "confusable", name: "连接池容量-扩容前",
|
||
query: "连接池扩容前的容量是多少",
|
||
want: "32/64",
|
||
notWant: "",
|
||
why: "同一维度两个值(32/64 与 128/256),考指标型并发",
|
||
},
|
||
{
|
||
dim: "confusable", name: "连接池告警阈值",
|
||
query: "等待队列长度告警阈值是多少",
|
||
want: "300~500",
|
||
notWant: "",
|
||
why: "同句的另一个维度,考不会把别的维度的值答上来",
|
||
},
|
||
}
|
||
|
||
// probeDB 打开真库;不存在时跳过。
|
||
func probeDB(t *testing.T, dbPath string) *GraphDB {
|
||
t.Helper()
|
||
if _, err := os.Stat(dbPath); err != nil {
|
||
t.Skipf("无真库 %s", dbPath)
|
||
}
|
||
g, err := NewGraphDB(dbPath)
|
||
if err != nil {
|
||
t.Skipf("打开真库失败: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = g.Close() })
|
||
return g
|
||
}
|
||
|
||
func probeEmbedder(t *testing.T, modelDir string) (*vector.ProviderAdapter, string) {
|
||
t.Helper()
|
||
p, err := embedding.Open("chineseclip", embedding.Config{
|
||
Options: map[string]string{"model_dir": modelDir}})
|
||
if err != nil {
|
||
t.Skipf("provider 打开失败(需 onnxruntime 与模型目录): %v", err)
|
||
}
|
||
t.Cleanup(p.Close)
|
||
ad, err := vector.AdaptProvider(p)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return ad, p.Info().Fingerprint
|
||
}
|
||
|
||
// TestProbe_端到端召回_仲裁前后对比 是端到端判据。
|
||
//
|
||
// 需要:真库已迁移+已拆分落块(homed-graph-migrate + homed-graph-distill),
|
||
// 且模型目录可读。任一条件不满足则跳过,不产出误导性的"通过"。
|
||
func TestProbe_端到端召回_仲裁前后对比(t *testing.T) {
|
||
const dbPath = "/var/tmp/ha-c/memory/graph.db"
|
||
const modelDir = "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx"
|
||
|
||
g := probeDB(t, dbPath)
|
||
ad, fp := probeEmbedder(t, modelDir)
|
||
|
||
// 前置:库里必须有拆分块,否则探针测不到仲裁
|
||
stats, err := g.BlockVectorStats()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
blocks, err := g.MemoryBlocks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
splitBlocks := 0
|
||
for _, b := range blocks {
|
||
if b.Source == "distill" {
|
||
splitBlocks++
|
||
}
|
||
}
|
||
t.Logf("块总数 %d(带向量 %d),其中拆分块 %d", stats.Total, stats.WithVector, splitBlocks)
|
||
if splitBlocks == 0 {
|
||
t.Skip("库里还没有拆分块 —— 先跑 homed-graph-distill -apply")
|
||
}
|
||
|
||
// 按维度统计两组结果
|
||
type dimStat struct{ before, after int }
|
||
byDim := map[string]*dimStat{}
|
||
perCase := make([]struct {
|
||
probeCase
|
||
beforeOK, afterOK bool
|
||
beforeTop, afterTop string
|
||
}, 0, len(probeCases))
|
||
|
||
for _, c := range probeCases {
|
||
vec, err := ad.VectorizeDense(c.query)
|
||
if err != nil {
|
||
t.Fatalf("向量化 %q: %v", c.query, err)
|
||
}
|
||
|
||
// 仲裁前:纯向量召回
|
||
before, err := g.RecallBlocks(BlockRecallQuery{
|
||
Vector: vec, Fingerprint: fp, TopK: 5})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 仲裁后
|
||
after, _, err := g.RecallBlocksWithArbitration(BlockRecallQuery{
|
||
Vector: vec, Fingerprint: fp, TopK: 5})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
beforeOK := judgeProbe(before, c)
|
||
afterOK := judgeProbe(after, c)
|
||
|
||
if byDim[c.dim] == nil {
|
||
byDim[c.dim] = &dimStat{}
|
||
}
|
||
if beforeOK {
|
||
byDim[c.dim].before++
|
||
}
|
||
if afterOK {
|
||
byDim[c.dim].after++
|
||
}
|
||
perCase = append(perCase, struct {
|
||
probeCase
|
||
beforeOK, afterOK bool
|
||
beforeTop, afterTop string
|
||
}{c, beforeOK, afterOK, topText(before), topText(after)})
|
||
|
||
mark := func(ok bool) string {
|
||
if ok {
|
||
return "✓"
|
||
}
|
||
return "✘"
|
||
}
|
||
t.Logf("[%s] %-22s 仲裁前=%s 仲裁后=%s", c.dim, c.name,
|
||
mark(beforeOK), mark(afterOK))
|
||
t.Logf(" 前: %s", perCase[len(perCase)-1].beforeTop)
|
||
t.Logf(" 后: %s", perCase[len(perCase)-1].afterTop)
|
||
if !afterOK {
|
||
t.Logf(" 原因: %s(期望含 %q,不该含 %q)", c.why, c.want, c.notWant)
|
||
}
|
||
}
|
||
|
||
total := 0
|
||
beforeTotal, afterTotal := 0, 0
|
||
for dim, st := range byDim {
|
||
t.Logf("维度 %-11s 仲裁前 %d/%d 仲裁后 %d/%d",
|
||
dim, st.before, st.after, st.after, st.after)
|
||
beforeTotal += st.before
|
||
afterTotal += st.after
|
||
total += st.after
|
||
}
|
||
t.Logf("\n合计:仲裁前 %d/%d,仲裁后 %d/%d", beforeTotal, total, afterTotal, total)
|
||
|
||
// ★ 判据不是「必须全过」—— 拆分块刚上线,覆盖面必然不全。
|
||
// 硬判据是:**仲裁不得让任何维度变差**(仲裁只剔除被明确取代的块,
|
||
// 理论上不会让正确结果消失)。出现回退就是 bug,不是数据不够。
|
||
for dim, st := range byDim {
|
||
if st.after < st.before {
|
||
t.Errorf("维度 %s 仲裁后变差:%d → %d(仲裁不该降低召回)",
|
||
dim, st.before, st.after)
|
||
}
|
||
}
|
||
if afterTotal == 0 {
|
||
t.Errorf("仲裁后一条都没命中 —— 探针或链路有问题")
|
||
}
|
||
}
|
||
|
||
// judgeProbe 判定一次召回结果是否答对。
|
||
//
|
||
// ★ 判据修过一次(原版有缺陷)
|
||
// ------------------------------
|
||
// 原版:`notWant` 不该排在 `want` 前面。
|
||
//
|
||
// 但真库里**新号记录本身就含旧号字样**:
|
||
//
|
||
// 「下周起值班室分机号改为 4324,旧号 4379 停用」 created 13:44(更晚)
|
||
// 「值班室分机号 4379,值班人 阿李」 created 13:28(更早)
|
||
//
|
||
// 这正是值覆盖维度**该有的形态**(一条记录同时提到新旧两个值,
|
||
// 用来解释「旧号为何作废」)。原判据会把第一条判成「notWant 排在前面」
|
||
// → 误判失败。
|
||
//
|
||
// 现在改成按**时间**判:含旧号的那条若比含新号的那条更新,它是
|
||
// 「新号生效、旧号作废」的说明,应该胜出。
|
||
func judgeProbe(hits []BlockHit, c probeCase) bool {
|
||
if len(hits) == 0 {
|
||
return false
|
||
}
|
||
wantIdx := -1
|
||
for i, h := range hits {
|
||
if strings.Contains(h.Block.Text, c.want) {
|
||
wantIdx = i
|
||
break
|
||
}
|
||
}
|
||
if wantIdx < 0 {
|
||
return false // 新号根本没被召回 —— 那是召回问题,与排序无关
|
||
}
|
||
if c.notWant == "" {
|
||
return true
|
||
}
|
||
|
||
// 找出「只含旧号、不含新号」的那些块(真正的旧值记录)
|
||
oldOnly := -1
|
||
for i, h := range hits {
|
||
txt := h.Block.Text
|
||
if strings.Contains(txt, c.want) {
|
||
continue // 这条也含新号(新号记录里提到旧号是正常的)
|
||
}
|
||
if strings.Contains(txt, c.notWant) {
|
||
oldOnly = i
|
||
break
|
||
}
|
||
}
|
||
if oldOnly < 0 {
|
||
return true // 没有纯旧值块,无从冲突
|
||
}
|
||
if oldOnly > wantIdx {
|
||
return true // 旧值排在新号之后 —— 正确
|
||
}
|
||
// 旧值排在前面:只有当它**更新**时才合理(那是「新号作废」的镜像)
|
||
return hits[oldOnly].Block.CreatedAt.After(hits[wantIdx].Block.CreatedAt)
|
||
}
|
||
|
||
func topText(hits []BlockHit) string {
|
||
if len(hits) == 0 {
|
||
return "(空)"
|
||
}
|
||
var parts []string
|
||
for i, h := range hits {
|
||
if i >= 3 {
|
||
break
|
||
}
|
||
parts = append(parts, truncText(h.Block.Text, 34))
|
||
}
|
||
out := ""
|
||
for i, p := range parts {
|
||
if i > 0 {
|
||
out += " | "
|
||
}
|
||
out += p
|
||
}
|
||
return out
|
||
}
|
||
|
||
func truncText(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|
||
|
||
var _ = time.Now
|
||
|
||
// ★ 探针判据本身的自检:judgeProbe 必须能分辨「答对」与实测的错答形态。
|
||
//
|
||
// 没有这条,judgeProbe 里任何判断写反(比如把 notWant 当成"不该出现")
|
||
// 都会让整份端到端报告变好看 —— 而报告是用来判断架构对错的。
|
||
func TestJudgeProbe_分辨错答形态(t *testing.T) {
|
||
okCase := []BlockHit{
|
||
{Block: MemoryBlock{Text: "值班室分机号|值班分机号=4324"}},
|
||
{Block: MemoryBlock{Text: "值班室分机号|旧分机号=4379"}},
|
||
}
|
||
// 实测的错答形态:旧号压在新号前面
|
||
reversed := []BlockHit{okCase[1], okCase[0]}
|
||
|
||
c := probeCase{want: "4324", notWant: "4379"}
|
||
if !judgeProbe(okCase, c) {
|
||
t.Error("新号在前应判通过")
|
||
}
|
||
if judgeProbe(reversed, c) {
|
||
t.Error("旧号压在新号前必须判失败 —— 这正是实测的错答形态")
|
||
}
|
||
if judgeProbe([]BlockHit{okCase[1]}, c) {
|
||
t.Error("结果里没有 want 应判失败")
|
||
}
|
||
if judgeProbe(nil, c) {
|
||
t.Error("空结果应判失败")
|
||
}
|
||
|
||
// 历史查询形态:notWant 为空,只查旧号,应判通过
|
||
if !judgeProbe([]BlockHit{okCase[1]}, probeCase{want: "4379"}) {
|
||
t.Error("只查旧号时应判通过(历史信息不是噪声)")
|
||
}
|
||
}
|