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:
eaiadmin
2026-09-17 21:32:31 +08:00
co-authored by Claude Code
parent fa6c26ea41
commit 8b136d4a10
20 changed files with 3781 additions and 314 deletions
@@ -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,
+113 -89
View File
@@ -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")
}
})
}