Files
HomeAgent/cmd/homed-graph-migrate/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

339 lines
13 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-migrate 把存量 entities 迁移成块节点(entities 退场第 3 步)。
//
// 背景
// ----
// entities 是旧形态:只有 name/type/mention_count,没有向量。块节点
// (memory_blocks) 带 vector/fingerprint/modality,是图节点的正统形态。
// MigrateLegacyMediaEntities 已经把媒体类实体迁过一次(internal/memory/migrate.go),
// 本命令处理剩下的文本类实体。
//
// 安全设计(与 homed-kb-migrate 同款)
// --------------------------------------
// 1. **默认只报告**(-apply 才真迁移)。迁移本身是单事务、全成功或全回滚,
// 但「迁完召回是否变好」只有跑过才知道。
// 2. **不删旧表**:迁移只加块与边,entities 原样保留。验证通过后由人工
// 另跑清理(下一步)。这样「效果不如预期」时的回滚就是什么都不做。
// 3. 迁移前自动快照 graph.db 到 <db>.bak-<时间戳>。
// 4. **向量是可选的**:-embed-provider 缺省则不带向量迁移(结构正确、
// 不参与向量召回)。可后续用 -backfill 单独回填,不必重跑迁移。
//
// 用法:
//
// homed-graph-migrate -db /data/homeagent/memory/graph.db # 报告
// homed-graph-migrate -db ... -apply # 迁移(不带向量)
// homed-graph-migrate -db ... -apply -embed chineseclip -model-dir ... # 带向量迁移
package main
import (
"flag"
"fmt"
"os"
"time"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
"github.com/JianFeeeee/HomeAgent/pkg/embedding"
_ "github.com/JianFeeeee/HomeAgent/providers/chineseclip"
_ "github.com/JianFeeeee/HomeAgent/providers/qwen3vl"
)
func main() {
dbPath := flag.String("db", "", "graph.db 路径(必填)")
apply := flag.Bool("apply", false, "真正迁移(缺省只报告)")
provider := flag.String("embed-provider", "", "向量 provider 名(chineseclip / qwen3vl);缺省则不带向量")
modelDir := flag.String("model-dir", "", "provider 的模型目录")
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()
// ── 报告现状 ──
before, err := legacyStats(db)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:读取现状失败: %v\n", err)
os.Exit(1)
}
stats, err := db.BlockVectorStats()
if err != nil {
fmt.Fprintf(os.Stderr, "错误:读取块向量状态失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("迁移前:%s\n", before)
fmt.Printf(" 块 %d(其中带向量 %d),块边 %d\n",
stats.Total, stats.WithVector, before.BlockEdges)
if before.Entities == 0 {
fmt.Println("\n库里没有 entities,无需迁移。")
return
}
// ── 准备 embed(可选)──
var embed memory.EntityEmbedder
// embedFP 记下 provider 指纹:迁移后重建中心要用(见文件末尾说明)。
var embedFP string
if *provider != "" {
adapter, err := buildEmbedder(*provider, *modelDir)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:打开 provider %q 失败: %v\n", *provider, err)
fmt.Fprintln(os.Stderr, "(不带向量也能迁移:结构正确,向量可后续回填)")
os.Exit(1)
}
defer adapter.Close()
embed = func(name string) ([]float64, string) {
vec, err := adapter.VectorizeDense(name)
if err != nil {
return nil, ""
}
return vec, adapter.Fingerprint()
}
embedFP = adapter.Fingerprint()
fmt.Printf("向量 provider:%s(指纹 %s,维度 %d)\n",
*provider, embedFP, adapter.Dim())
} else {
fmt.Println("向量 provider:未指定 → 块不带向量迁移(可后续回填)")
}
if !*apply {
fmt.Printf("\n这是报告模式(缺省)。加 -apply 真正迁移。\n")
fmt.Printf("预计产出:块 %d,块边 %d(实体名同时作为句子,句子--contains-->块)\n",
before.Entities, before.RelationsTotal)
// ★ 口径说明:块边数是**按 relations 全表**估计的,与迁移同口径。
// 孤儿关系(两端实体已不存在)会被跳过,实际产出可能略少。
fmt.Println(" (块边按 relations 全表计,孤儿关系会被跳过,实际可能略少)")
fmt.Printf("旧表 entities/relations/sentences 保持不动,验证通过后再单独清理。\n")
return
}
// ── 快照 ──
backup := fmt.Sprintf("%s.bak-%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)
// ── 执行 ──
t0 := time.Now()
res, err := db.MigrateLegacyTextEntities(embed)
if err != nil {
fmt.Fprintf(os.Stderr, "\n迁移失败(已整体回滚): %v\n", err)
fmt.Fprintf(os.Stderr, "快照可用于人工核对:%s\n", backup)
os.Exit(1)
}
// ★★ scene_refs 迁移必须与块迁移**同一次**完成(2026-10-04)
//
// scene_refs 里的 kind='entity' / 'relation' 指向旧表的行。
// 块迁移完成但 scene_refs 没迁 ⇒ 场景式记忆全部指向不存在的对象,
// 而 RecallByScene 只 JOIN 块/边 ⇒ **静默召回空**。
//
// ★ 之前它是独立函数(只在测试里被调过),
// 于是生产迁移留下 718 条旧 kind 引用 —— 验证脚本当场抓到。
scN, err := db.MigrateSceneRefsToBlocks()
if err != nil {
fmt.Fprintf(os.Stderr, "\nscene_refs 迁移失败(已整体回滚): %v\n", err)
fmt.Fprintf(os.Stderr, "快照可用于人工核对:%s\n", backup)
os.Exit(1)
}
fmt.Printf("\n scene_refs:迁移 %d 条(kind 分布迁移后应为 block/edge/document)\n", scN)
after, err := legacyStats(db)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:读取迁移后状态失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("\n迁移完成(%.1fs)\n", time.Since(t0).Seconds())
fmt.Printf(" 句子 +%d,块 +%d,边 +%d\n", res.Sentences, res.Blocks, res.Edges)
if res.SkippedOrphan > 0 {
fmt.Printf(" 跳过孤儿关系 %d 条(端点实体为空名;关系信息仍在句子里)\n", res.SkippedOrphan)
}
if res.SkippedNoVec > 0 {
fmt.Printf(" 无向量块 %d 个(可后续回填;本次迁移不影响它们按边查取)\n", res.SkippedNoVec)
}
fmt.Printf("\n迁移后:%s\n", after)
// ★ 迁移后必须重建中心向量,否则召回没有区分度。
//
// 实测(生产快照 1294 实体 / 1391 块):迁移后不建中心,
// 召回分数挤成一团 —— 查询「本机 13010 端口」返回的 8 条
// 全在 0.84~0.90,含精确串 13010 的目标块连 top2000 都进不去:
//
// 1. 0.9006 本机 443 按 SNI 透传到 192.168.2.106:3080
// 2. 0.8712 ACP回环调用自身12001
// 3. 0.8611 MAR/MDR与ALU不直通必须经CPU内部总线
//
// 而 RebuildCentroid 此前**只有测试在调用**,生产路径没有调用者
// —— ha-c 的中心是手工测试时留下的,生产侧从来没有过。
if embedFP != "" {
if _, anomalous, err := db.RebuildCentroid(embedFP); err != nil {
fmt.Fprintf(os.Stderr, "警告:重建中心失败(召回将无区分度): %v\n", err)
} else if anomalous {
fmt.Println("已重建中心向量(检测到各向异性,已启用双边中心化)")
} else {
fmt.Println("已重建中心向量(各向异性不显著,仍保存以便后续块增多时复用)")
}
} else {
fmt.Println("未指定向量 provider,跳过中心重建" +
"(无向量时中心无意义;回填向量后请重跑本命令或手工 RebuildCentroid)")
}
fmt.Println("\n下一步:验证召回改善(memory_recall 的跨维度探针),确认后再清理旧表。")
}
// buildEmbedder 按名字打开向量 provider 并适配成 VectorizeDense 形态。
//
// 走 pkg/embedding 注册表(内核无感边界):换模型只改 -embed-provider,
// 本命令不含任何 provider 名分支。
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
}
type legacyStat struct {
Entities, Relations, Blocks, BlockEdges int
// RelationsTotal 是 relations 全表数(不过滤 status)。
//
// 迁移转换全表(MigrateLegacyTextEntities 按 ORDER BY id 遍历),
// 而 Introspect 的 relation_count 是**活跃数**(status='active'),
// 两者语义不同:报告必须用这个,否则「预计产出」会少报
// (生产库实测 966 vs 980)。
RelationsTotal int
}
func (s legacyStat) String() string {
return fmt.Sprintf("实体 %d,关系 %d,块 %d,块边 %d",
s.Entities, s.Relations, s.Blocks, s.BlockEdges)
}
// legacyStats 汇总迁移前后的库规模。
//
// 复用既有的 Introspect(实体/关系统计)与 BlockVectorStats(块向量状态),
// 不新增统计 API:句数与块边数用只读查询补齐。
func legacyStats(db *memory.GraphDB) (legacyStat, error) {
var s legacyStat
// ★★ 全部直查旧表,不用 Introspect(2026-10-04)
//
// Introspect 的 entity_count / relation_count 在读侧切块之后
// 数的是**块侧**(块数、活跃块边数)。而本命令的全部意义是
// 「旧表还有多少没迁走」⇒ 口径必须是旧表。
//
// ★ 实测踩到的后果(生产快照):
//
// 迁移前:实体 1294,关系 0,块 98,块边 97
//
// 「关系 0」是假的 —— 旧 relations 表里明明有 980 行
// (active 966 / deleted 14)。而报告照抄这个数,
// 于是「预计产出:块 1294,块边 0」。
//
// ★★ 那句话会让运维以为「旧关系早迁完了」,从而跳过迁移 ——
// 而实际上 980 条关系一条都没迁。这是**诊断误导**,
// 比报错危险:报错了会有人查,误导了没人会。
// ★ 错误一律**返回**,不吞。
//
// 迁移报告的价值全在数字准确上;一个被静默吞掉的查询错误
// 会变成「库里没有关系」,而迁移会照跑,把该迁的漏掉。
for _, spec := range []struct {
table string
where []string
dst *int
}{
{"entities", nil, &s.Entities},
{"relations", []string{"status = 'active'"}, &s.Relations},
// 迁移报告用全表数(与 MigrateLegacyTextEntities 的遍历范围一致)
{"relations", nil, &s.RelationsTotal},
} {
v, err := legacyCount(db, spec.table, spec.where...)
if err != nil {
return s, err
}
*spec.dst = v
}
edges, err := db.MemoryBlockEdges()
if err != nil {
return s, err
}
s.BlockEdges = len(edges)
bs, err := db.BlockVectorStats()
if err != nil {
return s, err
}
s.Blocks = bs.Total
return s, nil
}
// countSentences 通过块边反推句子数不可靠(迁移前无块),
// 而 Introspect 不含句数 —— 迁移报告里句数不是关键指标,
// 因此不新增统计 API,只用实体/关系/块三个数(够判断迁移效果)。
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
// legacyCount 直查旧表的行数。
//
// ★ 为什么不用 Introspect:它数的是块侧(读侧切块后),
//
// 而迁移报告要的是「旧表还剩多少」。
//
// where 为空表示不加过滤。
func legacyCount(db *memory.GraphDB, table string, where ...string) (int, error) {
// 表名是内部常量调用方给的,不是外部输入;
// 仍用白名单校验 —— 迁移命令会拿用户给的 -db 路径,
// 而 SQL 拼接不该留任何口子。
switch table {
case "entities", "relations", "sentences":
default:
return 0, fmt.Errorf("legacyCount: 不支持的表 %q", table)
}
q := "SELECT COUNT(*) FROM " + table
for i, w := range where {
// ★ 第一个条件要 WHERE,后续才 AND。
//
// 我写成统一的 `q += " AND " + w`,于是第一条条件产生
// `FROM relations AND status='active'` —— SQL 语法错误。
//
// ★★ 而错误被调用方的 `if err == nil` 吞掉,
// 于是报告打出「关系 0」:一个**语法错误**伪装成
// 「库里没有关系」。
// 诊断报告里的假 0 比报错危险 —— 报错会有人查,0 不会。
if i == 0 {
q += " WHERE " + w
} else {
q += " AND " + w
}
}
n, err := db.LegacyRowCount(q)
if err != nil {
return 0, fmt.Errorf("legacyCount(%s): %w", table, err)
}
return n, nil
}