把工作区里其余在制品一并入库,主要是工作台产品化的推进:
后端:新增 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>
8.9 KiB
8.9 KiB
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 响应异常"} |