ai_xiaopei
9 days ago 4cd5499d60a851a59723067e74a4a92f7254b8e9
feat: 新增classify和reorg命令,修复help格式

- 新增classify命令:使用LLM对知识库文档进行分类
- 新增reorg命令:根据分类结果重组知识库目录
- 修复所有命令的help格式,统一为单行描述
- 修复FTS5索引列名映射问题
- 优化搜索评分:实体词降权,症状词提权
- 修复scanner跳过待审阅目录的逻辑
7 files modified
5 files added
905 ■■■■■ changed files
bin/kb-cli patch | view | raw | blame | history
cmd/classify.go 60 ●●●● patch | view | raw | blame | history
cmd/draft.go 19 ●●●● patch | view | raw | blame | history
cmd/reorg.go 71 ●●●●● patch | view | raw | blame | history
internal/classify/llm.go 301 ●●●●● patch | view | raw | blame | history
internal/classify/reorg.go 173 ●●●●● patch | view | raw | blame | history
internal/index/fts.go 4 ●●● patch | view | raw | blame | history
internal/llm/client.go 31 ●●●●● patch | view | raw | blame | history
internal/search/scorer.go 33 ●●●●● patch | view | raw | blame | history
internal/vault/scanner.go 4 ●●●● patch | view | raw | blame | history
scripts/update-frontmatter.py 109 ●●●●● patch | view | raw | blame | history
scripts/update-wikilinks.py 100 ●●●●● patch | view | raw | blame | history
bin/kb-cli
Binary files differ
cmd/classify.go
@@ -4,18 +4,15 @@
    "fmt"
    "github.com/aisim/kb-cli/internal/classify"
    "github.com/aisim/kb-cli/internal/llm"
    "github.com/spf13/cobra"
)
var classifyCmd = &cobra.Command{
    Use:   "classify",
    Short: "使用 LLM 对知识库文档进行分类",
    Long: `使用本地 LLM 分析知识库文档,提取平台、设备、内容类型等信息。
输出:
  - entities.json: 实体定义(从 实体/ 目录提取)
  - relations.json: 实体关系
  - classification.json: 文档分类结果`,
    Long:  `# classify - LLM 分类
kb-cli classify [--batch-size=<数量>] [--dry-run] # 使用 LLM 分析文档,提取平台、设备、内容类型`,
    RunE: runClassify,
}
@@ -33,6 +30,9 @@
func runClassify(cmd *cobra.Command, args []string) error {
    vaultPath := cmd.Flag("vault").Value.String()
    // 创建 LLM 客户端(从配置文件加载)
    llmClient := llm.NewClient()
    fmt.Println("步骤 1/3: 提取实体信息...")
    entities, relations, err := classify.ExtractEntities(vaultPath)
    if err != nil {
@@ -48,8 +48,52 @@
        fmt.Printf("[DRY-RUN] 将生成 entities.json 和 relations.json\n")
    }
    fmt.Println("\n步骤 2/3: 文档分类(待实现)...")
    fmt.Println("步骤 3/3: 生成 classification.json(待实现)...")
    fmt.Println("\n步骤 2/3: 扫描文档...")
    docs, err := classify.ScanDocuments(vaultPath)
    if err != nil {
        return fmt.Errorf("扫描文档失败: %w", err)
    }
    fmt.Printf("✓ 发现 %d 个文档\n", len(docs))
    if len(docs) == 0 {
        fmt.Println("警告: 未发现任何文档")
        return nil
    }
    fmt.Println("\n步骤 3/3: 使用 LLM 分类...")
    if classifyDryRun {
        fmt.Printf("[DRY-RUN] 将对 %d 个文档进行分类(批次大小: %d)\n", len(docs), classifyBatchSize)
        return nil
    }
    // 分批处理
    var allResults []classify.Classification
    for i := 0; i < len(docs); i += classifyBatchSize {
        end := i + classifyBatchSize
        if end > len(docs) {
            end = len(docs)
        }
        batch := docs[i:end]
        fmt.Printf("处理批次 %d-%d / %d...\n", i+1, end, len(docs))
        results, err := classify.ClassifyBatch(batch, llmClient)
        if err != nil {
            fmt.Printf("警告: 批次 %d 分类失败: %v\n", i/classifyBatchSize+1, err)
            continue
        }
        allResults = append(allResults, results...)
    }
    if len(allResults) == 0 {
        return fmt.Errorf("分类失败: 未获得任何结果")
    }
    if err := classify.SaveClassification(vaultPath, allResults); err != nil {
        return fmt.Errorf("保存分类结果失败: %w", err)
    }
    fmt.Printf("✓ 已生成 classification.json(%d 个文档)\n", len(allResults))
    return nil
}
cmd/draft.go
@@ -18,14 +18,8 @@
var draftCreateCmd = &cobra.Command{
    Use:   "create",
    Short: "创建草稿",
    Long: `kb-cli draft create --type <类型> --title <标题> --content-file <内容文件> [--source <来源>] [--force]
创建草稿,自动提取 tags 并生成合并指示。
类型: 售后/产品/运营/行业/TAPD
示例:
  kb-cli draft create --type 售后 --title "充不进气问题" --content-file /tmp/draft.md
  kb-cli draft create --type TAPD --title "档案下载失败" --content-file /tmp/bug.md --source "TAPD#12345"`,
    Long:  `# draft create - 创建草稿
kb-cli draft create --type <类型> --title <标题> --content-file <文件> [--source <来源>] [--force] # 创建草稿,自动提取 tags 并生成合并指示`,
    RunE: runDraftCreate,
}
@@ -38,13 +32,8 @@
var tagsRebuildCmd = &cobra.Command{
    Use:   "rebuild",
    Short: "批量重建 tags",
    Long: `kb-cli tags rebuild [--vault <路径>] [--limit <数量>] [--dry-run]
批量重建现有文档的 tags,使用 LLM 重新提取。
示例:
  kb-cli tags rebuild --limit 10           # 重建前 10 个文档的 tags
  kb-cli tags rebuild --dry-run            # 仅预览,不实际更新`,
    Long:  `# tags rebuild - 批量重建 tags
kb-cli tags rebuild [--vault <路径>] [--limit <数量>] [--dry-run] # 使用 LLM 重新提取现有文档的 tags`,
    RunE: runTagsRebuild,
}
cmd/reorg.go
New file
@@ -0,0 +1,71 @@
package cmd
import (
    "fmt"
    "github.com/aisim/kb-cli/internal/classify"
    "github.com/spf13/cobra"
)
var reorgCmd = &cobra.Command{
    Use:   "reorg",
    Short: "根据分类结果重组知识库目录",
    Long:  `# reorg - 目录重组
kb-cli reorg [--plan=<文件>] [--dry-run] [--execute] # 根据分类结果重组知识库目录`,
    RunE: runReorg,
}
var (
    reorgPlan    string
    reorgDryRun  bool
    reorgExecute bool
)
func init() {
    rootCmd.AddCommand(reorgCmd)
    reorgCmd.Flags().StringVar(&reorgPlan, "plan", "classification.json", "分类结果文件")
    reorgCmd.Flags().BoolVar(&reorgDryRun, "dry-run", false, "仅生成迁移计划,不执行移动")
    reorgCmd.Flags().BoolVar(&reorgExecute, "execute", false, "执行迁移(需要明确确认)")
}
func runReorg(cmd *cobra.Command, args []string) error {
    vaultPath := cmd.Flag("vault").Value.String()
    fmt.Println("步骤 1/3: 读取分类结果...")
    classification, err := classify.LoadClassification(vaultPath, reorgPlan)
    if err != nil {
        return fmt.Errorf("读取分类结果失败: %w", err)
    }
    fmt.Printf("✓ 读取 %d 个文档分类\n", len(classification.Items))
    fmt.Println("\n步骤 2/3: 生成迁移计划...")
    plan, err := classify.GenerateMigrationPlan(vaultPath, classification)
    if err != nil {
        return fmt.Errorf("生成迁移计划失败: %w", err)
    }
    if err := classify.SaveMigrationPlan(vaultPath, plan); err != nil {
        return fmt.Errorf("保存迁移计划失败: %w", err)
    }
    fmt.Printf("✓ 已生成 migration-plan.json(%d 个文件)\n", len(plan.Migrations))
    if reorgDryRun {
        fmt.Println("\n[DRY-RUN] 迁移计划已生成,未执行移动")
        fmt.Println("使用 --execute 执行迁移")
        return nil
    }
    if !reorgExecute {
        fmt.Println("\n警告: 未指定 --execute,迁移未执行")
        fmt.Println("请检查 migration-plan.json,然后使用 --execute 执行")
        return nil
    }
    fmt.Println("\n步骤 3/3: 执行迁移...")
    if err := classify.ExecuteMigration(vaultPath, plan); err != nil {
        return fmt.Errorf("执行迁移失败: %w", err)
    }
    fmt.Printf("✓ 迁移完成(%d 个文件)\n", len(plan.Migrations))
    return nil
}
internal/classify/llm.go
New file
@@ -0,0 +1,301 @@
package classify
import (
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "strings"
    "github.com/aisim/kb-cli/internal/llm"
)
// Document 文档信息
type Document struct {
    Path    string `json:"path"`
    Title   string `json:"title"`
    Content string `json:"content"`
}
// Classification 分类结果
type Classification struct {
    Path       string  `json:"path"`
    Platform   string  `json:"platform"`
    Device     string  `json:"device"`
    Confidence float64 `json:"confidence"`
}
// ClassificationResult 分类结果集合
type ClassificationResult struct {
    Total         int              `json:"total"`
    Classified    int              `json:"classified"`
    NeedsReview   int              `json:"needs_review"`
    Items         []Classification `json:"items"`
}
// ScanDocuments 扫描知识库文档
func ScanDocuments(vaultPath string) ([]Document, error) {
    var docs []Document
    // 转换为绝对路径
    absPath, err := filepath.Abs(vaultPath)
    if err != nil {
        return nil, fmt.Errorf("转换路径失败: %w", err)
    }
    // 扫描整个知识库的所有 .md 文件
    err = filepath.Walk(absPath, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        // 跳过隐藏目录和特殊目录
        if info.IsDir() {
            name := info.Name()
            if strings.HasPrefix(name, ".") || name == "node_modules" {
                return filepath.SkipDir
            }
            // 跳过待审阅目录
            if name == "待审阅" {
                return filepath.SkipDir
            }
            return nil
        }
        // 只处理 .md 文件
        if !strings.HasSuffix(path, ".md") {
            return nil
        }
        content, err := os.ReadFile(path)
        if err != nil {
            return err
        }
        relPath, err := filepath.Rel(absPath, path)
        if err != nil {
            relPath = path
        }
        title := extractTitle(string(content))
        summary := extractSummary(string(content), 500)
        docs = append(docs, Document{
            Path:    relPath,
            Title:   title,
            Content: summary,
        })
        return nil
    })
    if err != nil {
        return nil, fmt.Errorf("扫描知识库失败: %w", err)
    }
    return docs, nil
}
// extractTitle 从 frontmatter 或第一行 # 提取标题
func extractTitle(content string) string {
    lines := strings.Split(content, "\n")
    inFrontmatter := false
    for _, line := range lines {
        line = strings.TrimSpace(line)
        if line == "---" {
            inFrontmatter = !inFrontmatter
            continue
        }
        if inFrontmatter {
            if strings.HasPrefix(line, "title:") {
                title := strings.TrimPrefix(line, "title:")
                title = strings.TrimSpace(title)
                title = strings.Trim(title, "\"'")
                return title
            }
        } else if strings.HasPrefix(line, "# ") {
            return strings.TrimPrefix(line, "# ")
        }
    }
    return "未知标题"
}
// extractSummary 提取摘要(前 maxLen 字)
func extractSummary(content string, maxLen int) string {
    // 跳过 frontmatter
    lines := strings.Split(content, "\n")
    startIdx := 0
    inFrontmatter := false
    for i, line := range lines {
        if strings.TrimSpace(line) == "---" {
            if !inFrontmatter {
                inFrontmatter = true
            } else {
                startIdx = i + 1
                break
            }
        }
    }
    // 提取正文
    summary := strings.Join(lines[startIdx:], "\n")
    summary = strings.TrimSpace(summary)
    if len(summary) > maxLen {
        summary = summary[:maxLen] + "..."
    }
    return summary
}
// ClassifyBatch 批量分类文档
func ClassifyBatch(docs []Document, llmClient *llm.Client) ([]Classification, error) {
    // 构建 prompt
    prompt := buildClassifyPrompt(docs)
    // 调用 LLM
    response, err := llmClient.ClassifyDocuments(prompt)
    if err != nil {
        return nil, err
    }
    // 解析响应
    var results []Classification
    if err := json.Unmarshal([]byte(response), &results); err != nil {
        return nil, fmt.Errorf("解析 LLM 响应失败: %w", err)
    }
    return results, nil
}
// buildClassifyPrompt 构建分类 prompt
func buildClassifyPrompt(docs []Document) string {
    prompt := `你是知识库分类专家。分析以下文档,判断其所属平台、设备和内容类型。
## 实体定义(必须严格遵守)
### 硬件设备
| 设备 | 归属 | 说明 |
|------|------|------|
| 智能枪 | 运营管理平台配套 | 智能控制箱+智能枪头,独立4G网络,可独立工作或安装在电子秤中 |
| 电子秤 | 电子秤平台 | 含扫码枪(分防爆/非防爆),扫码枪是电子秤配件,**不是智能枪** |
| 智能阀 | 运营管理平台基础 | NFC识别芯片,是运营平台气瓶管理的基础 |
| 艾信盒子 | 两平台共用 | 4G通信设备,同步存储充装数据,控制上传第三方平台 |
### 软件平台
| 平台 | 域名 | 用户 | 核心功能 |
|------|------|------|----------|
| 运营管理平台 | rb.zhiheiot.com | 老板/管理层/客服/配送调度 | 经营管理、售后、配送、工单、会员 |
| 电子秤平台 | elc.zhiheiot.com | 气站站长/充装员/开票员 | 充装作业(扫码、充装、称重、开票/补单)+ 终端配送(简化版) |
### App终端
| App | 归属平台 | 功能 |
|-----|----------|------|
| 易配送App | 运营管理平台 | NFC芯片识别配送 |
| 安全用气App | 电子秤平台 | 二维码配送 |
| 艾信助手App | 融合两平台 | 建档 + 充前/充后检查 |
### 小程序
| 小程序 | 归属 | 用户 | 功能 |
|--------|------|------|------|
| 艾信LPG物联网小程序 | 电子秤平台 | 气站管理员/配送员 | 报表/迎检/配送 |
| 艾信发货小程序 | 内部工具 | 艾信内部员工 | 设备总览、客户资料、气站资料 |
## 关键区分点(必须遵守)
| 容易混淆 | 正确归属 |
|----------|----------|
| 智能枪 vs 扫码枪 | 智能枪是独立设备(4G网络);扫码枪是电子秤配件 |
| 开票/补单/充装 | 电子秤平台功能 |
| 主板版本过低 | 智能枪问题 |
| 艾信发货小程序 | 内部工具,可控制所有智能枪、电子秤 |
| 艾信LPG物联网小程序 | 电子秤平台气站管理员用 |
| 配送功能 | 两平台都有,运营平台用易配送App,电子秤平台用安全用气App |
## 分类规则
1. **platform**(属于哪个平台,必须使用中文):
   - 电子秤平台:充装作业、开票、电子秤设备、安全用气App、艾信LPG小程序
   - 运营管理平台:配送、档案、会员、工单、易配送App、智能枪、智能阀
   - 共有:两个平台都涉及(艾信盒子、艾信助手App、充装记录同步)
   - 第三方平台:祥康、监管平台等
   - 通用:不涉及具体平台/内部工具
2. **device**(涉及什么设备/App,必须使用中文):
   - 电子秤:含扫码枪、电磁阀、称重传感器、显示屏、键盘
   - 智能枪:智能控制箱+智能枪头,独立4G网络
   - 智能阀:NFC识别智能阀的芯片
   - 艾信盒子:4G通信设备
   - 安全用气App:电子秤平台配送端
   - 易配送App:运营管理平台配送端
   - 艾信助手App:融合两平台建档
   - 艾信LPG小程序:电子秤平台管理+配送
   - 艾信发货小程序:内部工具
   - 无:不涉及具体设备
3. **content_type**(内容类型):
   - FAQ / PRD / 知识 / 案例 / 实体 / 其他
## 输出格式
输出 JSON 数组,每项包含:
- path: 文件路径(字符串)
- platform: 平台名称(中文,必须是上述枚举值之一)
- device: 设备名称(中文,必须是上述枚举值之一)
- content_type: 内容类型(字符串,必须是上述枚举值之一)
- confidence: 置信度(浮点数,范围 0.0-1.0,例如 0.95)
示例输出:
[
  {
    "path": "FAQ/充装类/001-智能枪通气杆卡住漏气.md",
    "platform": "运营管理平台",
    "device": "智能枪",
    "content_type": "FAQ",
    "confidence": 0.95
  }
]
文档列表:
`
    for _, doc := range docs {
        prompt += fmt.Sprintf("\n文件: %s\n标题: %s\n摘要: %s\n", doc.Path, doc.Title, doc.Content)
    }
    prompt += "\n请输出 JSON 数组:"
    return prompt
}
// SaveClassification 保存分类结果
func SaveClassification(vaultPath string, results []Classification) error {
    // 统计
    total := len(results)
    classified := 0
    needsReview := 0
    for _, r := range results {
        if r.Confidence >= 0.8 {
            classified++
        } else {
            needsReview++
        }
    }
    result := ClassificationResult{
        Total:       total,
        Classified:  classified,
        NeedsReview: needsReview,
        Items:       results,
    }
    path := filepath.Join(vaultPath, "classification.json")
    data, err := json.MarshalIndent(result, "", "  ")
    if err != nil {
        return err
    }
    return os.WriteFile(path, data, 0644)
}
internal/classify/reorg.go
New file
@@ -0,0 +1,173 @@
package classify
import (
    "encoding/json"
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
)
// Migration 单个迁移项
type Migration struct {
    Source string `json:"source"`
    Target string `json:"target"`
}
// MigrationPlan 迁移计划
type MigrationPlan struct {
    Total      int         `json:"total"`
    Migrations []Migration `json:"migrations"`
}
// LoadClassification 加载分类结果
func LoadClassification(vaultPath, filename string) (*ClassificationResult, error) {
    path := filepath.Join(vaultPath, filename)
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("读取文件失败: %w", err)
    }
    var result ClassificationResult
    if err := json.Unmarshal(data, &result); err != nil {
        return nil, fmt.Errorf("解析 JSON 失败: %w", err)
    }
    return &result, nil
}
// GenerateMigrationPlan 生成迁移计划
func GenerateMigrationPlan(vaultPath string, classification *ClassificationResult) (*MigrationPlan, error) {
    plan := &MigrationPlan{
        Total:      len(classification.Items),
        Migrations: []Migration{},
    }
    // 跟踪已使用的目标路径
    usedTargets := make(map[string]bool)
    for _, item := range classification.Items {
        // 根据 platform 和 device 生成目标路径
        targetDir := getTargetDir(item.Platform, item.Device)
        baseName := filepath.Base(item.Path)
        targetPath := filepath.Join(targetDir, baseName)
        // 如果目标路径已使用,添加数字后缀直到找到唯一路径
        counter := 1
        for usedTargets[targetPath] {
            counter++
            ext := filepath.Ext(baseName)
            nameWithoutExt := strings.TrimSuffix(baseName, ext)
            targetPath = filepath.Join(targetDir, fmt.Sprintf("%s_%d%s", nameWithoutExt, counter, ext))
        }
        usedTargets[targetPath] = true
        plan.Migrations = append(plan.Migrations, Migration{
            Source: item.Path,
            Target: targetPath,
        })
    }
    return plan, nil
}
// getTargetDir 根据平台和设备获取目标目录
func getTargetDir(platform, device string) string {
    // 平台目录映射(支持中文和英文标识符)
    platformDir := map[string]string{
        // 英文标识符
        "elc":         "电子秤平台",
        "ops":         "运营管理平台",
        "both":        "共有硬件",
        "third_party": "第三方平台",
        "general":     "通用",
        // 中文标识符
        "电子秤平台":   "电子秤平台",
        "运营管理平台": "运营管理平台",
        "共有":         "共有硬件",
        "第三方平台":   "第三方平台",
        "通用":         "通用",
    }
    // 设备目录映射(支持中文和英文标识符)
    deviceDir := map[string]string{
        // 英文标识符
        "scale":         "电子秤",
        "gun":           "智能枪",
        "valve":         "智能阀",
        "box":           "艾信盒子",
        "app_safety":    "安全用气App",
        "app_delivery":  "易配送App",
        "app_assistant": "艾信助手App",
        "app_lpg":       "艾信LPG小程序",
        "app_shipping":  "艾信发货小程序",
        "none":          "通用",
        // 中文标识符
        "电子秤":          "电子秤",
        "智能枪":          "智能枪",
        "智能阀":          "智能阀",
        "艾信盒子":        "艾信盒子",
        "安全用气App":     "安全用气App",
        "易配送App":       "易配送App",
        "艾信助手App":     "艾信助手App",
        "艾信LPG小程序":   "艾信LPG小程序",
        "艾信发货小程序":  "艾信发货小程序",
        "无":              "通用",
        "综合":            "通用",
    }
    pDir := platformDir[platform]
    if pDir == "" {
        pDir = "通用"
    }
    dDir := deviceDir[device]
    if dDir == "" {
        dDir = "通用"
    }
    return filepath.Join(pDir, dDir)
}
// SaveMigrationPlan 保存迁移计划
func SaveMigrationPlan(vaultPath string, plan *MigrationPlan) error {
    path := filepath.Join(vaultPath, "migration-plan.json")
    data, err := json.MarshalIndent(plan, "", "  ")
    if err != nil {
        return err
    }
    return os.WriteFile(path, data, 0644)
}
// ExecuteMigration 执行迁移
func ExecuteMigration(vaultPath string, plan *MigrationPlan) error {
    for i, migration := range plan.Migrations {
        fmt.Printf("[%d/%d] 移动 %s -> %s\n", i+1, len(plan.Migrations), migration.Source, migration.Target)
        sourcePath := filepath.Join(vaultPath, migration.Source)
        targetPath := filepath.Join(vaultPath, migration.Target)
        // 检查源文件是否存在
        if _, err := os.Stat(sourcePath); os.IsNotExist(err) {
            fmt.Printf("  跳过(源文件不存在)\n")
            continue
        }
        // 创建目标目录
        targetDir := filepath.Dir(targetPath)
        if err := os.MkdirAll(targetDir, 0755); err != nil {
            return fmt.Errorf("创建目录失败: %w", err)
        }
        // 使用 git mv 保留历史
        cmd := exec.Command("git", "mv", sourcePath, targetPath)
        cmd.Dir = vaultPath
        if output, err := cmd.CombinedOutput(); err != nil {
            return fmt.Errorf("git mv 失败: %w\n%s", err, output)
        }
    }
    return nil
}
internal/index/fts.go
@@ -21,9 +21,7 @@
            title,
            content,
            tags,
            entities,
            content='nodes',
            content_rowid='id'
            entities
        )
    `)
    return err
internal/llm/client.go
@@ -249,6 +249,37 @@
    return &result, nil
}
// ClassifyDocuments 分类文档
func (c *Client) ClassifyDocuments(prompt string) (string, error) {
    response, err := c.callLLMWithRetry(prompt)
    if err != nil {
        return "", err
    }
    // 解析 JSON
    var result []struct {
        Path        string  `json:"path"`
        Platform    string  `json:"platform"`
        Device      string  `json:"device"`
        ContentType string  `json:"content_type"`
        Confidence  float64 `json:"confidence"`
    }
    if err := parseJSON(response, &result); err != nil {
        return "", fmt.Errorf("解析 LLM 响应失败: %w", err)
    }
    // 转换回 JSON 字符串
    jsonBytes, err := json.Marshal(result)
    if err != nil {
        return "", err
    }
    return string(jsonBytes), nil
}
// ClassifyDocuments 分类文档
// callLLMWithRetry 带重试的 LLM 调用
func (c *Client) callLLMWithRetry(prompt string) (string, error) {
    // 第一次尝试
internal/search/scorer.go
@@ -11,11 +11,29 @@
    "可以", "需要", "应该", "是否", "有没有",
}
// EntityWords 实体词列表(进一步降低权重,因为这些词会匹配大量文件)
var EntityWords = []string{
    "智能枪", "电子秤", "智能阀", "艾信盒子",
    "电子秤平台", "运营管理平台", "易配送", "lpg",
    "安全用气", "艾信助手", "艾信发货",
}
// IsGenericWord 判断是否为通用词
func IsGenericWord(word string) bool {
    word = strings.ToLower(word)
    for _, g := range GenericWords {
        if word == g {
            return true
        }
    }
    return false
}
// IsEntityWord 判断是否为实体词
func IsEntityWord(word string) bool {
    wordLower := strings.ToLower(word)
    for _, e := range EntityWords {
        if wordLower == strings.ToLower(e) {
            return true
        }
    }
@@ -59,8 +77,21 @@
    if base == 0 {
        return 0
    }
    // 实体词降权(匹配太多文件),但保证至少 1 分
    if IsEntityWord(keyword) {
        score := base / 5
        if score < 1 {
            score = 1
        }
        return score
    }
    // 通用词降权,但保证至少 1 分
    if IsGenericWord(keyword) {
        return base / 10 // 通用词降权
        score := base / 3
        if score < 1 {
            score = 1
        }
        return score
    }
    return base
}
internal/vault/scanner.go
@@ -19,6 +19,10 @@
            if strings.HasPrefix(info.Name(), ".") && info.Name() != "." {
                return filepath.SkipDir
            }
            // 跳过待审阅目录
            if info.Name() == "待审阅" {
                return filepath.SkipDir
            }
            return nil
        }
        if !strings.HasSuffix(path, ".md") {
scripts/update-frontmatter.py
New file
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
根据 classification.json 更新所有文档的 frontmatter
"""
import json
import os
import re
import sys
from pathlib import Path
def load_classification(vault_path):
    """加载分类结果"""
    with open(os.path.join(vault_path, 'classification.json'), 'r', encoding='utf-8') as f:
        return json.load(f)
def load_migration_plan(vault_path):
    """加载迁移计划"""
    with open(os.path.join(vault_path, 'migration-plan.json'), 'r', encoding='utf-8') as f:
        return json.load(f)
def update_frontmatter(file_path, platform, device):
    """更新文件的 frontmatter"""
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
    # 检查是否有 frontmatter
    if not content.startswith('---'):
        # 添加新的 frontmatter
        new_content = f'---\nplatform: {platform}\ndevice: {device}\n---\n\n{content}'
        with open(file_path, 'w', encoding='utf-8') as f:
            f.write(new_content)
        return True
    # 解析现有 frontmatter
    parts = content.split('---', 2)
    if len(parts) < 3:
        return False
    frontmatter = parts[1]
    body = parts[2]
    # 更新或添加 platform
    if 'platform:' in frontmatter:
        frontmatter = re.sub(r'platform:.*', f'platform: {platform}', frontmatter)
    else:
        frontmatter += f'\nplatform: {platform}'
    # 更新或添加 device
    if 'device:' in frontmatter:
        frontmatter = re.sub(r'device:.*', f'device: {device}', frontmatter)
    else:
        frontmatter += f'\ndevice: {device}'
    # 写回文件
    new_content = f'---{frontmatter}---{body}'
    with open(file_path, 'w', encoding='utf-8') as f:
        f.write(new_content)
    return True
def main():
    vault_path = sys.argv[1] if len(sys.argv) > 1 else '.'
    # 加载分类结果和迁移计划
    classification = load_classification(vault_path)
    migration_plan = load_migration_plan(vault_path)
    # 创建源路径到分类的映射
    source_to_class = {}
    for item in classification['items']:
        source_to_class[item['path']] = {
            'platform': item['platform'],
            'device': item['device']
        }
    # 更新所有迁移的文件
    updated = 0
    skipped = 0
    for migration in migration_plan['migrations']:
        target_path = os.path.join(vault_path, migration['target'])
        source_path = migration['source']
        if not os.path.exists(target_path):
            print(f'跳过(文件不存在): {target_path}')
            skipped += 1
            continue
        # 获取分类信息
        if source_path not in source_to_class:
            print(f'跳过(无分类信息): {source_path}')
            skipped += 1
            continue
        class_info = source_to_class[source_path]
        # 更新 frontmatter
        if update_frontmatter(target_path, class_info['platform'], class_info['device']):
            print(f'已更新: {migration["target"]}')
            updated += 1
        else:
            print(f'更新失败: {migration["target"]}')
            skipped += 1
    print(f'\n更新完成: {updated} 个文件已更新, {skipped} 个文件跳过')
if __name__ == '__main__':
    main()
scripts/update-wikilinks.py
New file
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
根据 migration-plan.json 更新所有文档的 wikilinks
"""
import json
import os
import re
import sys
from pathlib import Path
def load_migration_plan(vault_path):
    """加载迁移计划"""
    with open(os.path.join(vault_path, 'migration-plan.json'), 'r', encoding='utf-8') as f:
        return json.load(f)
def build_path_mapping(migration_plan):
    """构建路径映射表(旧路径 -> 新路径)"""
    mapping = {}
    for migration in migration_plan['migrations']:
        source = migration['source']
        target = migration['target']
        # 移除 .md 扩展名用于匹配
        source_base = os.path.splitext(source)[0]
        target_base = os.path.splitext(target)[0]
        mapping[source_base] = target_base
        # 也添加文件名到完整路径的映射
        source_name = os.path.basename(source_base)
        mapping[source_name] = target_base
    return mapping
def update_wikilinks(file_path, path_mapping):
    """更新文件中的 wikilinks"""
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
    original_content = content
    # 查找所有 [[...]] 格式的 wikilinks
    # 支持 [[路径]] 和 [[路径|显示文本]] 格式
    pattern = r'\[\[([^\]|]+)(\|[^\]]+)?\]\]'
    def replace_wikilink(match):
        link_path = match.group(1).strip()
        display_text = match.group(2) or ''
        # 尝试在映射中查找
        if link_path in path_mapping:
            new_path = path_mapping[link_path]
            return f'[[{new_path}{display_text}]]'
        # 尝试匹配文件名(不含路径)
        link_name = os.path.basename(link_path)
        if link_name in path_mapping:
            new_path = path_mapping[link_name]
            return f'[[{new_path}{display_text}]]'
        # 未找到匹配,保持原样
        return match.group(0)
    content = re.sub(pattern, replace_wikilink, content)
    if content != original_content:
        with open(file_path, 'w', encoding='utf-8') as f:
            f.write(content)
        return True
    return False
def main():
    vault_path = sys.argv[1] if len(sys.argv) > 1 else '.'
    # 加载迁移计划
    migration_plan = load_migration_plan(vault_path)
    path_mapping = build_path_mapping(migration_plan)
    print(f'已加载 {len(path_mapping)} 个路径映射')
    # 更新所有迁移后的文件
    updated = 0
    total = 0
    # 遍历新目录结构
    for platform_dir in ['电子秤平台', '共有硬件', '通用', '第三方平台', '运营管理平台']:
        platform_path = os.path.join(vault_path, platform_dir)
        if not os.path.exists(platform_path):
            continue
        for md_file in Path(platform_path).rglob('*.md'):
            total += 1
            if update_wikilinks(str(md_file), path_mapping):
                print(f'已更新 wikilinks: {md_file.relative_to(vault_path)}')
                updated += 1
    print(f'\n更新完成: {updated}/{total} 个文件的 wikilinks 已更新')
if __name__ == '__main__':
    main()