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