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 d3a796fde2
commit c4ba7b148c
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)
}
}

View File

@ -378,6 +378,61 @@ func initKnowledgeStore(cfg *types.Config) *knowledge.Store {
return ks
}
// initKnowledgeMigration 在知识库扫盘**之前**把存量目录名规范化。
//
// 为何不靠 Store 内部自己做:规范名是「内存键 + 盘上目录 + LLM 可见名字」
// 三者必须逐字一致,而磁盘重命名属于有破坏性的副作用,应该在 store 扫盘
// 之前、在明确的边界上一次性做完,而不是散在 Store 的初始化路径里。
//
// 为何默认只报告:os.Rename 不可逆,批量重命名生产数据必须由人确认。
// 需要真正迁移时用 homed-kb-migrate -apply(或把下面 defaultApply 打开)。
//
// 本函数体同样遵守 bootstrap 的平移原则。
func initKnowledgeMigration(cfg *types.Config) {
root := filepath.Join(cfg.Daemon.DataDir, "knowledge")
const (
// defaultApply = false ⇒ 启动时只扫描并报告,不改名。
defaultApply = false
// maxRenamePerRun 限制单次重命名数:给失控的目录规模设一个上限,
// 避免启动阶段被一次大迁移拖住。
maxRenamePerRun = 200
)
items, err := knowledge.PlanMigration(root)
if err != nil {
log.Printf("[homed] 知识库迁移扫描失败(跳过): %v", err)
return
}
need, illegal := 0, 0
for _, it := range items {
if it.Illegal {
illegal++
} else if it.NewName != "" {
need++
}
}
if need == 0 && illegal == 0 {
return
}
if illegal > 0 {
log.Printf("[homed] 知识库迁移:%d 条名称非法(含 .. / 点段 / 隐藏段),写入与删除均已拒绝,需人工处理", illegal)
}
if need == 0 {
return
}
log.Printf("[homed] 知识库迁移:%d/%d 条目录名待规范化(例:%s → %s)", need, len(items),
items[0].OldName, items[0].NewName)
if !defaultApply {
log.Printf("[homed] 知识库迁移:当前为只报告模式。确认清单后执行:homed-kb-migrate -root %s -apply", root)
return
}
if need > maxRenamePerRun {
log.Printf("[homed] 知识库迁移:需改名 %d 条超过单次上限 %d,本次只处理前 %d 条",
need, maxRenamePerRun, maxRenamePerRun)
}
applied, failed := knowledge.ApplyMigration(root, items, maxRenamePerRun)
log.Printf("[homed] 知识库迁移完成:成功 %d,失败 %d", applied, failed)
}
// loadPersonality 按「个人文件 > 配置项」的优先级解析人格内容,并对腐坏内容告警。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,

View File

@ -132,6 +132,9 @@ func main() {
multimodalSpace, mmProviderName, mmErr, closeMultimodal := initMultimodalSpace(cfgReg)
defer closeMultimodal()
// 迁移必须在 store 扫盘**之前**:改名后扫盘一次到位,
// 避免先以旧名建索引、再改名造成内存键与盘上目录短暂不一致。
initKnowledgeMigration(cfg)
ks := initKnowledgeStore(cfg)
// ---- 人格设定 ----