From ac966af3d126d59081d119e05ba02f946257f669 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sun, 26 Jul 2026 23:22:36 +0800
Subject: [PATCH] fix: 移除所有help中的等号格式

---
 internal/draft/intake.go |  569 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 569 insertions(+), 0 deletions(-)

diff --git a/internal/draft/intake.go b/internal/draft/intake.go
new file mode 100644
index 0000000..49b4545
--- /dev/null
+++ b/internal/draft/intake.go
@@ -0,0 +1,569 @@
+package draft
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/aisim/kb-cli/internal/index"
+	"github.com/aisim/kb-cli/internal/llm"
+	"github.com/aisim/kb-cli/internal/search"
+)
+
+// Intake 草稿录入器
+type Intake struct {
+	vaultPath string
+	llmClient *llm.Client
+	store     *index.Store
+}
+
+// DraftMeta 草稿元数据
+type DraftMeta struct {
+	Title   string   `yaml:"title"`
+	Type    string   `yaml:"type"`
+	Status  string   `yaml:"status"`
+	Source  string   `yaml:"source,omitempty"`
+	Tags    []string `yaml:"tags"`
+	Created string   `yaml:"created"`
+}
+
+// Counter 计数器
+type Counter struct {
+	Week    string `json:"week"`
+	Next    int    `json:"next"`
+	ResetDay string `json:"reset_day"`
+	LastUpdated string `json:"last_updated"`
+}
+
+// UnmarshalJSON 自定义反序列化,兼容 week 字段为数字或字符串
+func (c *Counter) UnmarshalJSON(data []byte) error {
+	type Alias Counter
+	aux := &struct {
+		Week interface{} `json:"week"`
+		*Alias
+	}{
+		Alias: (*Alias)(c),
+	}
+	if err := json.Unmarshal(data, aux); err != nil {
+		return err
+	}
+	switch v := aux.Week.(type) {
+	case float64:
+		c.Week = fmt.Sprintf("%d", int(v))
+	case string:
+		c.Week = v
+	}
+	return nil
+}
+
+// NewIntake 创建草稿录入器
+func NewIntake(vaultPath string, store *index.Store) *Intake {
+	// 展开 ~ 为实际的用户目录
+	if strings.HasPrefix(vaultPath, "~/") {
+		if home, err := os.UserHomeDir(); err == nil {
+			vaultPath = filepath.Join(home, vaultPath[2:])
+		}
+	}
+	
+	return &Intake{
+		vaultPath: vaultPath,
+		llmClient: llm.NewClient(),
+		store:     store,
+	}
+}
+
+// CreateDraft 创建草稿
+func (i *Intake) CreateDraft(draftType, title, content, source string, force bool) error {
+	// 1. 调用 LLM 提取 tags
+	fmt.Println("正在提取 tags...")
+	extractResult, err := i.llmClient.ExtractTags(content)
+	if err != nil {
+		return fmt.Errorf("提取 tags 失败: %w", err)
+	}
+	fmt.Printf("提取到 %d 个 tags: %v\n", len(extractResult.Tags), extractResult.Tags)
+
+	// 2. 使用 tags 搜索知识库
+	fmt.Println("正在搜索相关文档...")
+	candidates, err := i.searchByTags(extractResult.Tags)
+	if err != nil {
+		return fmt.Errorf("搜索知识库失败: %w", err)
+	}
+	fmt.Printf("找到 %d 个相关文档\n", len(candidates))
+
+	// 3. 调用 LLM 生成合并指示
+	var mergeHint *llm.MergeHint
+	if len(candidates) > 0 {
+		fmt.Println("正在生成合并指示...")
+		mergeHint, err = i.llmClient.GenerateMergeHint(content, candidates)
+		if err != nil {
+			fmt.Printf("警告: 生成合并指示失败: %v\n", err)
+			mergeHint = nil
+		}
+	}
+
+	// 4. 创建草稿目录和文件
+	draftDir, err := i.createDraftDir(draftType, title, content, source, extractResult.Tags, force)
+	if err != nil {
+		return fmt.Errorf("创建草稿目录失败: %w", err)
+	}
+
+	// 5. 写入 merge.md(无论是否有候选文档都生成)
+	if mergeHint != nil {
+		if err := i.writeMergeHint(draftDir, mergeHint, candidates); err != nil {
+			fmt.Printf("警告: 写入合并指示失败: %v\n", err)
+		}
+	} else {
+		// 没有候选文档时,生成默认的 merge.md
+		if err := i.writeDefaultMergeHint(draftDir); err != nil {
+			fmt.Printf("警告: 写入默认合并指示失败: %v\n", err)
+		}
+	}
+
+	fmt.Printf("草稿创建成功: %s\n", draftDir)
+	return nil
+}
+
+// searchByTags 使用 tags 搜索知识库
+func (i *Intake) searchByTags(tags []string) ([]llm.SearchCandidate, error) {
+	if len(tags) == 0 {
+		return nil, nil
+	}
+
+	// 使用所有 tags 作为关键词搜索,使用 OR 逻辑(任意一个匹配即可)
+	// 这样即使某些 tag 匹配不到,其他 tag 也能找到结果
+	opts := search.SearchOptions{
+		TopN: 3,
+	}
+
+	results, err := search.Search(i.store, tags, opts)
+	if err != nil {
+		// 如果搜索失败,尝试逐个 tag 搜索,返回第一个有结果的
+		for _, tag := range tags {
+			singleResults, singleErr := search.Search(i.store, []string{tag}, opts)
+			if singleErr == nil && len(singleResults) > 0 {
+				results = singleResults
+				break
+			}
+		}
+		if len(results) == 0 {
+			return nil, nil // 所有 tag 都搜索失败,返回空而不是错误
+		}
+	}
+
+	candidates := make([]llm.SearchCandidate, 0, len(results))
+	for _, r := range results {
+		candidates = append(candidates, llm.SearchCandidate{
+			Title: r.Title,
+			Path:  r.Path,
+			Score: float64(r.Score),
+		})
+	}
+
+	return candidates, nil
+}
+
+// createDraftDir 创建草稿目录和文件
+func (i *Intake) createDraftDir(draftType, title, content, source string, tags []string, force bool) (string, error) {
+	// 确定子目录
+	subDir := i.getSubDir(draftType)
+	reviewDir := filepath.Join(i.vaultPath, "待审阅", subDir)
+
+	// 检查是否已存在相同标题的草稿
+	if !force {
+		if err := i.checkDuplicate(reviewDir, title); err != nil {
+			return "", err
+		}
+	}
+
+	// 获取下一个编号
+	seq, err := i.getNextSeq(reviewDir)
+	if err != nil {
+		return "", fmt.Errorf("获取编号失败: %w", err)
+	}
+
+	// 生成目录名
+	now := time.Now()
+	dateStr := now.Format("20060102")
+	safeTitle := i.sanitizeTitle(title)
+	dirName := fmt.Sprintf("%03d-%s-%s", seq, dateStr, safeTitle)
+	draftDir := filepath.Join(reviewDir, dirName)
+
+	// 创建目录
+	if err := os.MkdirAll(draftDir, 0755); err != nil {
+		return "", fmt.Errorf("创建目录失败: %w", err)
+	}
+
+	// 写入 draft.md
+	draftPath := filepath.Join(draftDir, "draft.md")
+	if err := i.writeDraftFile(draftPath, title, draftType, source, tags, content); err != nil {
+		return "", fmt.Errorf("写入 draft.md 失败: %w", err)
+	}
+
+	return draftDir, nil
+}
+
+// getSubDir 获取子目录名
+func (i *Intake) getSubDir(draftType string) string {
+	typeMap := map[string]string{
+		"售后": "售后提取",
+		"产品": "产品提取",
+		"运营": "运营提取",
+		"行业": "行业提取",
+		"TAPD": "TAPD提取",
+	}
+	if subDir, ok := typeMap[draftType]; ok {
+		return subDir
+	}
+	return "沟通提取"
+}
+
+// checkDuplicate 检查重复
+func (i *Intake) checkDuplicate(reviewDir, title string) error {
+	entries, err := os.ReadDir(reviewDir)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return err
+	}
+
+	safeTitle := i.sanitizeTitle(title)
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
+		}
+		// 检查目录名是否包含相同标题
+		if strings.Contains(entry.Name(), safeTitle) {
+			return fmt.Errorf("待审阅区已存在相同标题的草稿: %s,使用 --force 强制创建", entry.Name())
+		}
+	}
+
+	return nil
+}
+
+// getNextSeq 获取下一个编号
+func (i *Intake) getNextSeq(reviewDir string) (int, error) {
+	// 读取 counter.json
+	counterPath := filepath.Join(i.vaultPath, "待审阅", "counter.json")
+	counter, err := i.readCounter(counterPath)
+	if err != nil {
+		// 如果不存在,从目录中推断
+		return i.inferNextSeq(reviewDir)
+	}
+
+	// 检查是否需要重置(每周重置)
+	now := time.Now()
+	currentWeek := i.getWeekNumber(now)
+	if counter.Week != currentWeek {
+		counter.Week = currentWeek
+		counter.Next = 1
+		counter.ResetDay = now.Format("2006-01-02")
+	}
+
+	nextSeq := counter.Next
+	counter.Next++
+	counter.LastUpdated = now.Format(time.RFC3339)
+
+	// 写回 counter.json
+	if err := i.writeCounter(counterPath, counter); err != nil {
+		return 0, fmt.Errorf("更新计数器失败: %w", err)
+	}
+
+	return nextSeq, nil
+}
+
+// readCounter 读取计数器
+func (i *Intake) readCounter(path string) (*Counter, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+
+	var counter Counter
+	if err := json.Unmarshal(data, &counter); err != nil {
+		return nil, err
+	}
+
+	return &counter, nil
+}
+
+// writeCounter 写入计数器
+func (i *Intake) writeCounter(path string, counter *Counter) error {
+	data, err := json.MarshalIndent(counter, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(path, data, 0644)
+}
+
+// inferNextSeq 从目录推断下一个编号
+func (i *Intake) inferNextSeq(reviewDir string) (int, error) {
+	entries, err := os.ReadDir(reviewDir)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return 1, nil
+		}
+		return 0, err
+	}
+
+	maxSeq := 0
+	re := regexp.MustCompile(`^(\d{3})-`)
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
+		}
+		matches := re.FindStringSubmatch(entry.Name())
+		if len(matches) > 1 {
+			var seq int
+			fmt.Sscanf(matches[1], "%d", &seq)
+			if seq > maxSeq {
+				maxSeq = seq
+			}
+		}
+	}
+
+	return maxSeq + 1, nil
+}
+
+// getWeekNumber 获取周数(返回纯数字周数,与 counter.json 格式一致)
+func (i *Intake) getWeekNumber(t time.Time) string {
+	_, week := t.ISOWeek()
+	return fmt.Sprintf("%d", week)
+}
+
+// sanitizeTitle 清理标题
+func (i *Intake) sanitizeTitle(title string) string {
+	// 替换不安全字符
+	re := regexp.MustCompile(`[\\/:*?"<>|\s]`)
+	safe := re.ReplaceAllString(title, "_")
+	// 限制长度
+	if len(safe) > 50 {
+		safe = safe[:50]
+	}
+	return safe
+}
+
+// writeDraftFile 写入 draft.md
+func (i *Intake) writeDraftFile(path, title, draftType, source string, tags []string, content string) error {
+	now := time.Now().Format("2006-01-02 15:04:05")
+
+	// 构建 frontmatter
+	meta := DraftMeta{
+		Title:   title,
+		Type:    draftType,
+		Status:  "draft",
+		Source:  source,
+		Tags:    tags,
+		Created: now,
+	}
+
+	var sb strings.Builder
+	sb.WriteString("---\n")
+	sb.WriteString(fmt.Sprintf("title: %q\n", meta.Title))
+	sb.WriteString(fmt.Sprintf("type: %q\n", meta.Type))
+	sb.WriteString(fmt.Sprintf("status: %q\n", meta.Status))
+	if meta.Source != "" {
+		sb.WriteString(fmt.Sprintf("source: %q\n", meta.Source))
+	}
+	sb.WriteString("tags: [")
+	for i, tag := range meta.Tags {
+		if i > 0 {
+			sb.WriteString(", ")
+		}
+		sb.WriteString(fmt.Sprintf("%q", tag))
+	}
+	sb.WriteString("]\n")
+	sb.WriteString(fmt.Sprintf("created: %q\n", meta.Created))
+	sb.WriteString("---\n\n")
+	sb.WriteString(content)
+
+	return os.WriteFile(path, []byte(sb.String()), 0644)
+}
+
+// writeMergeHint 写入 merge.md
+func (i *Intake) writeMergeHint(draftDir string, hint *llm.MergeHint, candidates []llm.SearchCandidate) error {
+	mergePath := filepath.Join(draftDir, "merge.md")
+	now := time.Now().Format("2006-01-02 15:04:05")
+
+	var sb strings.Builder
+	sb.WriteString("---\n")
+	sb.WriteString(fmt.Sprintf("generated_at: %q\n", now))
+	sb.WriteString(fmt.Sprintf("confidence: %q\n", hint.Recommendation.Confidence))
+	sb.WriteString("---\n\n")
+
+	sb.WriteString("## 合并指示\n\n")
+	sb.WriteString(fmt.Sprintf("**操作类型**: %s\n", hint.Recommendation.Action))
+	sb.WriteString(fmt.Sprintf("**目标**: %s\n", hint.Recommendation.Target))
+	sb.WriteString(fmt.Sprintf("**理由**: %s\n\n", hint.Recommendation.Reason))
+
+	sb.WriteString("## 依据\n\n")
+	sb.WriteString("### 搜索到的相关文档(Top 3)\n\n")
+	for i, cand := range candidates {
+		sb.WriteString(fmt.Sprintf("%d. **%s** (相似度: %.2f)\n", i+1, cand.Title, cand.Score))
+		sb.WriteString(fmt.Sprintf("   - 路径: `%s`\n\n", cand.Path))
+	}
+
+	sb.WriteString("## LLM 判断\n\n")
+	for _, a := range hint.Analysis {
+		sb.WriteString(fmt.Sprintf("- **%s**: %s - %s\n", a.Path, a.Relevance, a.Reason))
+	}
+
+	return os.WriteFile(mergePath, []byte(sb.String()), 0644)
+}
+
+// writeDefaultMergeHint 写入默认的 merge.md(当没有候选文档时)
+func (i *Intake) writeDefaultMergeHint(draftDir string) error {
+	mergePath := filepath.Join(draftDir, "merge.md")
+	now := time.Now().Format("2006-01-02 15:04:05")
+
+	var sb strings.Builder
+	sb.WriteString("---\n")
+	sb.WriteString(fmt.Sprintf("generated_at: %q\n", now))
+	sb.WriteString("confidence: \"low\"\n")
+	sb.WriteString("---\n\n")
+
+	sb.WriteString("## 合并指示\n\n")
+	sb.WriteString("**操作类型**: new\n")
+	sb.WriteString("**目标**: 新建独立文档\n")
+	sb.WriteString("**理由**: 未找到相关文档,建议新建\n\n")
+
+	sb.WriteString("## 依据\n\n")
+	sb.WriteString("### 搜索到的相关文档\n\n")
+	sb.WriteString("未找到相关文档。\n\n")
+
+	sb.WriteString("## 建议\n\n")
+	sb.WriteString("审阅时请根据草稿内容确定合适的分类和存放位置。\n")
+
+	return os.WriteFile(mergePath, []byte(sb.String()), 0644)
+}
+
+// RebuildTags 批量重建 tags
+func (i *Intake) RebuildTags(limit int, dryRun bool) error {
+	// 扫描知识库所有 .md 文件
+	files, err := i.scanMarkdownFiles()
+	if err != nil {
+		return fmt.Errorf("扫描文件失败: %w", err)
+	}
+
+	if limit > 0 && len(files) > limit {
+		files = files[:limit]
+	}
+
+	fmt.Printf("找到 %d 个文件\n", len(files))
+
+	updated := 0
+	for _, file := range files {
+		fmt.Printf("处理: %s\n", file)
+
+		// 读取文件内容
+		content, err := os.ReadFile(file)
+		if err != nil {
+			fmt.Printf("  警告: 读取失败: %v\n", err)
+			continue
+		}
+
+		// 提取正文(去掉 frontmatter)
+		body := i.extractBody(string(content))
+
+		// 调用 LLM 提取 tags
+		extractResult, err := i.llmClient.ExtractTags(body)
+		if err != nil {
+			fmt.Printf("  警告: 提取 tags 失败: %v\n", err)
+			continue
+		}
+
+		if dryRun {
+			fmt.Printf("  将更新 tags: %v\n", extractResult.Tags)
+		} else {
+			// 更新文件 frontmatter
+			if err := i.updateTagsInFile(file, string(content), extractResult.Tags); err != nil {
+				fmt.Printf("  警告: 更新 tags 失败: %v\n", err)
+				continue
+			}
+			fmt.Printf("  已更新 tags: %v\n", extractResult.Tags)
+		}
+
+		updated++
+	}
+
+	fmt.Printf("处理完成: %d/%d 文件\n", updated, len(files))
+	return nil
+}
+
+// scanMarkdownFiles 扫描 markdown 文件
+func (i *Intake) scanMarkdownFiles() ([]string, error) {
+	var files []string
+
+	// 扫描 FAQ、知识、文档目录
+	dirs := []string{"FAQ", "知识", "文档"}
+	for _, dir := range dirs {
+		dirPath := filepath.Join(i.vaultPath, dir)
+		if _, err := os.Stat(dirPath); os.IsNotExist(err) {
+			continue
+		}
+
+		err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
+			if err != nil {
+				return err
+			}
+			if !info.IsDir() && strings.HasSuffix(path, ".md") {
+				files = append(files, path)
+			}
+			return nil
+		})
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// 排序
+	sort.Strings(files)
+	return files, nil
+}
+
+// extractBody 提取正文(去掉 frontmatter)
+func (i *Intake) extractBody(content string) string {
+	// 查找 frontmatter 结束位置
+	re := regexp.MustCompile(`(?s)^---\n.*?\n---\n`)
+	loc := re.FindStringIndex(content)
+	if loc != nil {
+		return content[loc[1]:]
+	}
+	return content
+}
+
+// updateTagsInFile 更新文件中的 tags
+func (i *Intake) updateTagsInFile(path, content string, tags []string) error {
+	// 解析现有 frontmatter
+	re := regexp.MustCompile(`(?s)^---\n(.*?)\n---`)
+	matches := re.FindStringSubmatchIndex(content)
+	if matches == nil {
+		return fmt.Errorf("未找到 frontmatter")
+	}
+
+	frontmatter := content[matches[2]:matches[3]]
+
+	// 替换 tags 行
+	tagsRe := regexp.MustCompile(`(?m)^tags:.*$`)
+	tagsLine := "tags: ["
+	for i, tag := range tags {
+		if i > 0 {
+			tagsLine += ", "
+		}
+		tagsLine += fmt.Sprintf("%q", tag)
+	}
+	tagsLine += "]"
+
+	newFrontmatter := tagsRe.ReplaceAllString(frontmatter, tagsLine)
+
+	// 重建文件内容
+	newContent := content[:matches[2]] + newFrontmatter + content[matches[3]:]
+
+	return os.WriteFile(path, []byte(newContent), 0644)
+}

--
Gitblit v1.9.1