Files
HomeAgent/internal/memory/distill/split_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

265 lines
9.0 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"
"errors"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/pkg/generation"
)
// fakeGen 返回固定文本的假 provider,用于把「模型行为」与「本包逻辑」分开测。
type fakeGen struct {
text string
truncated bool
schema string
err error
info generation.Info
// sawPrompt/sawSchema 记录请求,用于断言提示词与 schema 真的传出去了。
sawPrompt string
sawSchema string
sawTemp float64
}
func (f *fakeGen) Generate(_ context.Context, r generation.Request) (generation.Response, error) {
f.sawPrompt = r.Prompt
f.sawSchema = r.JSONSchema
f.sawTemp = r.Temperature
if f.err != nil {
return generation.Response{}, f.err
}
return generation.Response{Text: f.text, Truncated: f.truncated}, nil
}
func (f *fakeGen) Info() generation.Info {
if f.info.Model == "" {
return generation.Info{Model: "fake", SupportsJSONSchema: true}
}
return f.info
}
func (f *fakeGen) Close() {}
const rec = "第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月"
// ★ 正常路径:字段 → 三元组,主语锚定,值原样。
func TestSplit_字段转三元组(t *testing.T) {
g := &fakeGen{text: `{"fields":[
{"name":"告警规则数","value":"9条"},
{"name":"值班手册版本","value":"第4版"},
{"name":"容量预警","value":"70%"}]}`}
e := NewExtractor(g, nil)
tr, err := e.Split(context.Background(), rec)
if err != nil {
t.Fatalf("Split: %v", err)
}
if len(tr) != 3 {
t.Fatalf("期望 3 条三元组,实际 %d:%+v", len(tr), tr)
}
for _, x := range tr {
if x.Subject != "第183批" {
t.Errorf("主语应为 第183批,实际 %q", x.Subject)
}
if !strings.Contains(rec, x.Object) {
t.Errorf("值 %q 不在原文里 —— 闸门失效", x.Object)
}
if x.SentenceText != rec {
t.Errorf("应保留原句以便回到原文")
}
}
// 提示词与 schema 真的传出去了(这是小模型能用的唯一机制)
if !strings.Contains(g.sawSchema, `"fields"`) {
t.Errorf("JSONSchema 未传出,字段约束丢失:%q", g.sawSchema)
}
if !strings.Contains(g.sawPrompt, rec) {
t.Errorf("记录未进入提示词")
}
if g.sawTemp != 0 {
t.Errorf("温度应为 0(拆分要可复现),实际 %v", g.sawTemp)
}
}
// ★ 闸门 1:值不在原文 → 丢弃。这是唯一的幻觉防线。
func TestSplit_值不在原文则丢弃(t *testing.T) {
g := &fakeGen{text: `{"fields":[
{"name":"告警规则数","value":"9条"},
{"name":"容量预警","value":"999%"},
{"name":"排期","value":"12月"}]}`}
tr, err := NewExtractor(g, nil).Split(context.Background(), rec)
if err != nil {
t.Fatalf("Split: %v", err)
}
if len(tr) != 1 || tr[0].Object != "9条" {
t.Fatalf("只应保留原样值 9条,实际 %+v", tr)
}
}
// ★ 闸门 2:维度名归一。不归一就建不了边。
func TestSplit_维度名归一(t *testing.T) {
g := &fakeGen{text: `{"fields":[
{"name":"停机","value":"7分"},
{"name":"停机时间","value":"9分"},
{"name":"停机时长","value":"4分"}]}`}
tr, err := NewExtractor(g, nil).Split(context.Background(), "第200批 停机7分")
if err != nil {
t.Fatalf("Split: %v", err)
}
rels := map[string]bool{}
for _, x := range tr {
rels[x.Relation] = true
}
if !rels["停机时长"] {
t.Fatalf("三种同义写法应归一为「停机时长」,实际 %v", rels)
}
}
// ★ 闸门 3:无主语 → 不写脏三元组。
func TestSplit_无主语不产出(t *testing.T) {
g := &fakeGen{text: `{"fields":[{"name":"容量预警","value":"70%"}]}`}
tr, err := NewExtractor(g, nil).Split(context.Background(), "就那么回事")
if err != nil {
t.Fatalf("Split: %v", err)
}
if len(tr) != 0 {
t.Fatalf("无批次号作主语时不应产出三元组(宁可少记不写脏),实际 %+v", tr)
}
}
// 「没有可拆字段」是正常结果,不是错误 —— 调用方要能区分它与「模型没跑」。
func TestSplit_空字段不是错误(t *testing.T) {
g := &fakeGen{text: `{"fields":[]}`}
tr, err := NewExtractor(g, nil).Split(context.Background(), "第129~143批均仅评审通过")
if err != nil {
t.Fatalf("空字段不应报错:%v", err)
}
if len(tr) != 0 {
t.Fatalf("期望 0 条,实际 %d", len(tr))
}
}
// provider 不支持 schema 时必须显式失败,不能静默降级成自由文本。
func TestSplit_不支持schema显式失败(t *testing.T) {
g := &fakeGen{
text: `{"fields":[]}`,
info: generation.Info{Model: "no-schema", SupportsJSONSchema: false},
}
_, err := NewExtractor(g, nil).Split(context.Background(), rec)
if err == nil || !errors.Is(err, generation.ErrSchemaUnsupported) {
t.Fatalf("应报 ErrSchemaUnsupported,实际 %v", err)
}
}
// 模型调用失败 → 报错(调用方据此回退 jieba 路径或重试)。
func TestSplit_模型失败透传错误(t *testing.T) {
g := &fakeGen{err: errors.New("connection refused")}
_, err := NewExtractor(g, nil).Split(context.Background(), rec)
if err == nil || !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("应透传模型错误,实际 %v", err)
}
}
// 非法 JSON → 报错;截断要标出来(调用方可选择重试而非丢弃)。
func TestSplit_非法JSON与截断(t *testing.T) {
g := &fakeGen{text: `{"fields":[{"name":"a"`}
_, err := NewExtractor(g, nil).Split(context.Background(), rec)
if err == nil {
t.Fatal("非法 JSON 应报错")
}
g2 := &fakeGen{text: `{"fields":[{"name":"a","value":"1"}`, truncated: true}
_, err2 := NewExtractor(g2, nil).Split(context.Background(), rec)
if err2 == nil || !strings.Contains(err2.Error(), "truncated") {
t.Fatalf("截断应标明(便于调用方重试),实际 %v", err2)
}
}
// 字段名超长(模型把整句当字段名)→ 丢弃。
func TestSplit_字段名超长丢弃(t *testing.T) {
long := "第183批周三凌晨1点·停机4分·回滚v2.29.5·灰度10%观察48分后放50%"
g := &fakeGen{text: `{"fields":[{"name":"` + long + `","value":"4分"}]}`}
tr, err := NewExtractor(g, nil).Split(context.Background(), rec)
if err != nil {
t.Fatalf("Split: %v", err)
}
if len(tr) != 0 {
t.Fatalf("超长字段名应丢弃,实际 %+v", tr)
}
}
// defaultSubject 覆盖多种批次形态。
func TestDefaultSubject(t *testing.T) {
cases := map[string]string{
"第183批 告警规则9条": "第183批",
"第128/130/131批同为33日": "第128/130/131批",
"第129~143批共十五个版本": "第129~143批",
"没有批次号的纯句子": "",
"": "",
}
for in, want := range cases {
if got := defaultSubject(in); got != want {
t.Errorf("defaultSubject(%q)=%q,期望 %q", in, got, want)
}
}
}
// 归一表里每个别名都要真的归一到规范名(防止有人加词时写错方向)。
func TestNormalizeDimension_别名表方向正确(t *testing.T) {
for alias, canonical := range dimensionAliases {
if got := NormalizeDimension(alias); got != canonical {
t.Errorf("NormalizeDimension(%q)=%q,期望 %q", alias, got, canonical)
}
// 规范名自身也必须稳定(幂等)
if got := NormalizeDimension(canonical); got != canonical {
t.Errorf("规范名 %q 不幂等:NormalizeDimension=%q", canonical, got)
}
}
}
// ★ 归一的**包含式路径**:模型输出带后缀的字段名(「停机时长(分钟)」)。
// 上一版测试只覆盖精确匹配,去掉精确分支后仍能通过 —— 包含式分支当时被
// `_ = canonical` 死代码掩盖着。这条测试专门盯它。
func TestNormalizeDimension_包含式最长匹配(t *testing.T) {
cases := map[string]string{
"停机时长(分钟)": "停机时长",
"值班手册第4版": "值班手册版本",
"回滚版本v2.29.5": "回滚版本",
"未知维度XYZ": "未知维度XYZ", // 不命中 → 原样返回,不丢弃
}
for in, want := range cases {
if got := NormalizeDimension(in); got != want {
t.Errorf("NormalizeDimension(%q)=%q,期望 %q", in, got, want)
}
}
}
// ★ 自环过滤:字段名或值等于主语时丢弃。
//
// 起因(实测):提示词里写「批次号是主语,不要单独列成字段」这条**负向指令**
// 会让 qwen3:1.7b 把所有字段都当成批次号相关而丢弃(4 字段 → 0 字段)。
// 去掉该指令后它改为主动拆出「批次号」字段,于是产生
// (第183批) -[批次号]-> 第183批 这种自环。过滤必须放在 Go 侧,
// 不能指望模型听懂否定指令。
func TestSplit_过滤主语自环(t *testing.T) {
g := &fakeGen{text: `{"fields":[
{"name":"批次号","value":"第183批"},
{"name":"批号","value":"第183批"},
{"name":"容量预警","value":"70%"}]}`}
tr, err := NewExtractor(g, nil).Split(context.Background(), rec)
if err != nil {
t.Fatalf("Split: %v", err)
}
for _, x := range tr {
if x.Subject == x.Object {
t.Errorf("自环未过滤: %+v", x)
}
if x.Relation == "第183批" || x.Relation == "批次号" || x.Relation == "批号" {
t.Errorf("主语被当成字段名: %+v", x)
}
}
if len(tr) != 1 || tr[0].Object != "70%" {
t.Fatalf("只应保留非自环字段,实际 %+v", tr)
}
}