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 保留。
315 lines
8.3 KiB
Go
315 lines
8.3 KiB
Go
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, ", ")
|
||
}
|