From 1196f409d86bc61e7596eb274840244a62ce84ba Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Wed, 29 Jul 2026 14:58:19 +0800
Subject: [PATCH] feat: config priority over env vars, export LoadConfig, add review feature
---
internal/draft/intake.go | 385 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 380 insertions(+), 5 deletions(-)
diff --git a/internal/draft/intake.go b/internal/draft/intake.go
index 49b4545..0f4c36f 100644
--- a/internal/draft/intake.go
+++ b/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
@@ -137,7 +149,8 @@
// 使用所有 tags 作为关键词搜索,使用 OR 逻辑(任意一个匹配即可)
// 这样即使某些 tag 匹配不到,其他 tag 也能找到结果
opts := search.SearchOptions{
- TopN: 3,
+ 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),
+ 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
+}
--
Gitblit v1.9.1