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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user