Files
pj0235-eai_agentplatform/docs/04_Backend/BE04_AI_Chat_Module.md
T
eaiadminandClaude Code 16d63de4e1 chore: 工作台产品化进行中的改动
把工作区里其余在制品一并入库,主要是工作台产品化的推进:

  后端:新增 capability_definition / project / my_app_center / office_skill
        接口与 action_definition / skill_definition / project / user_app_center
        模型,config 加路由健康上报。
  前端:新增 frontend/src/skills(Office 技能与 workbuddy 复刻)、
        项目管理、应用中心、能力目录页,以及配套 api / store / config;
        聊天侧新增 SpecialistChip / SpecialistPanel / SkillStrip / AppChatRail
        等组件。
  清理:移除旧 views/tools 下的单页工具(已并入工作台)、_frozen 冻结组件、
        cmd/inspect_oa_debug 调试入口,以及两份调试笔记。
  其它:文档与启动脚本同步。

(这批改动与上一提交的 SY23 工作并行进行,此前已在同一工作区内交织。)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 21:32:35 +08:00

8.9 KiB
Raw Blame History

BE04 — AI PathCoach 模块设计

版本:V2.0 | 技术:Go + Gin + GORM + SQLite + systemd + pdftotext + OpenAI 兼容接口 参考:backend-go/internal/api/ai_chat.go


1. 模块职责

  • 全局 AI 聊天框的后端支持
  • 上下文注入(当前产品/课程信息自动带入)
  • 知识检索(Go 内 brute-force 余弦向量检索 + 关键词兜底 → Prompt 注入)
  • SSE 流式响应 + 非流式调用(快捷动作)
  • 3 个快捷动作(情景演练/查佣金/产品对比)

2. 架构

用户消息 + 页面上下文
      │
      ▼
  ┌──────────────────┐
  │  知识检索          │
  │  Go 内 brute-force │
  │  余弦 + 关键词     │
  │  → 匹配段落       │
  └──────┬───────────┘
         │ 上下文片段
         ▼
  ┌──────────────────┐
  │  Prompt 组装      │
  │  System Prompt   │
  │  + 知识上下文     │
  │  + 对话历史       │
  └──────┬───────────┘
         │
         ▼
  ┌──────────────────────────────┐
  │  buildLLMAdapter() 工厂      │
  │  → 读取配置(DB → .env)     │
  │  → Fail Fast 缺配置抛 501   │
  │  → 返回 OpenAICompatible    │
  └──────┬───────────────────────┘
         │
         ▼
  ┌──────────────────┐
  │  net/http 调用    │
  │  /chat/completions│
  │  SSE 流式 / 非流式│
  └──────────────────┘

3. 配置优先级与 Fail Fast

// backend-go/internal/ai/llm.go

type LLMConfig struct {
    BaseURL string
    APIKey  string
    Model   string
}

func resolveLLMConfig(db *gorm.DB) (LLMConfig, error) {
    // 从 system_config 表读取(优先级最高)
    // 回退到 .env 文件
    // 缺任何一项 → 返回错误,由 Gin handler 抛 501
    baseURL := getSystemConfig(db, "llm_base_url")
    apiKey := getSystemConfig(db, "llm_api_key")
    model := getSystemConfig(db, "llm_model")

    if baseURL == "" || apiKey == "" || model == "" {
        return LLMConfig{}, fmt.Errorf("LLM 服务未配置——缺失配置项")
    }
    return LLMConfig{BaseURL: baseURL, APIKey: apiKey, Model: model}, nil
}

4. LLM 适配器(Go net/http 实现)

无 openai SDK 依赖,纯 Go 标准库实现,支持 SSE 流式 + token usage 采集。

// backend-go/internal/ai/llm.go

type LLMAdapter struct {
    baseURL  string
    apiKey   string
    model    string
    client   *http.Client
    lastUsage map[string]int
}

func (a *LLMAdapter) generate(messages []map[string]interface{}, stream bool) (string, error) {
    payload := map[string]interface{}{
        "model":    a.model,
        "messages": messages,
        "stream":   stream,
        "temperature": 0.7,
        "max_tokens": 2048,
    }
    if stream {
        payload["stream_options"] = map[string]interface{}{"include_usage": true}
    }

    resp, err := a.client.Post(
        a.baseURL+"/chat/completions",
        "application/json",
        json.NewEncoder(io.NopCloser(bytes.NewBuffer(payload))),
    )
    if err != nil {
        return "", fmt.Errorf("LLM 调用失败: %w", err)
    }
    defer resp.Body.Close()

    if stream {
        // SSE 流式处理
        reader := bufio.NewReader(resp.Body)
        for {
            line, err := reader.ReadString('\n')
            if err != nil { break }
            if !strings.HasPrefix(line, "data: ") { continue }
            // parse SSE chunk...
        }
    }
    // 非流式:直接解析 JSON
    var result struct {
        Choices []struct {
            Message struct { Content string }
        }
        Usage struct {
            PromptTokens     int
            CompletionTokens int
        }
    }
    json.NewDecoder(resp.Body).Decode(&result)
    return result.Choices[0].Message.Content, nil
}

