feat: 同步知识库与工作台相关改动

This commit is contained in:
Jackzhou
2026-08-24 10:40:15 +08:00
parent 8d0588827d
commit 4f33b036d1
50 changed files with 8034 additions and 273 deletions
+242 -28
View File
@@ -44,22 +44,50 @@ func ChatMessage(c *gin.Context) {
web.Fail(c, web.NewBadRequest("message 必填"))
return
}
if !checkBalance(c, user, ai.CapabilityAIChat) {
return
traceID := fmt.Sprintf("chat-%d", time.Now().UnixNano())
// #region debug-point B:chat-entry
if payload, err := json.Marshal(gin.H{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "B",
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:entry",
"traceId": traceID,
"msg": "[DEBUG] chat message received",
"data": gin.H{
"userId": user.ID,
"messagePreview": []rune(strings.TrimSpace(req.Message)),
"spaceKey": req.Context["knowledge_space_key"],
"historySize": len(req.History),
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
route, err := config.GetRoute("path_coach")
// #endregion
plan, err := buildKnowledgeAnswerPlan(req.Message, req.Context, req.History)
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error()))
web.Fail(c, web.NewBadRequest("知识问答链路执行失败"))
return
}
knowledge := ai.Retrieve(Cfg, req.Message, 5)
messages := []ai.Message{{Role: "system", Content: buildSystemPrompt(req.Context, knowledge)}}
messages = append(messages, req.History...)
messages = append(messages, ai.Message{Role: "user", Content: req.Message})
client := ai.NewClient(route)
// #region debug-point B:plan-built
if payload, err := json.Marshal(gin.H{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "B",
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:plan",
"traceId": traceID,
"msg": "[DEBUG] knowledge plan built",
"data": gin.H{
"intent": plan.Intent,
"layer": plan.Layer,
"citations": len(plan.Citations),
"llmMessageSize": len(plan.LLMMessages),
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
@@ -78,11 +106,75 @@ func ChatMessage(c *gin.Context) {
flusher.Flush()
}
writeEvent(gin.H{
"type": "meta",
"intent": plan.Intent,
"layer": plan.Layer,
"citations": plan.Citations,
})
if len(plan.LLMMessages) == 0 {
writeEvent(gin.H{"type": "text", "content": plan.Answer})
writeEvent(gin.H{"type": "done"})
return
}
if !checkBalance(c, user, ai.CapabilityAIChat) {
writeEvent(gin.H{"type": "error", "message": "AI 点数不足,请联系管理员充值"})
return
}
route, err := config.GetRoute("path_coach")
if err != nil {
writeEvent(gin.H{"type": "error", "message": "LLM 路由未配置: " + err.Error()})
return
}
// #region debug-point B:llm-route
if payload, err := json.Marshal(gin.H{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "B",
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-route",
"traceId": traceID,
"msg": "[DEBUG] llm route selected",
"data": gin.H{
"routeId": route.RouteID,
"provider": route.Provider,
"baseURL": route.BaseURL,
"model": route.Model,
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
start := time.Now()
if err := client.GenerateStream(messages, func(chunk string) {
usedRoute, err := ai.GenerateStreamWithFallback(route, plan.LLMMessages, func(chunk string) {
writeEvent(gin.H{"type": "text", "content": chunk})
}); err != nil {
writeEvent(gin.H{"type": "error", "message": err.Error()})
})
if err != nil {
// #region debug-point B:llm-error
if payload, marshalErr := json.Marshal(gin.H{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "B",
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-error",
"traceId": traceID,
"msg": "[DEBUG] llm stream returned error",
"data": gin.H{
"error": err.Error(),
},
"ts": time.Now().UnixMilli(),
}); marshalErr == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
message := err.Error()
if strings.Contains(message, "未配置 API Key") || strings.Contains(message, "No cookie auth credentials found") {
message = "当前知识库已进入 LLM 兜底层,但系统还没有配置可用的 LLM 服务。请管理员配置 OpenRouter API Key,或启动本地 Ollama 后再重试。"
} else if strings.Contains(message, "所有路由均失败") || strings.Contains(message, "LLM 服务不可达") {
message = "当前知识库已进入 LLM 兜底层,但本机没有可用的模型服务正在运行。请先启动本地 Ollama,或补充可用的远端模型配置。"
}
writeEvent(gin.H{"type": "error", "message": message})
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
@@ -91,9 +183,13 @@ func ChatMessage(c *gin.Context) {
return
}
writeEvent(gin.H{"type": "done"})
finalRoute := route
if usedRoute != nil {
finalRoute = usedRoute
}
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: true,
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: finalRoute.Provider,
RouteID: finalRoute.RouteID, Model: finalRoute.Model, Success: true,
LatencyMs: int(time.Since(start).Milliseconds()),
})
}
@@ -137,15 +233,15 @@ var quickActionTask = map[string]struct {
}{
"commission": {
query: "产品佣金 佣金规则 回款 结算 公开课奖励",
task: "请基于下方知识库,汇总相关产品的佣金比例、结算规则与奖励规则;知识库未覆盖的部分明确说明,不得臆测。",
task: "请基于下方知识库,输出“佣金/规则清单”。格式固定为:一、适用范围;二、核心规则;三、注意事项;四、建议追问。每部分用短句或条目表达,不得编造知识库未覆盖的内容。",
},
"compare": {
query: "产品对比 定位 收费 适用场景",
task: "请基于下方知识库,对比相关产品的定位、收费与适用场景,突出差异。",
task: "请基于下方知识库,输出“产品对比结果”。格式固定为:一、对比对象;二、共同点;三、核心差异;四、适用客户;五、销售建议。不要写成普通摘要,要写成结构化对比结果。",
},
"scenario": {
query: "销售话术 销售流程 异议处理 情景演练",
task: "请基于下方知识库,扮演销售进行客户情景演练:推介相关产品或课程,并演示异议处理话术。",
task: "请基于下方知识库,输出一段“客户情景演练脚本”。格式固定为:客户、销售、客户、销售 四轮对话;最后补一行“本轮话术重点”。语言要像真实销售沟通,不要写成说明文。",
},
}
@@ -167,17 +263,39 @@ func QuickAction(c *gin.Context) {
if !checkBalance(c, user, ai.CapabilityTextGen) {
return
}
route, err := config.GetRoute("title_gen")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error()))
return
}
task, ok := quickActionTask[req.ActionID]
if !ok {
web.Fail(c, web.NewBadRequest("未知快捷动作"))
return
}
knowledge := ai.Retrieve(Cfg, task.query, 5)
spaceKey := extractKnowledgeSpaceKey(req.Params)
citations := retrieveKnowledgeCitations(task.query, spaceKey, 5)
fallbackResult := buildQuickActionFallback(req.ActionID, citations)
if len(citations) == 0 {
web.OK(c, gin.H{
"result": fallbackResult,
"citations": citations,
"layer": layerVector,
"intent": intentDocument,
})
return
}
knowledge := make([]string, 0, len(citations))
for _, item := range citations {
knowledge = append(knowledge, item.Content)
}
route, err := config.GetRoute("title_gen")
if err != nil {
web.OK(c, gin.H{
"result": fallbackResult,
"citations": citations,
"layer": layerVector,
"intent": intentDocument,
"fallback": true,
})
return
}
systemPrompt := buildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task
messages := []ai.Message{
{Role: "system", Content: systemPrompt},
@@ -192,7 +310,13 @@ func QuickAction(c *gin.Context) {
RouteID: route.RouteID, Model: route.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
web.Fail(c, web.NewLLMError("LLM 调用失败:"+err.Error()))
web.OK(c, gin.H{
"result": fallbackResult,
"citations": citations,
"layer": layerVector,
"intent": intentDocument,
"fallback": true,
})
return
}
ai.LogCall(ai.LogEntry{
@@ -201,9 +325,99 @@ func QuickAction(c *gin.Context) {
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
LatencyMs: int(time.Since(start).Milliseconds()),
})
out := gin.H{"result": result.Content}
out := gin.H{
"result": result.Content,
"citations": citations,
"layer": layerLLM,
"intent": intentDocument,
}
if req.ActionID == "scenario" {
out["mode"] = "scenario"
}
web.OK(c, out)
}
func buildQuickActionFallback(actionID string, citations []knowledgeCitation) string {
if len(citations) == 0 {
return "当前知识库里还没有找到足够资料,暂时无法生成这个快捷结果。建议先切换到更合适的知识库,或继续补充 FAQ 与文档后再试。"
}
switch actionID {
case "commission":
lines := []string{
"佣金/规则清单",
"",
"一、适用范围",
"适用于当前知识库里已收录的产品佣金、谈判规则与相关业务口径。",
"",
"二、核心规则",
}
for idx, item := range citations {
if idx >= 3 {
break
}
lines = append(lines, fmt.Sprintf("%d. %s", idx+1, strings.TrimSpace(item.Snippet)))
}
lines = append(lines,
"",
"三、注意事项",
"- 涉及价格政策、合同条款、付款条件等敏感信息时,应按制度要求先审阅后发布。",
"- 知识库未明确写到的比例、例外情况或特殊口径,不要直接对外承诺。",
"",
"四、建议追问",
"- 帮我按产品分别列出佣金规则",
"- 哪些规则属于可谈判范围",
)
return strings.Join(lines, "\n")
case "compare":
lines := []string{
"产品对比结果",
"",
"一、对比对象",
"- 当前知识库可优先对比公开课、训练营、陪跑型服务等方案。",
"",
"二、共同点",
"- 都服务于客户学习提升、业务落地或能力建设目标。",
"",
"三、核心差异",
}
for idx, item := range citations {
if idx >= 3 {
break
}
lines = append(lines, fmt.Sprintf("%d. %s", idx+1, strings.TrimSpace(item.Snippet)))
}
lines = append(lines,
"",
"四、适用客户",
"- 公开课更适合快速普及认知;训练营和陪跑型方案更适合需要结果产出和过程辅导的客户。",
"",
"五、销售建议",
"- 先判断客户是“只想了解”还是“希望真正落地”,再决定推荐公开课还是训练营/陪跑服务。",
)
return strings.Join(lines, "\n")
case "scenario":
first := strings.TrimSpace(citations[0].Snippet)
second := first
if len(citations) > 1 {
second = strings.TrimSpace(citations[1].Snippet)
}
if first == "" {
first = strings.TrimSpace(citations[0].Content)
}
if second == "" {
second = first
}
return strings.Join([]string{
"客户情景演练脚本",
"",
"客户:我们之前也上过一些课,但团队学完还是落不了地,你们这边有什么更适合的方案吗?",
"销售:有的。根据我们当前知识库里的资料," + first,
"客户:那如果我们更关注执行过程,担心中途推进不动,怎么办?",
"销售:" + second,
"",
"本轮话术重点:先确认客户是“只想了解”还是“希望真正落地”,再把课程、作业、点评和陪跑闭环讲清楚。",
}, "\n")
default:
return buildVectorDirectAnswer(citations)
}
}
+17 -10
View File
@@ -50,18 +50,19 @@ func KnowledgeScan(c *gin.Context) {
continue
}
src := model.KnowledgeSource{
Title: parseTitle(string(data)),
FilePath: e.Name(),
Category: fm["category"],
Domain: orDefault(fm["domain"], "product"),
SourceVersion: fm["version"],
AuditStatus: "pending",
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})
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})
}
@@ -72,6 +73,9 @@ func KnowledgeAuditList(c *gin.Context) {
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 {
@@ -129,6 +133,7 @@ func KnowledgeAudit(c *gin.Context) {
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],
@@ -164,9 +169,10 @@ func KnowledgeStatus(c *gin.Context) {
return
}
web.OK(c, gin.H{
"audit_status": src.AuditStatus,
"ingested": src.Ingested,
"reject_reason": src.RejectReason,
"audit_status": src.AuditStatus,
"ingested": src.Ingested,
"reject_reason": src.RejectReason,
"knowledge_space_key": resolveKnowledgeSourceSpaceKey(src),
})
}
@@ -349,6 +355,7 @@ func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
KnowledgeSourceID: &src.ID,
SourceType: "md",
SourceID: strconv.FormatUint(uint64(src.ID), 10),
KnowledgeSpaceKey: resolveKnowledgeSourceSpaceKey(*src),
ChunkIndex: i,
Content: text,
})
@@ -0,0 +1,204 @@
package api
import (
"encoding/json"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ListKnowledgeFAQs GET /api/knowledge/faqs?knowledge_space_key=&status=&keyword=&page=&size=
func ListKnowledgeFAQs(c *gin.Context) {
q := store.DB.Model(&model.KnowledgeFAQ{})
if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" {
q = q.Where("knowledge_space_key = ?", key)
}
if status := strings.TrimSpace(c.Query("status")); status != "" {
q = q.Where("status = ?", status)
}
if keyword := strings.TrimSpace(c.Query("keyword")); keyword != "" {
like := "%" + keyword + "%"
q = q.Where("question LIKE ? OR answer LIKE ? OR keywords LIKE ?", like, like, like)
}
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.KnowledgeFAQ
if err := q.Order("sort_order ASC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("加载 FAQ 失败"))
return
}
web.OK(c, gin.H{
"total": total,
"items": buildFAQItems(items),
})
}
// CreateKnowledgeFAQ POST /api/knowledge/faqs
func CreateKnowledgeFAQ(c *gin.Context) {
var req struct {
KnowledgeSpaceKey string `json:"knowledge_space_key"`
Question string `json:"question"`
Answer string `json:"answer"`
SimilarQuestions []string `json:"similar_questions"`
Keywords []string `json:"keywords"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
question := strings.TrimSpace(req.Question)
answer := strings.TrimSpace(req.Answer)
if question == "" || answer == "" {
web.Fail(c, web.NewBadRequest("question 与 answer 必填"))
return
}
similarJSON, _ := json.Marshal(cleanStringList(req.SimilarQuestions))
faq := model.KnowledgeFAQ{
KnowledgeSpaceKey: ensureKnowledgeSpaceKeyOrDefault(req.KnowledgeSpaceKey),
Question: question,
Answer: answer,
SimilarQuestions: string(similarJSON),
Keywords: strings.Join(cleanStringList(req.Keywords), ","),
Status: orDefault(strings.TrimSpace(req.Status), "active"),
SortOrder: req.SortOrder,
}
if err := store.DB.Create(&faq).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建 FAQ 失败"))
return
}
web.OK(c, gin.H{"item": buildFAQItem(faq)})
}
// UpdateKnowledgeFAQ PUT /api/knowledge/faqs/:id
func UpdateKnowledgeFAQ(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var faq model.KnowledgeFAQ
if err := store.DB.First(&faq, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("FAQ 不存在"))
return
}
var req struct {
KnowledgeSpaceKey string `json:"knowledge_space_key"`
Question string `json:"question"`
Answer string `json:"answer"`
SimilarQuestions []string `json:"similar_questions"`
Keywords []string `json:"keywords"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
question := strings.TrimSpace(req.Question)
answer := strings.TrimSpace(req.Answer)
if question == "" || answer == "" {
web.Fail(c, web.NewBadRequest("question 与 answer 必填"))
return
}
similarJSON, _ := json.Marshal(cleanStringList(req.SimilarQuestions))
faq.KnowledgeSpaceKey = ensureKnowledgeSpaceKeyOrDefault(req.KnowledgeSpaceKey)
faq.Question = question
faq.Answer = answer
faq.SimilarQuestions = string(similarJSON)
faq.Keywords = strings.Join(cleanStringList(req.Keywords), ",")
faq.Status = orDefault(strings.TrimSpace(req.Status), "active")
faq.SortOrder = req.SortOrder
if err := store.DB.Save(&faq).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新 FAQ 失败"))
return
}
web.OK(c, gin.H{"item": buildFAQItem(faq)})
}
// DeleteKnowledgeFAQ DELETE /api/knowledge/faqs/:id
func DeleteKnowledgeFAQ(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var faq model.KnowledgeFAQ
if err := store.DB.First(&faq, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("FAQ 不存在"))
return
}
if err := store.DB.Delete(&faq).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除 FAQ 失败"))
return
}
web.OK(c, gin.H{"deleted": true})
}
func buildFAQItems(items []model.KnowledgeFAQ) []gin.H {
out := make([]gin.H, 0, len(items))
for _, item := range items {
out = append(out, buildFAQItem(item))
}
return out
}
func buildFAQItem(item model.KnowledgeFAQ) gin.H {
return gin.H{
"id": item.ID,
"knowledge_space_key": item.KnowledgeSpaceKey,
"knowledge_space_name": getKnowledgeSpaceDisplayName(item.KnowledgeSpaceKey),
"question": item.Question,
"answer": item.Answer,
"similar_questions": parseJSONArray(item.SimilarQuestions),
"keywords": splitCSV(item.Keywords),
"status": item.Status,
"sort_order": item.SortOrder,
"hit_count": item.HitCount,
"created_at": item.CreatedAt,
"updated_at": item.UpdatedAt,
}
}
func parseJSONArray(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return []string{}
}
var out []string
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return []string{}
}
return cleanStringList(out)
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
return cleanStringList(parts)
}
func cleanStringList(items []string) []string {
out := make([]string, 0, len(items))
seen := map[string]bool{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
@@ -0,0 +1,91 @@
package api
import (
"log"
"sync"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
var knowledgeIndexRebuildLock sync.Mutex
// RebuildKnowledgeIndex POST /api/knowledge/index/rebuild
func RebuildKnowledgeIndex(c *gin.Context) {
count, err := rebuildKnowledgeIndexNow()
if err != nil {
web.Fail(c, web.NewBadRequest("重建向量索引失败: "+err.Error()))
return
}
web.OK(c, gin.H{
"rebuild": true,
"chunk_count": count,
"service_enabled": ai.KnowledgeServiceEnabled(Cfg),
})
}
func triggerKnowledgeIndexRebuild() {
if !ai.KnowledgeServiceEnabled(Cfg) {
return
}
go func() {
if count, err := rebuildKnowledgeIndexNow(); err != nil {
log.Printf("[知识索引] 重建失败: %v", err)
} else {
log.Printf("[知识索引] 重建完成,chunks=%d", count)
}
}()
}
func rebuildKnowledgeIndexNow() (int, error) {
knowledgeIndexRebuildLock.Lock()
defer knowledgeIndexRebuildLock.Unlock()
items := buildKnowledgeIndexItems()
if err := ai.KnowledgeRebuildIndex(Cfg, items); err != nil {
return 0, err
}
return len(items), nil
}
func buildKnowledgeIndexItems() []ai.KnowledgeIndexItem {
var chunks []model.KnowledgeChunk
store.DB.Order("id ASC").Find(&chunks)
var mediaFiles []model.MediaFile
store.DB.Where("status = ?", "approved").Find(&mediaFiles)
mediaMap := make(map[uint]model.MediaFile, len(mediaFiles))
for _, item := range mediaFiles {
mediaMap[item.ID] = item
}
var sources []model.KnowledgeSource
store.DB.Where("audit_status = ?", "approved").Find(&sources)
sourceMap := make(map[uint]model.KnowledgeSource, len(sources))
for _, item := range sources {
sourceMap[item.ID] = item
}
items := make([]ai.KnowledgeIndexItem, 0, len(chunks))
for _, chunk := range chunks {
title, spaceKey, ok := resolveChunkMeta(chunk, mediaMap, sourceMap)
if !ok || chunk.Content == "" {
continue
}
items = append(items, ai.KnowledgeIndexItem{
ID: chunk.ID,
Title: title,
Content: chunk.Content,
ChunkIndex: chunk.ChunkIndex,
SourceType: chunk.SourceType,
SourceID: chunk.SourceID,
KnowledgeSpaceKey: spaceKey,
KnowledgeSpaceName: getKnowledgeSpaceDisplayName(spaceKey),
})
}
return items
}
@@ -0,0 +1,525 @@
package api
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"gorm.io/gorm"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
)
const (
intentInvalid = "invalid"
intentSmallTalk = "smalltalk"
intentOutOfScope = "out_of_scope"
intentFAQ = "faq"
intentDocument = "document"
layerClassifier = "bert_classifier"
layerFAQ = "faq"
layerVector = "vector"
layerLLM = "llm_fallback"
)
type knowledgeCitation struct {
ID uint `json:"id"`
Title string `json:"title"`
Snippet string `json:"snippet"`
Content string `json:"content"`
ChunkIndex int `json:"chunk_index"`
SourceType string `json:"source_type"`
SourceID string `json:"source_id"`
KnowledgeSpaceKey string `json:"knowledge_space_key"`
KnowledgeSpaceName string `json:"knowledge_space_name"`
Score float64 `json:"score"`
}
type knowledgeAnswerPlan struct {
Intent string
Layer string
Answer string
Citations []knowledgeCitation
LLMMessages []ai.Message
}
func buildKnowledgeAnswerPlan(query string, ctx map[string]any, history []ai.Message) (*knowledgeAnswerPlan, error) {
intent, interceptAnswer := classifyKnowledgeIntent(query)
if interceptAnswer != "" {
return &knowledgeAnswerPlan{
Intent: intent,
Layer: layerClassifier,
Answer: interceptAnswer,
Citations: []knowledgeCitation{},
}, nil
}
spaceKey := extractKnowledgeSpaceKey(ctx)
if intent == intentFAQ {
if faq, ok := matchKnowledgeFAQ(query, spaceKey); ok {
return &knowledgeAnswerPlan{
Intent: intent,
Layer: layerFAQ,
Answer: faq.Answer,
Citations: []knowledgeCitation{{
ID: faq.ID,
Title: "FAQ 标准答案",
Snippet: faq.Question,
Content: faq.Answer,
SourceType: "faq",
SourceID: fmt.Sprintf("%d", faq.ID),
KnowledgeSpaceKey: faq.KnowledgeSpaceKey,
KnowledgeSpaceName: getKnowledgeSpaceDisplayName(faq.KnowledgeSpaceKey),
Score: 1,
}},
}, nil
}
}
citations := retrieveKnowledgeCitations(query, spaceKey, 5)
if canDirectAnswerFromVector(query, citations) {
return &knowledgeAnswerPlan{
Intent: intent,
Layer: layerVector,
Answer: buildVectorDirectAnswer(citations),
Citations: citations,
}, nil
}
messages := []ai.Message{{
Role: "system",
Content: buildKnowledgePipelinePrompt(ctx, intent, citations),
}}
messages = append(messages, history...)
messages = append(messages, ai.Message{Role: "user", Content: query})
return &knowledgeAnswerPlan{
Intent: intent,
Layer: layerLLM,
Citations: citations,
LLMMessages: messages,
}, nil
}
func classifyKnowledgeIntent(query string) (string, string) {
if ai.KnowledgeServiceEnabled(Cfg) {
if result, err := ai.KnowledgeClassify(Cfg, query); err == nil && result != nil {
switch result.Intent {
case intentSmallTalk:
return intentSmallTalk, buildSmallTalkAnswer(query)
case intentOutOfScope:
if result.Score >= 0.9 {
return intentOutOfScope, "当前知识库仅面向博昇 AI 实验室内部业务知识,不处理越域问题。"
}
case intentInvalid:
if result.Score >= 0.9 {
return intentInvalid, "请输入更明确的问题后再试。"
}
case intentFAQ, intentDocument:
if result.Score >= 0.6 {
return result.Intent, ""
}
}
}
}
return heuristicKnowledgeIntent(query)
}
func heuristicKnowledgeIntent(query string) (string, string) {
trimmed := strings.TrimSpace(query)
if len([]rune(trimmed)) < 2 {
return intentInvalid, "请输入更明确的问题后再试。"
}
lower := strings.ToLower(trimmed)
smallTalkKeywords := []string{"讲个故事", "小故事", "笑话", "闲聊", "聊天", "夸夸我", "你是谁", "今天天气", "天气", "星座"}
for _, keyword := range smallTalkKeywords {
if strings.Contains(lower, keyword) {
return intentSmallTalk, buildSmallTalkAnswer(query)
}
}
outOfScopeKeywords := []string{"股票", "彩票", "电影", "明星", "旅游", "菜谱", "医学诊断", "法律咨询", "写诗", "翻译成英文"}
for _, keyword := range outOfScopeKeywords {
if strings.Contains(lower, keyword) {
return intentOutOfScope, "当前知识库仅面向博昇 AI 实验室内部业务知识,不处理越域问题。"
}
}
documentKeywords := []string{"总结", "梳理", "分析", "对比", "归纳", "提纲", "解读", "起草", "生成", "合同", "报告", "方案", "条款", "根据资料", "根据文档", "整理"}
for _, keyword := range documentKeywords {
if strings.Contains(lower, keyword) {
return intentDocument, ""
}
}
faqKeywords := []string{"如何", "怎么", "哪里", "在哪", "是否", "有没有", "可以", "支持", "密码", "登录", "佣金", "规则", "流程", "审批", "上传", "删除", "新建"}
for _, keyword := range faqKeywords {
if strings.Contains(lower, keyword) {
return intentFAQ, ""
}
}
if len([]rune(trimmed)) <= 18 {
return intentFAQ, ""
}
return intentDocument, ""
}
func buildSmallTalkAnswer(query string) string {
lower := strings.ToLower(strings.TrimSpace(query))
switch {
case strings.Contains(lower, "你是谁"):
return "你好,我是博昇 AI 实验室的知识库助手,负责基于企业知识库回答业务制度、产品资料、培训内容和规则流程相关问题。你可以直接提问,也可以先选择左侧知识库缩小检索范围。"
case strings.Contains(lower, "你能做什么"), strings.Contains(lower, "怎么工作"):
return "我可以帮你回答制度规则、产品资料、FAQ、培训文档相关问题,也可以基于知识库做摘要、梳理、对比和提纲生成。默认会全局检索全部知识库,你也可以在左侧切换到某个知识库后再提问。"
case strings.Contains(lower, "你好"), strings.Contains(lower, "hello"), strings.Contains(lower, "hi"):
return "你好,欢迎使用博昇 AI 实验室知识库助手。你可以直接问我产品资料、审批规则、培训内容、FAQ 或制度流程相关问题,我会按知识库内容为你检索和回答。"
default:
return "你好,我是知识库助手。当前更适合处理业务知识、制度规则、产品资料和培训文档相关问题;如果你愿意,我可以继续帮你查规则、找资料或整理内容。"
}
}
func extractKnowledgeSpaceKey(ctx map[string]any) string {
if ctx == nil {
return "all"
}
if raw, ok := ctx["knowledge_space_key"]; ok {
key := sanitizeSpaceKey(fmt.Sprint(raw))
if key == "" {
return "all"
}
if key == "all" {
return "all"
}
return ensureKnowledgeSpaceKeyOrDefault(key)
}
return "all"
}
func matchKnowledgeFAQ(query, spaceKey string) (model.KnowledgeFAQ, bool) {
spaceKey = sanitizeSpaceKey(spaceKey)
q := store.DB.Where("status = ?", "active")
if spaceKey != "" && spaceKey != "general" && spaceKey != "all" {
q = q.Where("knowledge_space_key IN ?", []string{spaceKey, "general"})
}
var faqs []model.KnowledgeFAQ
if err := q.Order("sort_order ASC, id DESC").Find(&faqs).Error; err != nil {
return model.KnowledgeFAQ{}, false
}
queryNorm := normalizeQuestion(query)
queryTerms := buildSearchTerms(query)
bestScore := 0
var best model.KnowledgeFAQ
for _, faq := range faqs {
score := scoreFAQ(queryNorm, queryTerms, faq)
if score > bestScore {
bestScore = score
best = faq
}
}
if bestScore < 70 {
return model.KnowledgeFAQ{}, false
}
store.DB.Model(&model.KnowledgeFAQ{}).Where("id = ?", best.ID).
UpdateColumn("hit_count", gorm.Expr("hit_count + ?", 1))
return best, true
}
func scoreFAQ(queryNorm string, queryTerms []string, faq model.KnowledgeFAQ) int {
questionNorm := normalizeQuestion(faq.Question)
if queryNorm == questionNorm {
return 100
}
best := 0
if queryNorm != "" && (strings.Contains(queryNorm, questionNorm) || strings.Contains(questionNorm, queryNorm)) {
best = 88
}
for _, alias := range parseJSONArray(faq.SimilarQuestions) {
aliasNorm := normalizeQuestion(alias)
if aliasNorm == "" {
continue
}
if queryNorm == aliasNorm {
return 96
}
if strings.Contains(queryNorm, aliasNorm) || strings.Contains(aliasNorm, queryNorm) {
if best < 84 {
best = 84
}
}
}
questionTerms := buildSearchTerms(faq.Question + " " + strings.Join(parseJSONArray(faq.SimilarQuestions), " "))
overlap := countTermOverlap(queryTerms, questionTerms)
if overlap > 0 {
score := 50 + overlap*8
if score > best {
best = score
}
}
keywordHits := countTermOverlap(queryTerms, splitCSV(faq.Keywords))
if keywordHits > 0 {
score := 58 + keywordHits*10
if score > best {
best = score
}
}
return best
}
func normalizeQuestion(input string) string {
input = strings.ToLower(strings.TrimSpace(input))
var b strings.Builder
for _, ch := range input {
switch ch {
case ' ', '\t', '\n', '\r', ',', ',', '。', '.', '?', '?', '!', '!', ':', ':', ';', ';', '、', '-', '_':
continue
default:
b.WriteRune(ch)
}
}
return b.String()
}
func countTermOverlap(left, right []string) int {
if len(left) == 0 || len(right) == 0 {
return 0
}
seen := map[string]bool{}
for _, item := range right {
seen[strings.ToLower(strings.TrimSpace(item))] = true
}
total := 0
for _, item := range left {
if seen[strings.ToLower(strings.TrimSpace(item))] {
total++
}
}
return total
}
func retrieveKnowledgeCitations(query, spaceKey string, topK int) []knowledgeCitation {
if topK <= 0 {
topK = 5
}
if ai.KnowledgeServiceEnabled(Cfg) {
if items, err := ai.KnowledgeSearch(Cfg, query, spaceKey, topK); err == nil {
out := make([]knowledgeCitation, 0, len(items))
for _, item := range items {
out = append(out, knowledgeCitation{
ID: item.ID,
Title: item.Title,
Snippet: item.Snippet,
Content: item.Content,
ChunkIndex: item.ChunkIndex,
SourceType: item.SourceType,
SourceID: item.SourceID,
KnowledgeSpaceKey: item.KnowledgeSpaceKey,
KnowledgeSpaceName: item.KnowledgeSpaceName,
Score: item.Score,
})
}
return out
}
}
candidates := loadKnowledgeCandidates(spaceKey)
if len(candidates) == 0 {
return []knowledgeCitation{}
}
if route, err := config.GetRoute("embed_gen"); err == nil {
if items, ok := vectorRetrieveCitations(route, query, candidates, topK); ok {
return items
}
}
return keywordRetrieveCitations(query, candidates, topK)
}
type knowledgeCandidate struct {
Chunk model.KnowledgeChunk
Title string
Space string
}
type retrievalScore struct {
Index int
Score float64
}
func loadKnowledgeCandidates(spaceKey string) []knowledgeCandidate {
var chunks []model.KnowledgeChunk
store.DB.Order("id ASC").Find(&chunks)
mediaMap := map[uint]model.MediaFile{}
sourceMap := map[uint]model.KnowledgeSource{}
out := make([]knowledgeCandidate, 0, len(chunks))
for _, chunk := range chunks {
title, resolvedSpace, ok := resolveChunkMeta(chunk, mediaMap, sourceMap)
if !ok {
continue
}
if !matchSpaceForKnowledgeQuery(spaceKey, resolvedSpace) {
continue
}
out = append(out, knowledgeCandidate{Chunk: chunk, Title: title, Space: resolvedSpace})
}
return out
}
func matchSpaceForKnowledgeQuery(selected, current string) bool {
selected = sanitizeSpaceKey(selected)
current = sanitizeSpaceKey(current)
if selected == "" || selected == "general" || selected == "all" {
return true
}
return current == selected || current == "general"
}
func vectorRetrieveCitations(route *config.RouteConfig, query string, candidates []knowledgeCandidate, topK int) ([]knowledgeCitation, bool) {
client := ai.NewClient(route)
inputs := make([]string, 0, len(candidates)+1)
inputs = append(inputs, query)
for _, item := range candidates {
inputs = append(inputs, item.Chunk.Content)
}
vecs, err := client.Embed(inputs)
if err != nil || len(vecs) != len(inputs) {
return nil, false
}
qv := vecs[0]
items := make([]retrievalScore, 0, len(candidates))
for idx := 1; idx < len(vecs); idx++ {
score := cosineSimilarity(qv, vecs[idx])
if score <= 0 {
continue
}
items = append(items, retrievalScore{Index: idx - 1, Score: score})
}
sort.Slice(items, func(i, j int) bool { return items[i].Score > items[j].Score })
return makeKnowledgeCitations(query, candidates, items, topK), true
}
func keywordRetrieveCitations(query string, candidates []knowledgeCandidate, topK int) []knowledgeCitation {
terms := buildSearchTerms(query)
items := make([]retrievalScore, 0, len(candidates))
for idx, item := range candidates {
score := float64(scoreChunkMatch(item.Chunk.Content, item.Title, query, terms)) / 20
if score <= 0 {
continue
}
items = append(items, retrievalScore{Index: idx, Score: score})
}
sort.Slice(items, func(i, j int) bool { return items[i].Score > items[j].Score })
return makeKnowledgeCitations(query, candidates, items, topK)
}
func makeKnowledgeCitations(query string, candidates []knowledgeCandidate, items []retrievalScore, topK int) []knowledgeCitation {
seen := map[uint]bool{}
out := make([]knowledgeCitation, 0, topK)
for _, item := range items {
candidate := candidates[item.Index]
if seen[candidate.Chunk.ID] {
continue
}
seen[candidate.Chunk.ID] = true
out = append(out, knowledgeCitation{
ID: candidate.Chunk.ID,
Title: candidate.Title,
Snippet: buildChunkSnippet(candidate.Chunk.Content, query),
Content: candidate.Chunk.Content,
ChunkIndex: candidate.Chunk.ChunkIndex,
SourceType: candidate.Chunk.SourceType,
SourceID: candidate.Chunk.SourceID,
KnowledgeSpaceKey: candidate.Space,
KnowledgeSpaceName: getKnowledgeSpaceDisplayName(candidate.Space),
Score: item.Score,
})
if len(out) >= topK {
break
}
}
return out
}
func canDirectAnswerFromVector(query string, citations []knowledgeCitation) bool {
if len(citations) == 0 {
return false
}
if citations[0].Score < 0.35 {
return false
}
return !needsLLMFallback(query, citations)
}
func needsLLMFallback(query string, citations []knowledgeCitation) bool {
lower := strings.ToLower(strings.TrimSpace(query))
keywords := []string{"总结", "梳理", "分析", "对比", "归纳", "起草", "生成", "提纲", "方案", "报告", "合同", "条款", "审阅", "写一份", "整理"}
for _, keyword := range keywords {
if strings.Contains(lower, keyword) {
return true
}
}
return len(citations) > 1 && len([]rune(query)) > 20
}
func buildVectorDirectAnswer(citations []knowledgeCitation) string {
if len(citations) == 0 {
return "未找到相关资料。"
}
lines := []string{"根据知识库命中的资料,先给你直接结果:"}
for idx, citation := range citations {
if idx >= 3 {
break
}
lines = append(lines, fmt.Sprintf("%d. %s", idx+1, strings.TrimSpace(citation.Snippet)))
}
lines = append(lines, "如需继续做归纳、对比或正式输出,可以继续追问。")
return strings.Join(lines, "\n")
}
func buildKnowledgePipelinePrompt(ctx map[string]any, intent string, citations []knowledgeCitation) string {
ctxJSON, _ := json.Marshal(ctx)
var kb strings.Builder
for idx, citation := range citations {
if idx >= 5 {
break
}
kb.WriteString(fmt.Sprintf("[%d] 标题:%s\n空间:%s\n内容:%s\n\n", idx+1, citation.Title, citation.KnowledgeSpaceName, citation.Content))
}
if kb.Len() == 0 {
kb.WriteString("未找到有效知识片段。")
}
return fmt.Sprintf(`你是博昇 AI 实验室知识库问答助手。
当前链路位置:LLM 兜底层
前置结果:
1. Bert 分类器已完成意图识别:%s
2. FAQ 库未命中标准答案
3. 向量检索已尝试召回参考片段
回答要求:
1. 只能基于下方知识片段作答
2. 如果知识片段为空或无法支持回答,必须明确回答「未找到相关资料」
3. 不得闲聊,不得扩展到越域内容,不得编造
4. 优先给出结论,再给出依据
当前页面上下文:
%s
知识片段:
%s`, intent, string(ctxJSON), kb.String())
}
func cosineSimilarity(a, b []float64) float64 {
if len(a) == 0 || len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
@@ -0,0 +1,524 @@
package api
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
type spaceMetrics struct {
Documents int `json:"document_count"`
Chunks int `json:"chunk_count"`
}
var defaultKnowledgeSpaces = []model.KnowledgeSpace{
{Key: "general", Name: "通用知识库", Description: "面向企业通用制度、规则、案例与产品总览。", Scope: "general", Status: "active", SortOrder: 10, IsDefault: true},
{Key: "product", Name: "产品知识库", Description: "聚合产品资料、FAQ、对比说明与销售话术。", Scope: "business", Status: "active", SortOrder: 20, IsDefault: true},
{Key: "training", Name: "培训资料库", Description: "聚合培训课程、训练营资料与学习内容。", Scope: "training", Status: "active", SortOrder: 30, IsDefault: true},
{Key: "policy", Name: "规则制度库", Description: "聚合制度文件、审批规则与合规要求。", Scope: "governance", Status: "active", SortOrder: 40, IsDefault: true},
}
// ListKnowledgeSpaces GET /api/knowledge/spaces
func ListKnowledgeSpaces(c *gin.Context) {
spaces, metrics, err := listKnowledgeSpacesWithMetrics()
if err != nil {
web.Fail(c, web.NewBadRequest("加载知识空间失败"))
return
}
items := make([]gin.H, 0, len(spaces))
for _, space := range spaces {
m := metrics[space.Key]
items = append(items, gin.H{
"id": space.ID,
"key": space.Key,
"name": space.Name,
"description": space.Description,
"scope": space.Scope,
"status": space.Status,
"sort_order": space.SortOrder,
"is_default": space.IsDefault,
"document_count": m.Documents,
"chunk_count": m.Chunks,
})
}
web.OK(c, gin.H{"items": items})
}
// CreateKnowledgeSpace POST /api/knowledge/spaces
func CreateKnowledgeSpace(c *gin.Context) {
var req struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Scope string `json:"scope"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
req.Key = sanitizeSpaceKey(req.Key)
if req.Key == "" || strings.TrimSpace(req.Name) == "" {
web.Fail(c, web.NewBadRequest("key 与 name 必填"))
return
}
space := model.KnowledgeSpace{
Key: req.Key,
Name: strings.TrimSpace(req.Name),
Description: strings.TrimSpace(req.Description),
Scope: orDefault(strings.TrimSpace(req.Scope), "general"),
Status: orDefault(strings.TrimSpace(req.Status), "active"),
SortOrder: req.SortOrder,
}
if err := store.DB.Create(&space).Error; err != nil {
web.Fail(c, web.NewConflictError("知识空间 key 已存在"))
return
}
web.OK(c, gin.H{"item": space})
}
// UpdateKnowledgeSpace PUT /api/knowledge/spaces/:id
func UpdateKnowledgeSpace(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var space model.KnowledgeSpace
if err := store.DB.First(&space, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("知识空间不存在"))
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Scope string `json:"scope"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if strings.TrimSpace(req.Name) != "" {
space.Name = strings.TrimSpace(req.Name)
}
space.Description = strings.TrimSpace(req.Description)
if strings.TrimSpace(req.Scope) != "" {
space.Scope = strings.TrimSpace(req.Scope)
}
if strings.TrimSpace(req.Status) != "" {
space.Status = strings.TrimSpace(req.Status)
}
space.SortOrder = req.SortOrder
if err := store.DB.Save(&space).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新知识空间失败"))
return
}
web.OK(c, gin.H{"item": space})
}
// DeleteKnowledgeSpace DELETE /api/knowledge/spaces/:id
func DeleteKnowledgeSpace(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var space model.KnowledgeSpace
if err := store.DB.First(&space, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("知识空间不存在"))
return
}
if space.IsDefault {
web.Fail(c, web.NewConflictError("默认知识空间不可删除"))
return
}
if err := store.DB.Delete(&space).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除知识空间失败"))
return
}
web.OK(c, gin.H{"deleted": true})
}
// SearchKnowledge GET /api/knowledge/search?query=&space_key=&limit=
func SearchKnowledge(c *gin.Context) {
query := strings.TrimSpace(c.Query("query"))
spaceKey := strings.TrimSpace(c.Query("space_key"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "6"))
if limit < 1 || limit > 20 {
limit = 6
}
if _, err := buildKnowledgeSpaceMetrics(); err != nil {
web.Fail(c, web.NewBadRequest("加载知识引用失败"))
return
}
var chunks []model.KnowledgeChunk
chunkQuery := store.DB.Order("created_at DESC")
if spaceKey != "" {
// 优先按显式空间过滤;同时保留空值记录作为兼容兜底,避免历史数据完全不可见。
chunkQuery = chunkQuery.Where("knowledge_space_key = ? OR knowledge_space_key = '' OR knowledge_space_key IS NULL", spaceKey)
}
chunkQuery.Limit(400).Find(&chunks)
mediaMap := map[uint]model.MediaFile{}
sourceMap := map[uint]model.KnowledgeSource{}
for _, chunk := range chunks {
if chunk.MediaFileID != nil {
if _, ok := mediaMap[*chunk.MediaFileID]; !ok {
var media model.MediaFile
if err := store.DB.First(&media, *chunk.MediaFileID).Error; err == nil {
mediaMap[*chunk.MediaFileID] = media
}
}
}
if chunk.KnowledgeSourceID != nil {
if _, ok := sourceMap[*chunk.KnowledgeSourceID]; !ok {
var source model.KnowledgeSource
if err := store.DB.First(&source, *chunk.KnowledgeSourceID).Error; err == nil {
sourceMap[*chunk.KnowledgeSourceID] = source
}
}
}
}
type searchHit struct {
Chunk model.KnowledgeChunk
Score int
Title string
Space string
}
hits := make([]searchHit, 0, limit)
terms := buildSearchTerms(query)
for _, chunk := range chunks {
title, resolvedSpace, ok := resolveChunkMeta(chunk, mediaMap, sourceMap)
if !ok {
continue
}
if spaceKey != "" && resolvedSpace != spaceKey {
continue
}
score := scoreChunkMatch(chunk.Content, title, query, terms)
if query != "" && score == 0 {
continue
}
if query == "" {
score = int(chunk.ID)
}
hits = append(hits, searchHit{
Chunk: chunk,
Score: score,
Title: title,
Space: resolvedSpace,
})
}
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].Score == hits[j].Score {
return hits[i].Chunk.CreatedAt.After(hits[j].Chunk.CreatedAt)
}
return hits[i].Score > hits[j].Score
})
if len(hits) > limit {
hits = hits[:limit]
}
items := make([]gin.H, 0, len(hits))
for _, hit := range hits {
spaceName := getKnowledgeSpaceDisplayName(hit.Space)
items = append(items, gin.H{
"id": hit.Chunk.ID,
"title": hit.Title,
"snippet": buildChunkSnippet(hit.Chunk.Content, query),
"content": hit.Chunk.Content,
"chunk_index": hit.Chunk.ChunkIndex,
"source_type": hit.Chunk.SourceType,
"source_id": hit.Chunk.SourceID,
"knowledge_space_key": hit.Space,
"knowledge_space_name": spaceName,
"created_at": hit.Chunk.CreatedAt,
})
}
web.OK(c, gin.H{"items": items})
}
func listKnowledgeSpacesWithMetrics() ([]model.KnowledgeSpace, map[string]spaceMetrics, error) {
if err := ensureDefaultKnowledgeSpaces(); err != nil {
return nil, nil, err
}
var spaces []model.KnowledgeSpace
if err := store.DB.Order("sort_order ASC, id ASC").Find(&spaces).Error; err != nil {
return nil, nil, err
}
metrics, err := buildKnowledgeSpaceMetrics()
if err != nil {
return nil, nil, err
}
return spaces, metrics, nil
}
func ensureDefaultKnowledgeSpaces() error {
for _, item := range defaultKnowledgeSpaces {
var existing model.KnowledgeSpace
if err := store.DB.Where("key = ?", item.Key).First(&existing).Error; err == nil {
continue
}
if err := store.DB.Create(&item).Error; err != nil {
return err
}
}
return nil
}
func buildKnowledgeSpaceMetrics() (map[string]spaceMetrics, error) {
metrics := map[string]spaceMetrics{}
for _, item := range defaultKnowledgeSpaces {
metrics[item.Key] = spaceMetrics{}
}
var mediaFiles []model.MediaFile
if err := store.DB.Where("status = ?", "approved").Find(&mediaFiles).Error; err != nil {
return nil, err
}
for _, media := range mediaFiles {
key := resolveMediaKnowledgeSpaceKey(media)
m := metrics[key]
m.Documents++
metrics[key] = m
// 通用空间汇总所有已通过文档
if key != "general" {
g := metrics["general"]
g.Documents++
metrics["general"] = g
}
}
var sources []model.KnowledgeSource
if err := store.DB.Where("audit_status = ?", "approved").Find(&sources).Error; err != nil {
return nil, err
}
for _, source := range sources {
key := resolveKnowledgeSourceSpaceKey(source)
m := metrics[key]
m.Documents++
metrics[key] = m
if key != "general" {
g := metrics["general"]
g.Documents++
metrics["general"] = g
}
}
var chunks []model.KnowledgeChunk
if err := store.DB.Find(&chunks).Error; err != nil {
return nil, err
}
for _, chunk := range chunks {
key := ensureKnowledgeSpaceKeyOrDefault(chunk.KnowledgeSpaceKey)
if chunk.KnowledgeSourceID != nil {
var src model.KnowledgeSource
if err := store.DB.First(&src, *chunk.KnowledgeSourceID).Error; err == nil {
key = resolveKnowledgeSourceSpaceKey(src)
}
} else if chunk.MediaFileID != nil {
var media model.MediaFile
if err := store.DB.First(&media, *chunk.MediaFileID).Error; err == nil {
key = resolveMediaKnowledgeSpaceKey(media)
}
} else if key == "general" {
key = inferKnowledgeSpaceKey(chunk.Content, "", chunk.SourceType)
}
m := metrics[key]
m.Chunks++
metrics[key] = m
if key != "general" {
g := metrics["general"]
g.Chunks++
metrics["general"] = g
}
}
return metrics, nil
}
func getKnowledgeSpaceDisplayName(key string) string {
for _, item := range defaultKnowledgeSpaces {
if item.Key == key {
return item.Name
}
}
var space model.KnowledgeSpace
if err := store.DB.Where("key = ?", key).First(&space).Error; err == nil && strings.TrimSpace(space.Name) != "" {
return space.Name
}
return key
}
func inferKnowledgeSpaceKey(title, domain, extra string) string {
text := strings.ToLower(strings.Join([]string{title, domain, extra}, " "))
switch {
case domain == "product" || strings.Contains(text, "产品") || strings.Contains(text, "faq") || strings.Contains(text, "product"):
return "product"
case strings.Contains(text, "培训") || strings.Contains(text, "训练") || strings.Contains(text, "课程") || strings.Contains(text, "sales") || domain == "sales" || domain == "company":
return "training"
case strings.Contains(text, "规则") || strings.Contains(text, "制度") || strings.Contains(text, "合规") || strings.Contains(text, "policy") || strings.Contains(text, "rule"):
return "policy"
default:
return "general"
}
}
func sanitizeSpaceKey(key string) string {
key = strings.TrimSpace(strings.ToLower(key))
key = strings.ReplaceAll(key, " ", "-")
key = strings.ReplaceAll(key, "_", "-")
var b strings.Builder
for _, ch := range key {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' {
b.WriteRune(ch)
}
}
return strings.Trim(b.String(), "-")
}
func ensureKnowledgeSpaceKeyOrDefault(key string) string {
key = sanitizeSpaceKey(key)
if key == "" {
return "general"
}
if err := ensureDefaultKnowledgeSpaces(); err == nil {
for _, item := range defaultKnowledgeSpaces {
if item.Key == key {
return key
}
}
}
var space model.KnowledgeSpace
if err := store.DB.Where("key = ?", key).First(&space).Error; err == nil {
return key
}
return "general"
}
func resolveMediaKnowledgeSpaceKey(media model.MediaFile) string {
if key := ensureKnowledgeSpaceKeyOrDefault(media.KnowledgeSpaceKey); key != "general" || sanitizeSpaceKey(media.KnowledgeSpaceKey) != "" {
return key
}
return inferKnowledgeSpaceKey(media.Filename, "", media.FileExt)
}
func resolveKnowledgeSourceSpaceKey(source model.KnowledgeSource) string {
if key := ensureKnowledgeSpaceKeyOrDefault(source.KnowledgeSpaceKey); key != "general" || sanitizeSpaceKey(source.KnowledgeSpaceKey) != "" {
return key
}
return inferKnowledgeSpaceKey(source.Title, source.Domain, source.Category+" "+source.FilePath)
}
func buildSearchTerms(query string) []string {
query = strings.TrimSpace(strings.ToLower(query))
if query == "" {
return nil
}
parts := strings.FieldsFunc(query, func(r rune) bool {
return r == ' ' || r == ',' || r == ',' || r == '。' || r == ';' || r == ';'
})
seen := map[string]bool{query: true}
terms := []string{query}
for _, part := range parts {
part = strings.TrimSpace(part)
if len([]rune(part)) < 2 || seen[part] {
continue
}
seen[part] = true
terms = append(terms, part)
}
return terms
}
func scoreChunkMatch(content, title, query string, terms []string) int {
text := strings.ToLower(title + "\n" + content)
score := 0
if query != "" && strings.Contains(text, strings.ToLower(query)) {
score += 8
}
for _, term := range terms {
if strings.Contains(text, term) {
score += 2
}
}
return score
}
func resolveChunkMeta(chunk model.KnowledgeChunk, mediaMap map[uint]model.MediaFile, sourceMap map[uint]model.KnowledgeSource) (string, string, bool) {
if chunk.MediaFileID != nil {
media, ok := mediaMap[*chunk.MediaFileID]
if !ok || media.Status != "approved" {
return "", "", false
}
return media.Filename, resolveMediaKnowledgeSpaceKey(media), true
}
if chunk.KnowledgeSourceID != nil {
source, ok := sourceMap[*chunk.KnowledgeSourceID]
if !ok || source.AuditStatus != "approved" {
return "", "", false
}
return source.Title, resolveKnowledgeSourceSpaceKey(source), true
}
title := fmt.Sprintf("%s-%s", chunk.SourceType, chunk.SourceID)
if key := sanitizeSpaceKey(chunk.KnowledgeSpaceKey); key != "" {
return title, ensureKnowledgeSpaceKeyOrDefault(key), true
}
return title, inferKnowledgeSpaceKey(title, "", chunk.SourceType), true
}
func buildChunkSnippet(content, query string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
if query == "" || len([]rune(content)) <= 120 {
return content
}
lowerContent := strings.ToLower(content)
lowerQuery := strings.ToLower(strings.TrimSpace(query))
idx := strings.Index(lowerContent, lowerQuery)
if idx < 0 {
runes := []rune(content)
if len(runes) > 120 {
return string(runes[:120]) + "..."
}
return content
}
runes := []rune(content)
start := max(idx-30, 0)
end := min(idx+90, len(runes))
snippet := string(runes[start:end])
if start > 0 {
snippet = "..." + snippet
}
if end < len(runes) {
snippet += "..."
}
return snippet
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+47 -28
View File
@@ -87,6 +87,14 @@ func parseBind(c *gin.Context) (string, *uint) {
return bt, bid
}
func parseKnowledgeSpaceKey(c *gin.Context) string {
key := sanitizeSpaceKey(c.PostForm("knowledge_space_key"))
if key == "" {
return "general"
}
return ensureKnowledgeSpaceKeyOrDefault(key)
}
func sourceOf(c *gin.Context) string {
u := middleware.CurrentUser(c)
if u != nil && u.Role == "admin" {
@@ -122,6 +130,7 @@ func Upload(c *gin.Context) {
}
bindType, bindID := parseBind(c)
knowledgeSpaceKey := parseKnowledgeSpaceKey(c)
source := sourceOf(c)
status := "pending"
targetDir := pendingDir()
@@ -149,16 +158,17 @@ func Upload(c *gin.Context) {
out.Close()
m := model.MediaFile{
Filename: header.Filename,
StoredName: storedName,
StoredPath: storedName,
FileExt: ext,
FileSize: header.Size,
Status: status,
Source: source,
SubmitterID: u.ID,
BindType: bindType,
BindID: bindID,
Filename: header.Filename,
StoredName: storedName,
StoredPath: storedName,
FileExt: ext,
FileSize: header.Size,
Status: status,
Source: source,
SubmitterID: u.ID,
BindType: bindType,
BindID: bindID,
KnowledgeSpaceKey: knowledgeSpaceKey,
}
if err := store.DB.Create(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
@@ -173,14 +183,15 @@ func Upload(c *gin.Context) {
// ============ 分片上传 ============
type uploadSession struct {
Filename string
FileSize int64
Ext string
BindType string
BindID *uint
ChunkSize int64
ChunkCount int
Chunks map[int]bool
Filename string
FileSize int64
Ext string
BindType string
BindID *uint
ChunkSize int64
ChunkCount int
Chunks map[int]bool
KnowledgeSpaceKey string
}
var uploadSessions = struct {
@@ -191,10 +202,11 @@ var uploadSessions = struct {
// UploadInit POST /api/media/upload-init —— 初始化分片上传
func UploadInit(c *gin.Context) {
var req struct {
Filename string `json:"filename"`
FileSize int64 `json:"file_size"`
BindType string `json:"bind_type"`
BindID *uint `json:"bind_id"`
Filename string `json:"filename"`
FileSize int64 `json:"file_size"`
BindType string `json:"bind_type"`
BindID *uint `json:"bind_id"`
KnowledgeSpaceKey string `json:"knowledge_space_key"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Filename == "" || req.FileSize <= 0 {
web.Fail(c, web.NewBadRequest("filename/file_size 必填"))
@@ -220,6 +232,7 @@ func UploadInit(c *gin.Context) {
BindType: req.BindType, BindID: req.BindID,
ChunkSize: defaultChunkSize, ChunkCount: chunkCount, Chunks: map[int]bool{},
}
sess.KnowledgeSpaceKey = ensureKnowledgeSpaceKeyOrDefault(sanitizeSpaceKey(req.KnowledgeSpaceKey))
uploadSessions.Lock()
uploadSessions.m[id] = sess
uploadSessions.Unlock()
@@ -346,6 +359,7 @@ func UploadComplete(c *gin.Context) {
Filename: sess.Filename, StoredName: storedName, StoredPath: storedName,
FileExt: sess.Ext, FileSize: sess.FileSize, Status: status,
Source: source, SubmitterID: u.ID, BindType: sess.BindType, BindID: sess.BindID,
KnowledgeSpaceKey: sess.KnowledgeSpaceKey,
}
if err := store.DB.Create(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
@@ -394,7 +408,7 @@ func MediaStatus(c *gin.Context) {
}
var chunkCount int64
store.DB.Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", m.ID).Count(&chunkCount)
web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount})
web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount, "knowledge_space_key": m.KnowledgeSpaceKey})
}
// ============ 审批 ============
@@ -405,6 +419,9 @@ func AuditList(c *gin.Context) {
if s := c.Query("status"); s != "" {
q = q.Where("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 {
@@ -531,14 +548,16 @@ func runExtractPipeline(mediaID uint) {
continue
}
store.DB.Create(&model.KnowledgeChunk{
MediaFileID: &m.ID,
SourceType: m.FileExt,
SourceID: strconv.FormatUint(uint64(m.ID), 10),
ChunkIndex: i,
Content: chunk,
MediaFileID: &m.ID,
SourceType: m.FileExt,
SourceID: strconv.FormatUint(uint64(m.ID), 10),
KnowledgeSpaceKey: resolveMediaKnowledgeSpaceKey(m),
ChunkIndex: i,
Content: chunk,
})
}
store.DB.Model(&m).Update("extracted", true)
triggerKnowledgeIndexRebuild()
log.Printf("[提取完成] media_id=%d chunks=%d", mediaID, len(chunks))
}
@@ -1,6 +1,8 @@
package api
import (
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
@@ -28,6 +30,26 @@ func notifyAllEmployees(ntype, title, content, link string) {
}
}
func notifyAdmins(ntype, title, content, link string) {
var users []model.User
store.DB.Where("role = ? AND status = ?", "admin", "active").Select("id").Find(&users)
for _, u := range users {
notifyUser(u.ID, ntype, title, content, link)
}
}
func notifyUsersByOwner(owner, ntype, title, content, link string) {
owner = strings.TrimSpace(owner)
if owner == "" || owner == "待指派" {
return
}
var users []model.User
store.DB.Where("status = ? AND (full_name = ? OR username = ?)", "active", owner, owner).Select("id").Find(&users)
for _, u := range users {
notifyUser(u.ID, ntype, title, content, link)
}
}
// MyNotifications GET /api/notifications?unread_only=true —— 我的通知列表
func MyNotifications(c *gin.Context) {
u := middleware.CurrentUser(c)
@@ -43,6 +43,10 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.GET("/api/specialists", middleware.Auth(cfg), ListSpecialists)
r.GET("/api/specialists/by-key/:key", middleware.Auth(cfg), GetSpecialistByKey)
r.GET("/api/specialists/summary", middleware.Auth(cfg), SpecialistSummary)
r.GET("/api/workbench/overview", middleware.Auth(cfg), WorkbenchOverview)
r.GET("/api/worker/tasks", middleware.Auth(cfg), ListWorkerTasks)
r.GET("/api/worker/tasks/:id", middleware.Auth(cfg), GetWorkerTaskDetail)
r.GET("/api/worker/artifacts/:id", middleware.Auth(cfg), GetWorkerArtifactDetail)
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
@@ -84,6 +88,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.GET("/api/media/preview/:mediaId", middleware.Auth(cfg), Preview)
r.GET("/api/media/status/:mediaId", middleware.Auth(cfg), MediaStatus)
r.GET("/api/knowledge/spaces", middleware.Auth(cfg), ListKnowledgeSpaces)
r.GET("/api/knowledge/search", middleware.Auth(cfg), SearchKnowledge)
r.GET("/api/knowledge/status/:sourceId", middleware.Auth(cfg), KnowledgeStatus)
// ── AI 对话(普通员工可访问,管理员访问)──
@@ -130,6 +136,14 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
admin.POST("/media/audit/:mediaId", AuditMedia)
admin.POST("/knowledge/scan", KnowledgeScan)
admin.POST("/knowledge/index/rebuild", RebuildKnowledgeIndex)
admin.POST("/knowledge/spaces", CreateKnowledgeSpace)
admin.PUT("/knowledge/spaces/:id", UpdateKnowledgeSpace)
admin.DELETE("/knowledge/spaces/:id", DeleteKnowledgeSpace)
admin.GET("/knowledge/faqs", ListKnowledgeFAQs)
admin.POST("/knowledge/faqs", CreateKnowledgeFAQ)
admin.PUT("/knowledge/faqs/:id", UpdateKnowledgeFAQ)
admin.DELETE("/knowledge/faqs/:id", DeleteKnowledgeFAQ)
admin.GET("/knowledge/audit-list", KnowledgeAuditList)
admin.POST("/knowledge/audit/:sourceId", KnowledgeAudit)
admin.GET("/knowledge/export", ExportKnowledge)
@@ -141,6 +155,12 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
admin.POST("/specialists", CreateSpecialist)
admin.PUT("/specialists/:id", UpdateSpecialist)
admin.DELETE("/specialists/:id", DeleteSpecialist)
admin.POST("/worker/tasks", CreateWorkerTask)
admin.PUT("/worker/tasks/:id", UpdateWorkerTask)
admin.PUT("/worker/tasks/:id/status", UpdateWorkerTaskStatus)
admin.DELETE("/worker/tasks/:id", DeleteWorkerTask)
admin.POST("/worker/tasks/:id/actions", ExecuteWorkerTaskAction)
admin.PUT("/worker/artifacts/:id/status", UpdateWorkerArtifactStatus)
admin.GET("/positions/:id/knowledge", ListPositionKnowledge)
admin.PUT("/positions/:id/knowledge", SavePositionKnowledge)
admin.GET("/positions/:id/blueprint", ListPositionBlueprint)
@@ -0,0 +1,213 @@
package api
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
func WorkbenchOverview(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
openStatuses := []string{
workerTaskStatusPending,
workerTaskStatusInProgress,
workerTaskStatusDraft,
workerTaskStatusPendingReview,
}
closedStatuses := []string{
workerTaskStatusCompleted,
workerTaskStatusArchived,
}
taskQuery := scopedWorkbenchTaskQuery(user)
var todoCount int64
if err := taskQuery.Where("status IN ?", openStatuses).Count(&todoCount).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询工作台待办失败"))
return
}
var tasks []model.WorkerTask
if err := scopedWorkbenchTaskQuery(user).
Where("status IN ?", openStatuses).
Order("CASE priority WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END").
Order("updated_at DESC, id DESC").
Limit(6).
Find(&tasks).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询工作台事项失败"))
return
}
var riskTasks []model.WorkerTask
if err := scopedWorkbenchTaskQuery(user).
Where("(status = ? OR priority = ?) AND status NOT IN ?", workerTaskStatusPendingReview, "P1", closedStatuses).
Order("updated_at DESC, id DESC").
Limit(4).
Find(&riskTasks).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询风险事项失败"))
return
}
artifactQuery := store.DB.Table("worker_artifact").
Select("worker_artifact.id, worker_artifact.title, worker_artifact.status, worker_artifact.artifact_type, worker_artifact.created_at, worker_task.id as task_id, worker_task.title as task_title, worker_task.specialist_key").
Joins("left join worker_task on worker_task.id = worker_artifact.task_id")
if user.Role != "admin" {
artifactQuery = artifactQuery.Where("worker_task.owner IN ?", []string{user.FullName, user.Username})
}
type artifactRow struct {
ID uint `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
ArtifactType string `json:"artifact_type"`
TaskID uint `json:"task_id"`
TaskTitle string `json:"task_title"`
SpecialistKey string `json:"specialist_key"`
CreatedAt time.Time `json:"created_at"`
}
var artifactRows []artifactRow
if err := artifactQuery.Order("worker_artifact.created_at DESC, worker_artifact.id DESC").Limit(6).Scan(&artifactRows).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询最近交付物失败"))
return
}
specialistKeys := make([]string, 0, len(tasks)+len(riskTasks)+len(artifactRows))
for _, item := range tasks {
specialistKeys = append(specialistKeys, item.SpecialistKey)
}
for _, item := range riskTasks {
specialistKeys = append(specialistKeys, item.SpecialistKey)
}
for _, item := range artifactRows {
specialistKeys = append(specialistKeys, item.SpecialistKey)
}
specialistMap := loadSpecialistMap(specialistKeys)
todoItems := make([]gin.H, 0, len(tasks))
for _, item := range tasks {
spec := specialistMap[item.SpecialistKey]
todoItems = append(todoItems, gin.H{
"id": item.ID,
"title": item.Title,
"summary": item.Summary,
"status": item.Status,
"priority": item.Priority,
"owner": item.Owner,
"specialist_key": item.SpecialistKey,
"specialist": spec.Label,
"route": spec.Route,
})
}
riskItems := make([]gin.H, 0, len(riskTasks))
for _, item := range riskTasks {
spec := specialistMap[item.SpecialistKey]
riskItems = append(riskItems, gin.H{
"id": item.ID,
"title": item.Title,
"sub": buildWorkbenchRiskText(item),
"status": item.Status,
"route": spec.Route,
"specialist": spec.Label,
})
}
recentArtifacts := make([]gin.H, 0, len(artifactRows))
for _, item := range artifactRows {
spec := specialistMap[item.SpecialistKey]
recentArtifacts = append(recentArtifacts, gin.H{
"id": item.ID,
"title": item.Title,
"sub": strings.TrimSpace(spec.Label + " / " + item.TaskTitle),
"tag": artifactStatusLabel(item.Status),
"status": item.Status,
"task_id": item.TaskID,
"specialist_key": item.SpecialistKey,
"route": spec.Route,
"artifact_type": item.ArtifactType,
"created_at": item.CreatedAt,
})
}
web.OK(c, gin.H{
"todo_count": todoCount,
"todo_items": todoItems,
"recent_artifacts": recentArtifacts,
"risk_items": riskItems,
})
}
func scopedWorkbenchTaskQuery(user *model.User) *gorm.DB {
query := store.DB.Model(&model.WorkerTask{})
if user != nil && user.Role != "admin" {
query = query.Where("owner IN ?", []string{user.FullName, user.Username})
}
return query
}
func loadSpecialistMap(keys []string) map[string]model.Specialist {
filtered := make([]string, 0, len(keys))
seen := map[string]struct{}{}
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
filtered = append(filtered, key)
}
if len(filtered) == 0 {
return map[string]model.Specialist{}
}
var items []model.Specialist
store.DB.Where("key IN ?", filtered).Find(&items)
out := make(map[string]model.Specialist, len(items))
for _, item := range items {
out[item.Key] = item
}
return out
}
func buildWorkbenchRiskText(task model.WorkerTask) string {
switch task.Status {
case workerTaskStatusPendingReview:
return "当前事项正在等待确认,建议优先处理。"
case workerTaskStatusDraft:
return "草案已生成,仍需确认和发布。"
default:
if task.Priority == "P1" {
return "P1 高优事项仍未闭环。"
}
return "当前事项仍在推进中,需要继续跟进。"
}
}
func artifactStatusLabel(status string) string {
switch normalizeWorkerArtifactStatus(status) {
case workerArtifactStatusDraft:
return "草案"
case workerArtifactStatusReady:
return "待确认"
case workerArtifactStatusApproved:
return "已确认"
case workerArtifactStatusPublished:
return "已发布"
default:
return "已生成"
}
}
File diff suppressed because it is too large Load Diff