Files
HomeAgent/cmd/homed-graph-distill/main.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

383 lines
14 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.

// Command homed-graph-distill 把存量实体跑字段拆分、落成块节点。
//
// 背景
// ------
// MigrateLegacyTextEntities 把 entities 迁成了块(a3e4c1c/09f31e2),但迁移
// 刻意**不做内容改写** —— 迁出来的块仍是整句:
//
// 「下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周」
//
// 实测这种形态在值覆盖维度上答错(真实 chineseclip,真库 188 块):
//
// 查询「值班室分机号是多少」→ top1 = 旧号 4379(score 0.8127)
// 新号 4324 那条进不了 top8
//
// 归因实验(改写形态重算向量)证实病根是**长复合句**把新旧值混在一句里:
//
// 现状·旧号(短句) cos=0.8127
// 现状·新号(长复合句)cos=0.7327 ← 正确但排第二
// 改写·新号(短句) cos=0.9284 ← 形态一变跃升 0.20
//
// 本命令做那一步改写:LLM 拆字段 → 「<主语>|<维度>=<值>」块 → 向量。
// 拆完的块才带得到仲裁(arbitration.go 需要 主语|维度=值 才解析得出)。
//
// 安全设计(与 homed-graph-migrate / homed-kb-migrate 同款)
// --------------------------------------------------------
// 1. **默认只报告**(-apply 才真写)。
// 2. 写入前自动快照 graph.db。
// 3. **不删任何东西**:整句块保留(它们是原始素材,回退路径)。
// 拆分块是新增的独立块(ID 由内容派生,重复跑幂等)。
// 4. 拆不动就记「零字段」并跳过,不编造。
//
// 用法:
//
// homed-graph-distill -db /data/homeagent/memory/graph.db # 报告
// homed-graph-distill -db ... -apply # 拆分落块
// homed-graph-distill -db ... -apply -limit 20 # 先试 20 条
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/distill"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
"github.com/JianFeeeee/HomeAgent/pkg/embedding"
"github.com/JianFeeeee/HomeAgent/pkg/generation"
_ "github.com/JianFeeeee/HomeAgent/providers/chineseclip"
_ "github.com/JianFeeeee/HomeAgent/providers/ollama"
)
func main() {
dbPath := flag.String("db", "", "graph.db 路径(必填)")
apply := flag.Bool("apply", false, "真正拆分落块(缺省只报告)")
limit := flag.Int("limit", 0, "最多处理多少条实体,0 = 不限")
// minLen 过滤掉太短的实体(标题类如「order-gw 运维进展」「每天」
// 本来就没有可拆字段,跑它们只会浪费模型时间并让报告全是「零字段」)。
//
// ★ 实测(真库):前 8 条实体全是这类短标题,拆分结果 8/8 零字段 ——
// 那是**正确**结果,不是故障。不加这个过滤会误以为拆分坏了。
minLen := flag.Int("min-len", 18, "实体名最小字符数,短于此的跳过")
embedProvider := flag.String("embed-provider", "chineseclip", "向量 provider 名")
modelDir := flag.String("model-dir", "", "向量 provider 的模型目录")
genModel := flag.String("gen-model", "qwen3:1.7b", "拆分用的生成模型")
flag.Parse()
if *dbPath == "" {
fmt.Fprintln(os.Stderr, "错误:必须指定 -db <graph.db 路径>")
os.Exit(2)
}
if _, err := os.Stat(*dbPath); err != nil {
fmt.Fprintf(os.Stderr, "错误:打不开 %s: %v\n", *dbPath, err)
os.Exit(2)
}
db, err := memory.NewGraphDB(*dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:打开图库失败: %v\n", err)
os.Exit(1)
}
defer db.Close()
entities, err := loadEntities(db, *limit, *minLen)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:读取实体失败: %v\n", err)
os.Exit(1)
}
if len(entities) == 0 {
fmt.Println("库里没有 entities,无需拆分。")
return
}
stats, _ := db.BlockVectorStats()
fmt.Printf("待处理实体 %d 条;现有块 %d(带向量 %d)\n", len(entities), stats.Total, stats.WithVector)
// ★ 只对**有主语**的实体跑拆分。
//
// 实测(生产库 429 条实体):
//
// 有主语 5 条(1%),无主语 424 条(99%)
// 路径/URL 38 条 全无主语
// 符号开头 2 条 全无主语
// 其他 387 条 98.7% 无主语
//
// 而 ha-c 的主力句式「第114批…」「值班室分机号 4324」在生产库
// **一条都没有**(含「第N批」0 条、含「分机」0 条)。
//
// 无主语的实体跑 LLM 是纯浪费:Split 里
// if len(subjects) == 0 { continue }
// 会把 LLM 已经拆对的字段**全部丢弃**(实测 60 条 / 837 秒 / 0 产出)。
//
// ⇒ 这里先过滤,并在报告里说明跳过了多少 —— 让「没产出」这件事
// 变成可解释的数字,而不是又一次「零字段 N 条」的谜。
withSubj := make([]legacyEntity, 0, len(entities))
noSubj := make([]legacyEntity, 0, len(entities))
for _, e := range entities {
if len(distill.DeriveSubjects(e.name)) > 0 {
withSubj = append(withSubj, e)
} else {
noSubj = append(noSubj, e)
}
}
if len(noSubj) > 0 {
fmt.Printf("\n★ 跳过无主语实体 %d 条(%.0f%%):Split 的第五道闸门"+
"「无主语不写悬空属性」会把 LLM 已拆对的字段全部丢弃。\n",
len(noSubj), float64(len(noSubj))/float64(len(entities))*100)
for i, e := range noSubj {
if i >= 3 {
fmt.Printf(" … 另 %d 条\n", len(noSubj)-3)
break
}
fmt.Printf(" · %s\n", truncForLog(e.name, 56))
}
}
entities = withSubj
if len(entities) == 0 {
fmt.Println("\n没有可拆分的实体(有主语的为 0)。" +
"这些内容应保持整句块形态 —— 迁移已把它们写成 legacy-entity 块。")
return
}
fmt.Printf("\n实际处理 %d 条(其余 %d 条无主语,保持整句块形态)\n",
len(entities), len(noSubj))
// 打开两个 provider
gen, err := generation.Open("ollama", generation.Config{
Options: map[string]string{
"model": *genModel, "num_thread": "10",
"think": "false", "keep_alive": "30m",
}})
if err != nil {
fmt.Fprintf(os.Stderr, "错误:打开生成 provider 失败: %v\n", err)
os.Exit(1)
}
defer gen.Close()
adapter, err := buildEmbedder(*embedProvider, *modelDir)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:打开向量 provider 失败: %v\n", err)
os.Exit(1)
}
defer adapter.Close()
embed := func(text string) ([]float64, string) {
vec, err := adapter.VectorizeDense(text)
if err != nil {
return nil, ""
}
return vec, adapter.Fingerprint()
}
ex := distill.NewExtractor(gen, nil)
// ── 干跑:先只统计,不写 ──
// ★ 拆一次,落库复用同一份结果。
//
// 初版在 -apply 分支里又调了一次 ex.Blocks —— 每条记录跑两遍模型。
// 两个后果,都实测撞到了:
// 1. 耗时翻倍:真库 188 条在 CPU 上第一轮就超了 2400s 超时上限。
// 2. **结果可能不一致**:qwen3:1.7b 在 Temperature=0 下仍有波动,
// 干跑报告的字段与落库的实际字段可能不是同一批 ——
// 报告与数据对不上,整个命令的可信度就没了。
//
// 所以 payload 在这里算一次并保留,-apply 直接用它落库。
type splitResult struct {
payload *distill.BlockPayload
err error
}
results := make([]splitResult, len(entities))
triples := 0
zeroField := 0
failed := 0
t0 := time.Now()
for i, e := range entities {
payload, err := ex.Blocks(context.Background(), e.name)
if err != nil {
results[i] = splitResult{err: err}
failed++
} else {
results[i] = splitResult{payload: payload}
n := len(payload.Fields)
if n == 0 {
zeroField++
} else {
triples += n
}
}
// ★ 进度必须实时可见:这活儿在 CPU 上要十几分钟,
// 没有进度就只能等超时(第一版就是这么废掉的 —— 2400s 超时,
// 跑完的 35 条结果全丢)。每 10 条或每 20 秒打一行。
if (i+1)%10 == 0 || i+1 == len(entities) {
el := time.Since(t0).Seconds()
rate := float64(i+1) / el
eta := 0.0
if rate > 0 {
eta = float64(len(entities)-i-1) / rate
}
fmt.Printf(" [%d/%d] %s 用时%.0fs 预计剩余%.0fs\n",
i+1, len(entities), truncForLog(e.name, 26), el, eta)
}
// ★ 前 8 条无条件打印 —— 包括「报错」和「零字段」两种情况。
//
// 原版只在 `payload != nil` 时打印字段,于是:
// - 报错 → payload 为 nil → 什么都不打印
// - 零字段 → Fields 为空 → 什么都不打印
// 而生产蒸馏实测「零字段 60 条」,屏幕上**一行内容都没有**
// —— 判据能显示 0,是因为它只看得到 0。
//
// 实测故障根因:ollama schema 污染让模型返回
// {"fields":[{"name":"text","value":"commit f91b27a…"}]}
// 这种「字段名=字段类型、值=整句」的垃圾,被三道闸门全拦掉 ⇒ 零字段。
// 那个原始响应就藏在 err 里(split.go 现在会带上),必须打出来。
if i < 8 {
switch {
case results[i].err != nil:
fmt.Printf(" ✘ %s\n", truncForLog(results[i].err.Error(), 400))
case results[i].payload != nil && len(results[i].payload.Fields) > 0:
for _, f := range results[i].payload.Fields {
fmt.Printf(" %s|%s=%s\n", f.Subject, f.Dimension, f.Value)
}
default:
fmt.Printf(" · 零字段(LLM 返回了合法 JSON 但没有字段)\n")
}
}
}
// ★ 报错与零字段必须分开报 —— 它们的根因完全不同:
// 报错 = 工具链故障(JSON 解析失败、模型返回垃圾)
// 零字段 = 数据事实(确实没有可拆字段)
// 实测 60 条零字段的根因是前者(ollama schema 污染),
// 但旧统计把它们混在一个数字里 ⇒ 看不出根因。
fmt.Printf("\n拆解结果:%d 条实体 → %d 个字段块;零字段 %d 条,报错 %d 条\n",
len(entities), triples, zeroField, failed)
if !*apply {
fmt.Println("\n这是报告模式(缺省)。加 -apply 真正落块。")
fmt.Println("注意:整句块保留不动;拆分块是新增的独立块(内容派生 ID,重复跑幂等)。")
return
}
// ── 快照 ──
backup := fmt.Sprintf("%s.bak-distill-%s", *dbPath, time.Now().Format("20060102-150405"))
if err := copyFile(*dbPath, backup); err != nil {
fmt.Fprintf(os.Stderr, "错误:快照失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("\n已快照:%s\n", backup)
// ── 落块 ──
writeStart := time.Now()
written, zeroWritten, writeFailed := 0, 0, 0
for i, e := range entities {
r := results[i]
if r.err != nil {
writeFailed++
continue
}
if len(r.payload.Fields) == 0 {
zeroWritten++
continue
}
// 复用干跑阶段算出的 payload(不再调模型)
n, err := distill.WritePayload(context.Background(), db, r.payload, embed)
if err != nil {
fmt.Fprintf(os.Stderr, " 写入失败 %.40s: %v\n", e.name, err)
writeFailed++
continue
}
written += n
if (i+1)%10 == 0 || i+1 == len(entities) {
fmt.Printf(" 落库 [%d/%d] 已写 %d 块\n", i+1, len(entities), written)
}
}
elapsed := time.Since(writeStart)
after, _ := db.BlockVectorStats()
fmt.Printf("\n落块完成(%.1fs):写入字段块 %d,零字段跳过 %d,写入失败 %d\n",
elapsed.Seconds(), written, zeroWritten, writeFailed)
fmt.Printf("块总数 %d → %d(带向量 %d)\n", stats.Total, after.Total, after.WithVector)
// ★ 蒸馏加了新块,中心向量必须跟着重建。
//
// 中心是「全库带向量块的均值」,块集变了它就过期。
// 迁移后建的那份不覆盖新蒸馏的块 —— 而新块恰恰是**拆分块**
// (格式 <主语>|<维度>=<值>),它们与整句块分布不同,
// 混在一个中心里会把中心拉偏,反而削弱区分度。
if *embedProvider != "" {
if _, anomalous, err := db.RebuildCentroid(adapter.Fingerprint()); err != nil {
fmt.Fprintf(os.Stderr, "警告:重建中心失败(召回将无区分度): %v\n", err)
} else if anomalous {
fmt.Println("已重建中心向量(检测到各向异性,已启用双边中心化)")
} else {
fmt.Println("已重建中心向量")
}
}
// 抽样展示落库后的块文本
if b, err := db.MemoryBlocks(); err == nil && len(b) > 0 {
fmt.Println("\n抽样块文本(应有 <主语>|<维度>=<值> 形态):")
shown := 0
for _, x := range b {
if x.Source == "distill" {
fmt.Printf(" %s\n", x.Text)
shown++
if shown >= 5 {
break
}
}
}
}
fmt.Println("\n下一步:跑 memory_recall 端到端,对比仲裁前后的召回效果。")
}
type legacyEntity struct {
id int64
name string
}
func loadEntities(db *memory.GraphDB, limit, minLen int) ([]legacyEntity, error) {
out, err := db.LegacyEntities(limit, minLen)
if err != nil {
return nil, err
}
var res []legacyEntity
for _, e := range out {
res = append(res, legacyEntity{id: e.ID, name: e.Name})
}
return res, nil
}
func buildEmbedder(name, modelDir string) (*vector.ProviderAdapter, error) {
opts := map[string]string{}
if modelDir != "" {
opts["model_dir"] = modelDir
}
p, err := embedding.Open(name, embedding.Config{Options: opts})
if err != nil {
return nil, err
}
adapted, err := vector.AdaptProvider(p)
if err != nil {
p.Close()
return nil, err
}
return adapted, nil
}
func truncForLog(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}