feat(kb-migrate): 存量知识库目录名迁移工具 + 启动期只报告

背景:旧版 Add 整串 sanitize 名字、逐段 sanitize 建目录,留下 tech/_go_/note、
Tech/Upper、a/b with space 这类「知识名与盘上目录不一致」的目录。修复后
normalizeName 要求二者逐字一致,故需一次性改名。

- 迁移逻辑放在 internal/knowledge/migrate_names.go(PlanMigration/
  ApplyMigration),CLI 与 homed 启动**共用同一份实现**,避免口径漂移。
- 三条硬约束:
  1. 默认只报告(-apply 才真改名)—— 批量 os.Rename 不可逆。
  2. 检出目标名冲突(两条迁到同一目标 / 目标已存在)则**整批拒绝**,
     不做部分迁移:半迁移状态比不迁移更难收拾。
  3. 逐条失败不中断,最后统一报告;执行前重查冲突(计划生成与执行之间
     可能有人改过盘上状态),并校验目标不越出知识根。
- homed 启动在 initKnowledgeStore **之前**调 initKnowledgeMigration:改名后
  扫盘一次到位,避免先以旧名建索引再改名造成内存键与盘上目录短暂不一致。
  defaultApply=false ⇒ 启动只扫描+报告+打印可执行命令行,不替人决定。
  单次改名上限 200 条,防失控目录规模拖住启动。

实测:报告模式零改动;冲突场景整批拒绝且盘上原封不动;迁移后 Store
正确载入 4 条并可按规范名逐条删除。
This commit is contained in:
JianFeeeee
2026-09-26 11:37:22 +08:00
parent cc353765d6
commit d10ab3fbf0
3 changed files with 158 additions and 0 deletions

View File

@ -0,0 +1,100 @@
// Command homed-kb-migrate 迁移存量知识库的目录名到规范名。
//
// 背景:旧版 Add 对名字**整串** sanitize、对路径**逐段** sanitize,
// 于是知识名(内存键 / LLM 可见的名字)与盘上目录从第一次落盘起就对不上。
// 典型残留:
//
// tech/_go_/note 分类段内的空格未被 TrimSpace 掉
// Tech/Upper 未小写化
// a/b with space 空格未替换成下划线
//
// 迁移把它们重命名到规范名,使三者一致。
//
// 安全设计:
// 1. **默认只报告**(-apply 才真改名)。批量 os.Rename 不可逆。
// 2. 检出目标名冲突则**整批拒绝**,不做部分迁移——半迁移状态比不迁移更难收拾。
// 3. 单条失败不中断整体,最后统一报告;执行前再查一次目标越界。
//
// 与 homed 启动时的关系:`homed` 启动会调同一个 PlanMigration 并**只报告**
// (见 cmd/homed/bootstrap.go 的 defaultApply)。本命令是人工确认后真正执行
// 的那一步。两者共用 internal/knowledge 里的同一份实现,避免口径漂移。
//
// 用法:
//
// homed-kb-migrate -root /data/homeagent/knowledge # 报告
// homed-kb-migrate -root /data/homeagent/knowledge -apply # 执行
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
)
func main() {
root := flag.String("root", "", "知识库根目录(必填)")
apply := flag.Bool("apply", false, "真正执行重命名(缺省只报告)")
dryRun := flag.Bool("dry-run", false, "只报告(显式写法,与默认相同)")
limit := flag.Int("limit", 0, "单次最多改名条数,0 = 不限")
flag.Parse()
if *root == "" {
fmt.Fprintln(os.Stderr, "错误:必须指定 -root <知识库根目录>")
flag.Usage()
os.Exit(2)
}
if *apply && *dryRun {
fmt.Fprintln(os.Stderr, "错误:-apply 与 -dry-run 互斥")
os.Exit(2)
}
abs, err := filepath.Abs(*root)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:%v\n", err)
os.Exit(1)
}
abs = filepath.Clean(abs)
items, err := knowledge.PlanMigration(abs)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:无法读取 %s:%v\n", abs, err)
os.Exit(1)
}
if len(items) == 0 {
fmt.Printf("未发现任何知识条目(%s)\n", abs)
return
}
var need, illegal int
for _, it := range items {
switch {
case it.Illegal:
illegal++
fmt.Printf(" [非法] %-40s 含 .. / 点段 / 隐藏段;写入与删除均已拒绝,需人工处理\n", it.OldName)
case it.NewName != "":
need++
fmt.Printf(" [迁移] %-40s → %s\n", it.OldName, it.NewName)
}
}
fmt.Printf("\n共 %d 条:需迁移 %d,已规范 %d,非法 %d\n",
len(items), need, len(items)-need-illegal, illegal)
if !*apply {
if need == 0 {
fmt.Println("\n无需迁移。加 -apply 不会改变任何东西。")
return
}
fmt.Println("\n这是报告(未改动任何文件)。确认无误后加 -apply 执行。")
return
}
applied, failed := knowledge.ApplyMigration(abs, items, *limit)
fmt.Printf("\n迁移完成:成功 %d,失败 %d\n", applied, failed)
if failed > 0 {
fmt.Fprintln(os.Stderr, "存在失败项。若为名称冲突,请先人工处理冲突的目录再重跑。")
os.Exit(1)
}
}