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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# BE04 — AI PathCoach 模块设计
|
||||
|
||||
> **版本:V1.1 | httpx 适配器 + 配置链 + Fail Fast | 参考:pj006-zhilianyuan2 llm/openai_adapter.py**
|
||||
> **配置优先级:system_config 数据库表 → .env 文件**
|
||||
> **版本:V2.0 | 技术:Go + Gin + GORM + SQLite + systemd + pdftotext + OpenAI 兼容接口**
|
||||
> **参考:`backend-go/internal/api/ai_chat.go`**
|
||||
|
||||
---
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
|
||||
- 全局 AI 聊天框的后端支持
|
||||
- 上下文注入(当前产品/课程信息自动带入)
|
||||
- 知识检索(MySQL FULLTEXT 召回 → Prompt 注入)
|
||||
- 知识检索(Go 内 brute-force 余弦向量检索 + 关键词兜底 → Prompt 注入)
|
||||
- SSE 流式响应 + 非流式调用(快捷动作)
|
||||
- 3 个快捷动作(情景演练/查佣金/产品对比)
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构
|
||||
|
||||
```
|
||||
@@ -21,7 +23,8 @@
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ 知识检索 │
|
||||
│ MySQL FULLTEXT │
|
||||
│ Go 内 brute-force │
|
||||
│ 余弦 + 关键词 │
|
||||
│ → 匹配段落 │
|
||||
└──────┬───────────┘
|
||||
│ 上下文片段
|
||||
@@ -35,195 +38,140 @@
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ build_llm_adapter() 工厂 │
|
||||
│ → 读取配置(库→.env) │
|
||||
│ buildLLMAdapter() 工厂 │
|
||||
│ → 读取配置(DB → .env) │
|
||||
│ → Fail Fast 缺配置抛 501 │
|
||||
│ → 返回 OpenAICompatible │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ httpx 调用 │
|
||||
│ net/http 调用 │
|
||||
│ /chat/completions│
|
||||
│ SSE 流式 / 非流式│
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 配置优先级与 Fail Fast
|
||||
|
||||
```python
|
||||
# services/ai_service.py
|
||||
from __future__ import annotations
|
||||
import httpx
|
||||
from typing import Generator
|
||||
from dataclasses import dataclass, field
|
||||
from app.core.config import Settings
|
||||
from app.errors import AppError
|
||||
```go
|
||||
// backend-go/internal/ai/llm.go
|
||||
|
||||
type LLMConfig struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Model string
|
||||
}
|
||||
|
||||
class LLMNotConfiguredError(AppError):
|
||||
"""LLM 未配置(缺 api_key / base_url / model)"""
|
||||
status_code = 501
|
||||
error_code = "llm_not_configured"
|
||||
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")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""LLM 连接所需的三项配置"""
|
||||
base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
|
||||
|
||||
def resolve_llm_config(settings: Settings) -> LLMConfig:
|
||||
"""按优先级链解析 LLM 配置。缺任何一项即抛 LLMNotConfiguredError。
|
||||
|
||||
优先级(高 → 低):
|
||||
1. settings.llm_*(来自 system_config 数据库表)
|
||||
2. settings 中从 .env 读取的默认值
|
||||
"""
|
||||
base_url = getattr(settings, "llm_base_url", None) or ""
|
||||
api_key = getattr(settings, "llm_api_key", None) or ""
|
||||
model = getattr(settings, "llm_model", None) or ""
|
||||
|
||||
missing = []
|
||||
if not base_url:
|
||||
missing.append("llm_base_url")
|
||||
if not api_key:
|
||||
missing.append("llm_api_key")
|
||||
if not model:
|
||||
missing.append("llm_model")
|
||||
|
||||
if missing:
|
||||
raise LLMNotConfiguredError(
|
||||
f"LLM 服务未配置——缺失:{', '.join(missing)}。"
|
||||
f"请管理员在【系统参数配置】中补充。"
|
||||
)
|
||||
|
||||
return LLMConfig(base_url=base_url, api_key=api_key, model=model)
|
||||
if baseURL == "" || apiKey == "" || model == "" {
|
||||
return LLMConfig{}, fmt.Errorf("LLM 服务未配置——缺失配置项")
|
||||
}
|
||||
return LLMConfig{BaseURL: baseURL, APIKey: apiKey, Model: model}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 4. LLM 适配器(httpx 实现,参考 zhilianyuan2 openai_adapter.py)
|
||||
---
|
||||
|
||||
使用 httpx 替代 OpenAI SDK,减少依赖、更可控、支持 token usage 采集。
|
||||
## 4. LLM 适配器(Go net/http 实现)
|
||||
|
||||
```python
|
||||
# services/ai_service.py
|
||||
无 openai SDK 依赖,纯 Go 标准库实现,支持 SSE 流式 + token usage 采集。
|
||||
|
||||
class LLMAdapter:
|
||||
"""OpenAI 兼容接口适配器(httpx 实现,无 openai SDK 依赖)"""
|
||||
```go
|
||||
// backend-go/internal/ai/llm.go
|
||||
|
||||
def __init__(self, *, config: LLMConfig, http_client: httpx.Client | None = None):
|
||||
self._base_url = config.base_url.rstrip("/")
|
||||
self._api_key = config.api_key
|
||||
self._model = config.model
|
||||
self._client = http_client or httpx.Client(timeout=60.0)
|
||||
# 最后一次流式调用的 token 用量(供日志埋点)
|
||||
self.last_stream_usage: dict[str, int] = {}
|
||||
type LLMAdapter struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
client *http.Client
|
||||
lastUsage map[string]int
|
||||
}
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
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...
|
||||
}
|
||||
|
||||
def _payload(self, messages: list[dict], *, stream: bool, **kwargs) -> dict:
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
"temperature": kwargs.get("temperature", 0.7),
|
||||
"max_tokens": kwargs.get("max_tokens", 2048),
|
||||
}
|
||||
// 非流式:直接解析 JSON
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct { Content string }
|
||||
}
|
||||
if stream:
|
||||
payload["stream_options"] = {"include_usage": True}
|
||||
return payload
|
||||
|
||||
def generate(self, messages: list[dict], **kwargs) -> str:
|
||||
"""非流式调用,返回完整正文。用于快捷动作等一次性请求。"""
|
||||
try:
|
||||
resp = self._client.post(
|
||||
f"{self._base_url}/chat/completions",
|
||||
headers=self._headers(),
|
||||
json=self._payload(messages, stream=False, **kwargs),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
if not content or not content.strip():
|
||||
raise LLMError("LLM 返回空正文")
|
||||
return content
|
||||
except httpx.HTTPError as e:
|
||||
raise LLMError(f"LLM 调用失败:{e}") from e
|
||||
except (KeyError, ValueError) as e:
|
||||
raise LLMError(f"LLM 响应解析失败:{e}") from e
|
||||
|
||||
def generate_stream(self, messages: list[dict], **kwargs) -> Generator[str, None, None]:
|
||||
"""SSE 流式调用。逐 chunk yield 文本,末包采集 usage。"""
|
||||
self.last_stream_usage = {}
|
||||
try:
|
||||
with self._client.stream(
|
||||
"POST",
|
||||
f"{self._base_url}/chat/completions",
|
||||
headers=self._headers(),
|
||||
json=self._payload(messages, stream=True, **kwargs),
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
for line in resp.iter_lines():
|
||||
if not line or not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[6:].strip()
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
chunk = json.loads(payload)
|
||||
# 末包采集 usage(include_usage=true)
|
||||
usage = chunk.get("usage")
|
||||
if usage:
|
||||
self.last_stream_usage = {
|
||||
"input_tokens": int(usage.get("prompt_tokens", 0) or 0),
|
||||
"output_tokens": int(usage.get("completion_tokens", 0) or 0),
|
||||
}
|
||||
choices = chunk.get("choices", [])
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
yield content
|
||||
except httpx.HTTPError as e:
|
||||
raise LLMError(f"LLM 流式调用失败:{e}") from e
|
||||
except (KeyError, ValueError) as e:
|
||||
raise LLMError(f"LLM 流式响应解析失败:{e}") from e
|
||||
|
||||
|
||||
def build_llm_adapter(settings: Settings) -> LLMAdapter:
|
||||
"""工厂方法:解析配置 → 构造适配器。配置不全即 Fail Fast。"""
|
||||
config = resolve_llm_config(settings)
|
||||
return LLMAdapter(config=config)
|
||||
Usage struct {
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
}
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
return result.Choices[0].Message.Content, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 知识检索
|
||||
---
|
||||
|
||||
```python
|
||||
# services/ai_service.py
|
||||
## 5. 知识检索(Go 内 brute-force 余弦 + 关键词)
|
||||
|
||||
def retrieve_knowledge(keywords: str, db: Session, top_k: int = 5) -> list[str]:
|
||||
"""MySQL 全文索引检索知识块"""
|
||||
results = db.execute(
|
||||
text(
|
||||
"SELECT content FROM knowledge_chunk "
|
||||
"WHERE MATCH(content) AGAINST(:keywords IN NATURAL LANGUAGE MODE) "
|
||||
"LIMIT :limit"
|
||||
),
|
||||
{"keywords": keywords, "limit": top_k},
|
||||
).fetchall()
|
||||
return [r[0] for r in results]
|
||||
```go
|
||||
// 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
|
||||
|
||||
```python
|
||||
SYSTEM_PROMPT = """你是一个博昇内部培训平台的 AI 助教 PathCoach。
|
||||
```go
|
||||
const SYSTEM_PROMPT = `你是一个博昇内部培训平台的 AI 助教 PathCoach。
|
||||
|
||||
你的职责:
|
||||
1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关的问题
|
||||
@@ -241,89 +189,100 @@ SYSTEM_PROMPT = """你是一个博昇内部培训平台的 AI 助教 PathCoach
|
||||
|
||||
知识库相关片段:
|
||||
{knowledge_context}
|
||||
"""
|
||||
`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. API 实现
|
||||
|
||||
```python
|
||||
# api/ai_chat.py
|
||||
from fastapi.responses import StreamingResponse
|
||||
### 路由注册(`backend-go/internal/api/router.go`)
|
||||
|
||||
router = APIRouter(prefix="/api/ai-chat", tags=["ai_chat"])
|
||||
|
||||
@router.post("/message")
|
||||
def chat_message(
|
||||
request: ChatRequest,
|
||||
current_user: CurrentUser,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
"""SSE 流式对话"""
|
||||
# 1. 检索知识
|
||||
knowledge = retrieve_knowledge(request.message, db)
|
||||
|
||||
# 2. 组装 Prompt
|
||||
system = SYSTEM_PROMPT.format(
|
||||
page_context=json.dumps(request.context or {}),
|
||||
knowledge_context="\n\n".join(knowledge),
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
*request.history,
|
||||
{"role": "user", "content": request.message},
|
||||
]
|
||||
|
||||
# 3. 构建 LLM 适配器(缺配置即抛 501)
|
||||
llm = build_llm_adapter(settings)
|
||||
|
||||
def generate():
|
||||
for chunk in llm.generate_stream(messages):
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': chunk})}\n\n"
|
||||
yield "data: {\"type\": \"done\"}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/quick-actions")
|
||||
def get_quick_actions():
|
||||
"""获取 3 个快捷按钮"""
|
||||
return {"data": {"actions": [
|
||||
{"id": "scenario", "label": "客户情景演练"},
|
||||
{"id": "commission", "label": "查询佣金/规则"},
|
||||
{"id": "compare", "label": "产品对比"},
|
||||
]}}
|
||||
|
||||
|
||||
@router.post("/quick-action")
|
||||
def trigger_quick_action(
|
||||
request: QuickActionRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
"""触发快捷动作(非流式 LLM 调用)"""
|
||||
llm = build_llm_adapter(settings)
|
||||
|
||||
if request.action_id == "commission":
|
||||
# 查佣金:构建 prompt → 非流式调用
|
||||
prompt = f"查询产品佣金信息,产品参数:{request.params}"
|
||||
resp = llm.generate([{"role": "user", "content": prompt}])
|
||||
return {"data": {"result": resp}}
|
||||
|
||||
elif request.action_id == "compare":
|
||||
prompt = f"对比以下产品:{request.params}"
|
||||
resp = llm.generate([{"role": "user", "content": prompt}])
|
||||
return {"data": {"result": resp}}
|
||||
|
||||
elif request.action_id == "scenario":
|
||||
# 情景演练:返回初始话术,后续走流式对话
|
||||
prompt = f"开始销售情景演练,场景参数:{request.params}"
|
||||
resp = llm.generate([{"role": "user", "content": prompt}])
|
||||
return {"data": {"result": resp, "mode": "scenario"}}
|
||||
```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 流式)
|
||||
|
||||
```go
|
||||
// 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
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 快捷动作
|
||||
|
||||
```go
|
||||
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. 上下文注入规则
|
||||
|
||||
| 页面 | 自动注入上下文 | 说明 |
|
||||
@@ -332,6 +291,8 @@ def trigger_quick_action(
|
||||
| 课程详情 | `course_id`, `course_name`, `related_product` | 自动带入当前课程及关联产品 |
|
||||
| 公司介绍 | `page: "company_intro"` | 提示 AI 当前页为公司介绍 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 配置项
|
||||
|
||||
| 配置键 | 来源 | 说明 |
|
||||
@@ -340,6 +301,8 @@ def trigger_quick_action(
|
||||
| `llm_api_key` | system_config 表 / .env | API Key,本地 Ollama 可填 `ollama` |
|
||||
| `llm_model` | system_config 表 / .env | 模型名,如 `qwen2.5:7b` |
|
||||
|
||||
---
|
||||
|
||||
## 10. 错误处理
|
||||
|
||||
| 场景 | HTTP 状态 | 响应 |
|
||||
@@ -347,4 +310,4 @@ def trigger_quick_action(
|
||||
| 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 响应异常"}` |
|
||||
| LLM 响应格式异常 | 502 | `{"error": "llm_response_error", "message": "LLM 响应异常"}` |
|
||||
|
||||
Reference in New Issue
Block a user