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
@@ -58,8 +58,8 @@ func runBatchExtract(req BatchExtractRequest) BatchExtractResult {
// 构建字段说明
fieldDesc := buildFieldDescription(req.Fields)
// 调用 AI 提取
content, err := ai.GenerateWithFallback(nil, []ai.Message{
// 调用 AI 提取(此前传 nil 会让 AI 层空指针,见 helpers.generateWithDefaultRoute)
content, err := generateWithDefaultRoute([]ai.Message{
{Role: "system", Content: "你是一个字段提取专家。请从用户输入的内容中提取以下字段信息,以JSON格式返回:\n" + fieldDesc},
{Role: "user", Content: req.Content},
})
@@ -66,7 +66,6 @@ func ReviewContract(c *gin.Context) {
web.Fail(c, web.NewBadRequest("content 必填"))
return
}
req.RiskStandard = req.RiskStandard
if req.RiskStandard == "" {
req.RiskStandard = "standard"
}
@@ -357,7 +356,9 @@ func checkRisks(sections []ContractSection) []RiskItem {
// aiDeepReview AI 深度审查
func aiDeepReview(req ContractReviewRequest) []RiskItem {
content, err := ai.GenerateWithFallback(nil, []ai.Message{
// 此前传 nil 会让 AI 层空指针(见 helpers.generateWithDefaultRoute);
// 失败时返回 nil,由调用方只保留规则引擎结果。
content, err := generateWithDefaultRoute([]ai.Message{
{Role: "system", Content: "你是一位资深法务专家。请审查以下合同文本,识别法律风险。" +
"重点关注:条款完整性、权责对等性、违约责任、争议解决、不可抗力、保密条款、知识产权。"},
{Role: "user", Content: req.Content},
@@ -1,10 +1,13 @@
package api
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/web"
)
@@ -17,3 +20,19 @@ func parseID(c *gin.Context, name string) (uint, bool) {
}
return uint(id), true
}
// generateWithDefaultRoute 取通用文本生成路由后调用 LLM(非流式 + 回退链)。
//
// 用于请求体里没有 ai_route_id、服务端自行挑路由的场景。路由名沿用代码里既有惯例
// "title_gen"(agent_routes 里指向通用 chat 模型);GetRoute 本身在查不到时会回落到
// ai_config.json 的 default_route,所以这里只可能在配置整体不可用时失败。
//
// 存在的意义:取代此前 ai.GenerateWithFallback(nil, …) 的写法。传 nil 会让
// ai.buildRouteChain 直接报错——历史上前者会让调用方必 500。
func generateWithDefaultRoute(messages []ai.Message) (string, error) {
aiRoute, err := config.GetRoute("title_gen")
if err != nil || aiRoute == nil {
return "", fmt.Errorf("通用文本生成路由不可用: %w", err)
}
return ai.GenerateWithFallback(aiRoute, messages)
}
@@ -6,37 +6,44 @@ import (
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
// SmartAssistantRequest POST /api/assistant/chat —— 智能助手请求
// SmartAssistantRequest POST /api/assistant/chat —— 通用助手请求
type SmartAssistantRequest struct {
Message string `json:"message"`
Context string `json:"context"`
Mode string `json:"mode"`
TaskID uint `json:"task_id"`
Temperature float64 `json:"temperature"`
Stream bool `json:"stream"`
Message string `json:"message"`
Context string `json:"context"`
Mode string `json:"mode"`
AIRouteID string `json:"ai_route_id"`
// TaskID 当前会话挂在哪条事项上。带上它就能反查事项的专员,
// 让这次回答以那个专员的身份说出来(见 resolveSpecialist)。
TaskID uint `json:"task_id"`
// SpecialistKey 直接指定专员,用于还没有事项的新会话。
// 与 TaskID 同时给出时以事项上的专员为准。
SpecialistKey string `json:"specialist_key"`
Temperature float64 `json:"temperature"`
Stream bool `json:"stream"`
}
// AssistantMessage 助手消息
type AssistantMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
Type string `json:"type"`
Type string `json:"type"`
}
// TaskPlan 任务拆解结果
type TaskPlan struct {
TaskID uint `json:"task_id"`
Title string `json:"title"`
Steps []Step `json:"steps"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
TaskID uint `json:"task_id"`
Title string `json:"title"`
Steps []Step `json:"steps"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
// Step 任务步骤
@@ -48,17 +55,16 @@ type Step struct {
Order int `json:"order"`
}
// SmartAssistantResult 智能助手响应
// SmartAssistantResult 通用助手响应
type SmartAssistantResult struct {
Message AssistantMessage `json:"message"`
TaskPlan *TaskPlan `json:"task_plan,omitempty"`
IsPlan bool `json:"is_plan"`
IsCopy bool `json:"is_copy"`
IsExtract bool `json:"is_extract"`
Stream bool `json:"stream"`
Message AssistantMessage `json:"message"`
TaskPlan *TaskPlan `json:"task_plan,omitempty"`
IsPlan bool `json:"is_plan"`
IsExpert bool `json:"is_expert"`
Stream bool `json:"stream"`
}
// Chat POST /api/assistant/chat —— 智能助手对话入口
// Chat POST /api/assistant/chat —— 通用助手对话入口
func Chat(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
@@ -70,39 +76,36 @@ func Chat(c *gin.Context) {
web.Fail(c, web.NewBadRequest("message 必填"))
return
}
req.Mode = req.Mode
if req.Mode == "" {
req.Mode = "chat"
req.Mode = "quick"
}
req.Temperature = req.Temperature
if req.Temperature == 0 {
req.Temperature = 0.7
}
result := processAssistantMessage(user.ID, req)
logAssistantMessage(user.ID, req.Message, result)
// 专员只解析一次,供 prompt 组装与审计日志共用
specialist := resolveSpecialist(req)
result := processAssistantMessage(user.ID, req, specialist)
logAssistantMessage(user.ID, req.Message, result, specialist)
web.OK(c, result)
}
// processAssistantMessage 处理智能助手消息
func processAssistantMessage(userID uint, req SmartAssistantRequest) SmartAssistantResult {
// processAssistantMessage 处理通用助手消息
func processAssistantMessage(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
switch req.Mode {
case "chat":
return handleChat(userID, req)
case "task":
return handleTaskGeneration(userID, req)
case "copy":
return handleCopyWriting(userID, req)
case "task_plan":
return handleTaskPlan(userID, req)
case "quick":
return handleQuick(userID, req, specialist)
case "expert":
return handleExpert(userID, req, specialist)
default:
return handleChat(userID, req)
return handleQuick(userID, req, specialist)
}
}
// handleChat 普通对话模式
func handleChat(userID uint, req SmartAssistantRequest) SmartAssistantResult {
aiResponse := callAssistantAI(userID, req)
// handleQuick 快速模式:直出回答,不开启 thinking 和多步规划
func handleQuick(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
aiResponse := callAssistantAI(userID, req, specialist, false)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
@@ -110,70 +113,49 @@ func handleChat(userID uint, req SmartAssistantRequest) SmartAssistantResult {
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "text",
},
IsPlan: false,
IsCopy: false,
IsExtract: false,
Stream: req.Stream,
}
}
// handleTaskGeneration 任务生成模式
func handleTaskGeneration(userID uint, req SmartAssistantRequest) SmartAssistantResult {
plan := generateTaskPlan(userID, req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: "已为您生成任务计划:" + plan.Title,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "plan",
},
TaskPlan: plan,
IsPlan: true,
IsCopy: false,
IsExtract: false,
Stream: req.Stream,
}
}
// handleCopyWriting 文案生成模式
func handleCopyWriting(userID uint, req SmartAssistantRequest) SmartAssistantResult {
copys := generateCopyWriting(userID, req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: copys,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "copy",
},
IsPlan: false,
IsCopy: true,
IsExtract: false,
Stream: req.Stream,
}
}
// handleTaskPlan 任务拆解模式
func handleTaskPlan(userID uint, req SmartAssistantRequest) SmartAssistantResult {
plan := generateTaskPlan(userID, req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: "任务已创建并拆解:" + plan.Title,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "plan",
},
TaskPlan: plan,
IsPlan: true,
IsCopy: false,
IsExtract: false,
IsPlan: false,
IsExpert: false,
Stream: req.Stream,
}
}
// callAssistantAI 调用AI生成对话回复
func callAssistantAI(userID uint, req SmartAssistantRequest) string {
content, err := ai.GenerateWithFallback(nil, []ai.Message{
{Role: "system", Content: "你是一位专业的AI智能助手,能够提供各类办公场景的辅助,包括文档翻译、文案校对、文案生成、任务拆解等。请根据用户的问题提供专业、简洁的回答。"},
// handleExpert 专家模式:开启 thinking,进行多步智能体规划
func handleExpert(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
// 先用 expert system prompt 调用 LLM 生成多步规划和回复
aiResponse := callAssistantAI(userID, req, specialist, true)
plan := generateTaskPlan(req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: aiResponse,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "text",
},
TaskPlan: plan,
IsPlan: true,
IsExpert: true,
Stream: req.Stream,
}
}
// callAssistantAI 调用AI生成对话回复,enableThinking 控制是否使用 expert prompt
//
// system prompt 由 buildAssistantSystemPrompt 组装:基础角色 + 专员岗位说明书。
// 没解析到专员时(未指定 / key 失效)与改动前逐字一致。
func callAssistantAI(userID uint, req SmartAssistantRequest, specialist *model.Specialist, enableThinking bool) string {
systemPrompt := buildAssistantSystemPrompt(specialist, enableThinking)
aiRouteID := req.AIRouteID
if aiRouteID == "" {
aiRouteID = "path_coach"
}
aiRoute, err := config.GetRoute(aiRouteID)
if err != nil {
return "抱歉,当前后台AI模型不可用,请检查 AI 路由配置。"
}
content, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: req.Message},
})
if err != nil {
@@ -183,8 +165,7 @@ func callAssistantAI(userID uint, req SmartAssistantRequest) string {
}
// generateTaskPlan 生成任务拆解
func generateTaskPlan(userID uint, prompt string) *TaskPlan {
_ = userID
func generateTaskPlan(prompt string) *TaskPlan {
plan := &TaskPlan{
Title: prompt,
Status: "pending",
@@ -198,24 +179,16 @@ func generateTaskPlan(userID uint, prompt string) *TaskPlan {
return plan
}
// generateCopyWriting 生成文案
func generateCopyWriting(userID uint, prompt string) string {
_ = userID
c, err := ai.GenerateWithFallback(nil, []ai.Message{
{Role: "system", Content: "你是一位文案专家,请根据以下要求生成文案。"},
{Role: "user", Content: prompt},
})
if err != nil {
return "生成失败,请稍后重试。"
}
return c
}
// logAssistantMessage 记录助手消息
func logAssistantMessage(userID uint, message string, result SmartAssistantResult) {
func logAssistantMessage(userID uint, message string, result SmartAssistantResult, specialist *model.Specialist) {
specialistKey := ""
if specialist != nil {
specialistKey = specialist.Key
}
store.DB.Create(&model.AiCallLog{
UserID: userID,
Capability: "assistant_chat",
Status: "success",
UserID: userID,
Capability: "assistant_chat",
SpecialistKey: specialistKey,
Status: "success",
})
}
@@ -34,7 +34,7 @@ func ListSpecialists(c *gin.Context) {
default:
q = q.Where("state = ?", state)
}
// state = system 的内置记录(通用智能助手)是任务的默认归属,不是可安装的专员,
// state = system 的内置记录(通用助手)是任务的默认归属,不是可安装的专员,
// 任何常规列表都不该带出它 —— 只有显式 state=system 才查得到。
if c.Query("state") != "system" {
q = q.Where("state <> ?", "system")
@@ -110,26 +110,34 @@ func SpecialistSummary(c *gin.Context) {
}
type specialistReq struct {
Key string `json:"key"`
Label string `json:"label"`
Tier string `json:"tier"`
WorkerType string `json:"worker_type"`
Route string `json:"route"`
Summary string `json:"summary"`
WorkStatus string `json:"work_status"`
RiskLabel string `json:"risk_label"`
Color string `json:"color"`
Stage string `json:"stage"`
Progress int `json:"progress"`
MarketTag string `json:"market_tag"`
Version string `json:"version"`
ConnectorScope string `json:"connector_scope"`
PermissionScope string `json:"permission_scope"`
ResourceBindings string `json:"resource_bindings"`
InfoSources string `json:"info_sources"`
BaseSkills string `json:"base_skills"`
AIAssistance string `json:"ai_assistance"`
GeneratedSkills string `json:"generated_skills"`
Key string `json:"key"`
Label string `json:"label"`
DisplayCode string `json:"display_code"`
EAILogicCode string `json:"eailogic_code"`
Tier string `json:"tier"`
WorkerType string `json:"worker_type"`
ObjectEntryRoute string `json:"object_entry_route"`
Summary string `json:"summary"`
RoleCardJSON string `json:"role_card_json"`
WorkStatus string `json:"work_status"`
RiskLabel string `json:"risk_label"`
Color string `json:"color"`
Stage string `json:"stage"`
Progress int `json:"progress"`
MarketTag string `json:"market_tag"`
Version string `json:"version"`
ConnectorScope string `json:"connector_scope"`
PermissionScope string `json:"permission_scope"`
ResourceBindings string `json:"resource_bindings"`
InfoSources string `json:"info_sources"`
BaseSkills string `json:"base_skills"`
AIAssistance string `json:"ai_assistance"`
GeneratedSkills string `json:"generated_skills"`
// RuleFileMarkdown 岗位说明书正文(Markdown),会话创建时注入 System Prompt
RuleFileMarkdown string `json:"rule_file_markdown"`
// AllowedSkills 绑定技能 key 的 JSON 数组字符串,顺序即优先级(首个为主技能)。
// 元素须在 model.ValidSkillKeys 内;写入前会被规范化成紧凑 JSON。
AllowedSkills string `json:"allowed_skills"`
InputsRecordsJSON string `json:"inputs_records_json"`
LegacySourceRecordsJSON string `json:"source_records_json"`
PermissionRecordsJSON string `json:"permission_records_json"`
@@ -142,10 +150,13 @@ type specialistReq struct {
func normalizeSpecialistReq(req *specialistReq) {
req.Key = strings.TrimSpace(req.Key)
req.Label = strings.TrimSpace(req.Label)
req.DisplayCode = strings.TrimSpace(req.DisplayCode)
req.EAILogicCode = strings.TrimSpace(req.EAILogicCode)
req.Tier = strings.TrimSpace(req.Tier)
req.WorkerType = strings.TrimSpace(req.WorkerType)
req.Route = strings.TrimSpace(req.Route)
req.ObjectEntryRoute = strings.TrimSpace(req.ObjectEntryRoute)
req.Summary = strings.TrimSpace(req.Summary)
req.RoleCardJSON = strings.TrimSpace(req.RoleCardJSON)
req.WorkStatus = strings.TrimSpace(req.WorkStatus)
req.RiskLabel = strings.TrimSpace(req.RiskLabel)
req.Color = strings.TrimSpace(req.Color)
@@ -159,6 +170,8 @@ func normalizeSpecialistReq(req *specialistReq) {
req.BaseSkills = strings.TrimSpace(req.BaseSkills)
req.AIAssistance = strings.TrimSpace(req.AIAssistance)
req.GeneratedSkills = strings.TrimSpace(req.GeneratedSkills)
req.RuleFileMarkdown = strings.TrimSpace(req.RuleFileMarkdown)
req.AllowedSkills = strings.TrimSpace(req.AllowedSkills)
req.InputsRecordsJSON = strings.TrimSpace(req.InputsRecordsJSON)
req.LegacySourceRecordsJSON = strings.TrimSpace(req.LegacySourceRecordsJSON)
if req.InputsRecordsJSON == "" {
@@ -172,8 +185,8 @@ func normalizeSpecialistReq(req *specialistReq) {
func validateSpecialistReq(req *specialistReq) *web.AppError {
normalizeSpecialistReq(req)
if req.Key == "" || req.Label == "" || req.Tier == "" || req.Route == "" {
return web.NewBadRequest("key、label、tier、route 为必填")
if req.Key == "" || req.Label == "" || req.Tier == "" || req.ObjectEntryRoute == "" {
return web.NewBadRequest("key、label、tier、object_entry_route 为必填")
}
if req.Tier != "generic" && req.Tier != "industry" {
return web.NewBadRequest("tier 只能是 generic 或 industry")
@@ -199,6 +212,9 @@ func validateSpecialistReq(req *specialistReq) *web.AppError {
if !store.ValidateStructuredRecordsJSON(req.InputsRecordsJSON) {
return web.NewBadRequest("inputs_records_json 必须是 JSON 数组")
}
if !store.ValidateJSONObjectJSON(req.RoleCardJSON) {
return web.NewBadRequest("role_card_json 必须是 JSON 对象")
}
if !store.ValidateStructuredRecordsJSON(req.PermissionRecordsJSON) {
return web.NewBadRequest("permission_records_json 必须是 JSON 数组")
}
@@ -208,9 +224,36 @@ func validateSpecialistReq(req *specialistReq) *web.AppError {
if !store.ValidateStructuredRecordsJSON(req.ResultRecordsJSON) {
return web.NewBadRequest("result_records_json 必须是 JSON 数组")
}
// 绑定技能:非法 key / 重复项在录入侧就挡住(见 SY23 §2.4)。
// 顺带把值规范化成紧凑 JSON,避免 "[ \"a\" , \"b\" ]" 这类写法进库后
// 让 ParseAllowedSkills 之外的下游(如直接字符串比较)踩坑。
if normalized, appErr := normalizeAllowedSkills(req.AllowedSkills); appErr != nil {
return appErr
} else {
req.AllowedSkills = normalized
}
return nil
}
// normalizeAllowedSkills 校验绑定技能列表并回写规范化后的 JSON 字符串。
// 空值合法(表示未绑定技能),统一存空串而不是 "[]"。
func normalizeAllowedSkills(raw string) (string, *web.AppError) {
keys := model.ParseAllowedSkills(raw)
// ParseAllowedSkills 对非法 JSON 返回 nil,这里要与「空数组」区分开:
// 传了内容但解析不出来,说明格式错了,应报错而不是静默丢成未绑定。
if strings.TrimSpace(raw) != "" && keys == nil {
return "", web.NewBadRequest(`allowed_skills 必须是 JSON 字符串数组,例如 ["contract-review","batch-extract"]`)
}
if err := model.ValidateAllowedSkills(keys); err != nil {
return "", web.NewBadRequest(err.Error())
}
normalized, err := model.MarshalAllowedSkills(keys)
if err != nil {
return "", web.NewBadRequest("allowed_skills 序列化失败")
}
return normalized, nil
}
// CreateSpecialist POST /api/specialists (admin)
func CreateSpecialist(c *gin.Context) {
var req specialistReq
@@ -233,10 +276,13 @@ func CreateSpecialist(c *gin.Context) {
item := model.Specialist{
Key: req.Key,
Label: req.Label,
DisplayCode: req.DisplayCode,
EAILogicCode: req.EAILogicCode,
Tier: req.Tier,
WorkerType: req.WorkerType,
Route: req.Route,
ObjectEntryRoute: req.ObjectEntryRoute,
Summary: req.Summary,
RoleCardJSON: req.RoleCardJSON,
WorkStatus: req.WorkStatus,
RiskLabel: req.RiskLabel,
Color: req.Color,
@@ -251,6 +297,8 @@ func CreateSpecialist(c *gin.Context) {
BaseSkills: req.BaseSkills,
AIAssistance: req.AIAssistance,
GeneratedSkills: req.GeneratedSkills,
RuleFileMarkdown: req.RuleFileMarkdown,
AllowedSkills: req.AllowedSkills,
InputsRecordsJSON: req.InputsRecordsJSON,
PermissionRecordsJSON: req.PermissionRecordsJSON,
ActionRecordsJSON: req.ActionRecordsJSON,
@@ -297,10 +345,13 @@ func UpdateSpecialist(c *gin.Context) {
item.Key = req.Key
item.Label = req.Label
item.DisplayCode = req.DisplayCode
item.EAILogicCode = req.EAILogicCode
item.Tier = req.Tier
item.WorkerType = req.WorkerType
item.Route = req.Route
item.ObjectEntryRoute = req.ObjectEntryRoute
item.Summary = req.Summary
item.RoleCardJSON = req.RoleCardJSON
item.WorkStatus = req.WorkStatus
item.RiskLabel = req.RiskLabel
item.Color = req.Color
@@ -315,6 +366,8 @@ func UpdateSpecialist(c *gin.Context) {
item.BaseSkills = req.BaseSkills
item.AIAssistance = req.AIAssistance
item.GeneratedSkills = req.GeneratedSkills
item.RuleFileMarkdown = req.RuleFileMarkdown
item.AllowedSkills = req.AllowedSkills
item.InputsRecordsJSON = req.InputsRecordsJSON
item.PermissionRecordsJSON = req.PermissionRecordsJSON
item.ActionRecordsJSON = req.ActionRecordsJSON
@@ -0,0 +1,127 @@
package api
import (
"fmt"
"strings"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
)
// 本文件回答一个问题:一次对话/一次动作,到底该以哪个专员的身份说话。
//
// 在此之前,工作台对话走的是 smart_assistant.go 里两条写死的 prompt,
// 专员表根本不参与——所以选谁都是同一个声音。这里把「专员 → 岗位说明书 → 技能」
// 接进 system prompt,让专员真正决定怎么说话、怎么推进。
// loadSpecialistByKey 按 key 取专员;取不到返回 nil。
//
// 刻意不报错:专员查不到(key 写错、被下线、删了)应当退化成通用助手,
// 而不是把用户的对话打断成 500。
func loadSpecialistByKey(key string) *model.Specialist {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
var s model.Specialist
if err := store.DB.Where("key = ?", key).First(&s).Error; err != nil {
return nil
}
// state = inactive 的专员不再接新会话,但 system(通用助手)要能查到。
if s.State == "inactive" {
return nil
}
return &s
}
// resolveSpecialist 解析本次请求该用的专员。
//
// 优先级:
// 1. task_id 指向的任务上的 specialist_key —— 专员是任务的字段,任务才是权威来源,
// 前端的 attachSpecialistToCurrentTask 也是把专员写到任务上而不是写到会话上。
// 2. 请求体里的 specialist_key —— 还没有任务时(新建会话早期)用这个。
//
// 都取不到返回 nil,调用方退回通用助手。
func resolveSpecialist(req SmartAssistantRequest) *model.Specialist {
if req.TaskID > 0 {
// 与 worker_task.go 其余读路径一致,不按 created_by 收窄:
// 专员目录本身就是所有登录用户可读的,这里不构成新的信息暴露。
var task model.WorkerTask
if err := store.DB.First(&task, req.TaskID).Error; err == nil {
if s := loadSpecialistByKey(task.SpecialistKey); s != nil {
return s
}
}
}
return loadSpecialistByKey(req.SpecialistKey)
}
// specialistPromptSection 生成要拼进 system prompt 的专员段落。
//
// 三块内容,顺序有讲究:
// - 身份:让模型知道自己现在是谁
// - 可用技能:告诉它能调用什么,避免它承诺做不到的事
// - 岗位说明书:放在最后,离用户消息最近,优先级最高
//
// 专员为 nil 或什么都没配时返回空串,调用方不做任何拼接——
// 这样「没选专员」与「选了但没配说明书」都不改变原有行为。
func specialistPromptSection(s *model.Specialist) string {
if s == nil {
return ""
}
var b strings.Builder
// 身份
b.WriteString("【当前专员】\n")
label := strings.TrimSpace(s.Label)
if label == "" {
label = s.Key
}
fmt.Fprintf(&b, "%s(%s)\n", label, s.Key)
if summary := strings.TrimSpace(s.Summary); summary != "" {
b.WriteString(summary + "\n")
}
if workStatus := strings.TrimSpace(s.WorkStatus); workStatus != "" {
b.WriteString("当前状态:" + workStatus + "\n")
}
// 可用技能
if skills := model.ParseAllowedSkills(s.AllowedSkills); len(skills) > 0 {
b.WriteString("\n【可用技能】\n")
b.WriteString(strings.Join(skills, "、"))
b.WriteString("\n首个是主技能。只在这些技能范围内承诺能力,范围外的需求如实说做不了。\n")
}
// 岗位说明书
if rule := strings.TrimSpace(s.RuleFileMarkdown); rule != "" {
b.WriteString("\n【岗位说明书】\n")
b.WriteString(rule)
b.WriteString("\n")
}
return strings.TrimSpace(b.String())
}
// buildAssistantSystemPrompt 组装工作台对话的 system prompt。
//
// 结构:基础角色说明(随模式变化) + 专员段落。
// 没有专员时结果与改动前完全一致,保证这是纯增量。
func buildAssistantSystemPrompt(s *model.Specialist, enableThinking bool) string {
var base string
if enableThinking {
base = "你是一位专家级AI数字员工,使用 thinking 模式。在回答前先进行多步思考,将复杂任务拆解为可执行的步骤,给出深度分析。回答要专业、结构化、有洞察力。"
} else {
base = "你是一位专业的AI通用助手,回答简洁直接,不展开思考过程,快速响应用户问题。"
}
section := specialistPromptSection(s)
if section == "" {
return base
}
// 选了专员就不再自称「通用助手」——身份冲突会让模型按通用助手的方式答话,
// 这正是「选谁说话都一样」的老毛病。
base = strings.Replace(base, "AI通用助手", "AI数字员工", 1)
return base + "\n\n" + section
}
@@ -0,0 +1,227 @@
package api
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
)
func sampleSpecialist() *model.Specialist {
return &model.Specialist{
Key: "contract-review",
Label: "合同审查专员",
Summary: "审合同、找风险、给红线",
WorkStatus: "3 份待审",
AllowedSkills: `["contract-review","contract-brief"]`,
RuleFileMarkdown: "# 合同审查专员\n\n## 你是谁\n你面对的是要签字的人。",
}
}
// TestSpecialistPromptSectionNil 没有专员时必须返回空串,
// 让调用方能靠「空串 = 不拼接」保持改动前的行为。
func TestSpecialistPromptSectionNil(t *testing.T) {
if got := specialistPromptSection(nil); got != "" {
t.Errorf("专员为 nil 时应返回空串,实际: %q", got)
}
}
// TestSpecialistPromptSectionEmptySpecialist 专员存在但什么都没配,同样不该产出段落,
// 否则 prompt 里会多出一个空壳的【当前专员】把模型带偏。
func TestSpecialistPromptSectionEmptySpecialist(t *testing.T) {
empty := &model.Specialist{Key: "bare", Label: "空壳专员"}
got := specialistPromptSection(empty)
// 身份块本身是有内容的(key + label),所以这里只要求不含技能/说明书两节
if strings.Contains(got, "【可用技能】") {
t.Errorf("没绑技能不该出现【可用技能】: %q", got)
}
if strings.Contains(got, "【岗位说明书】") {
t.Errorf("没写说明书不该出现【岗位说明书】: %q", got)
}
}
// TestSpecialistPromptSectionOrdering 岗位说明书必须排在最后 —— 离用户消息最近,
// 优先级最高,否则模型会按基础角色说明(简洁直接)而不是说明书里的流程来答。
func TestSpecialistPromptSectionOrdering(t *testing.T) {
got := specialistPromptSection(sampleSpecialist())
iIdentity := strings.Index(got, "【当前专员】")
iSkills := strings.Index(got, "【可用技能】")
iRule := strings.Index(got, "【岗位说明书】")
if iIdentity < 0 || iSkills < 0 || iRule < 0 {
t.Fatalf("三段都应出现,实际: %q", got)
}
if !(iIdentity < iSkills && iSkills < iRule) {
t.Errorf("顺序应为 身份 < 技能 < 说明书,实际位置 %d/%d/%d", iIdentity, iSkills, iRule)
}
}
// TestSpecialistPromptSectionCarriesContent 段落里要真的带上说明书正文与技能 key。
func TestSpecialistPromptSectionCarriesContent(t *testing.T) {
s := sampleSpecialist()
got := specialistPromptSection(s)
for _, want := range []string{s.Label, s.Key, s.Summary, "contract-review", "contract-brief", "你面对的是要签字的人"} {
if !strings.Contains(got, want) {
t.Errorf("专员段落里缺少 %q\n实际内容:\n%s", want, got)
}
}
}
// TestBuildAssistantSystemPromptWithoutSpecialistIsUnchanged 守住「纯增量」:
// 没有专员时,system prompt 必须与改动前逐字一致(只有基础角色说明)。
func TestBuildAssistantSystemPromptWithoutSpecialistIsUnchanged(t *testing.T) {
const quickBase = "你是一位专业的AI通用助手,回答简洁直接,不展开思考过程,快速响应用户问题。"
const expertBase = "你是一位专家级AI数字员工,使用 thinking 模式。在回答前先进行多步思考,将复杂任务拆解为可执行的步骤,给出深度分析。回答要专业、结构化、有洞察力。"
if got := buildAssistantSystemPrompt(nil, false); got != quickBase {
t.Errorf("快速模式无专员时 prompt 被改动了:\n实际: %q\n期望: %q", got, quickBase)
}
if got := buildAssistantSystemPrompt(nil, true); got != expertBase {
t.Errorf("专家模式无专员时 prompt 被改动了:\n实际: %q\n期望: %q", got, expertBase)
}
}
// TestBuildAssistantSystemPromptWithSpecialistDropsGenericIdentity
// 选了专员就不该再自称「通用助手」——身份冲突正是「选谁说话都一样」的成因。
func TestBuildAssistantSystemPromptWithSpecialistDropsGenericIdentity(t *testing.T) {
got := buildAssistantSystemPrompt(sampleSpecialist(), false)
if strings.Contains(got, "AI通用助手") {
t.Errorf("选了专员后仍在自称通用助手: %q", got)
}
if !strings.HasPrefix(got, "你是一位专业的AI数字员工") {
t.Errorf("基础角色说明应被换成数字员工,实际开头: %q", got)
}
if !strings.Contains(got, "【当前专员】") {
t.Errorf("缺少专员段落: %q", got)
}
}
// --- 解析专员(需要 DB)---
func setupAPITestDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "api.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
if err := db.AutoMigrate(&model.Specialist{}, &model.WorkerTask{}); err != nil {
t.Fatalf("迁移失败: %v", err)
}
prev := store.DB
store.DB = db
t.Cleanup(func() { store.DB = prev })
for _, s := range []model.Specialist{
{Key: "contract-review", Label: "合同审查专员", State: "active", Tier: "industry", ObjectEntryRoute: "/apps/contract-review"},
{Key: "report-generation", Label: "报告生成专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/report-generation"},
{Key: "retired-one", Label: "已下线专员", State: "inactive", Tier: "generic", ObjectEntryRoute: "/apps/retired"},
{Key: "general-assistant", Label: "通用助手", State: "system", Tier: "generic", ObjectEntryRoute: "/home"},
} {
item := s
if err := store.DB.Create(&item).Error; err != nil {
t.Fatalf("建专员 %s 失败: %v", s.Key, err)
}
}
}
// TestResolveSpecialistFromTask 任务上的专员是权威来源(专员是任务的字段)。
func TestResolveSpecialistFromTask(t *testing.T) {
setupAPITestDB(t)
task := model.WorkerTask{SpecialistKey: "contract-review", Title: "审一份采购合同", Status: "待处理"}
if err := store.DB.Create(&task).Error; err != nil {
t.Fatalf("建任务失败: %v", err)
}
got := resolveSpecialist(SmartAssistantRequest{TaskID: task.ID})
if got == nil || got.Key != "contract-review" {
t.Fatalf("应从任务解析出 contract-review,实际 %v", got)
}
}
// TestResolveSpecialistTaskWinsOverRequest 两者都给且不一致时,以任务上的为准。
func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) {
setupAPITestDB(t)
task := model.WorkerTask{SpecialistKey: "contract-review", Title: "审一份采购合同", Status: "待处理"}
if err := store.DB.Create(&task).Error; err != nil {
t.Fatalf("建任务失败: %v", err)
}
got := resolveSpecialist(SmartAssistantRequest{TaskID: task.ID, SpecialistKey: "report-generation"})
if got == nil || got.Key != "contract-review" {
t.Fatalf("任务上的专员应优先,实际 %v", got)
}
}
// TestResolveSpecialistFallsBackToRequest 还没有任务时用请求里带的 key。
func TestResolveSpecialistFallsBackToRequest(t *testing.T) {
setupAPITestDB(t)
got := resolveSpecialist(SmartAssistantRequest{SpecialistKey: "report-generation"})
if got == nil || got.Key != "report-generation" {
t.Fatalf("应回退到请求里的 specialist_key,实际 %v", got)
}
}
// TestResolveSpecialistUnknownKeyIsNotFatal 未知 key / 已下线专员都不能报错,
// 只能退化成 nil(通用助手),否则用户会把对话失败归咎于平台坏了。
func TestResolveSpecialistUnknownKeyIsNotFatal(t *testing.T) {
setupAPITestDB(t)
cases := []struct {
name string
req SmartAssistantRequest
}{
{"不存在的 key", SmartAssistantRequest{SpecialistKey: "no-such-specialist"}},
{"已下线专员", SmartAssistantRequest{SpecialistKey: "retired-one"}},
{"任务不存在", SmartAssistantRequest{TaskID: 999999}},
{"什么都没给", SmartAssistantRequest{}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := resolveSpecialist(c.req); got != nil {
t.Errorf("应返回 nil(退回通用助手),实际 %s", got.Key)
}
})
}
}
// TestResolveSpecialistAllowsSystemAssistant 通用助手是 state=system,
// 它必须能被解析到(否则默认入口没有岗位说明书)。
func TestResolveSpecialistAllowsSystemAssistant(t *testing.T) {
setupAPITestDB(t)
got := resolveSpecialist(SmartAssistantRequest{SpecialistKey: "general-assistant"})
if got == nil || got.Key != "general-assistant" {
t.Fatalf("state=system 的通用助手应可解析,实际 %v", got)
}
}
// TestResolveSpecialistTaskWithoutSpecialistFallsBack 任务存在但没挂专员时,
// 仍应回退到请求里的 key,而不是直接返回 nil。
func TestResolveSpecialistTaskWithoutSpecialistFallsBack(t *testing.T) {
setupAPITestDB(t)
task := model.WorkerTask{SpecialistKey: "no-such-specialist", Title: "孤儿任务", Status: "待处理"}
if err := store.DB.Create(&task).Error; err != nil {
t.Fatalf("建任务失败: %v", err)
}
got := resolveSpecialist(SmartAssistantRequest{TaskID: task.ID, SpecialistKey: "report-generation"})
if got == nil || got.Key != "report-generation" {
t.Fatalf("任务专员失效时应回退到请求里的 key,实际 %v", got)
}
}