package api import ( "encoding/json" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "eaisalestrain/backend/internal/middleware" "eaisalestrain/backend/internal/model" "eaisalestrain/backend/internal/store" "eaisalestrain/backend/internal/web" ) // ============ 扫描 ============ // KnowledgeScan POST /api/knowledge/scan —— 扫描 knowledge_source 目录建 pending 记录 func KnowledgeScan(c *gin.Context) { entries, err := os.ReadDir(Cfg.KnowledgeSourceDir) if err != nil { web.Fail(c, web.NewBadRequest("知识源目录不存在:"+Cfg.KnowledgeSourceDir)) return } results := make([]gin.H, 0, len(entries)) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".md") { continue } if strings.HasPrefix(strings.ToUpper(e.Name()), "README") { continue } full := filepath.Join(Cfg.KnowledgeSourceDir, e.Name()) data, err := os.ReadFile(full) if err != nil { continue } fm := parseFrontMatter(string(data)) if fm["category"] == "" || fm["version"] == "" { continue // 非知识源文档,跳过 } var existing model.KnowledgeSource if err := store.DB.Where("file_path = ?", e.Name()).First(&existing).Error; err == nil { results = append(results, gin.H{"file_path": e.Name(), "status": "skipped", "title": existing.Title}) continue } src := model.KnowledgeSource{ Title: parseTitle(string(data)), FilePath: e.Name(), Category: fm["category"], Domain: orDefault(fm["domain"], "product"), SourceVersion: fm["version"], AuditStatus: "pending", KnowledgeSpaceKey: ensureKnowledgeSpaceKeyOrDefault(orDefault(fm["knowledge_space_key"], inferKnowledgeSpaceKey(parseTitle(string(data)), fm["domain"], fm["category"]+" "+e.Name()))), } if err := store.DB.Create(&src).Error; err != nil { results = append(results, gin.H{"file_path": e.Name(), "status": "error", "title": src.Title}) continue } results = append(results, gin.H{"file_path": e.Name(), "status": "created", "title": src.Title, "knowledge_space_key": src.KnowledgeSpaceKey}) } web.OK(c, gin.H{"results": results}) } // KnowledgeAuditList GET /api/knowledge/audit-list?status=&page=&size= func KnowledgeAuditList(c *gin.Context) { q := store.DB.Model(&model.KnowledgeSource{}) if s := c.Query("status"); s != "" { q = q.Where("audit_status = ?", s) } if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" { q = q.Where("knowledge_space_key = ?", key) } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } var total int64 q.Count(&total) var items []model.KnowledgeSource q.Order("id ASC").Offset((page - 1) * size).Limit(size).Find(&items) web.OK(c, gin.H{"total": total, "items": items}) } // KnowledgeAudit POST /api/knowledge/audit/{sourceId} —— 审批(approve→摄入 / reject→驳回) func KnowledgeAudit(c *gin.Context) { u := middleware.CurrentUser(c) id, ok := parseID(c, "sourceId") if !ok { return } var src model.KnowledgeSource if err := store.DB.First(&src, id).Error; err != nil { web.Fail(c, web.NewNotFoundError("知识源不存在")) return } var req struct { Action string `json:"action"` RejectReason string `json:"reject_reason"` } if err := c.ShouldBindJSON(&req); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return } now := time.Now() auditBy := u.ID switch req.Action { case "approve": if src.Ingested { web.OK(c, gin.H{"status": "approved", "source_id": src.ID, "products": 0, "chunks": 0, "questions": 0, "already_ingested": true}) return } counts, err := ingestSource(&src) if err != nil { web.Fail(c, web.NewBadRequest("摄入失败:"+err.Error())) return } src.AuditStatus = "approved" src.AuditBy = &auditBy src.AuditAt = &now src.RejectReason = "" src.Ingested = true if err := store.DB.Save(&src).Error; err != nil { web.Fail(c, web.NewBadRequest("审批失败")) return } triggerKnowledgeIndexRebuild() web.OK(c, gin.H{ "status": "approved", "source_id": src.ID, "products": counts[0], "chunks": counts[1], "questions": counts[2], }) case "reject": if strings.TrimSpace(req.RejectReason) == "" { web.Fail(c, web.NewBadRequest("驳回理由必填")) return } src.AuditStatus = "rejected" src.RejectReason = req.RejectReason src.AuditBy = &auditBy src.AuditAt = &now if err := store.DB.Save(&src).Error; err != nil { web.Fail(c, web.NewBadRequest("审批失败")) return } web.OK(c, gin.H{"status": "rejected", "source_id": src.ID}) default: web.Fail(c, web.NewBadRequest("action 必须为 approve 或 reject")) } } // KnowledgeStatus GET /api/knowledge/status/{sourceId} func KnowledgeStatus(c *gin.Context) { id, ok := parseID(c, "sourceId") if !ok { return } var src model.KnowledgeSource if err := store.DB.First(&src, id).Error; err != nil { web.Fail(c, web.NewNotFoundError("知识源不存在")) return } web.OK(c, gin.H{ "audit_status": src.AuditStatus, "ingested": src.Ingested, "reject_reason": src.RejectReason, "knowledge_space_key": resolveKnowledgeSourceSpaceKey(src), }) } // ============ md 解析 ============ func parseFrontMatter(content string) map[string]string { m := map[string]string{} parts := strings.SplitN(content, "---", 3) if len(parts) < 3 { return m } for _, line := range strings.Split(parts[1], "\n") { line = strings.TrimSpace(line) if line == "" || !strings.Contains(line, ":") { continue } kv := strings.SplitN(line, ":", 2) m[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) } return m } func parseTitle(content string) string { for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "# ") { return strings.TrimSpace(strings.TrimPrefix(line, "# ")) } } return "" } type mdBlock struct { title string body string } func splitSections(content string) map[string]string { sections := map[string]string{} lines := strings.Split(content, "\n") current := "" var buf strings.Builder for _, line := range lines { if strings.HasPrefix(line, "## ") { if current != "" { sections[current] = buf.String() } current = strings.TrimSpace(strings.TrimPrefix(line, "## ")) buf.Reset() } else { buf.WriteString(line + "\n") } } if current != "" { sections[current] = buf.String() } return sections } func splitBlocks(body string) []mdBlock { var blocks []mdBlock current := "" var buf strings.Builder flush := func() { if current != "" || strings.TrimSpace(buf.String()) != "" { blocks = append(blocks, mdBlock{current, buf.String()}) } } for _, line := range strings.Split(body, "\n") { if strings.HasPrefix(line, "### ") { flush() current = strings.TrimSpace(strings.TrimPrefix(line, "### ")) buf.Reset() } else { buf.WriteString(line + "\n") } } flush() return blocks } func parseKV(body string) map[string]string { m := map[string]string{} for _, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "- ") { continue } line = strings.TrimPrefix(line, "- ") if i := strings.Index(line, ":"); i >= 0 { m[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:]) } } return m } // indexKey 选项索引 → 键(0→A, 1→B ...) func indexKey(i int) string { if i < 0 || i > 25 { return "?" } return string(rune('A' + i)) } func convertOptions(raw []string) []Option { opts := make([]Option, len(raw)) for i, text := range raw { opts[i] = Option{Key: indexKey(i), Text: text} } return opts } func convertAnswer(raw []any) []string { out := make([]string, 0, len(raw)) for _, a := range raw { switch v := a.(type) { case bool: out = append(out, strconv.FormatBool(v)) case float64: out = append(out, indexKey(int(v))) case string: out = append(out, v) } } return out } // ============ 摄入 ============ // ingestSource 解析 md → product / knowledge_chunk / question,返回三表写入计数 func ingestSource(src *model.KnowledgeSource) ([3]int, error) { var counts [3]int full := filepath.Join(Cfg.KnowledgeSourceDir, src.FilePath) data, err := os.ReadFile(full) if err != nil { return counts, fmt.Errorf("读取文件失败") } content := string(data) fm := parseFrontMatter(content) cat := orDefault(src.Category, fm["category"]) sections := splitSections(content) // 1. 产品 for _, b := range splitBlocks(sections["结构化产品数据"]) { kv := parseKV(b.body) if kv["code"] == "" || kv["name"] == "" { continue } p := model.Product{ Code: kv["code"], Name: kv["name"], Category: orDefault(kv["category"], cat), Tags: kv["tags"], Description: kv["description"], Pricing: kv["pricing"], CommissionRecommend: kv["commission_recommend"], CommissionNegotiate: kv["commission_negotiate"], PublicCourseBonus: kv["public_course_bonus"], VersionRisk: kv["version_risk"], ReportRules: kv["report_rules"], Status: "active", } var existing model.Product if err := store.DB.Where("code = ?", p.Code).First(&existing).Error; err == nil { p.ID = existing.ID store.DB.Save(&p) } else { store.DB.Create(&p) } counts[0]++ } // 2. AI 检索知识 → knowledge_chunk for i, b := range splitBlocks(sections["AI 检索知识"]) { text := strings.TrimSpace(b.body) if text == "" { continue } store.DB.Create(&model.KnowledgeChunk{ KnowledgeSourceID: &src.ID, SourceType: "md", SourceID: strconv.FormatUint(uint64(src.ID), 10), KnowledgeSpaceKey: resolveKnowledgeSourceSpaceKey(*src), ChunkIndex: i, Content: text, }) counts[1]++ } // 3. 考试题目 → question for _, b := range splitBlocks(sections["考试题目"]) { kv := parseKV(b.body) if kv["type"] == "" || kv["stem"] == "" { continue } var optsRaw []string _ = json.Unmarshal([]byte(kv["options"]), &optsRaw) var ansRaw []any _ = json.Unmarshal([]byte(kv["answer"]), &ansRaw) optsJSON, _ := json.Marshal(convertOptions(optsRaw)) ansJSON, _ := json.Marshal(convertAnswer(ansRaw)) q := model.Question{ Domain: orDefault(kv["domain"], src.Domain), Type: kv["type"], Stem: kv["stem"], Options: string(optsJSON), Answer: string(ansJSON), Explanation: kv["explanation"], Status: "active", } store.DB.Create(&q) counts[2]++ } return counts, nil } func orDefault(v, def string) string { if strings.TrimSpace(v) == "" { return def } return v }