5. 知识检索(Go 内 brute-force 余弦 + 关键词)

// backend-go/internal/ai/retrieve.go

func RetrieveKnowledge(db *gorm.DB, query string, topK int) ([]string, error) {
    // 1. 向量检索:embedding 走 Ollama bge-m3,Go 内 brute-force 余弦相似度
    queryVec := embedSingle(query)  // []float32

    // 2. 关键词兜底:LIKE 前缀匹配
    var chunks []model.KnowledgeChunk
    db.Model(&model.KnowledgeChunk{}).
        Where("content LIKE ? LIMIT ?", "%"+query+"%", topK).
        Find(&chunks)

    // 合并去重,取 topK
    return mergeAndTopK(queryVec, chunks, topK)
}

6. System Prompt

const SYSTEM_PROMPT = `你是一个博昇内部培训平台的 AI 助教 PathCoach。

你的职责:
1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关的问题
2. 严格依赖已审批知识库的内容回答
3. 如果知识库中未找到相关资料,明确回答「未找到相关资料」,不得臆测

禁止行为:
1. 禁止闲聊
2. 禁止编造数据
3. 禁止回答超出业务范围的问题
4. 禁止泄露敏感信息

当前页面上下文:
{page_context}

知识库相关片段:
{knowledge_context}
`

7. API 实现

路由注册(backend-go/internal/api/router.go)

// ai 组
router.POST("/api/ai-chat/message", middleware.JWTAuth(), chat.ChatMessage)
router.GET("/api/ai-chat/quick-actions", middleware.JWTAuth(), chat.GetQuickActions)
router.POST("/api/ai-chat/quick-action", middleware.JWTAuth(), chat.TriggerQuickAction)

ChatMessage(SSE 流式)

// backend-go/internal/api/ai_chat.go

func ChatMessage(c *gin.Context) {
    uid := middleware.GetUserID(c)
    var req struct {
        Message string              `json:"message"`
        Context map[string]any      `json:"context"`
        History []map[string]string  `json:"history"`
    }
    c.ShouldBindJSON(&req)

    // 1. 检索知识
    knowledge, _ := ai.RetrieveKnowledge(store.DB, req.Message, 5)

    // 2. 组装 Prompt
    system := fmt.Sprintf(ai.SYSTEM_PROMPT,
        "page_context": jsonEncode(req.Context),
        "knowledge_context": strings.Join(knowledge, "\n\n"),
    )
    messages := []map[string]string{
        {"role": "system", "content": system},
    }
    messages = append(messages, req.History...)
    messages = append(messages, map[string]string{
        "role": "user",
        "content": req.Message,
    })

    // 3. 构建 LLM 适配器(缺配置即抛 501)
    llm, err := ai.BuildLLMAdapter(store.DB)
    if err != nil {
        c.JSON(501, gin.M{"error": "llm_not_configured", "message": "请管理员在系统参数配置中补充..."})
        return
    }

    // 4. SSE 流式响应
    c.Stream(func(w io.Writer) bool {
        // write SSE chunks...
        return true
    })
}

快捷动作

func TriggerQuickAction(c *gin.Context) {
    uid := middleware.GetUserID(c)
    var req struct {
        ActionID string `json:"action_id" binding:"required"`
        Params   string `json:"params"`
    }
    c.ShouldBindJSON(&req)

    llm, _ := ai.BuildLLMAdapter(store.DB)
    var prompt string
    switch req.ActionID {
    case "commission":
        prompt = fmt.Sprintf("查询产品佣金信息,产品参数:%s", req.Params)
    case "compare":
        prompt = fmt.Sprintf("对比以下产品:%s", req.Params)
    case "scenario":
        prompt = fmt.Sprintf("开始销售情景演练,场景参数:%s", req.Params)
    }

    resp, _ := llm.Generate(messages)
    c.JSON(200, gin.M{
        "code": 0,
        "data": gin.M{"result": resp},
    })
}

8. 上下文注入规则

页面 自动注入上下文 说明
产品详情 product_id, product_name, product_code 自动带入当前产品
课程详情 course_id, course_name, related_product 自动带入当前课程及关联产品
公司介绍 page: "company_intro" 提示 AI 当前页为公司介绍

9. 配置项

配置键 来源 说明
llm_base_url system_config 表 / .env LLM 服务地址,如 http://192.168.1.100:11434/v1
llm_api_key system_config 表 / .env API Key,本地 Ollama 可填 ollama
llm_model system_config 表 / .env 模型名,如 qwen2.5:7b

10. 错误处理

场景 HTTP 状态 响应
LLM 未配置(缺 base_url/key/model) 501 {"error": "llm_not_configured", "message": "请管理员在系统参数配置中补充..."}
LLM 调用超时/网络错误 502 {"error": "llm_request_failed", "message": "LLM 服务不可达,请检查网络连接"}
LLM 返回空正文 502 {"error": "llm_empty_response", "message": "LLM 返回空结果"}
LLM 响应格式异常 502 {"error": "llm_response_error", "message": "LLM 响应异常"}