Files
HomeAgent/internal/agent/core/utils.go
JianFeeeee 5dd98d7a05 feat(knowledge): 目录批量导入 + 派生数据批量收口
## 能力缺口

导入只能一条条 Add(knowledge_create)。agent 拿到一份 200 页的
文档目录要调 200 次工具,且每次都得自己决定分类与名字。

新增工具 `knowledge_import_dir(dir, category?, include_media?, dry_run?, max_items?)`。
语义是**复制**不是引用:源文件删改不影响已导入的副本。
- 文本经 Write 整份写入 <知识根>/<分类>/<名>/content.md
- 媒体按 sha256 进媒体库(内容寻址天然去重),条目只存 digest 引用

## 目录约定(自动适配,不要求改造资料)

1. 含 content.md 的目录 ⇒ 整体作为一个条目(与 scanDir 既有语义一致,
   所以知识库自身目录能被原样再导入而不会被拆散)
2. 否则 .md/.txt 等文件各成一条,**目录路径即分类**

## 三个语义决策

- category 是**前缀叠加**(tech + 源结构),不替换:替换会丢掉源目录
  自身最有价值的层级信息
- 同名冲突**跳过并计数**,绝不覆盖:Write 对同名本就是覆盖语义
  (knowledge_create 靠它做更新),若直接调它,一次重导就会把手工
  补充的内容悄悄抹掉,而日志只写"导入完成"
- dry_run **默认 true**:批量写,agent 第一次试某目录应先看清会写什么

## 安全边界(批量操作,缺一道就可能读到不该读的)

- 必须绝对路径:agent 的 cwd 不受控,相对路径会静默导到别处
- 符号链接不跟随:否则一个软链就把知识根之外的文件导进来
- 拒绝把知识库自身当源(自导会无限自我复制)
- category 复用 normalizeName(与 Write 同一道闸,两处分叉就成了绕过)
- MaxItems 默认 500:防 agent 误传 "/" 把盘灌满

## ★ 批量导入暴露的既有 O(N²)

Write 每条末尾都调 flushDenseLocked,而 saveDenseCacheLocked 是
**全量序列化整个 items map 再重写整个文件**。按 512 维 float64 估,
单条约 10KB,导入 500 条累计要写约 1.4GB。

仓库里索引侧早已有 indexDirty 的「标脏+延迟收口」(实测 writeIndexLocked
6.7ms/次、占单条 Add 绝大部分),**稠密缓存却还是逐条全量重写** ——
同一类开销只修了一半。

照 indexDirty 的模式补 batchDepth:批量期只标脏,endBatch 收口一次。
用 defer 保证提前 return 也会收口 —— 否则这批向量会留成"标脏未写",
下次启动被当作缺失而全量重算。

## 判据:17 条 + 变异

安全边界做了 4 组变异验证(去符号链接拦截/去绝对路径要求/去自导检查/
content.md 目录不下钻)。

★ 判据第一版有两处自己骗自己,被变异抓出来:
1. 符号链接判据造的是**目录软链**,而 WalkDir 对目录软链本来就不下钻
   ⇒ 有无防护结果都一样,是假绿。改成**文件软链**后才真正判红。
2. 同名冲突判据里已有条目写成 "a/b"、源映射出的是 "b"(不同名),
   判据自己就错了 —— 修判据而不是改实现。

媒体路径用假 MediaPutter:验的是「调了 Put 且 digest 挂到条目上」,
媒体库自身的落盘去重是 media 包的判据,不该在这里重测。

全量 41 包绿。
2026-09-26 16:19:10 +08:00

115 lines
2.6 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.

package core
import (
"fmt"
"strings"
"unicode/utf8"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
)
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// getBool 从工具参数里取布尔值。
//
// 为何不直接 v.(bool):LLM 的工具参数经 JSON 解码后可能是
// bool、也可能是字符串("true")或数字(1)—— 实际调用里三种都出现过。
// 只认 bool 会让模型明明传了 true 却走默认值(表现为「参数被无视」,
// 排查时很难看出是解析问题)。
func getBool(m map[string]interface{}, key string) (bool, bool) {
v, ok := m[key]
if !ok {
return false, false
}
switch b := v.(type) {
case bool:
return b, true
case string:
switch strings.ToLower(strings.TrimSpace(b)) {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off":
return false, true
}
case float64:
return b != 0, true
}
return false, false
}
func getFloat(m map[string]interface{}, key string) float64 {
if v, ok := m[key]; ok {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
}
}
return 0
}
// getStringSlice 从工具参数里取字符串数组。
//
// 需要单独一个 helper 而不是直接断言 []string:LLM 的参数经 JSON 解码后是
// []interface{},直接断言 []string 恒失败——静默拿到 nil,参数像没传一样。
func getStringSlice(m map[string]interface{}, key string) []string {
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
var out []string
for _, v := range raw {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
func truncateStr(s string, max int) string {
if utf8.RuneCountInString(s) <= max {
return s
}
var truncated int
for i := range s {
if truncated >= max {
return s[:i] + "..."
}
truncated++
}
return s
}
func formatTree(node *knowledge.TreeIndex, depth int) string {
var sb strings.Builder
indent := strings.Repeat(" ", depth)
for _, child := range node.Children {
sb.WriteString(fmt.Sprintf("%s%s/\n", indent, child.Name))
sb.WriteString(formatTree(child, depth+1))
}
for _, item := range node.Items {
preview := item.Preview
if len([]rune(preview)) > 60 {
preview = string([]rune(preview)[:60]) + "..."
}
tags := ""
if len(item.Tags) > 0 {
tags = " [" + strings.Join(item.Tags, ", ") + "]"
}
sb.WriteString(fmt.Sprintf("%s· %s%s\n", indent, item.Name, tags))
sb.WriteString(fmt.Sprintf("%s %s\n", indent, preview))
}
if sb.Len() == 0 {
sb.WriteString("(空)")
}
return sb.String()
}