feat: 同步知识库与工作台相关改动
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user