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
@@ -0,0 +1,149 @@
package ai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"eaisalestrain/backend/internal/config"
)
type KnowledgeClassifyResult struct {
Intent string `json:"intent"`
Score float64 `json:"score"`
Reason string `json:"reason"`
}
type KnowledgeSearchItem 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 KnowledgeIndexItem struct {
ID uint `json:"id"`
Title string `json:"title"`
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"`
}
func KnowledgeServiceEnabled(cfg *config.Config) bool {
return cfg != nil && strings.TrimSpace(cfg.KnowledgeServiceURL) != ""
}
func KnowledgeClassify(cfg *config.Config, query string) (*KnowledgeClassifyResult, error) {
var resp struct {
Data KnowledgeClassifyResult `json:"data"`
}
if err := postKnowledgeService(cfg, "/classify", map[string]any{
"query": query,
}, &resp); err != nil {
return nil, err
}
return &resp.Data, nil
}
func KnowledgeSearch(cfg *config.Config, query, spaceKey string, topK int) ([]KnowledgeSearchItem, error) {
var resp struct {
Data struct {
Items []KnowledgeSearchItem `json:"items"`
} `json:"data"`
}
if err := postKnowledgeService(cfg, "/search", map[string]any{
"query": query,
"space_key": spaceKey,
"top_k": topK,
}, &resp); err != nil {
return nil, err
}
return resp.Data.Items, nil
}
func KnowledgeRebuildIndex(cfg *config.Config, items []KnowledgeIndexItem) error {
var resp map[string]any
return postKnowledgeService(cfg, "/index/rebuild", map[string]any{
"index_dir": cfg.KnowledgeIndexDir,
"items": items,
}, &resp)
}
func postKnowledgeService(cfg *config.Config, path string, payload any, out any) error {
if !KnowledgeServiceEnabled(cfg) {
return fmt.Errorf("knowledge service disabled")
}
b, err := json.Marshal(payload)
if err != nil {
return err
}
url := strings.TrimRight(cfg.KnowledgeServiceURL, "/") + path
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
// #region debug-point D:knowledge-service-request
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "D",
"location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:request",
"msg": "[DEBUG] knowledge service request",
"data": map[string]any{
"url": url,
"path": path,
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
// #region debug-point D:knowledge-service-non200
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "D",
"location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:non200",
"msg": "[DEBUG] knowledge service non-200",
"data": map[string]any{
"url": url,
"status": resp.StatusCode,
"bodyPreview": truncate(string(body), 240),
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
return fmt.Errorf("knowledge service %d: %s", resp.StatusCode, truncate(string(body), 200))
}
if out == nil || len(body) == 0 {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("knowledge service parse failed: %w", err)
}
return nil
}
+103 -12
View File
@@ -44,12 +44,12 @@ type ChatResult struct {
// Client 低层 HTTP 客户端(基于 RouteConfig)
type Client struct {
baseURL string
apiKey string
model string
maxTokens int
baseURL string
apiKey string
model string
maxTokens int
temperature float64
hc *http.Client
hc *http.Client
}
// NewClient 从 RouteConfig 创建客户端
@@ -100,7 +100,45 @@ func (c *Client) post(path string, body any) (*http.Response, error) {
for k, v := range c.headers() {
req.Header.Set(k, v)
}
return c.hc.Do(req)
// #region debug-point C:llm-post
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "C",
"location": "backend-go/internal/ai/llm.go:Client.post:request",
"msg": "[DEBUG] llm client request",
"data": map[string]any{
"url": c.url(path),
"model": c.model,
"hasAPIKey": c.apiKey != "",
"authHeader": req.Header.Get("Authorization") != "",
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
// #region debug-point C:llm-response
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "C",
"location": "backend-go/internal/ai/llm.go:Client.post:response",
"msg": "[DEBUG] llm client response",
"data": map[string]any{
"url": c.url(path),
"status": resp.StatusCode,
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
return resp, nil
}
// ──────────────────────────────────────────────
@@ -222,6 +260,24 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
// #region debug-point C:llm-non200
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "C",
"location": "backend-go/internal/ai/llm.go:GenerateStream:non200",
"msg": "[DEBUG] llm stream non-200",
"data": map[string]any{
"status": resp.StatusCode,
"bodyPreview": truncate(string(data), 240),
"model": c.model,
"baseURL": c.baseURL,
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
return fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
}
@@ -335,6 +391,41 @@ func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (
return nil, nil, fmt.Errorf("所有路由均失败: %w", lastErr)
}
// GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由
func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) {
chain := []*config.RouteConfig{primary}
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 {
chain = append(chain, fallbacks...)
}
var lastErr error
for _, route := range chain {
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
lastErr = fmt.Errorf("[%s] 未配置 API Key", route.RouteID)
continue
}
client := NewClient(route)
if err := client.GenerateStream(messages, onChunk); err == nil {
return route, nil
} else {
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
}
}
return nil, fmt.Errorf("所有路由均失败: %w", lastErr)
}
func requiresAPIKey(route *config.RouteConfig) bool {
if route == nil {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
}
provider := strings.ToLower(strings.TrimSpace(route.Provider))
return provider == "openrouter" || provider == "openai"
}
// ──────────────────────────────────────────────
// 配置解析(兼容旧接口)
// ──────────────────────────────────────────────
@@ -375,11 +466,11 @@ func ResolveLLM(cfg *config.Config) (LLMConfig, bool) {
}
c := LLMConfig{
BaseURL: baseURL,
APIKey: apiKey,
Model: modelName,
EmbedModel: embedModel,
MaxTokens: 2048,
BaseURL: baseURL,
APIKey: apiKey,
Model: modelName,
EmbedModel: embedModel,
MaxTokens: 2048,
Temperature: 0.7,
}
if c.BaseURL == "" || c.Model == "" {
@@ -394,4 +485,4 @@ func truncate(s string, n int) string {
return s
}
return string(r[:n]) + "..."
}
}
+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
@@ -19,6 +19,9 @@ type Config struct {
LLMModel string
EmbedModel string
KnowledgeServiceURL string
KnowledgeIndexDir string
KBDataDir string
KnowledgeSourceDir string
@@ -52,6 +55,9 @@ func Load() *Config {
LLMModel: getenv("LLM_MODEL", "qwen2.5:7b"),
EmbedModel: getenv("EMBED_MODEL", "bge-m3"),
KnowledgeServiceURL: getenv("KNOWLEDGE_SERVICE_URL", "http://127.0.0.1:10233"),
KnowledgeIndexDir: getenv("KNOWLEDGE_INDEX_DIR", filepath.Join(baseDir, "data", "faiss")),
KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")),
KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(baseDir, "knowledge_source")),
@@ -5,10 +5,11 @@ import "time"
// KnowledgeChunk 知识块表(AI 检索最小单元)
type KnowledgeChunk struct {
ID uint `gorm:"primaryKey" json:"id"`
MediaFileID *uint `gorm:"index" json:"media_file_id"` // 与 knowledge_source_id 二选一
KnowledgeSourceID *uint `gorm:"index" json:"knowledge_source_id"` // 与 media_file_id 二选一
MediaFileID *uint `gorm:"index" json:"media_file_id"` // 与 knowledge_source_id 二选一
KnowledgeSourceID *uint `gorm:"index" json:"knowledge_source_id"` // 与 media_file_id 二选一
SourceType string `gorm:"size:32;not null" json:"source_type"` // pdf/doc/md
SourceID string `gorm:"size:64" json:"source_id"`
KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"`
ChunkIndex int `gorm:"not null" json:"chunk_index"`
Content string `gorm:"type:text;not null" json:"content"`
CreatedAt time.Time `json:"created_at"`
@@ -0,0 +1,20 @@
package model
import "time"
// KnowledgeFAQ FAQ 标准问答表
type KnowledgeFAQ struct {
ID uint `gorm:"primaryKey" json:"id"`
KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"`
Question string `gorm:"size:512;not null" json:"question"`
Answer string `gorm:"type:text;not null" json:"answer"`
SimilarQuestions string `gorm:"type:text" json:"similar_questions"`
Keywords string `gorm:"size:512" json:"keywords"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
HitCount int `gorm:"not null;default:0" json:"hit_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (KnowledgeFAQ) TableName() string { return "knowledge_faq" }
@@ -4,19 +4,20 @@ import "time"
// KnowledgeSource 结构化知识源表
type KnowledgeSource struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:256;not null" json:"title"`
FilePath string `gorm:"size:512;uniqueIndex;not null" json:"file_path"`
Category string `gorm:"size:64;not null;index" json:"category"` // general/capital_consulting/qualification_counseling/ai_consulting/ai_tools_platform
Domain string `gorm:"size:16;not null;default:product" json:"domain"` // company / product / sales
SourceVersion string `gorm:"size:32;not null" json:"source_version"`
AuditStatus string `gorm:"size:16;not null;default:pending;index" json:"audit_status"` // pending / approved / rejected
AuditBy *uint `json:"audit_by"`
AuditAt *time.Time `json:"audit_at"`
RejectReason string `gorm:"size:512" json:"reject_reason"`
Ingested bool `gorm:"not null;default:false" json:"ingested"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:256;not null" json:"title"`
FilePath string `gorm:"size:512;uniqueIndex;not null" json:"file_path"`
Category string `gorm:"size:64;not null;index" json:"category"` // general/capital_consulting/qualification_counseling/ai_consulting/ai_tools_platform
Domain string `gorm:"size:16;not null;default:product" json:"domain"` // company / product / sales
SourceVersion string `gorm:"size:32;not null" json:"source_version"`
AuditStatus string `gorm:"size:16;not null;default:pending;index" json:"audit_status"` // pending / approved / rejected
KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"`
AuditBy *uint `json:"audit_by"`
AuditAt *time.Time `json:"audit_at"`
RejectReason string `gorm:"size:512" json:"reject_reason"`
Ingested bool `gorm:"not null;default:false" json:"ingested"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (KnowledgeSource) TableName() string { return "knowledge_source" }
@@ -0,0 +1,19 @@
package model
import "time"
// KnowledgeSpace 知识空间定义,供知识库应用与知识库管理共享。
type KnowledgeSpace struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
Name string `gorm:"size:128;not null" json:"name"`
Description string `gorm:"size:512" json:"description"`
Scope string `gorm:"size:32;not null;default:general" json:"scope"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
IsDefault bool `gorm:"not null;default:false" json:"is_default"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (KnowledgeSpace) TableName() string { return "knowledge_space" }
@@ -4,23 +4,24 @@ import "time"
// MediaFile 素材文件表
type MediaFile struct {
ID uint `gorm:"primaryKey" json:"id"`
Filename string `gorm:"size:256;not null" json:"filename"`
StoredName string `gorm:"size:64;not null" json:"stored_name"`
StoredPath string `gorm:"size:512;not null" json:"stored_path"`
FileExt string `gorm:"size:16;not null" json:"file_ext"`
FileSize int64 `gorm:"not null" json:"file_size"`
Status string `gorm:"size:16;not null;default:pending;index" json:"status"` // pending / approved / rejected
Source string `gorm:"size:16;not null" json:"source"` // employee / admin
SubmitterID uint `gorm:"not null;index" json:"submitter_id"`
BindType string `gorm:"size:16;not null;default:none" json:"bind_type"` // company / product / course / none
BindID *uint `gorm:"index" json:"bind_id"`
Remark string `gorm:"size:512" json:"remark"` // 提交备注(员工素材建议)
RejectReason string `gorm:"size:512" json:"reject_reason"`
AuditBy *uint `json:"audit_by"`
AuditAt *time.Time `json:"audit_at"`
Extracted bool `gorm:"not null;default:false;index" json:"extracted"`
CreatedAt time.Time `json:"created_at"`
ID uint `gorm:"primaryKey" json:"id"`
Filename string `gorm:"size:256;not null" json:"filename"`
StoredName string `gorm:"size:64;not null" json:"stored_name"`
StoredPath string `gorm:"size:512;not null" json:"stored_path"`
FileExt string `gorm:"size:16;not null" json:"file_ext"`
FileSize int64 `gorm:"not null" json:"file_size"`
Status string `gorm:"size:16;not null;default:pending;index" json:"status"` // pending / approved / rejected
Source string `gorm:"size:16;not null" json:"source"` // employee / admin
SubmitterID uint `gorm:"not null;index" json:"submitter_id"`
BindType string `gorm:"size:16;not null;default:none" json:"bind_type"` // company / product / course / none
BindID *uint `gorm:"index" json:"bind_id"`
Remark string `gorm:"size:512" json:"remark"` // 提交备注(员工素材建议)
RejectReason string `gorm:"size:512" json:"reject_reason"`
AuditBy *uint `json:"audit_by"`
AuditAt *time.Time `json:"audit_at"`
KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"`
Extracted bool `gorm:"not null;default:false;index" json:"extracted"`
CreatedAt time.Time `json:"created_at"`
}
func (MediaFile) TableName() string { return "media_file" }
@@ -0,0 +1,21 @@
package model
import "time"
// WorkerArtifact 专员交付物
type WorkerArtifact struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"not null;index" json:"task_id"`
SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"`
Title string `gorm:"size:128;not null" json:"title"`
ArtifactType string `gorm:"size:32;not null;default:text" json:"artifact_type"`
Status string `gorm:"size:32;not null;default:draft;index" json:"status"`
ContentText string `gorm:"type:text" json:"content_text"`
ContentJSON string `gorm:"type:text" json:"content_json"`
SourceRefsJSON string `gorm:"type:text" json:"source_refs_json"`
CreatedByRunID *uint `gorm:"index" json:"created_by_run_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (WorkerArtifact) TableName() string { return "worker_artifact" }
@@ -0,0 +1,23 @@
package model
import "time"
// WorkerRun 专员动作执行记录
type WorkerRun struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"not null;index" json:"task_id"`
SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"`
ActionKey string `gorm:"size:64;not null;index" json:"action_key"`
ActionTitle string `gorm:"size:128;not null" json:"action_title"`
ActionType string `gorm:"size:32;default:''" json:"action_type"`
Status string `gorm:"size:16;not null;default:done;index" json:"status"`
InputJSON string `gorm:"type:text" json:"input_json"`
OutputJSON string `gorm:"type:text" json:"output_json"`
LogsJSON string `gorm:"type:text" json:"logs_json"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (WorkerRun) TableName() string { return "worker_run" }
@@ -0,0 +1,24 @@
package model
import "time"
// WorkerTask 专员事项/任务
type WorkerTask struct {
ID uint `gorm:"primaryKey" json:"id"`
SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"`
Title string `gorm:"size:128;not null" json:"title"`
Summary string `gorm:"type:text" json:"summary"`
Owner string `gorm:"size:64;default:''" json:"owner"`
Priority string `gorm:"size:16;not null;default:P2" json:"priority"`
Status string `gorm:"size:32;not null;default:待处理;index" json:"status"`
ContextJSON string `gorm:"type:text" json:"context_json"`
CurrentRunID *uint `gorm:"index" json:"current_run_id"`
CurrentResult string `gorm:"type:text" json:"current_result"`
DueAt *time.Time `json:"due_at"`
CreatedBy *uint `gorm:"index" json:"created_by"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (WorkerTask) TableName() string { return "worker_task" }
@@ -35,6 +35,8 @@ func Init(dbPath string) (*gorm.DB, error) {
&model.Product{},
&model.Course{},
&model.MediaFile{},
&model.KnowledgeSpace{},
&model.KnowledgeFAQ{},
&model.KnowledgeChunk{},
&model.Question{},
&model.ExamPaper{},
@@ -53,6 +55,9 @@ func Init(dbPath string) (*gorm.DB, error) {
&model.Notification{},
&model.StudyNote{},
&model.Specialist{},
&model.WorkerTask{},
&model.WorkerArtifact{},
&model.WorkerRun{},
); err != nil {
return nil, err
}
+41 -6
View File
@@ -1,11 +1,13 @@
package store
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"eaisalestrain/backend/internal/auth"
"eaisalestrain/backend/internal/config"
@@ -633,7 +635,40 @@ func ensureMediaLink(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.Symlink(absSrc, dst)
if err := os.Symlink(absSrc, dst); err == nil {
return nil
} else if shouldCopySeedMedia(err) {
return copySeedMedia(absSrc, dst)
} else {
return err
}
}
func shouldCopySeedMedia(err error) bool {
if err == nil {
return false
}
// Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。
return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege")
}
func copySeedMedia(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
func firstExistingPath(paths ...string) string {
@@ -0,0 +1,55 @@
---
knowledge_source: 规则制度库审批制度样本 V1.0
version: V1.0
category: policy
domain: governance
knowledge_space_key: policy
audit_status: pending
---
# 规则制度库:事项审批与资料提交流程
## 结构化产品数据
> 本文件为制度规则样本,不写入产品表,仅生成知识切片与题目,供 04 知识库应用做制度问答演示。
## AI 检索知识
### 审批适用范围
涉及合同盖章、付款申请、客户报价确认、对外正式承诺、知识库正式发布的事项,必须先完成系统审批,未经审批不得直接对外执行。
### 提交流程要求
提交审批时,申请人必须填写事项名称、业务背景、影响范围、期望完成时间,并上传支撑材料。缺少业务背景或附件不全的申请,管理员可以直接退回并要求补充。
### 审批时效要求
普通事项应在 1 个工作日内完成审批;涉及合同、报价、付款与对外承诺的高风险事项,应优先处理并在 4 小时内给出审批意见。
### 审批结论要求
审批结论分为通过、退回补充、驳回三种。通过后方可进入正式执行;退回补充后需重新提交;驳回代表当前事项不予继续推进。
### 知识库发布要求
知识库文档、FAQ 与切片内容在正式对员工开放前,必须由管理员完成审批。未审批内容只能停留在待审核状态,不得作为正式知识来源对外回答。
## 考试题目
### Q1
- type: judge
- domain: governance
- stem: 涉及合同盖章与付款申请的事项,可以不经过审批直接执行。
- answer: [false]
- explanation: 合同盖章与付款申请属于必须先审批的高风险事项。
### Q2
- type: single
- domain: governance
- stem: 审批结论包含以下哪三种?
- options: ["通过、退回补充、驳回", "通过、存档、结束", "补录、审批、归档", "通过、修改、删除"]
- answer: [0]
- explanation: 审批结论标准为通过、退回补充、驳回。
### Q3
- type: judge
- domain: governance
- stem: 未完成管理员审批的知识库内容,可以直接作为正式知识来源对外回答。
- answer: [false]
- explanation: 未审批内容不能作为正式知识来源对外回答。
@@ -0,0 +1,48 @@
---
knowledge_source: 通用知识库日常协同样本 V1.0
version: V1.0
category: general
domain: collaboration
knowledge_space_key: general
audit_status: pending
---
# 通用知识库:日常协同与交付规范
## 结构化产品数据
> 本文件用于知识库检索演示,不写入产品表。
## AI 检索知识
### 日报提交要求
员工应在每个工作日 18:00 前提交当日工作日报,至少包含今日完成事项、当前阻塞问题、明日计划三项内容。连续两次未提交日报的,由直属管理员进行提醒。
### 周会材料准备要求
部门周会材料应在会议开始前 2 小时上传到共享空间,内容至少包含上周进展、本周重点、风险事项和需要协同支持的问题。
### 客户沟通记录要求
涉及正式业务推进的客户沟通,应在沟通结束后 30 分钟内补录到系统,记录沟通对象、核心结论、后续动作与负责人。
### 交付物命名规范
正式交付物命名格式统一为“项目简称-交付物类型-版本号-日期”,便于后续检索、审批与归档。
### 高风险事项升级要求
若事项存在客户投诉、合同争议、付款异常或超期未处理情况,负责人需在 1 小时内同步管理员,并在系统中标记为高风险。
## 考试题目
### Q1
- type: judge
- domain: collaboration
- stem: 日报至少要包含今日完成事项、当前阻塞问题、明日计划。
- answer: [true]
- explanation: 这三项是日报提交的基础要求。
### Q2
- type: single
- domain: collaboration
- stem: 正式交付物命名格式应包含哪些内容?
- options: ["项目简称-交付物类型-版本号-日期", "项目简称-日期", "交付物类型-负责人", "客户名称-版本号"]
- answer: [0]
- explanation: 正式交付物命名格式统一为项目简称-交付物类型-版本号-日期。
@@ -0,0 +1,59 @@
---
knowledge_source: 产品知识库产品卖点样本 V1.0
version: V1.0
category: product
domain: sales
knowledge_space_key: product
audit_status: pending
---
# 产品知识库:核心产品卖点与销售话术
## 结构化产品数据
### 产品A
- code: DEMO-PRO-01
- name: 智能训练营陪跑服务
- category: 服务产品
- tags: 培训,陪跑,顾问式交付
- description: 面向企业客户提供训练营设计、作业点评、过程复盘和陪跑式辅导。
- pricing: 标准版 3 万 / 期,高配版 5 万 / 期
- commission_recommend: 12%
- commission_negotiate: 8%
- public_course_bonus: 底价内按回款结算,超额部分归乙方
- version_risk: 高配版需要提前锁定顾问排期
- report_rules: 成交后需同步客户阶段目标和交付节奏
## AI 检索知识
### 智能训练营核心卖点
智能训练营强调“课程学习 + 实战作业 + 顾问点评 + 复盘陪跑”的闭环,不只是交付课程,而是帮助客户把学习结果转成真实业务动作。
### 公开课与训练营区别
公开课适合快速普及认知,交付标准化;训练营更适合需要过程陪跑、结果产出的客户,投入更高但业务转化更强。
### 销售切入建议
当客户表达“学了很多但落不了地”“团队不知道怎么推进”时,优先推荐训练营或陪跑型产品,而不是单次公开课。
### 佣金说明口径
标准产品优先执行推荐佣金比例;当客户价格需要谈判时,可在谈判佣金区间内申请调整,并在系统中补充谈判原因。
### 演示话术示例
可以对客户说:我们不是只交付一套课件,而是把课程、作业、复盘和顾问陪跑一起交付,帮助团队真正把方法用起来。
## 考试题目
### Q1
- type: single
- domain: sales
- stem: 训练营更适合哪类客户?
- options: ["需要过程陪跑和结果产出的客户", "只想快速了解概念的客户", "只关注价格最低的客户", "没有明确目标的客户"]
- answer: [0]
- explanation: 训练营适合需要过程陪跑、结果产出的客户。
### Q2
- type: judge
- domain: sales
- stem: 公开课与训练营的交付方式完全相同。
- answer: [false]
- explanation: 公开课更标准化,训练营强调陪跑和结果产出。
@@ -0,0 +1,48 @@
---
knowledge_source: 培训资料库学习运营样本 V1.0
version: V1.0
category: training
domain: learning
knowledge_space_key: training
audit_status: pending
---
# 培训资料库:学习运营与训练营执行样本
## 结构化产品数据
> 本文件用于培训资料检索演示,不写入产品表。
## AI 检索知识
### 开营前准备
训练营开营前需完成学员名单确认、群内通知、课表发布、预习资料发放和讲师试讲检查。以上事项应在开营前 1 个工作日内全部完成。
### 作业点评要求
训练营作业点评应在学员提交后 24 小时内完成,并明确指出亮点、问题和下一步改进建议,避免只给结论不给方法。
### 学习完成率跟进
若某一期训练营学习完成率低于 80%,运营负责人需在周复盘中说明原因,并提出补课、提醒、督学等改进措施。
### 优秀案例沉淀要求
学员在训练营中形成的高质量案例,应在结营后 3 个工作日内整理成标准案例卡片,沉淀到培训资料库中。
### 复盘输出要求
每期训练营结束后应输出一份复盘报告,至少包含参与情况、完成率、优秀案例、常见问题和下一期优化建议。
## 考试题目
### Q1
- type: judge
- domain: learning
- stem: 作业点评只要告知结果即可,不需要给改进建议。
- answer: [false]
- explanation: 作业点评应指出亮点、问题和下一步改进建议。
### Q2
- type: single
- domain: learning
- stem: 若学习完成率低于 80%,负责人需要做什么?
- options: ["在周复盘中说明原因并提出改进措施", "不处理,等待下一期", "只在群里提醒一次", "直接结束本期训练营"]
- answer: [0]
- explanation: 完成率偏低时需要复盘原因并提出改进措施。
@@ -0,0 +1,48 @@
---
knowledge_source: 规则制度库发布合规样本 V1.0
version: V1.0
category: policy
domain: governance
knowledge_space_key: policy
audit_status: pending
---
# 规则制度库:知识发布与合规要求
## 结构化产品数据
> 本文件用于制度类知识检索演示,不写入产品表。
## AI 检索知识
### FAQ 发布要求
FAQ 上线前应由管理员确认问法、答案口径、适用范围和所属知识空间。未经确认的 FAQ 不得直接对员工开放。
### 知识切片质量要求
知识切片应保持语义完整,单条切片聚焦一个主题,避免把多个无关规则混在同一条内容中,影响向量召回质量。
### 合规审阅要求
涉及客户承诺、价格政策、合同条款、付款条件等敏感信息时,必须由管理员或授权负责人完成审阅后方可发布。
### 失效知识处理要求
当制度更新、产品下线或话术失效时,管理员需在 1 个工作日内完成 FAQ 下线、知识切片更新和历史版本标记。
### 展示演示要求
用于客户演示的知识库内容,应优先使用已审批、低风险、口径统一的资料,避免展示内部草稿和未确认结论。
## 考试题目
### Q1
- type: judge
- domain: governance
- stem: 知识切片可以把多个无关规则混在一条里,方便一次性展示。
- answer: [false]
- explanation: 知识切片应聚焦一个主题,保持语义完整。
### Q2
- type: single
- domain: governance
- stem: 演示知识库时应优先使用哪类资料?
- options: ["已审批、低风险、口径统一的资料", "内部草稿", "未确认结论", "随手记录内容"]
- answer: [0]
- explanation: 演示应优先使用已审批且口径统一的资料。