From 4cd5499d60a851a59723067e74a4a92f7254b8e9 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sun, 26 Jul 2026 23:16:32 +0800
Subject: [PATCH] feat: 新增classify和reorg命令,修复help格式

---
 scripts/update-frontmatter.py |  109 ++++++
 scripts/update-wikilinks.py   |  100 ++++++
 cmd/draft.go                  |   19 
 internal/vault/scanner.go     |    4 
 bin/kb-cli                    |    0 
 cmd/reorg.go                  |   71 ++++
 internal/classify/llm.go      |  301 ++++++++++++++++++
 internal/index/fts.go         |    4 
 cmd/classify.go               |   60 +++
 internal/classify/reorg.go    |  173 ++++++++++
 internal/search/scorer.go     |   33 ++
 internal/llm/client.go        |   31 +
 12 files changed, 878 insertions(+), 27 deletions(-)

diff --git a/bin/kb-cli b/bin/kb-cli
index 6e1ba3f..0d8c5eb 100755
--- a/bin/kb-cli
+++ b/bin/kb-cli
Binary files differ
diff --git a/cmd/classify.go b/cmd/classify.go
index d698dd0..ac37d96 100644
--- a/cmd/classify.go
+++ b/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
 }
diff --git a/cmd/draft.go b/cmd/draft.go
index 32d280c..f16da92 100644
--- a/cmd/draft.go
+++ b/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,
 }
 
diff --git a/cmd/reorg.go b/cmd/reorg.go
new file mode 100644
index 0000000..f3509e3
--- /dev/null
+++ b/cmd/reorg.go
@@ -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
+}
diff --git a/internal/classify/llm.go b/internal/classify/llm.go
new file mode 100644
index 0000000..dff37e2
--- /dev/null
+++ b/internal/classify/llm.go
@@ -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)
+}
diff --git a/internal/classify/reorg.go b/internal/classify/reorg.go
new file mode 100644
index 0000000..80fc219
--- /dev/null
+++ b/internal/classify/reorg.go
@@ -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
+}
diff --git a/internal/index/fts.go b/internal/index/fts.go
index c06b250..07bc115 100644
--- a/internal/index/fts.go
+++ b/internal/index/fts.go
@@ -21,9 +21,7 @@
 			title,
 			content,
 			tags,
-			entities,
-			content='nodes',
-			content_rowid='id'
+			entities
 		)
 	`)
 	return err
diff --git a/internal/llm/client.go b/internal/llm/client.go
index 5ffd7e2..a4a52bf 100644
--- a/internal/llm/client.go
+++ b/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) {
 	// 第一次尝试
diff --git a/internal/search/scorer.go b/internal/search/scorer.go
index ae676b4..03c2e17 100644
--- a/internal/search/scorer.go
+++ b/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
 }
diff --git a/internal/vault/scanner.go b/internal/vault/scanner.go
index f19def5..41b37b8 100644
--- a/internal/vault/scanner.go
+++ b/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") {
diff --git a/scripts/update-frontmatter.py b/scripts/update-frontmatter.py
new file mode 100644
index 0000000..d03ff83
--- /dev/null
+++ b/scripts/update-frontmatter.py
@@ -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()
diff --git a/scripts/update-wikilinks.py b/scripts/update-wikilinks.py
new file mode 100644
index 0000000..870c827
--- /dev/null
+++ b/scripts/update-wikilinks.py
@@ -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()

--
Gitblit v1.9.1