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 保留。
265 lines
9.0 KiB
Go
265 lines
9.0 KiB
Go
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)
|
||
}
|
||
}
|