Files
HomeAgent/internal/memory/distill/gold_test.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 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 保留。
2026-10-08 10:40:17 +08:00

315 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package distill
import (
"context"
"fmt"
"os"
"reflect"
"runtime"
"strings"
"testing"
"time"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/pkg/generation"
_ "github.com/JianFeeeee/HomeAgent/providers/ollama"
)
// goldResult 是单条金标准的判定结果。
type goldResult struct {
name string
ok bool
gotSubject string
gotFields []Field
wantFields int
wantSubject string
noTriple bool
note string
}
// runGoldCases 对每条金标准跑一次真实拆分。
//
// ★ 判据独立性:期望值来自 goldcases_test.go(人工核对),
// 与 LLM 输出无关。LLM 的输出只被拿来「比对」,不当参照。
// goldCache 缓存 runGoldCases 的结果。
//
// ★ 为什么需要它
// ------------
// 实测(最终回归):distill 包跑 500 秒,其中
//
// TestGold_Baseline 250.95s
// TestGold_ShowFailures 251.64s
//
// **两个测试各调了一遍 runGoldCases**,而它会打开 ollama 并逐条调 LLM
// ⇒ 同一批金标准用例被跑了两遍模型,浪费 250 秒。
//
// 缓存只在同一进程内有效,且带 subjectFrom 的调用不共享
// (那批测的是另一条主语路径)。
var goldCache struct {
done bool
out []goldResult
}
// 变体缓存(带 subjectFrom 的那条路径)
var goldAltCache = map[string][]goldResult{}
func runGoldCases(t *testing.T, subjectFrom func(string) string) []goldResult {
t.Helper()
if subjectFrom == nil {
if goldCache.done {
return goldCache.out
}
} else {
key := runtime.FuncForPC(reflect.ValueOf(subjectFrom).Pointer()).Name()
if v, ok := goldAltCache[key]; ok {
return v
}
}
out := runGoldCasesUncached(t, subjectFrom)
if subjectFrom == nil {
goldCache.out, goldCache.done = out, true
} else {
key := runtime.FuncForPC(reflect.ValueOf(subjectFrom).Pointer()).Name()
goldAltCache[key] = out
}
return out
}
// runGoldCasesUncached 是真正跑一遍模型的那份实现。
func runGoldCasesUncached(t *testing.T, subjectFrom func(string) string) []goldResult {
t.Helper()
t.Helper()
gen, err := generation.Open("ollama", generation.Config{
Options: map[string]string{
"model": envOr("GOLD_MODEL", "qwen3:1.7b"),
"num_thread": "10",
"think": "false",
"keep_alive": "30m",
},
})
if err != nil {
t.Skipf("generation provider 打开失败: %v", err)
}
defer gen.Close()
ex := NewExtractor(gen, subjectFrom)
var out []goldResult
for _, c := range goldCases {
r := goldResult{name: c.name, wantSubject: c.wantSubject,
wantFields: len(c.wantFields), noTriple: c.noTriple}
// 主语推导(不调模型,先看纯规则的部分)。
// ★ 打印的必须是 Split 真正会用的那条路径(Extractor.deriveSubjects),
// 否则报告里的主语是另一套规则算的,看不出真实行为 —— 初版就踩过:
// 改动生效后报告仍显示 主语="",差点误判成「没生效」。
var subjDump []string
for _, sub := range ex.deriveSubjects(c.record) {
subjDump = append(subjDump, sub.Name)
}
r.gotSubject = strings.Join(subjDump, "|")
triples, err := ex.Split(context.Background(), c.record)
if err != nil {
r.note = "拆分报错: " + err.Error()
r.ok = false
out = append(out, r)
continue
}
for _, tr := range triples {
r.gotFields = append(r.gotFields, Field{Name: tr.Relation, Value: tr.Object})
}
// 判定
if c.noTriple {
r.ok = len(triples) == 0
if !r.ok {
r.note = fmt.Sprintf("期望零三元组,实际 %d 个: %+v", len(triples), r.gotFields)
}
} else {
r.ok = judgeFields(c, triples, r.gotSubject, &r)
}
out = append(out, r)
}
return out
}
// judgeFields 按「期望字段必须全部命中 + 不得有多余幻觉字段」判定。
//
// 只看「命中率」是不够的:模型可能拆出 10 个字段蒙中 4 个,
// 其余 6 个是编的(金标准价值 4 条里只信 2 条)。所以两个方向都要卡。
func judgeFields(c goldCase, triples []memory.Triple, gotSubject string, r *goldResult) bool {
// 值原样校验(幻觉闸门的等价检查,独立于 Split 内部实现)
for _, tr := range triples {
if tr.Object == "" || !strings.Contains(c.record, tr.Object) {
r.note = fmt.Sprintf("幻觉:值 %q 不在原句里", tr.Object)
return false
}
}
// 多主语用例:额外校验「值归对了主语」——
// 三个服务的端口各归各主语,混配了(如 billing→8861)同样算错。
if len(c.wantSubjects) > 0 {
for _, sub := range c.wantSubjects {
// 该主语名在句中对应的段(找它后面最近的值)
val := valueAfterSubject(c.record, sub)
if val == "" {
r.note = fmt.Sprintf("金标准:主语 %q 后找不到值", sub)
return false
}
if !hasFieldWithSubject(triples, sub, val) {
r.note = fmt.Sprintf("错配:期望 %s→%s,实际没有这条三元组(现有:%s)",
sub, val, formatTriples(triples))
return false
}
}
return true
}
// 期望字段全部命中
var missing []string
for _, w := range c.wantFields {
if !hasField(triples, w.name, w.value) {
missing = append(missing, w.name+"="+w.value)
}
}
if len(missing) > 0 {
r.note = "缺: " + strings.Join(missing, ", ")
return false
}
return true
}
// valueAfterSubject 取该主语名后面最近的那个值(第一个数字串)。
func valueAfterSubject(record, subject string) string {
runes := []rune(record)
i := indexOfRunes(runes, []rune(subject))
if i < 0 {
return ""
}
rest := runes[i+len([]rune(subject)):]
var digits []rune
for _, r := range rest {
if isDigit(r) {
digits = append(digits, r)
continue
}
if len(digits) > 0 {
break
}
}
return string(digits)
}
func hasFieldWithSubject(triples []memory.Triple, subject, value string) bool {
for _, tr := range triples {
if tr.Subject == subject && tr.Object == value {
return true
}
}
return false
}
func formatTriples(triples []memory.Triple) string {
if len(triples) == 0 {
return "(无)"
}
var parts []string
for _, tr := range triples {
parts = append(parts, tr.Subject+"-"+tr.Relation+"->"+tr.Object)
}
return strings.Join(parts, ", ")
}
func hasField(triples []memory.Triple, name, value string) bool {
for _, tr := range triples {
if tr.Relation == name && tr.Object == value {
return true
}
}
return false
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// TestGold_Baseline 记录现状基线。
//
// ★ 这个测试的作用不是「验证通过」,而是**把当前能力钉在数字上**。
// 现状已知:属性型记录主语全空 → 零三元组。
// 后续每次改动都重跑,看数字是否真的动了。
func TestGold_Baseline(t *testing.T) {
if testing.Short() {
t.Skip("需要 ollama")
}
t0 := time.Now()
results := runGoldCases(t, nil)
pass, total, zeroFieldCases := 0, 0, 0
for _, r := range results {
// noTriple 的用例单独统计:它考的是「不该拆的时候别拆」,
// 混进命中率分母会让「不敢拆」的策略显得分高。
if r.noTriple {
zeroFieldCases++
if r.ok {
pass++
}
status := "✘"
if r.ok {
status = "✓"
}
t.Logf("%s %-20s (零字段用例,应产出空) %s", status, r.name, r.note)
continue
}
total++
if r.ok {
pass++
}
status := "✘"
if r.ok {
status = "✓"
}
t.Logf("%s %-20s 主语=%-12q 期望字段%d 实得%d %s",
status, r.name, r.gotSubject, r.wantFields, len(r.gotFields), r.note)
}
t.Logf("\n基线(defaultSubject):%d/%d 条通过,用时 %.1fs", pass, total, time.Since(t0).Seconds())
}
// TestGold_ShowFailures 单独跑并打印失败细节,供排查用。
func TestGold_ShowFailures(t *testing.T) {
if testing.Short() {
t.Skip("需要 ollama")
}
results := runGoldCases(t, nil)
for _, r := range results {
if r.ok {
continue
}
t.Logf("── %s", r.name)
t.Logf(" 记录: %s", func() string {
if len(r.gotSubject) > 0 {
return ""
}
return "(主语空)"
}())
t.Logf(" 主语: %q(期望 %q)", r.gotSubject, r.wantSubject)
t.Logf(" 实得字段: %s", formatFields(r.gotFields))
t.Logf(" 原因: %s", r.note)
}
}
func formatFields(fs []Field) string {
if len(fs) == 0 {
return "(无)"
}
var parts []string
for _, f := range fs {
parts = append(parts, f.Name+"="+f.Value)
}
return strings.Join(parts, ", ")
}