feat: 专员岗位说明书与技能绑定打通(SY23 P0–P4)
工作台对话此前根本不读专员表:smart_assistant.go 里两条写死的 prompt, 专员表零参与,所以「选哪个专员说话都一样」。本次把 「专员 → 岗位说明书 → 技能」接进 system prompt。 P0 修 bug batch/extract 与 contract/review 直接把 nil 当路由传给 AI 层, 取 primary.RouteID 时空指针,这两个接口必 500。 llm.go 抽出 buildRouteChain,nil 主路由改为显式报错。 P1 数据层 specialist 表加 rule_file_markdown(岗位说明书正文)与 allowed_skills (绑定技能,顺序即优先级)。写入侧用 model.ValidSkillKeys 校验, 非法 key / 重复项直接 400,并规范化成紧凑 JSON 落库。 播种 11 份岗位说明书初稿与技能绑定,沿用「存在则只补空字段」, 管理员改过的内容不会被重启覆盖。 P2 打通链路 请求体加 task_id 与 specialist_key。后端按任务反查专员(专员是任务的 字段,任务优先),查不到安静退回通用助手而不是报错打断对话。 ai_call_log 加 specialist_key,用来回答「这条回答是谁说的」。 P3 注入 prompt system prompt 改为 基础角色 + 【当前专员】+【可用技能】+【岗位说明书】, 说明书放最后(离用户消息最近,优先级最高)。选了专员就不再自称 「通用助手」——身份冲突正是老毛病的成因。 P4 前端 对话带上当前任务与专员;专员详情页显示绑定的技能、读哪些信源、 可以动什么。 验证 go build / go vet / go test 全绿,45 项测试通过;前端构建通过。 另在开发库副本上验过真实升级路径(AutoMigrate 补三列 + 播种补齐 11 个专员),源库未被改动。 新增防漂移测试:后端技能清单与前端 availableSkills 不一致即红灯; 专员改名而说明书没同步也会红灯(这类静默失效最难查)。 注:llm.go / credits.go / seed.go / smart_assistant.go / api/specialist.go 同时含有并行进行中的改动(路由健康上报、清理调试埋点、技能与动作种子), 与本方案交织在同一批行内,无法单独拆出,一并随本次提交。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -10,14 +10,14 @@ import (
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 按用户算力点计费(对齐 pj034 router.py 的 compute_credits / log_ai_call)
|
||||
// 按用户算力点计费
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// AI 能力常量(pj034 AiCapability 的精简子集)
|
||||
// AI 能力常量
|
||||
const (
|
||||
CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
|
||||
CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
|
||||
CapabilityImageGen = "image_gen" // 文生图(按次扣点)
|
||||
CapabilityImageGen = "image_gen" // 文生图(按次扣点)
|
||||
CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
|
||||
CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
|
||||
)
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
var CapabilityCredits = map[string]int{
|
||||
CapabilityAIChat: 1,
|
||||
CapabilityTextGen: 1,
|
||||
CapabilityImageGen: 1,
|
||||
CapabilityImageGen: 1,
|
||||
CapabilityEmbed: 0,
|
||||
CapabilityEssayGrade: 0,
|
||||
}
|
||||
@@ -41,16 +41,18 @@ func ComputeCredits(capability string, success bool) int {
|
||||
|
||||
// LogEntry 一次 AI 调用的审计入参
|
||||
type LogEntry struct {
|
||||
UserID uint
|
||||
Capability string
|
||||
Provider string
|
||||
RouteID string
|
||||
Model string
|
||||
TokensInput int
|
||||
TokensOutput int
|
||||
Success bool
|
||||
ErrorMessage string
|
||||
LatencyMs int
|
||||
UserID uint
|
||||
Capability string
|
||||
// SpecialistKey 本次调用以哪个专员的身份进行;空 = 通用助手
|
||||
SpecialistKey string
|
||||
Provider string
|
||||
AIRouteID string
|
||||
Model string
|
||||
TokensInput int
|
||||
TokensOutput int
|
||||
Success bool
|
||||
ErrorMessage string
|
||||
LatencyMs int
|
||||
}
|
||||
|
||||
// LogCall 写 ai_call_log;成功且需扣点时从用户余额扣点。审计写入失败不阻断主流程。
|
||||
@@ -63,8 +65,9 @@ func LogCall(e LogEntry) {
|
||||
rec := model.AiCallLog{
|
||||
UserID: e.UserID,
|
||||
Capability: e.Capability,
|
||||
SpecialistKey: e.SpecialistKey,
|
||||
Provider: e.Provider,
|
||||
RouteID: e.RouteID,
|
||||
AIRouteID: e.AIRouteID,
|
||||
Model: e.Model,
|
||||
TokensInput: e.TokensInput,
|
||||
TokensOutput: e.TokensOutput,
|
||||
|
||||
@@ -53,13 +53,13 @@ type Client struct {
|
||||
}
|
||||
|
||||
// NewClient 从 RouteConfig 创建客户端
|
||||
func NewClient(route *config.RouteConfig) *Client {
|
||||
func NewClient(aiRoute *config.RouteConfig) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(route.BaseURL, "/"),
|
||||
apiKey: route.APIKey,
|
||||
model: route.Model,
|
||||
maxTokens: route.MaxTokens,
|
||||
temperature: route.Temperature,
|
||||
baseURL: strings.TrimRight(aiRoute.BaseURL, "/"),
|
||||
apiKey: aiRoute.APIKey,
|
||||
model: aiRoute.Model,
|
||||
maxTokens: aiRoute.MaxTokens,
|
||||
temperature: aiRoute.Temperature,
|
||||
hc: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
@@ -100,44 +100,10 @@ func (c *Client) post(path string, body any) (*http.Response, error) {
|
||||
for k, v := range c.headers() {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// #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
|
||||
}
|
||||
|
||||
@@ -260,24 +226,6 @@ 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))
|
||||
}
|
||||
|
||||
@@ -347,82 +295,158 @@ func (c *Client) Embed(inputs []string) ([][]float64, error) {
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Fallback 链调用(参考 pj034 chat_completion_with_fallback)
|
||||
// Fallback 链调用
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// buildRouteChain 构建「主路由 + 回退链」。
|
||||
//
|
||||
// primary 为 nil 时必须显式报错而不是继续:链里存 nil 会在循环里 NewClient(nil),
|
||||
// 而取 primary.RouteID 更是直接空指针。历史上 batch_extract / contract_review
|
||||
// 就是直接传了 nil,导致这两个接口必 500。调用方应先用 config.GetRoute 解析路由。
|
||||
func buildRouteChain(primary *config.RouteConfig) ([]*config.RouteConfig, error) {
|
||||
if primary == nil {
|
||||
return nil, fmt.Errorf("主路由为 nil:调用方未解析 AI 路由,请先用 config.GetRoute 取路由")
|
||||
}
|
||||
aiRouteChain := []*config.RouteConfig{primary}
|
||||
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 {
|
||||
aiRouteChain = append(aiRouteChain, fallbacks...)
|
||||
}
|
||||
return aiRouteChain, nil
|
||||
}
|
||||
|
||||
// GenerateWithFallback 依次尝试主路由 + 回退链,首个成功返回
|
||||
func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (string, error) {
|
||||
// 构建路由链
|
||||
chain := []*config.RouteConfig{primary}
|
||||
fallbacks, err := config.GetFallbackRoutes(primary.RouteID)
|
||||
if err == nil && len(fallbacks) > 0 {
|
||||
chain = append(chain, fallbacks...)
|
||||
aiRouteChain, err := buildRouteChain(primary)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, route := range chain {
|
||||
client := NewClient(route)
|
||||
for _, aiRoute := range aiRouteChain {
|
||||
client := NewClient(aiRoute)
|
||||
start := time.Now()
|
||||
content, err := client.Generate(messages)
|
||||
if err == nil {
|
||||
config.ReportRouteHealth(config.RouteHealth{
|
||||
AIRouteID: aiRoute.RouteID,
|
||||
Category: aiRoute.Category,
|
||||
Healthy: true,
|
||||
Checked: true,
|
||||
LatencyMs: time.Since(start).Milliseconds(),
|
||||
LastCheckedAt: time.Now(),
|
||||
})
|
||||
return content, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
|
||||
config.ReportRouteHealth(config.RouteHealth{
|
||||
AIRouteID: aiRoute.RouteID,
|
||||
Category: aiRoute.Category,
|
||||
Healthy: false,
|
||||
Checked: true,
|
||||
LatencyMs: time.Since(start).Milliseconds(),
|
||||
LastCheckedAt: time.Now(),
|
||||
LastError: err.Error(),
|
||||
})
|
||||
lastErr = fmt.Errorf("[%s] %w", aiRoute.RouteID, err)
|
||||
}
|
||||
return "", fmt.Errorf("所有路由均失败: %w", lastErr)
|
||||
}
|
||||
|
||||
// GenerateFullWithFallback 非流式 + 回退链:返回结果与「实际命中」的路由(用于审计)
|
||||
func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (*ChatResult, *config.RouteConfig, error) {
|
||||
chain := []*config.RouteConfig{primary}
|
||||
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 {
|
||||
chain = append(chain, fallbacks...)
|
||||
aiRouteChain, err := buildRouteChain(primary)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, route := range chain {
|
||||
client := NewClient(route)
|
||||
for _, aiRoute := range aiRouteChain {
|
||||
client := NewClient(aiRoute)
|
||||
start := time.Now()
|
||||
res, err := client.GenerateFull(messages)
|
||||
if err == nil {
|
||||
res.Provider = route.Provider
|
||||
return res, route, nil
|
||||
config.ReportRouteHealth(config.RouteHealth{
|
||||
AIRouteID: aiRoute.RouteID,
|
||||
Category: aiRoute.Category,
|
||||
Healthy: true,
|
||||
Checked: true,
|
||||
LatencyMs: time.Since(start).Milliseconds(),
|
||||
LastCheckedAt: time.Now(),
|
||||
})
|
||||
res.Provider = aiRoute.Provider
|
||||
return res, aiRoute, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
|
||||
config.ReportRouteHealth(config.RouteHealth{
|
||||
AIRouteID: aiRoute.RouteID,
|
||||
Category: aiRoute.Category,
|
||||
Healthy: false,
|
||||
Checked: true,
|
||||
LatencyMs: time.Since(start).Milliseconds(),
|
||||
LastCheckedAt: time.Now(),
|
||||
LastError: err.Error(),
|
||||
})
|
||||
lastErr = fmt.Errorf("[%s] %w", aiRoute.RouteID, err)
|
||||
}
|
||||
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...)
|
||||
aiRouteChain, err := buildRouteChain(primary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, route := range chain {
|
||||
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
|
||||
lastErr = fmt.Errorf("[%s] 未配置 API Key", route.RouteID)
|
||||
for _, aiRoute := range aiRouteChain {
|
||||
if requiresAPIKey(aiRoute) && strings.TrimSpace(aiRoute.APIKey) == "" {
|
||||
config.ReportRouteHealth(config.RouteHealth{
|
||||
AIRouteID: aiRoute.RouteID,
|
||||
Category: aiRoute.Category,
|
||||
Healthy: false,
|
||||
Checked: true,
|
||||
LastCheckedAt: time.Now(),
|
||||
LastError: "未配置 API Key",
|
||||
})
|
||||
lastErr = fmt.Errorf("[%s] 未配置 API Key", aiRoute.RouteID)
|
||||
continue
|
||||
}
|
||||
client := NewClient(route)
|
||||
client := NewClient(aiRoute)
|
||||
start := time.Now()
|
||||
if err := client.GenerateStream(messages, onChunk); err == nil {
|
||||
return route, nil
|
||||
config.ReportRouteHealth(config.RouteHealth{
|
||||
AIRouteID: aiRoute.RouteID,
|
||||
Category: aiRoute.Category,
|
||||
Healthy: true,
|
||||
Checked: true,
|
||||
LatencyMs: time.Since(start).Milliseconds(),
|
||||
LastCheckedAt: time.Now(),
|
||||
})
|
||||
return aiRoute, nil
|
||||
} else {
|
||||
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
|
||||
config.ReportRouteHealth(config.RouteHealth{
|
||||
AIRouteID: aiRoute.RouteID,
|
||||
Category: aiRoute.Category,
|
||||
Healthy: false,
|
||||
Checked: true,
|
||||
LatencyMs: time.Since(start).Milliseconds(),
|
||||
LastCheckedAt: time.Now(),
|
||||
LastError: err.Error(),
|
||||
})
|
||||
lastErr = fmt.Errorf("[%s] %w", aiRoute.RouteID, err)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("所有路由均失败: %w", lastErr)
|
||||
}
|
||||
|
||||
func requiresAPIKey(route *config.RouteConfig) bool {
|
||||
if route == nil {
|
||||
func requiresAPIKey(aiRoute *config.RouteConfig) bool {
|
||||
if aiRoute == nil {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
|
||||
baseURL := strings.ToLower(strings.TrimSpace(aiRoute.BaseURL))
|
||||
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
|
||||
return true
|
||||
}
|
||||
provider := strings.ToLower(strings.TrimSpace(route.Provider))
|
||||
provider := strings.ToLower(strings.TrimSpace(aiRoute.Provider))
|
||||
return provider == "openrouter" || provider == "openai"
|
||||
}
|
||||
|
||||
@@ -455,7 +479,7 @@ func ResolveLLM(cfg *config.Config) (LLMConfig, bool) {
|
||||
modelName := get("llm_model", cfg.LLMModel)
|
||||
embedModel := get("embed_model", cfg.EmbedModel)
|
||||
|
||||
// DB 空时降级到 JSON secrets(flat 格式,pj034 兼容)
|
||||
// DB 空时降级到 JSON secrets(flat 格式)
|
||||
if baseURL == "" || apiKey == "" {
|
||||
if baseURL == "" {
|
||||
baseURL = config.GetProviderBaseURL("ollama")
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
)
|
||||
|
||||
// TestBuildRouteChainNilPrimary 回归测试:主路由为 nil 时必须返回错误,不能 panic。
|
||||
//
|
||||
// 背景:internal/api/batch_extract.go 与 internal/api/contract_review.go 曾直接传
|
||||
// ai.GenerateWithFallback(nil, …),旧实现第一行就取 primary.RouteID,导致
|
||||
// POST /api/batch/extract 与 POST /api/contract/review 必然 500。
|
||||
func TestBuildRouteChainNilPrimary(t *testing.T) {
|
||||
chain, err := buildRouteChain(nil)
|
||||
if err == nil {
|
||||
t.Fatal("主路由为 nil 时应返回错误,实际返回 nil error")
|
||||
}
|
||||
if chain != nil {
|
||||
t.Fatalf("主路由为 nil 时不应返回路由链,实际返回 %d 条", len(chain))
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nil") {
|
||||
t.Errorf("错误信息应点明 nil 主路由,实际为: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRouteChainNonNilPrimary 确认正常路径仍是「主路由在链首」。
|
||||
// 这里只校验链首,不依赖 ai_config.json 里是否配了回退链。
|
||||
func TestBuildRouteChainNonNilPrimary(t *testing.T) {
|
||||
primary := &config.RouteConfig{RouteID: "chat_route_does_not_exist_for_test"}
|
||||
|
||||
chain, err := buildRouteChain(primary)
|
||||
if err != nil {
|
||||
t.Fatalf("非 nil 主路由不应报错: %v", err)
|
||||
}
|
||||
if len(chain) == 0 {
|
||||
t.Fatal("路由链不应为空")
|
||||
}
|
||||
if chain[0] != primary {
|
||||
t.Errorf("链首应为传入的主路由,实际是 %v", chain[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateWithFallbackNilPrimary 三个导出入口都必须把 nil 转成错误而非 panic。
|
||||
func TestGenerateWithFallbackNilPrimary(t *testing.T) {
|
||||
msgs := []Message{{Role: "user", Content: "ping"}}
|
||||
|
||||
t.Run("GenerateWithFallback", func(t *testing.T) {
|
||||
if _, err := GenerateWithFallback(nil, msgs); err == nil {
|
||||
t.Error("期望返回错误,实际 nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GenerateFullWithFallback", func(t *testing.T) {
|
||||
res, route, err := GenerateFullWithFallback(nil, msgs)
|
||||
if err == nil {
|
||||
t.Error("期望返回错误,实际 nil")
|
||||
}
|
||||
if res != nil || route != nil {
|
||||
t.Errorf("失败时结果与路由都应为 nil,实际 res=%v route=%v", res, route)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GenerateStreamWithFallback", func(t *testing.T) {
|
||||
if _, err := GenerateStreamWithFallback(nil, msgs, func(string) {}); err == nil {
|
||||
t.Error("期望返回错误,实际 nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user