feat: 新增classify和reorg命令,修复help格式
- 新增classify命令:使用LLM对知识库文档进行分类
- 新增reorg命令:根据分类结果重组知识库目录
- 修复所有命令的help格式,统一为单行描述
- 修复FTS5索引列名映射问题
- 优化搜索评分:实体词降权,症状词提权
- 修复scanner跳过待审阅目录的逻辑
7 files modified
5 files added
| | |
| | | "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, |
| | | } |
| | | |
| | |
| | | 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 { |
| | |
| | | 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 |
| | | } |
| | |
| | | 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, |
| | | } |
| | | |
| | |
| | | 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, |
| | | } |
| | | |
| New file |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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) |
| | | } |
| New file |
| | |
| | | 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 |
| | | } |
| | |
| | | title, |
| | | content, |
| | | tags, |
| | | entities, |
| | | content='nodes', |
| | | content_rowid='id' |
| | | entities |
| | | ) |
| | | `) |
| | | return err |
| | |
| | | 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) { |
| | | // 第一次尝试 |
| | |
| | | "可以", "需要", "应该", "是否", "有没有", |
| | | } |
| | | |
| | | // 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 |
| | | } |
| | | } |
| | |
| | | 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 |
| | | } |
| | |
| | | if strings.HasPrefix(info.Name(), ".") && info.Name() != "." { |
| | | return filepath.SkipDir |
| | | } |
| | | // 跳过待审阅目录 |
| | | if info.Name() == "待审阅" { |
| | | return filepath.SkipDir |
| | | } |
| | | return nil |
| | | } |
| | | if !strings.HasSuffix(path, ".md") { |
| New file |
| | |
| | | #!/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() |
| New file |
| | |
| | | #!/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() |