ai_xiaopei
6 days ago 1196f409d86bc61e7596eb274840244a62ce84ba
internal/draft/intake.go
@@ -22,6 +22,17 @@
   store     *index.Store
}
// DraftInfo 草稿信息(用于 list 输出)
type DraftInfo struct {
   Filepath     string `json:"filepath"`
   Filename     string `json:"filename"`
   Title        string `json:"title,omitempty"`
   Status       string `json:"status,omitempty"`
   Type         string `json:"type,omitempty"`
   Created      string `json:"created,omitempty"`
   HasMergeHint bool   `json:"has_merge_hint"`
}
// DraftMeta 草稿元数据
type DraftMeta struct {
   Title   string   `yaml:"title"`
@@ -99,7 +110,8 @@
   var mergeHint *llm.MergeHint
   if len(candidates) > 0 {
      fmt.Println("正在生成合并指示...")
      mergeHint, err = i.llmClient.GenerateMergeHint(content, candidates)
      dirStructure := i.scanDirectoryStructure()
      mergeHint, err = i.llmClient.GenerateMergeHint(content, candidates, dirStructure)
      if err != nil {
         fmt.Printf("警告: 生成合并指示失败: %v\n", err)
         mergeHint = nil
@@ -138,6 +150,7 @@
   // 这样即使某些 tag 匹配不到,其他 tag 也能找到结果
   opts := search.SearchOptions{
      TopN: 3,
      WithContent: true, // 需要内容给 LLM 评估
   }
   results, err := search.Search(i.store, tags, opts)
@@ -157,10 +170,16 @@
   candidates := make([]llm.SearchCandidate, 0, len(results))
   for _, r := range results {
      // 截取前5000字给 LLM 评估
      content := r.Content
      if len(content) > 5000 {
         content = content[:5000] + "\n...(内容已截断)"
      }
      candidates = append(candidates, llm.SearchCandidate{
         Title: r.Title,
         Path:  r.Path,
         Score: float64(r.Score),
         Content: content,
      })
   }
@@ -443,6 +462,34 @@
   return os.WriteFile(mergePath, []byte(sb.String()), 0644)
}
// scanDirectoryStructure 扫描知识库目录结构,返回格式化的目录树字符串
func (i *Intake) scanDirectoryStructure() string {
   // 知识库根目录下的主要分类目录
   topDirs := []string{
      "电子秤平台",
      "运营管理平台",
      "第三方平台",
      "共有硬件",
      "通用",
   }
   var sb strings.Builder
   for _, topDir := range topDirs {
      dirPath := filepath.Join(i.vaultPath, topDir)
      entries, err := os.ReadDir(dirPath)
      if err != nil {
         continue
      }
      for _, entry := range entries {
         if entry.IsDir() {
            sb.WriteString(fmt.Sprintf("%s/%s/\n", topDir, entry.Name()))
         }
      }
   }
   return sb.String()
}
// RebuildTags 批量重建 tags
func (i *Intake) RebuildTags(limit int, dryRun bool) error {
   // 扫描知识库所有 .md 文件
@@ -567,3 +614,331 @@
   return os.WriteFile(path, []byte(newContent), 0644)
}
// ListDrafts 列出所有活跃草稿
func (i *Intake) ListDrafts() ([]DraftInfo, error) {
   reviewBase := filepath.Join(i.vaultPath, "待审阅")
   if _, err := os.Stat(reviewBase); os.IsNotExist(err) {
      return []DraftInfo{}, nil
   }
   var drafts []DraftInfo
   err := filepath.Walk(reviewBase, func(path string, info os.FileInfo, err error) error {
      if err != nil {
         return err
      }
      // 跳过 archive 目录
      if info.IsDir() && info.Name() == "archive" {
         return filepath.SkipDir
      }
      // 检查是否是目录结构(包含 draft.md)
      if !info.IsDir() && info.Name() == "draft.md" {
         draftDir := filepath.Dir(path)
         mergeFile := filepath.Join(draftDir, "merge.md")
         content, err := os.ReadFile(path)
         if err != nil {
            return nil
         }
         relPath, _ := filepath.Rel(i.vaultPath, path)
         hasMerge := fileExists(mergeFile)
         draft := DraftInfo{
            Filepath:     relPath,
            Filename:     filepath.Base(draftDir),
            HasMergeHint: hasMerge,
         }
         // 解析 frontmatter
         fm := parseFrontmatter(string(content))
         if title, ok := fm["title"]; ok {
            draft.Title = title
         }
         if status, ok := fm["status"]; ok {
            draft.Status = status
         }
         if draftType, ok := fm["type"]; ok {
            draft.Type = draftType
         }
         if created, ok := fm["created"]; ok {
            draft.Created = created
         }
         drafts = append(drafts, draft)
      }
      // 兼容旧结构(单文件 .md)
      if !info.IsDir() && filepath.Ext(info.Name()) == ".md" && info.Name() != "draft.md" && info.Name() != "merge.md" && info.Name() != "索引.json" {
         // 检查是否是数字开头
         name := info.Name()
         if len(name) >= 3 && isDigit(name[0]) && isDigit(name[1]) && isDigit(name[2]) {
            content, err := os.ReadFile(path)
            if err != nil {
               return nil
            }
            relPath, _ := filepath.Rel(i.vaultPath, path)
            draft := DraftInfo{
               Filepath:     relPath,
               Filename:     name,
               HasMergeHint: false,
            }
            // 解析 frontmatter
            fm := parseFrontmatter(string(content))
            if title, ok := fm["title"]; ok {
               draft.Title = title
            }
            if status, ok := fm["status"]; ok {
               draft.Status = status
            }
            if draftType, ok := fm["type"]; ok {
               draft.Type = draftType
            }
            if created, ok := fm["created"]; ok {
               draft.Created = created
            }
            drafts = append(drafts, draft)
         }
      }
      return nil
   })
   if err != nil {
      return nil, err
   }
   return drafts, nil
}
// ConvertOldDrafts 转换老格式草稿到新格式
func (i *Intake) ConvertOldDrafts(target string) (int, error) {
   reviewDir := filepath.Join(i.vaultPath, "待审阅")
   var dirs []string
   if target != "" {
      dirs = append(dirs, filepath.Join(reviewDir, target))
   } else {
      entries, err := os.ReadDir(reviewDir)
      if err != nil {
         return 0, err
      }
      for _, entry := range entries {
         if entry.IsDir() && entry.Name() != "archive" {
            dirs = append(dirs, filepath.Join(reviewDir, entry.Name()))
         }
      }
   }
   total := 0
   for _, dir := range dirs {
      if _, err := os.Stat(dir); os.IsNotExist(err) {
         continue
      }
      entries, err := os.ReadDir(dir)
      if err != nil {
         continue
      }
      var oldFiles []string
      for _, entry := range entries {
         if !entry.IsDir() && filepath.Ext(entry.Name()) == ".md" {
            // 检查是否是数字开头的单文件
            name := entry.Name()
            if len(name) >= 3 && isDigit(name[0]) && isDigit(name[1]) && isDigit(name[2]) {
               oldFiles = append(oldFiles, filepath.Join(dir, name))
            }
         }
      }
      if len(oldFiles) == 0 {
         continue
      }
      fmt.Printf("\n🔍 %s: 找到 %d 个老格式文件\n\n", filepath.Base(dir), len(oldFiles))
      for _, oldFile := range oldFiles {
         if convertOneDraft(oldFile) {
            total++
         }
      }
   }
   return total, nil
}
// convertOneDraft 转换单个老格式草稿
func convertOneDraft(oldFile string) bool {
   fmt.Printf("📄 转换: %s\n", filepath.Base(oldFile))
   content, err := os.ReadFile(oldFile)
   if err != nil {
      fmt.Printf("  ⚠️  读取失败: %v\n", err)
      return false
   }
   // 解析文件名
   name := filepath.Base(oldFile)
   ext := filepath.Ext(name)
   nameWithoutExt := strings.TrimSuffix(name, ext)
   // 提取编号、日期、标题
   parts := strings.SplitN(nameWithoutExt, "-", 3)
   if len(parts) < 3 {
      fmt.Printf("  ⚠️  文件名格式不匹配: %s\n", name)
      return false
   }
   num := parts[0]
   dateStr := parts[1]
   title := parts[2]
   // 创建新目录
   newDirName := fmt.Sprintf("%s-%s-%s", num, dateStr, title)
   newDir := filepath.Join(filepath.Dir(oldFile), newDirName)
   if err := os.MkdirAll(newDir, 0755); err != nil {
      fmt.Printf("  ⚠️  创建目录失败: %v\n", err)
      return false
   }
   // 解析 frontmatter
   fm := parseFrontmatter(string(content))
   // 构建标准化 frontmatter
   var sb strings.Builder
   sb.WriteString("---\n")
   if title, ok := fm["title"]; ok {
      sb.WriteString(fmt.Sprintf("title: %q\n", title))
   } else {
      sb.WriteString(fmt.Sprintf("title: %q\n", strings.ReplaceAll(title, "_", "/")))
   }
   sb.WriteString("status: \"draft\"\n")
   if draftType, ok := fm["type"]; ok {
      sb.WriteString(fmt.Sprintf("type: %q\n", draftType))
   } else {
      sb.WriteString("type: \"TAPD\"\n")
   }
   if source, ok := fm["source"]; ok {
      sb.WriteString(fmt.Sprintf("source: %q\n", source))
   } else {
      sb.WriteString(fmt.Sprintf("source: \"TAPD-%s\"\n", dateStr))
   }
   if tags, ok := fm["tags"]; ok {
      // 如果 tags 已经是数组格式,直接使用
      if strings.HasPrefix(tags, "[") {
         sb.WriteString(fmt.Sprintf("tags: %s\n", tags))
      } else {
         sb.WriteString(fmt.Sprintf("tags: [%s]\n", tags))
      }
   } else {
      sb.WriteString("tags: []\n")
   }
   if created, ok := fm["created"]; ok {
      sb.WriteString(fmt.Sprintf("created: %q\n", created))
   } else if created, ok := fm["date"]; ok {
      sb.WriteString(fmt.Sprintf("created: %q\n", created))
   } else {
      // 从日期字符串构造
      if len(dateStr) == 8 {
         created := fmt.Sprintf("%s-%s-%s", dateStr[:4], dateStr[4:6], dateStr[6:8])
         sb.WriteString(fmt.Sprintf("created: %q\n", created))
      }
   }
   sb.WriteString("---\n\n")
   // 提取正文(去掉原 frontmatter)
   body := extractBody(string(content))
   sb.WriteString(body)
   // 写入 draft.md
   draftFile := filepath.Join(newDir, "draft.md")
   if err := os.WriteFile(draftFile, []byte(sb.String()), 0644); err != nil {
      fmt.Printf("  ⚠️  写入 draft.md 失败: %v\n", err)
      return false
   }
   fmt.Printf("  ✅ 创建: draft.md\n")
   // 删除原文件
   if err := os.Remove(oldFile); err != nil {
      fmt.Printf("  ⚠️  删除原文件失败: %v\n", err)
   } else {
      fmt.Printf("  🗑️  删除: %s\n", name)
   }
   return true
}
// extractBody 提取正文(去掉 frontmatter)
func extractBody(content string) string {
   if !strings.HasPrefix(content, "---") {
      return content
   }
   endIdx := strings.Index(content[3:], "\n---")
   if endIdx == -1 {
      return content
   }
   return strings.TrimSpace(content[endIdx+7:])
}
// parseFrontmatter 解析 frontmatter
func parseFrontmatter(content string) map[string]string {
   result := make(map[string]string)
   if !strings.HasPrefix(content, "---") {
      return result
   }
   endIdx := strings.Index(content[3:], "\n---")
   if endIdx == -1 {
      return result
   }
   fmText := content[3 : endIdx+3]
   lines := strings.Split(fmText, "\n")
   for _, line := range lines {
      line = strings.TrimSpace(line)
      if line == "" || !strings.Contains(line, ":") {
         continue
      }
      parts := strings.SplitN(line, ":", 2)
      if len(parts) != 2 {
         continue
      }
      key := strings.TrimSpace(parts[0])
      value := strings.TrimSpace(parts[1])
      // 去除引号
      value = strings.Trim(value, "\"'")
      result[key] = value
   }
   return result
}
// isDigit 检查字符是否为数字
func isDigit(c byte) bool {
   return c >= '0' && c <= '9'
}
// fileExists 检查文件是否存在
func fileExists(path string) bool {
   _, err := os.Stat(path)
   return err == nil
}