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 保留。
339 lines
13 KiB
Go
339 lines
13 KiB
Go
// 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
|
||
}
|