| | |
| | | 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"` |
| | | Type string `yaml:"type"` |
| | | Status string `yaml:"status"` |
| | | Draft bool `yaml:"draft"` |
| | | Source string `yaml:"source,omitempty"` |
| | | Tags []string `yaml:"tags"` |
| | | Created string `yaml:"created"` |
| | |
| | | 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 创建草稿录入器 |
| | |
| | | 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 |
| | |
| | | return fmt.Errorf("创建草稿目录失败: %w", err) |
| | | } |
| | | |
| | | // 5. 写入 merge.md |
| | | // 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) |
| | | } |
| | | } |
| | | |
| | |
| | | return nil, nil |
| | | } |
| | | |
| | | // 使用所有 tags 作为关键词搜索 |
| | | // 使用所有 tags 作为关键词搜索,使用 OR 逻辑(任意一个匹配即可) |
| | | // 这样即使某些 tag 匹配不到,其他 tag 也能找到结果 |
| | | opts := search.SearchOptions{ |
| | | TopN: 3, |
| | | TopN: 3, |
| | | WithContent: true, // 需要内容给 LLM 评估 |
| | | } |
| | | |
| | | results, err := search.Search(i.store, tags, opts) |
| | | if err != nil { |
| | | return nil, err |
| | | // 如果搜索失败,尝试逐个 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 { |
| | | // 截取前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), |
| | | Title: r.Title, |
| | | Path: r.Path, |
| | | Score: float64(r.Score), |
| | | Content: content, |
| | | }) |
| | | } |
| | | |
| | |
| | | return maxSeq + 1, nil |
| | | } |
| | | |
| | | // getWeekNumber 获取周数 |
| | | // getWeekNumber 获取周数(返回纯数字周数,与 counter.json 格式一致) |
| | | func (i *Intake) getWeekNumber(t time.Time) string { |
| | | year, week := t.ISOWeek() |
| | | return fmt.Sprintf("%d-W%02d", year, week) |
| | | _, week := t.ISOWeek() |
| | | return fmt.Sprintf("%d", week) |
| | | } |
| | | |
| | | // sanitizeTitle 清理标题 |
| | |
| | | meta := DraftMeta{ |
| | | Title: title, |
| | | Type: draftType, |
| | | Status: "待确认", |
| | | Draft: true, |
| | | Status: "draft", |
| | | Source: source, |
| | | Tags: tags, |
| | | Created: now, |
| | |
| | | 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)) |
| | | sb.WriteString(fmt.Sprintf("draft: %v\n", meta.Draft)) |
| | | if meta.Source != "" { |
| | | sb.WriteString(fmt.Sprintf("source: %q\n", meta.Source)) |
| | | } |
| | |
| | | } |
| | | |
| | | 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) |
| | | } |
| | | |
| | | // 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 |
| | |
| | | |
| | | 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 |
| | | } |