「仓库层」是 repository 的直译,中文里与「代码仓库 / git 仓库」同词, 而这一层做的事就是数据访问。名字改成它实际在做的事。 改名口径(纯机械替换,无逻辑改动): - 包:internal/repository → internal/dal(package repository → package dal) - 类型:XxxRepo → XxxDAO(TaskRecordDAO / SpecialistDAO / PositionDAO …) - 变量:xxxRepo → xxxDAO - import 路径、包限定符、日志前缀 [repository] → [dal] 同步 - 注释里的「仓库层」→「数据访问层」;core.go 包注释补上 DAL/DAO 全称 命名规范补登(AR09 是命名问题的最高依据,改了名就得回去登记): - AR09 §3.1 术语表新增「数据访问层 dal / DAO」一行 - AR09 §5.6 缩写表新增 DAO / dal —— 原文是「只有下表内的缩写允许使用」, 不登记就是自己破自己的规矩 - PROJECT_STATE.md 新增 D27 记录本次更名决策 验证:全部在 db 副本上做,生产库 data/eai_agentplatform.db 未触碰。 - 等价性对照:拿 HEAD 源码 + 仅改名 造出第二棵树,两棵树各自起 httptest 服务跑同一份探针(60 个 GET + 13 个写/回读,覆盖专员/技能/应用/ 任务/交付物/项目/岗位/考试/知识/积分/管理端只读等),逐端点比对响应体: 73 项里 52 项字节完全一致、21 项仅运行期时间戳不同、内容差异 0。 - 探针非空:往改名后的树注入「SpecialistDAO.List 限 3 条」变异, /api/specialists 立刻被抓出 —— 证明上面那个 0 不是没测到。 - 暂存区自洽:把索引整个导出成源码树,go build / go vet / go test ./... 全绿。 - gofmt:因 import 排序变化而错位的 19 个文件已修;另 2 个文件(skill_definition.go、 seed.go)的格式问题是工作区里别人的在制品带来的,未替其改动。 未纳入本次提交:工作区里正在进行中的「文生语音技能 + 技能展示色/交互卡」 (tts_handlers.go、text_to_speech/manifest.go、skillCatalog.js 等), 以及 router.go / skill_definition.go / seed.go 三个文件里属于该在制品的改动 —— 这三个文件只把「改名那一版」放进索引,工作区原样保留。 Co-Authored-By: Claude Code <noreply@anthropic.com>
399 lines
11 KiB
Go
399 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"eai_agentplatform/backend/internal/dal"
|
|
"eai_agentplatform/backend/internal/middleware"
|
|
"eai_agentplatform/backend/internal/model"
|
|
"eai_agentplatform/backend/internal/web"
|
|
)
|
|
|
|
// sourceDAO 知识源仓库(便于测试时覆写),包内共享。
|
|
var sourceDAO dal.KnowledgeSourceDAO
|
|
|
|
func init() {
|
|
sourceDAO = dal.KnowledgeSourceDAO{}
|
|
}
|
|
|
|
// ============ 扫描 ============
|
|
|
|
// 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 // 非知识源文档,跳过
|
|
}
|
|
|
|
if existing, found := sourceDAO.GetByFilePath(e.Name()); found {
|
|
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 !sourceDAO.Insert(&src) {
|
|
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) {
|
|
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
|
|
}
|
|
total, items := sourceDAO.ListForAudit(
|
|
c.Query("status"), sanitizeSpaceKey(c.Query("knowledge_space_key")), page, size)
|
|
web.OK(c, gin.H{"total": total, "items": items})
|
|
}
|
|
|
|
// KnowledgeAudit POST /api/knowledge/audit/{source_id} —— 审批(approve→摄入 / reject→驳回)
|
|
func KnowledgeAudit(c *gin.Context) {
|
|
u := middleware.CurrentUser(c)
|
|
id, ok := parseID(c, "source_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
src, found := sourceDAO.GetByID(id)
|
|
if !found {
|
|
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 !sourceDAO.Update(&src) {
|
|
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 !sourceDAO.Update(&src) {
|
|
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/{source_id}
|
|
func KnowledgeStatus(c *gin.Context) {
|
|
id, ok := parseID(c, "source_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
src, found := sourceDAO.GetByID(id)
|
|
if !found {
|
|
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,返回三表计数
|
|
//
|
|
// 口径提示:counts 记的是**从 md 解析出的条目数**,不是落库成功数 ——
|
|
// 单条写入失败不中断、也不扣减计数(沿用原有行为,未改)。
|
|
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",
|
|
}
|
|
if existing, found := productDAO.GetByCode(p.Code); found {
|
|
p.ID = existing.ID
|
|
p.CreatedAt = existing.CreatedAt // Save 整字段覆盖,不回填会把创建时间写成零值
|
|
productDAO.Update(&p)
|
|
} else {
|
|
productDAO.Insert(&p)
|
|
}
|
|
counts[0]++
|
|
}
|
|
|
|
// 2. AI 检索知识 → knowledge_chunk
|
|
for i, b := range splitBlocks(sections["AI 检索知识"]) {
|
|
text := strings.TrimSpace(b.body)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
chunkDAO.Insert(&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",
|
|
}
|
|
questionDAO.Insert(&q)
|
|
counts[2]++
|
|
}
|
|
|
|
return counts, nil
|
|
}
|
|
|
|
func orDefault(v, def string) string {
|
|
if strings.TrimSpace(v) == "" {
|
|
return def
|
|
}
|
|
return v
|
|
}
|