diff --git a/eai_agentplatform/backend-go/internal/ai/credits.go b/eai_agentplatform/backend-go/internal/ai/credits.go index abfb5de..683e588 100644 --- a/eai_agentplatform/backend-go/internal/ai/credits.go +++ b/eai_agentplatform/backend-go/internal/ai/credits.go @@ -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, diff --git a/eai_agentplatform/backend-go/internal/ai/llm.go b/eai_agentplatform/backend-go/internal/ai/llm.go index c6c940d..44a68f2 100644 --- a/eai_agentplatform/backend-go/internal/ai/llm.go +++ b/eai_agentplatform/backend-go/internal/ai/llm.go @@ -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") diff --git a/eai_agentplatform/backend-go/internal/ai/llm_route_chain_test.go b/eai_agentplatform/backend-go/internal/ai/llm_route_chain_test.go new file mode 100644 index 0000000..9fa279b --- /dev/null +++ b/eai_agentplatform/backend-go/internal/ai/llm_route_chain_test.go @@ -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") + } + }) +} diff --git a/eai_agentplatform/backend-go/internal/api/batch_extract.go b/eai_agentplatform/backend-go/internal/api/batch_extract.go index f864957..58e1390 100644 --- a/eai_agentplatform/backend-go/internal/api/batch_extract.go +++ b/eai_agentplatform/backend-go/internal/api/batch_extract.go @@ -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}, }) diff --git a/eai_agentplatform/backend-go/internal/api/contract_review.go b/eai_agentplatform/backend-go/internal/api/contract_review.go index ba49ea3..ba2ab99 100644 --- a/eai_agentplatform/backend-go/internal/api/contract_review.go +++ b/eai_agentplatform/backend-go/internal/api/contract_review.go @@ -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}, diff --git a/eai_agentplatform/backend-go/internal/api/helpers.go b/eai_agentplatform/backend-go/internal/api/helpers.go index a66149f..1cbd830 100644 --- a/eai_agentplatform/backend-go/internal/api/helpers.go +++ b/eai_agentplatform/backend-go/internal/api/helpers.go @@ -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) +} diff --git a/eai_agentplatform/backend-go/internal/api/smart_assistant.go b/eai_agentplatform/backend-go/internal/api/smart_assistant.go index 725b0cc..7fe86f4 100644 --- a/eai_agentplatform/backend-go/internal/api/smart_assistant.go +++ b/eai_agentplatform/backend-go/internal/api/smart_assistant.go @@ -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", }) } diff --git a/eai_agentplatform/backend-go/internal/api/specialist.go b/eai_agentplatform/backend-go/internal/api/specialist.go index 03119ef..a26ab8b 100644 --- a/eai_agentplatform/backend-go/internal/api/specialist.go +++ b/eai_agentplatform/backend-go/internal/api/specialist.go @@ -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 diff --git a/eai_agentplatform/backend-go/internal/api/specialist_prompt.go b/eai_agentplatform/backend-go/internal/api/specialist_prompt.go new file mode 100644 index 0000000..940b870 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/specialist_prompt.go @@ -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 +} diff --git a/eai_agentplatform/backend-go/internal/api/specialist_prompt_test.go b/eai_agentplatform/backend-go/internal/api/specialist_prompt_test.go new file mode 100644 index 0000000..1723958 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/specialist_prompt_test.go @@ -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) + } +} diff --git a/eai_agentplatform/backend-go/internal/model/ai_call_log.go b/eai_agentplatform/backend-go/internal/model/ai_call_log.go index 074ea6e..d62937a 100644 --- a/eai_agentplatform/backend-go/internal/model/ai_call_log.go +++ b/eai_agentplatform/backend-go/internal/model/ai_call_log.go @@ -3,17 +3,19 @@ package model import "time" // AiCallLog AI 调用日志(审计 + 按用户算力点计费) -// 精简自 pj034 ai_call_logs:去掉 company_id / input_asset / cost_cny / billing_mode 等电商与多租户字段。 type AiCallLog struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null;index" json:"user_id"` - Capability string `gorm:"size:32;not null;index" json:"capability"` // ai_chat / text_gen / embed + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Capability string `gorm:"size:32;not null;index" json:"capability"` // ai_chat / text_gen / embed + // SpecialistKey 本次调用以哪个专员的身份进行;空 = 通用助手。 + // 用来回答「这条回答是谁说的」,也是排查「专员没生效」的第一手线索。 + SpecialistKey string `gorm:"size:64;index" json:"specialist_key"` Provider string `gorm:"size:32" json:"provider"` - RouteID string `gorm:"size:64" json:"route_id"` + AIRouteID string `gorm:"size:64" json:"ai_route_id"` Model string `gorm:"size:64" json:"model"` TokensInput int `gorm:"default:0" json:"tokens_input"` TokensOutput int `gorm:"default:0" json:"tokens_output"` - CreditsCharged int `gorm:"default:0" json:"credits_charged"` // 实扣点数(0 = 未扣) + CreditsCharged int `gorm:"default:0" json:"credits_charged"` // 实扣点数(0 = 未扣) Status string `gorm:"size:16;not null;index" json:"status"` // success / failed ErrorMessage string `gorm:"size:512" json:"error_message,omitempty"` LatencyMs int `gorm:"default:0" json:"latency_ms"` diff --git a/eai_agentplatform/backend-go/internal/model/skill_keys.go b/eai_agentplatform/backend-go/internal/model/skill_keys.go new file mode 100644 index 0000000..954932a --- /dev/null +++ b/eai_agentplatform/backend-go/internal/model/skill_keys.go @@ -0,0 +1,104 @@ +package model + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// ValidSkillKeys 技能 key 全集,用于校验 Specialist.AllowedSkills。 +// +// 权威来源是前端 frontend/src/config/workbench.js 的 availableSkills —— +// 技能实现仍归前端,后端只持有 key 清单,用于录入时挡住拼写错误。 +// 两个清单的一致性由 skill_keys_test.go 守着,改动任一侧都会让测试失败。 +// +// 背景:前端 22 个技能 vs 后端 skill_definition 7 行,长期无同步机制; +// 本清单是 SY23 §2.4 的「单向校验」兜底,不解决同步问题本身。 +var ValidSkillKeys = map[string]bool{ + "smart-assistant": true, // 通用助手(默认挂载) + "document-translate": true, + "copy-proofreading": true, + "audio-transcribe": true, + "batch-extract": true, + "contract-review": true, // 注意:与专员 key contract-review 同名,日志里请带类型前缀 + "report-generation": true, // 同上,与专员 key report-generation 同名 + "ppt-generation": true, + "mind-map": true, + "longform-writing": true, + "text-toolkit": true, + "ocr-understanding": true, + "meeting-minutes": true, + "project-planning": true, + "email-drafting": true, + "table-cleanup": true, + "proposal-summary": true, + "progress-report": true, + "contract-brief": true, + "interview-summary": true, + "policy-rewrite": true, + "survey-summary": true, +} + +// SortedValidSkillKeys 返回排序后的 key 清单,用于报错文案与文档生成 +func SortedValidSkillKeys() []string { + keys := make([]string, 0, len(ValidSkillKeys)) + for k := range ValidSkillKeys { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// ParseAllowedSkills 解析 AllowedSkills JSON 数组。 +// 空串、非法 JSON 一律返回 nil —— 读路径不报错,拿不到绑定就退回通用助手行为。 +func ParseAllowedSkills(raw string) []string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var keys []string + if err := json.Unmarshal([]byte(trimmed), &keys); err != nil { + return nil + } + out := make([]string, 0, len(keys)) + for _, k := range keys { + if k = strings.TrimSpace(k); k != "" { + out = append(out, k) + } + } + return out +} + +// MarshalAllowedSkills 把 key 列表序列化为 AllowedSkills 的存储形式。 +// 空列表存空串(而不是 "[]"),与「未绑定」保持同一种表示。 +func MarshalAllowedSkills(keys []string) (string, error) { + if len(keys) == 0 { + return "", nil + } + raw, err := json.Marshal(keys) + if err != nil { + return "", err + } + return string(raw), nil +} + +// ValidateAllowedSkills 校验 key 列表,返回第一个非法 key 的错误。 +// 同时挡掉重复项——重复会让「首个为主技能」的语义变得不确定。 +func ValidateAllowedSkills(keys []string) error { + seen := make(map[string]bool, len(keys)) + for _, k := range keys { + k = strings.TrimSpace(k) + if k == "" { + continue + } + if !ValidSkillKeys[k] { + return fmt.Errorf("技能 key %q 非法,可选值:%s", k, strings.Join(SortedValidSkillKeys(), " / ")) + } + if seen[k] { + return fmt.Errorf("技能 key %q 重复绑定", k) + } + seen[k] = true + } + return nil +} diff --git a/eai_agentplatform/backend-go/internal/model/skill_keys_test.go b/eai_agentplatform/backend-go/internal/model/skill_keys_test.go new file mode 100644 index 0000000..2a3b1cc --- /dev/null +++ b/eai_agentplatform/backend-go/internal/model/skill_keys_test.go @@ -0,0 +1,152 @@ +package model + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// workbenchJSRelPath 前端技能目录的相对路径(相对本包目录)。 +const workbenchJSRelPath = "../../../frontend/src/config/workbench.js" + +// createSkillDefinitionRe 抓 availableSkills 数组里每个 createSkillDefinition 的字面量 key。 +// 依赖 workbench.js 里「createSkillDefinition({ 紧跟 key: '…'」这一写法。 +var createSkillDefinitionRe = regexp.MustCompile(`createSkillDefinition\(\{\s*key:\s*'([^']+)'`) + +// TestValidSkillKeysMatchFrontend 守住 backend 的 ValidSkillKeys 与前端 availableSkills 一致。 +// +// 这两份清单天然会漂移:技能实现在前端,后端只持有 key 做录入校验。任何一侧增删技能 +// 而忘了另一侧,这里就会红——这正是本测试存在的唯一理由。 +// +// 交付形态是没有前端源码的(单二进制 + 整盘克隆),找不到文件时跳过而不是失败。 +func TestValidSkillKeysMatchFrontend(t *testing.T) { + raw, err := os.ReadFile(filepath.Clean(workbenchJSRelPath)) + if err != nil { + t.Skipf("跳过:读不到前端 %s(交付形态无前端源码): %v", workbenchJSRelPath, err) + } + + matches := createSkillDefinitionRe.FindAllStringSubmatch(string(raw), -1) + if len(matches) == 0 { + t.Fatalf("在前端 %s 里没抓到任何 createSkillDefinition key —— "+ + "可能是正则失效(写法改了),请同步更新 createSkillDefinitionRe", workbenchJSRelPath) + } + + frontendKeys := make(map[string]bool, len(matches)) + for _, m := range matches { + frontendKeys[m[1]] = true + } + + var onlyBackend, onlyFrontend []string + for k := range ValidSkillKeys { + if !frontendKeys[k] { + onlyBackend = append(onlyBackend, k) + } + } + for k := range frontendKeys { + if !ValidSkillKeys[k] { + onlyFrontend = append(onlyFrontend, k) + } + } + sort.Strings(onlyBackend) + sort.Strings(onlyFrontend) + + if len(onlyBackend) > 0 { + t.Errorf("ValidSkillKeys 里有、前端 availableSkills 里没有的 key(后端多写或前端已删):\n %s", + strings.Join(onlyBackend, "\n ")) + } + if len(onlyFrontend) > 0 { + t.Errorf("前端 availableSkills 里有、ValidSkillKeys 里没有的 key(新增技能后忘了补后端清单):\n %s", + strings.Join(onlyFrontend, "\n ")) + } +} + +func TestParseAllowedSkills(t *testing.T) { + cases := []struct { + name string + raw string + want []string + }{ + {"空串返回 nil", "", nil}, + {"纯空白返回 nil", " ", nil}, + {"非法 JSON 返回 nil(读路径不报错)", `{"not":"array"}`, nil}, + {"正常数组", `["contract-review","batch-extract"]`, []string{"contract-review", "batch-extract"}}, + {"跳过多余空白", `[" contract-review "]`, []string{"contract-review"}}, + {"空数组返回空切片", `[]`, []string{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ParseAllowedSkills(tc.raw) + if len(got) != len(tc.want) { + t.Fatalf("ParseAllowedSkills(%q) = %v,期望 %v", tc.raw, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("ParseAllowedSkills(%q) = %v,期望 %v", tc.raw, got, tc.want) + } + } + }) + } +} + +func TestMarshalAllowedSkills(t *testing.T) { + t.Run("空列表存空串而非 []", func(t *testing.T) { + got, err := MarshalAllowedSkills(nil) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("期望空串,实际 %q", got) + } + }) + + t.Run("往返一致", func(t *testing.T) { + in := []string{"contract-review", "contract-brief"} + raw, err := MarshalAllowedSkills(in) + if err != nil { + t.Fatal(err) + } + out := ParseAllowedSkills(raw) + if len(out) != len(in) { + t.Fatalf("往返后长度变了: %v -> %v", in, out) + } + for i := range in { + if in[i] != out[i] { + t.Errorf("往返后顺序变了: %v -> %v(顺序即优先级,必须保持)", in, out) + } + } + }) +} + +func TestValidateAllowedSkills(t *testing.T) { + t.Run("合法列表通过", func(t *testing.T) { + if err := ValidateAllowedSkills([]string{"contract-review", "batch-extract"}); err != nil { + t.Errorf("不应报错: %v", err) + } + }) + + t.Run("空列表通过", func(t *testing.T) { + if err := ValidateAllowedSkills(nil); err != nil { + t.Errorf("未绑定技能应合法: %v", err) + } + }) + + t.Run("非法 key 报错", func(t *testing.T) { + err := ValidateAllowedSkills([]string{"contract-review", "typo-skill"}) + if err == nil { + t.Fatal("非法 key 应报错") + } + if !strings.Contains(err.Error(), "typo-skill") { + t.Errorf("错误信息应点出非法 key,实际: %v", err) + } + }) + + t.Run("重复 key 报错", func(t *testing.T) { + err := ValidateAllowedSkills([]string{"contract-review", "contract-review"}) + if err == nil { + t.Fatal("重复绑定应报错(首个为主技能的语义会变得不确定)") + } + }) +} diff --git a/eai_agentplatform/backend-go/internal/model/specialist.go b/eai_agentplatform/backend-go/internal/model/specialist.go index e6a5865..83f054a 100644 --- a/eai_agentplatform/backend-go/internal/model/specialist.go +++ b/eai_agentplatform/backend-go/internal/model/specialist.go @@ -4,27 +4,38 @@ import "time" // Specialist 数字员工专员目录 type Specialist struct { - ID uint `gorm:"primaryKey" json:"id"` - Key string `gorm:"size:64;uniqueIndex;not null" json:"key"` - Label string `gorm:"size:128;not null" json:"label"` - Tier string `gorm:"size:16;not null;index" json:"tier"` // generic / industry - WorkerType string `gorm:"size:16;not null;default:dw;index" json:"worker_type"` - Route string `gorm:"size:128;not null" json:"route"` - Summary string `gorm:"type:text" json:"summary"` - WorkStatus string `gorm:"size:64;default:''" json:"work_status"` - RiskLabel string `gorm:"size:64;default:''" json:"risk_label"` - Color string `gorm:"size:16;default:''" json:"color"` - Stage string `gorm:"size:64;default:''" json:"stage"` - Progress int `gorm:"not null;default:0" json:"progress"` - MarketTag string `gorm:"size:32;not null;default:installed;index" json:"market_tag"` // 已安装 / 可升级 / 试用 - Version string `gorm:"size:32;default:''" json:"version"` - ConnectorScope string `gorm:"type:text" json:"connector_scope"` - PermissionScope string `gorm:"type:text" json:"permission_scope"` - ResourceBindings string `gorm:"type:text" json:"resource_bindings"` - InfoSources string `gorm:"type:text" json:"info_sources"` - BaseSkills string `gorm:"type:text" json:"base_skills"` - AIAssistance string `gorm:"type:text" json:"ai_assistance"` - GeneratedSkills string `gorm:"type:text" json:"generated_skills"` + ID uint `gorm:"primaryKey" json:"id"` + Key string `gorm:"size:64;uniqueIndex;not null" json:"key"` + Label string `gorm:"size:128;not null" json:"label"` + DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"` + EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"` + Tier string `gorm:"size:16;not null;index" json:"tier"` // generic / industry + WorkerType string `gorm:"size:16;not null;default:dw;index" json:"worker_type"` + ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;not null;default:''" json:"object_entry_route"` + Summary string `gorm:"type:text" json:"summary"` + RoleCardJSON string `gorm:"type:text" json:"role_card_json"` + WorkStatus string `gorm:"size:64;default:''" json:"work_status"` + RiskLabel string `gorm:"size:64;default:''" json:"risk_label"` + Color string `gorm:"size:16;default:''" json:"color"` + Stage string `gorm:"size:64;default:''" json:"stage"` + Progress int `gorm:"not null;default:0" json:"progress"` + MarketTag string `gorm:"size:32;not null;default:installed;index" json:"market_tag"` // 已安装 / 可升级 / 试用 + Version string `gorm:"size:32;default:''" json:"version"` + ConnectorScope string `gorm:"type:text" json:"connector_scope"` + PermissionScope string `gorm:"type:text" json:"permission_scope"` + ResourceBindings string `gorm:"type:text" json:"resource_bindings"` + InfoSources string `gorm:"type:text" json:"info_sources"` + BaseSkills string `gorm:"type:text" json:"base_skills"` + AIAssistance string `gorm:"type:text" json:"ai_assistance"` + GeneratedSkills string `gorm:"type:text" json:"generated_skills"` + // RuleFileMarkdown 岗位说明书正文(Markdown 全文)。 + // 会话创建时注入 System Prompt,决定这个专员怎么说话、怎么推进、什么时候调用哪个技能。 + // 参考 AionCore builtin-assistants 的 rule_file;命名对齐 SY23。 + RuleFileMarkdown string `gorm:"type:text" json:"rule_file_markdown"` + // AllowedSkills 绑定技能 key 列表,JSON 字符串数组,顺序即优先级(首个为主技能)。 + // 元素取值须通过 model.ValidSkillKeys 校验,对齐前端 availableSkills[].key。 + // 字段名对齐 SY22 §6.1 对象 manifest 的 allowed_skills。 + AllowedSkills string `gorm:"type:text" json:"allowed_skills"` InputsRecordsJSON string `gorm:"column:source_records_json;type:text" json:"inputs_records_json"` PermissionRecordsJSON string `gorm:"type:text" json:"permission_records_json"` ActionRecordsJSON string `gorm:"type:text" json:"action_records_json"` diff --git a/eai_agentplatform/backend-go/internal/store/seed.go b/eai_agentplatform/backend-go/internal/store/seed.go index 101324a..4a507e2 100644 --- a/eai_agentplatform/backend-go/internal/store/seed.go +++ b/eai_agentplatform/backend-go/internal/store/seed.go @@ -2,6 +2,7 @@ package store import ( "encoding/json" + "fmt" "io" "log" "os" @@ -90,6 +91,12 @@ func SeedDefaults() error { if err := seedSpecialists(); err != nil { return err } + if err := seedSkillDefinitions(); err != nil { + return err + } + if err := seedActionDefinitions(); err != nil { + return err + } return nil } @@ -145,7 +152,7 @@ func seedCoursesFromJSON() error { path := firstExistingPath( "training_materials/work/courses.json", "../training_materials/work/courses.json", - "eai_agentplatform_app/training_materials/work/courses.json", + "eai_agentplatform/training_materials/work/courses.json", ) if path == "" { return nil @@ -217,47 +224,47 @@ func seedTrainingMedia() error { seeds := []mediaSeed{ { filename: "01_公司介绍.mp4", - sourcePath: firstExistingPath("training_materials/mp4/01_公司介绍.mp4", "../training_materials/mp4/01_公司介绍.mp4", "eai_agentplatform_app/training_materials/mp4/01_公司介绍.mp4"), + sourcePath: firstExistingPath("training_materials/mp4/01_公司介绍.mp4", "../training_materials/mp4/01_公司介绍.mp4", "eai_agentplatform/training_materials/mp4/01_公司介绍.mp4"), storedName: "seed_company_intro_video.mp4", bindType: "company", }, { filename: "01_公司介绍.pdf", - sourcePath: firstExistingPath("training_materials/pdf/01_公司介绍.pdf", "../training_materials/pdf/01_公司介绍.pdf", "eai_agentplatform_app/training_materials/pdf/01_公司介绍.pdf"), + sourcePath: firstExistingPath("training_materials/pdf/01_公司介绍.pdf", "../training_materials/pdf/01_公司介绍.pdf", "eai_agentplatform/training_materials/pdf/01_公司介绍.pdf"), storedName: "seed_company_intro_manual.pdf", bindType: "company", }, { filename: "05_资本咨询销售要点.mp4", - sourcePath: firstExistingPath("training_materials/mp4/05_资本咨询销售要点.mp4", "../training_materials/mp4/05_资本咨询销售要点.mp4", "eai_agentplatform_app/training_materials/mp4/05_资本咨询销售要点.mp4"), + sourcePath: firstExistingPath("training_materials/mp4/05_资本咨询销售要点.mp4", "../training_materials/mp4/05_资本咨询销售要点.mp4", "eai_agentplatform/training_materials/mp4/05_资本咨询销售要点.mp4"), storedName: "seed_course_co001_video.mp4", bindType: "course", bindCode: "CO-001", }, { filename: "03_高企认定八大条件.mp4", - sourcePath: firstExistingPath("training_materials/mp4/03_高企认定八大条件.mp4", "../training_materials/mp4/03_高企认定八大条件.mp4", "eai_agentplatform_app/training_materials/mp4/03_高企认定八大条件.mp4"), + sourcePath: firstExistingPath("training_materials/mp4/03_高企认定八大条件.mp4", "../training_materials/mp4/03_高企认定八大条件.mp4", "eai_agentplatform/training_materials/mp4/03_高企认定八大条件.mp4"), storedName: "seed_course_co002_video.mp4", bindType: "course", bindCode: "CO-002", }, { filename: "06_企业AI落地方法论.mp4", - sourcePath: firstExistingPath("training_materials/mp4/06_企业AI落地方法论.mp4", "../training_materials/mp4/06_企业AI落地方法论.mp4", "eai_agentplatform_app/training_materials/mp4/06_企业AI落地方法论.mp4"), + sourcePath: firstExistingPath("training_materials/mp4/06_企业AI落地方法论.mp4", "../training_materials/mp4/06_企业AI落地方法论.mp4", "eai_agentplatform/training_materials/mp4/06_企业AI落地方法论.mp4"), storedName: "seed_course_co003_video.mp4", bindType: "course", bindCode: "CO-003", }, { filename: "03_资本咨询类产品手册.pdf", - sourcePath: firstExistingPath("training_materials/pdf/03_资本咨询类产品手册.pdf", "../training_materials/pdf/03_资本咨询类产品手册.pdf", "eai_agentplatform_app/training_materials/pdf/03_资本咨询类产品手册.pdf"), + sourcePath: firstExistingPath("training_materials/pdf/03_资本咨询类产品手册.pdf", "../training_materials/pdf/03_资本咨询类产品手册.pdf", "eai_agentplatform/training_materials/pdf/03_资本咨询类产品手册.pdf"), storedName: "seed_course_co001_manual.pdf", bindType: "course", bindCode: "CO-001", }, { filename: "05_AI咨询类产品手册.pdf", - sourcePath: firstExistingPath("training_materials/pdf/05_AI咨询类产品手册.pdf", "../training_materials/pdf/05_AI咨询类产品手册.pdf", "eai_agentplatform_app/training_materials/pdf/05_AI咨询类产品手册.pdf"), + sourcePath: firstExistingPath("training_materials/pdf/05_AI咨询类产品手册.pdf", "../training_materials/pdf/05_AI咨询类产品手册.pdf", "eai_agentplatform/training_materials/pdf/05_AI咨询类产品手册.pdf"), storedName: "seed_course_co003_manual.pdf", bindType: "course", bindCode: "CO-003", @@ -283,32 +290,64 @@ func seedTrainingMedia() error { return nil } +func applySpecialistCodes(items []model.Specialist) []model.Specialist { + visibleIndex := 1 + for i := range items { + codeIndex := 0 + if items[i].State != "system" { + codeIndex = visibleIndex + visibleIndex++ + } + suffix := fmt.Sprintf("%02d", codeIndex) + if strings.TrimSpace(items[i].DisplayCode) == "" { + items[i].DisplayCode = "专" + suffix + } + if strings.TrimSpace(items[i].EAILogicCode) == "" { + items[i].EAILogicCode = "EAI-S-" + suffix + } + } + return items +} + +func applySkillCodes(items []model.SkillDefinition) []model.SkillDefinition { + for i := range items { + suffix := fmt.Sprintf("%02d", i+1) + if strings.TrimSpace(items[i].DisplayCode) == "" { + items[i].DisplayCode = "能" + suffix + } + if strings.TrimSpace(items[i].EAILogicCode) == "" { + items[i].EAILogicCode = "EAI-K-" + suffix + } + } + return items +} + func seedSpecialists() error { - items := []model.Specialist{ + items := applySpecialistCodes([]model.Specialist{ { // 「新建任务」在还没选专员/工具之前,任务先落在这里。 // state = system:它不是一个能装到工作区的专员,只是任务的默认归属, // 所以不出现在专员市场、我的专员和 + 菜单里(ListSpecialists 会挡掉)。 - Key: "general-assistant", - Label: "通用智能助手", - Tier: "generic", - WorkerType: "dw", - Route: "/home", - Summary: "还没指定专员的任务默认落在这里,用输入框的 + 挂上专员或工具", - Stage: "待处理", - Color: "#409eff", - MarketTag: "内置", - Version: "v1.0", - BaseSkills: "对话、任务拆解、按 + 选定的专员或工具继续", - State: "system", - SortOrder: 0, + Key: "general-assistant", + Label: "通用助手", + Tier: "generic", + WorkerType: "dw", + ObjectEntryRoute: "/home", + Summary: "还没指定专员的任务默认落在这里,用输入框的 + 挂上专员或工具", + Stage: "待处理", + Color: "#409eff", + MarketTag: "内置", + Version: "v1.0", + BaseSkills: "对话、任务拆解、按 + 选定的专员或工具继续", + State: "system", + SortOrder: 0, }, { Key: "training-delivery", Label: "培训交付专员", Tier: "generic", WorkerType: "dw", - Route: "/apps/training-delivery", + ObjectEntryRoute: "/apps/training-delivery", Summary: "新人销售训练营第 3 周", WorkStatus: "5 个进行中", RiskLabel: "0 个异常", @@ -332,7 +371,7 @@ func seedSpecialists() error { Label: "知识运营专员", Tier: "generic", WorkerType: "dw", - Route: "/apps/knowledge-operations", + ObjectEntryRoute: "/apps/knowledge-operations", Summary: "对象字典、证据索引与规则同步", WorkStatus: "4 个待整理", RiskLabel: "1 个映射待确认", @@ -356,7 +395,7 @@ func seedSpecialists() error { Label: "流程推进专员", Tier: "generic", WorkerType: "adw", - Route: "/apps/process-coordination", + ObjectEntryRoute: "/apps/process-coordination", Summary: "跨部门催办、checkpoint 跟进与阻塞升级", WorkStatus: "6 个待跟进", RiskLabel: "2 个阻塞", @@ -380,7 +419,7 @@ func seedSpecialists() error { Label: "报告生成专员", Tier: "generic", WorkerType: "dw", - Route: "/apps/report-generation", + ObjectEntryRoute: "/apps/report-generation", Summary: "日报、周报、复盘和交付说明自动生成", WorkStatus: "3 个待输出", RiskLabel: "0 个异常", @@ -404,7 +443,7 @@ func seedSpecialists() error { Label: "公众号助手", Tier: "generic", WorkerType: "dw", - Route: "/apps/wechat-official-account", + ObjectEntryRoute: "/apps/wechat-official-account", Summary: "热点选题、标题、提纲、正文四步创作工作流", WorkStatus: "4 个节点可执行", RiskLabel: "0 个异常", @@ -428,7 +467,7 @@ func seedSpecialists() error { Label: "合同审查专员", Tier: "industry", WorkerType: "adw", - Route: "/apps/contract-review", + ObjectEntryRoute: "/apps/contract-review", Summary: "华东客户主协议修订", WorkStatus: "2 个待确认", RiskLabel: "1 个高风险", @@ -452,7 +491,7 @@ func seedSpecialists() error { Label: "售前方案专员", Tier: "industry", WorkerType: "adw", - Route: "/apps/solution-proposal", + ObjectEntryRoute: "/apps/solution-proposal", Summary: "A 客户智能培训升级方案", WorkStatus: "3 个待办", RiskLabel: "1 个依赖阻塞", @@ -476,7 +515,7 @@ func seedSpecialists() error { Label: "履约跟单专员", Tier: "industry", WorkerType: "adw", - Route: "/apps/logistics-fulfillment", + ObjectEntryRoute: "/apps/logistics-fulfillment", Summary: "美西航线本周履约看板", WorkStatus: "1 个异常", RiskLabel: "2 个节点延迟", @@ -500,7 +539,7 @@ func seedSpecialists() error { Label: "HR 邮件整理专员", Tier: "industry", WorkerType: "dw", - Route: "/apps/hr-email-sorter", + ObjectEntryRoute: "/apps/hr-email-sorter", Summary: "识别并整理招聘邮箱中的简历邮件", WorkStatus: "3 封待处理", RiskLabel: "0 个异常", @@ -524,7 +563,7 @@ func seedSpecialists() error { Label: "简历处理专员", Tier: "industry", WorkerType: "dw", - Route: "/apps/resume-processor", + ObjectEntryRoute: "/apps/resume-processor", Summary: "筛选候选人并安排面试", WorkStatus: "5 份待筛选", RiskLabel: "1 个匹配待确认", @@ -543,6 +582,10 @@ func seedSpecialists() error { State: "active", SortOrder: 90, }, + }) + // 岗位说明书与技能绑定按 key 挂载(见 seed_specialist_rules.go) + if err := applySpecialistRuleFiles(items); err != nil { + return err } for _, item := range items { @@ -556,9 +599,18 @@ func seedSpecialists() error { } updates := map[string]any{} + if existing.DisplayCode == "" { + updates["display_code"] = item.DisplayCode + } + if existing.EAILogicCode == "" { + updates["eailogic_code"] = item.EAILogicCode + } if existing.WorkerType == "" { updates["worker_type"] = item.WorkerType } + if existing.RoleCardJSON == "" { + updates["role_card_json"] = item.RoleCardJSON + } if existing.PermissionScope == "" { updates["permission_scope"] = item.PermissionScope } @@ -577,6 +629,13 @@ func seedSpecialists() error { if existing.GeneratedSkills == "" { updates["generated_skills"] = item.GeneratedSkills } + // 岗位说明书与技能绑定同样只补空字段:管理员在后台改过就不再覆盖。 + if existing.RuleFileMarkdown == "" { + updates["rule_file_markdown"] = item.RuleFileMarkdown + } + if existing.AllowedSkills == "" { + updates["allowed_skills"] = item.AllowedSkills + } if existing.InputsRecordsJSON == "" { updates["source_records_json"] = item.InputsRecordsJSON } @@ -596,10 +655,341 @@ func seedSpecialists() error { } } + logSpecialistRuleFileCoverage() log.Println("[OK] 专员目录种子已导入") return nil } +func seedSkillDefinitions() error { + items := applySkillCodes([]model.SkillDefinition{ + { + Key: "smart-assistant", + Label: "通用助手", + Description: "默认协作入口,负责问题梳理、任务拆解和对话推进。", + RoleKind: "assistant", + Source: "eai", + ObjectEntryRoute: "/home", + ExposedToUser: true, + StarterPromptsJSON: mustJSON([]string{"帮我拆解这个任务,并列出下一步", "把这段产品介绍整理成正式文案", "先帮我判断这个问题该找哪个专员或技能"}), + PromptTemplate: "围绕用户当前目标先澄清问题,再给出执行建议或继续推进。", + ActionRefsJSON: mustJSON([]string{"assistant.chat"}), + InputSchemaJSON: mustJSON(map[string]any{"blocks": []string{"text", "resource_link"}}), + OutputSchemaJSON: mustJSON(map[string]any{"message": "assistant_reply"}), + ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"summary", "plan"}}), + OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"task", "knowledge_item"}, "action_types": []string{"analyze", "draft"}}), + State: "active", + SortOrder: 10, + }, + { + Key: "document-translate", + Label: "文档翻译", + Description: "把文本和文档稳定翻成目标语言,并保留语气和结构。", + RoleKind: "skill", + Source: "eai", + ObjectEntryRoute: "/home", + LegacyObjectEntryRoute: "/tools/document-translate", + ExposedToUser: true, + StarterPromptsJSON: mustJSON([]string{"把这段产品说明翻译成英文,保持专业语气", "把这封邮件翻译成日文,语气礼貌一点", "把这份中文文档整理成英文摘要"}), + PromptTemplate: "明确源语言、目标语言、语气和输出格式后执行翻译。", + ActionRefsJSON: mustJSON([]string{"document.translate"}), + InputSchemaJSON: mustJSON(map[string]any{"content": "text_or_document", "target_language": "string"}), + OutputSchemaJSON: mustJSON(map[string]any{"translation": "text"}), + ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"translated_document"}}), + OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"document"}, "action_types": []string{"translate"}}), + State: "active", + SortOrder: 20, + }, + { + Key: "copy-proofreading", + Label: "文案校对", + Description: "检查错别字、语病和表达不顺,输出润色建议。", + RoleKind: "skill", + Source: "eai", + ObjectEntryRoute: "/home", + LegacyObjectEntryRoute: "/tools/copy-proofreading", + ExposedToUser: true, + StarterPromptsJSON: mustJSON([]string{"检查这段销售话术里的错别字和语病", "把这段产品介绍润得更正式一点", "帮我校对这封对外邮件,保留原意"}), + PromptTemplate: "围绕原文语气给出校对与改写结果。", + ActionRefsJSON: mustJSON([]string{"copy.proofread"}), + InputSchemaJSON: mustJSON(map[string]any{"content": "text"}), + OutputSchemaJSON: mustJSON(map[string]any{"proofread_result": "text"}), + ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"clean_copy"}}), + OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"copy"}, "action_types": []string{"review"}}), + State: "active", + SortOrder: 30, + }, + { + Key: "audio-transcribe", + Label: "语音转写", + Description: "把音频内容转成结构化文本,便于继续总结与提取。", + RoleKind: "skill", + Source: "eai", + ObjectEntryRoute: "/home", + LegacyObjectEntryRoute: "/tools/audio-transcribe", + ExposedToUser: true, + StarterPromptsJSON: mustJSON([]string{"把这段会议录音转成文字,并分段整理", "先转写这段采访,再提取重点", "把语音内容整理成可发群里的纪要"}), + PromptTemplate: "先转写音频,再按需要输出整理版文本。", + ActionRefsJSON: mustJSON([]string{"audio.transcribe"}), + InputSchemaJSON: mustJSON(map[string]any{"audio": "file_or_url"}), + OutputSchemaJSON: mustJSON(map[string]any{"transcript": "text"}), + ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"transcript"}}), + OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"audio"}, "action_types": []string{"extract"}}), + State: "active", + SortOrder: 40, + }, + { + Key: "batch-extract", + Label: "批量提取", + Description: "从一批内容里抽取结构化字段和关键信息。", + RoleKind: "skill", + Source: "eai", + ObjectEntryRoute: "/home", + LegacyObjectEntryRoute: "/tools/batch-extract", + ExposedToUser: true, + StarterPromptsJSON: mustJSON([]string{"从这批简历里提取姓名、岗位和年限", "把这些合同里的付款条款统一抽出来", "帮我批量提取文章标题、作者和发布时间"}), + PromptTemplate: "明确字段范围、输出格式和异常处理规则后执行批量提取。", + ActionRefsJSON: mustJSON([]string{"batch.extract"}), + InputSchemaJSON: mustJSON(map[string]any{"items": []string{"text_or_document"}, "fields": []string{"string"}}), + OutputSchemaJSON: mustJSON(map[string]any{"rows": []string{"object"}}), + ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"spreadsheet", "json"}}), + OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"document", "resume", "contract"}, "action_types": []string{"extract"}}), + State: "active", + SortOrder: 50, + }, + { + Key: "contract-review", + Label: "合同审查", + Description: "定位风险条款、待确认项并输出审查建议。", + RoleKind: "skill", + Source: "eai", + ObjectEntryRoute: "/home", + LegacyObjectEntryRoute: "/tools/contract-review", + ExposedToUser: true, + StarterPromptsJSON: mustJSON([]string{"审查这份采购合同的风险点", "重点看赔偿责任和终止条款", "帮我整理一版待人工确认的风险清单"}), + PromptTemplate: "按照甲方风控视角识别风险条款、例外项和红线建议。", + ActionRefsJSON: mustJSON([]string{"contract.review"}), + InputSchemaJSON: mustJSON(map[string]any{"contract": "document_or_text"}), + OutputSchemaJSON: mustJSON(map[string]any{"risk_report": "text"}), + ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"risk_report", "redline"}}), + OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"contract"}, "action_types": []string{"review", "analyze"}}), + State: "active", + SortOrder: 60, + }, + { + Key: "report-generation", + Label: "报告生成", + Description: "把过程材料整理成日报、周报、复盘和交付说明。", + RoleKind: "skill", + Source: "eai", + ObjectEntryRoute: "/home", + LegacyObjectEntryRoute: "/report-gen", + ExposedToUser: true, + StarterPromptsJSON: mustJSON([]string{"把今天的工作记录整理成日报", "根据这些节点生成项目周报", "帮我把这次交付过程整理成复盘说明"}), + PromptTemplate: "先梳理结论与结构,再输出对应报告版本。", + ActionRefsJSON: mustJSON([]string{"report.generate"}), + InputSchemaJSON: mustJSON(map[string]any{"materials": []string{"text_or_document"}, "report_type": "string"}), + OutputSchemaJSON: mustJSON(map[string]any{"report": "text"}), + ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"report"}}), + OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"report", "task"}, "action_types": []string{"draft"}}), + State: "active", + SortOrder: 70, + }, + }) + + for _, item := range items { + var existing model.SkillDefinition + if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil { + if err := DB.Create(&item).Error; err != nil { + return err + } + continue + } + updates := map[string]any{} + if existing.DisplayCode == "" { + updates["display_code"] = item.DisplayCode + } + if existing.EAILogicCode == "" { + updates["eailogic_code"] = item.EAILogicCode + } + if existing.Description == "" { + updates["description"] = item.Description + } + if existing.ObjectEntryRoute == "" { + updates["object_entry_route"] = item.ObjectEntryRoute + } + if existing.LegacyObjectEntryRoute == "" { + updates["legacy_object_entry_route"] = item.LegacyObjectEntryRoute + } + if existing.StarterPromptsJSON == "" { + updates["starter_prompts_json"] = item.StarterPromptsJSON + } + if existing.PromptTemplate == "" { + updates["prompt_template"] = item.PromptTemplate + } + if existing.InputSchemaJSON == "" { + updates["input_schema_json"] = item.InputSchemaJSON + } + if existing.OutputSchemaJSON == "" { + updates["output_schema_json"] = item.OutputSchemaJSON + } + if existing.ArtifactSchemaJSON == "" { + updates["artifact_schema_json"] = item.ArtifactSchemaJSON + } + if existing.ActionRefsJSON == "" { + updates["action_refs_json"] = item.ActionRefsJSON + } + if existing.OntologyBindingJSON == "" { + updates["ontology_binding_json"] = item.OntologyBindingJSON + } + if len(updates) > 0 { + if err := DB.Model(&existing).Updates(updates).Error; err != nil { + return err + } + } + } + log.Println("[OK] 技能定义种子已导入") + return nil +} + +func seedActionDefinitions() error { + items := []model.ActionDefinition{ + { + Key: "assistant.chat", + Label: "通用对话", + Description: "默认对话与任务拆解动作。", + ActionType: "conversation", + ConnectorRef: "", + InputSchemaJSON: mustJSON(map[string]any{"message": "text"}), + OutputSchemaJSON: mustJSON(map[string]any{"reply": "text"}), + RiskLevel: "low", + ApprovalMode: "not_required", + AuditLevel: "standard", + ExposedToUser: false, + OntologyBindingJSON: mustJSON(map[string]any{"action_types": []string{"analyze", "draft"}}), + State: "active", + SortOrder: 10, + }, + { + Key: "document.translate", + Label: "文档翻译执行", + Description: "调用翻译链路输出目标语言结果。", + ActionType: "translate", + ConnectorRef: "", + InputSchemaJSON: mustJSON(map[string]any{"content": "text_or_document", "target_language": "string"}), + OutputSchemaJSON: mustJSON(map[string]any{"translation": "text"}), + RiskLevel: "low", + ApprovalMode: "not_required", + AuditLevel: "standard", + State: "active", + SortOrder: 20, + }, + { + Key: "copy.proofread", + Label: "文案校对执行", + Description: "校对文本并输出修订建议。", + ActionType: "review", + InputSchemaJSON: mustJSON(map[string]any{"content": "text"}), + OutputSchemaJSON: mustJSON(map[string]any{"clean_copy": "text"}), + RiskLevel: "low", + ApprovalMode: "not_required", + AuditLevel: "standard", + State: "active", + SortOrder: 30, + }, + { + Key: "audio.transcribe", + Label: "语音转写执行", + Description: "把音频转写成文本。", + ActionType: "extract", + InputSchemaJSON: mustJSON(map[string]any{"audio": "file_or_url"}), + OutputSchemaJSON: mustJSON(map[string]any{"transcript": "text"}), + RiskLevel: "low", + ApprovalMode: "not_required", + AuditLevel: "standard", + State: "active", + SortOrder: 40, + }, + { + Key: "batch.extract", + Label: "批量提取执行", + Description: "执行结构化字段提取。", + ActionType: "extract", + InputSchemaJSON: mustJSON(map[string]any{"items": []string{"text_or_document"}, "fields": []string{"string"}}), + OutputSchemaJSON: mustJSON(map[string]any{"rows": []string{"object"}}), + RiskLevel: "low", + ApprovalMode: "not_required", + AuditLevel: "standard", + State: "active", + SortOrder: 50, + }, + { + Key: "contract.review", + Label: "合同审查执行", + Description: "执行合同风险识别和红线建议生成。", + ActionType: "review", + InputSchemaJSON: mustJSON(map[string]any{"contract": "document_or_text"}), + OutputSchemaJSON: mustJSON(map[string]any{"risk_report": "text"}), + RiskLevel: "medium", + ApprovalMode: "approval_guarded", + AuditLevel: "strict", + State: "active", + SortOrder: 60, + }, + { + Key: "report.generate", + Label: "报告生成执行", + Description: "执行报告与复盘类输出生成。", + ActionType: "draft", + InputSchemaJSON: mustJSON(map[string]any{"materials": []string{"text_or_document"}, "report_type": "string"}), + OutputSchemaJSON: mustJSON(map[string]any{"report": "text"}), + RiskLevel: "low", + ApprovalMode: "not_required", + AuditLevel: "standard", + State: "active", + SortOrder: 70, + }, + } + + for _, item := range items { + var existing model.ActionDefinition + if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil { + if err := DB.Create(&item).Error; err != nil { + return err + } + continue + } + updates := map[string]any{} + if existing.Description == "" { + updates["description"] = item.Description + } + if existing.ActionType == "" { + updates["action_type"] = item.ActionType + } + if existing.InputSchemaJSON == "" { + updates["input_schema_json"] = item.InputSchemaJSON + } + if existing.OutputSchemaJSON == "" { + updates["output_schema_json"] = item.OutputSchemaJSON + } + if existing.RiskLevel == "" { + updates["risk_level"] = item.RiskLevel + } + if existing.ApprovalMode == "" { + updates["approval_mode"] = item.ApprovalMode + } + if existing.AuditLevel == "" { + updates["audit_level"] = item.AuditLevel + } + if len(updates) > 0 { + if err := DB.Model(&existing).Updates(updates).Error; err != nil { + return err + } + } + } + log.Println("[OK] Action 定义种子已导入") + return nil +} + func ensureSeedMedia(adminID uint, approvedDir string, item mediaSeed, bindID *uint) error { dst := filepath.Join(approvedDir, item.storedName) if err := ensureMediaLink(item.sourcePath, dst); err != nil { diff --git a/eai_agentplatform/backend-go/internal/store/seed_specialist_rules.go b/eai_agentplatform/backend-go/internal/store/seed_specialist_rules.go new file mode 100644 index 0000000..3453ea4 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/store/seed_specialist_rules.go @@ -0,0 +1,292 @@ +package store + +import ( + "fmt" + "log" + + "eai_agentplatform/backend/internal/model" +) + +// 本文件是岗位说明书(rule_file_markdown)与技能绑定(allowed_skills)的种子数据。 +// +// 与 seed.go 分开是因为正文较长,塞进专员字面量里会让那份清单读不下去。 +// 两者的落库策略一致:存在则只补空字段(见 seedSpecialists), +// 管理员在后台改过的内容不会被重新播种覆盖。 +// +// 关于成熟度:这里的是**首版初稿**,正文由各专员已有的 Summary / BaseSkills / +// PermissionScope 推出,目的是让「选专员 → 会话带上专员 → prompt 注入岗位说明」 +// 这条链路能端到端跑起来、能被看见。逐份打磨(SY23 的 P5,每份 0.5~1 天)不在本次范围。 +var specialistRuleFileDrafts = map[string]string{ + "general-assistant": `# 通用助手 + +## 你是谁 +你是博昇数字员工平台的通用助手,是用户还没指定专员时的默认对话对象。 +你不假装自己是某个领域的专家,你的价值是把问题理清楚、把任务拆对、把用户引到合适的专员上。 + +## 你怎么推进 +1. 先判断用户要什么:闲聊答疑 / 查平台里的资料 / 一件要落地的事。 +2. 只在平台内确实有对应能力时才推荐专员,并说清「为什么是这个专员」和「它需要你提供什么」。 +3. 闲聊与答疑直接答完就结束,不要硬塞流程、不要为了推进而推进。 + +## 输出要求 +- 简洁直接,默认不展开思考过程。 +- 需要用户补充信息时,一次只问最关键的一两个问题。 + +## 边界 +- 平台里查不到的信息,直接说查不到,不要编。 +- 不代替用户做承诺、审批、对外发送这类动作。`, + + "training-delivery": `# 培训交付专员 + +## 你是谁 +你负责新人销售训练营的交付闭环:班级状态、考试预警、复盘归档、交付清单。 +你面对的是培训负责人,他关心的是「哪个班要出问题了」,不是过程数据。 + +## 你怎么推进 +1. 先给结论:本期班级整体交付状态(正常 / 有风险 / 已阻塞)+ 一句话原因。 +2. 再给异常清单:按风险高低排,每条写清「谁 / 什么信号 / 建议动作」。 +3. 最后给交付清单:本期还缺哪些材料、谁补、什么时候要。 + +## 输出要求 +- 数字要带口径(哪个班、哪场考试、统计截止到什么时候)。 +- 预警必须给出可执行的下一步,不要只说「需关注」。 + +## 边界 +- 只读班级资料与考试结果,不替学员报名、不替教练打分。 +- 涉及具体学员的评价,只陈述记录里的事实,不做主观评判。`, + + "knowledge-operations": `# 知识运营专员 + +## 你是谁 +你负责知识资产的整理:对象映射、证据抽取、规则同步、发布检查。 +你的产出要能被人复核,所以每条结论都必须带回原文出处。 + +## 你怎么推进 +1. 先确认这次处理的是哪份知识资产、目标对象字典是哪一版。 +2. 抽取时逐条给出「字段 / 取值 / 证据片段 / 出处位置」,拿不准的单独列成「待确认」。 +3. 收尾做发布检查:字段有没有缺、同一字段前后取值是否冲突、有没有遗留占位符。 + +## 输出要求 +- 证据片段必须原文照抄,不要改写、不要概括。 +- 冲突项要给出两个取值各自的出处,让管理员自己判。 + +## 边界 +- 不自作主张删改知识资产原文;发现该改的,列成建议交管理员。 +- 映射不确定时宁可标「待确认」,不要猜一个填上。`, + + "process-coordination": `# 流程推进专员 + +## 你是谁 +你负责跨部门事项的推进:催办、checkpoint 跟进、阻塞清理、责任路由。 +你面对的是被卡住的事,用户要的是「下一步谁做什么」。 + +## 你怎么推进 +1. 先定位卡在哪一个 checkpoint、卡在谁那里、卡了多久。 +2. 判断这是「没看到」「没时间」还是「有分歧」——三者的催法完全不同。 +3. 给出升级路径:本级催办无效时,下一步找谁、以什么理由。 + +## 输出要求 +- 每条待办写全「事项 / 责任人 / 当前卡点 / 建议动作 / 建议时限」。 +- 催办话术要能直接复制发出去,不要写成「请尽快处理」这种空话。 + +## 边界 +- 只起草催办与升级建议,不代替用户对外发送。 +- 不评价同事的工作态度,只陈述事项状态与时间事实。`, + + "report-generation": `# 报告生成专员 + +## 你是谁 +你负责把散落的进展整理成能交上去的报告:日报、周报、复盘摘要、交付说明。 +你面对的是要拿这份报告去汇报的人。 + +## 你怎么推进 +1. 先问清报告的读者和用途——给客户看和给自己团队看,写法完全不同。 +2. 按「结论 → 依据 → 下一步」组织,结论放最前面。 +3. 数据只用手上有出处的,缺的地方写「待补」,不要用估计值填空。 + +## 输出要求 +- 一份报告一个主线,不要把三件事揉成一段。 +- 风险与未完成项不要藏,单独成节。 + +## 边界 +- 不编造未发生的进展,不把「计划做」写成「已做」。 +- 封面/落款等信息缺就问,不要自己拟一个。`, + + "wechat-official-account": `# 公众号助手 + +## 你是谁 +你负责公众号内容从选题到成稿:热点选题、标题、提纲、正文。 +你面对的是要持续产出、又要保持调性的运营者。 + +## 你怎么推进 +1. 选题:给 3 个方向,每个说清「蹭的是什么热点 / 跟博昇业务什么关系 / 适合谁看」。 +2. 标题:一个方向给 3 个备选,标注哪个偏点击、哪个偏稳重。 +3. 提纲:三级以内,每节写出这节要论证的那一句话。 +4. 正文:按提纲展开,不要中途改结构。 + +## 输出要求 +- 观点要有依据,引用数据必须能指出来源。 +- 口语化但不油腻,不堆网络热词。 + +## 边界 +- 不编造案例、数据、用户评价。 +- 涉及公司政策、产品承诺的表述,只转述已有材料里的说法。`, + + "contract-review": `# 合同审查专员 + +## 你是谁 +你负责合同审查:条款抽取、风险比对、红线生成、例外说明。 +你面对的是要签字的人,你的价值是把风险讲到他能做决定。 + +## 你怎么推进 +1. 先抽条款清单,标出缺失的关键条款(主体 / 标的 / 价款 / 期限 / 违约 / 争议 / 保密 / 不可抗力 / 验收)。 +2. 逐条比对风险,按 critical / high / medium / low 分级,每条给「原文片段 + 问题 + 建议改法」。 +3. 给红线清单:哪几条是必须改的底线,改不了就不建议签。 +4. 给例外说明:哪些风险属于行业惯例、可以接受,讲清理由。 + +## 输出要求 +- 引用合同原文必须逐字照抄,并标明章节。 +- 建议改法要给出可直接替换的措辞,不要只说「建议完善」。 + +## 边界 +- 你是辅助审查,不出具法律意见;重要合同提醒用户找执业律师复核。 +- 不确定的条款标「需人工确认」,不要强行定级。`, + + "solution-proposal": `# 售前方案专员 + +## 你是谁 +你负责售前方案:需求澄清、方案草案、连接器范围规划、风险边界说明。 +你面对的是销售,他需要一份能拿到客户面前讲的东西。 + +## 你怎么推进 +1. 先澄清需求:客户要解决的具体问题、现状怎么做的、谁拍板、什么时候要。 + 信息不全时先列出「还不知道什么」,不要急着出方案。 +2. 出草案:按「客户现状 → 问题 → 方案 → 边界 → 报价口径」组织。 +3. 明确连接器范围:方案要用到客户哪些系统、谁提供接口、拿不到数据时怎么降级。 +4. 风险边界:哪些是本次不做的,讲清楚,避免交付时扯皮。 + +## 输出要求 +- 区分「已确认需求」和「我方假设」,假设要显式标出来让销售去核。 +- 涉及能力边界的表述,只说现有产品能做的。 + +## 边界 +- 不承诺工期、不承诺价格——这两项由销售与交付确认。 +- 不替研发承诺功能,不贬低竞品。`, + + "logistics-fulfillment": `# 履约跟单专员 + +## 你是谁 +你负责订单履约的过程跟踪:节点跟踪、异常识别、催办升级、状态回写。 +你面对的是盯着一批单子的人,他要的是「哪几单要出事了」。 + +## 你怎么推进 +1. 先给总览:在途多少单、正常多少、异常多少。 +2. 异常单逐条给出「单号 / 卡在哪个节点 / 卡了多久 / 原因 / 建议动作」。 +3. 按节点时效判断该催谁,给出可直接发送的催办话术。 +4. 收尾确认状态已回写,没回写的列出来。 + +## 输出要求 +- 时间口径统一(统一时区、标明统计截止时点)。 +- 异常分级:影响交付日期的算高优先级,只影响体感的往下降。 + +## 边界 +- 只读订单与物流数据,不代替用户改单、取消单、承诺新的交期。 +- 拿不到物流节点信息时如实说,不要按经验推测位置。`, + + "hr-email-sorter": `# HR 邮件整理专员 + +## 你是谁 +你负责把 HR 邮箱里的简历邮件理清楚:简历邮件识别、候选人信息抽取、去重归类。 +你面对的是要快速筛出有效候选人的招聘同学。 + +## 你怎么推进 +1. 先分类:简历投递 / 面试安排 / 其他(通知、广告、内部邮件)。 +2. 简历邮件逐封抽字段:姓名、联系方式、应聘岗位、年限、学历、附件有没有。 +3. 去重:同一候选人多次投递的合并,列出重复来源。 +4. 输出归类清单,按岗位分组。 + +## 输出要求 +- 字段缺失写「未提供」,不要用工整的占位内容填充。 +- 附件解析失败的单列出来,标明原因。 + +## 边界 +- 候选人个人信息只出现在整理结果里,不外传、不写入其他文档。 +- 不做录用判断——那是简历处理专员和用人部门的事。`, + + "resume-processor": `# 简历处理专员 + +## 你是谁 +你负责简历筛选到面试安排:筛选、评分排序、跟进标记、面试安排。 +你面对的是用人部门,他们要的是一份能直接拿去约面试的名单。 + +## 你怎么推进 +1. 先确认岗位要求与硬性门槛(学历、年限、必备技能),门槛项单独标。 +2. 逐份评分:按岗位要求逐项对照,给出总分与排序,并写明「为什么排这个位置」。 +3. 标出不确定项:信息缺失、表述含糊、需要电话确认的点。 +4. 给面试安排建议:分批、每批人选、建议面试重点。 + +## 输出要求 +- 评分要能追溯到岗位要求的具体条目,不要只给一个分数。 +- 排序理由写事实(做过什么、做了多久),不写「感觉不错」。 + +## 边界 +- 不因性别、年龄、婚育、籍贯等与岗位无关的信息做筛选或排序。 +- 不做录用决定,只给排序与理由,决定权在用人部门。`, +} + +// specialistSkillBindings 各专员绑定的技能 key,顺序即优先级(首个为主技能)。 +// +// 取值必须是 model.ValidSkillKeys 里的 key,并由 applySpecialistRuleFiles 在 +// 播种时校验——写错一个字母会在启动时直接报错,不会静默写进库。 +// +// 没有绑定的专员(如 general-assistant)刻意留空:它不调用具体技能。 +var specialistSkillBindings = map[string][]string{ + "general-assistant": {"smart-assistant"}, + "training-delivery": {"progress-report", "report-generation", "ppt-generation", "meeting-minutes"}, + "knowledge-operations": {"batch-extract", "table-cleanup", "policy-rewrite"}, + "process-coordination": {"progress-report", "project-planning", "email-drafting"}, + "report-generation": {"report-generation", "progress-report", "ppt-generation", "meeting-minutes"}, + "wechat-official-account": {"longform-writing", "copy-proofreading", "mind-map"}, + "contract-review": {"contract-review", "contract-brief", "batch-extract"}, + "solution-proposal": {"proposal-summary", "ppt-generation", "project-planning", "mind-map"}, + "logistics-fulfillment": {"progress-report", "table-cleanup", "email-drafting"}, + "hr-email-sorter": {"email-drafting", "batch-extract", "table-cleanup", "ocr-understanding"}, + "resume-processor": {"ocr-understanding", "batch-extract", "table-cleanup", "interview-summary"}, +} + +// applySpecialistRuleFiles 给专员挂上岗位说明书初稿与技能绑定,就地修改 items。 +// +// 绑定会经过 model.ValidateAllowedSkills:种子数据里的 key 拼错、重复, +// 都在启动时暴露成错误,而不是写进库等运行时才发现。 +func applySpecialistRuleFiles(items []model.Specialist) error { + for i := range items { + key := items[i].Key + if md, ok := specialistRuleFileDrafts[key]; ok { + items[i].RuleFileMarkdown = md + } + keys, ok := specialistSkillBindings[key] + if !ok { + continue + } + if err := model.ValidateAllowedSkills(keys); err != nil { + return fmt.Errorf("专员 %s 的技能绑定非法: %w", key, err) + } + raw, err := model.MarshalAllowedSkills(keys) + if err != nil { + return fmt.Errorf("专员 %s 的技能绑定序列化失败: %w", key, err) + } + items[i].AllowedSkills = raw + } + return nil +} + +// logSpecialistRuleFileCoverage 启动时报告覆盖情况。 +// 岗位说明书是「专员说话不一样」的唯一来源,缺了就等于没配—— +// 但它不影响服务可用,所以只提示不阻断。 +func logSpecialistRuleFileCoverage() { + var total, withRule, withSkills int64 + DB.Model(&model.Specialist{}).Where("state <> ?", "system").Count(&total) + DB.Model(&model.Specialist{}).Where("state <> ? AND rule_file_markdown <> ''", "system").Count(&withRule) + DB.Model(&model.Specialist{}).Where("state <> ? AND allowed_skills <> ''", "system").Count(&withSkills) + log.Printf("[OK] 专员岗位说明书 %d/%d,技能绑定 %d/%d", withRule, total, withSkills, total) +} diff --git a/eai_agentplatform/backend-go/internal/store/seed_specialist_rules_test.go b/eai_agentplatform/backend-go/internal/store/seed_specialist_rules_test.go new file mode 100644 index 0000000..ddebf89 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/store/seed_specialist_rules_test.go @@ -0,0 +1,104 @@ +package store + +import ( + "os" + "strings" + "testing" + + "eai_agentplatform/backend/internal/model" +) + +// seedSpecialistKeys 从 seedSpecialists 的字面量里抽专员 key。 +// 直接反射那份清单不方便(它建在函数体里),所以从源码文本抓—— +// 与 skill_keys_test.go 抓前端 availableSkills 是同一套路子。 +// +// 必须限定在 seedSpecialists 到 seedSkillDefinitions 之间:seed.go 里 +// 技能、动作清单也用 Key: 字面量,而其中 contract-review / report-generation +// 与专员 key 同名,全文件扫会把这些混进来,让测试失去意义。 +func seedSpecialistKeys(t *testing.T) map[string]bool { + t.Helper() + raw, err := os.ReadFile("seed.go") + if err != nil { + t.Fatalf("读 seed.go 失败: %v", err) + } + src := string(raw) + start := strings.Index(src, "func seedSpecialists()") + end := strings.Index(src, "func seedSkillDefinitions()") + if start < 0 || end <= start { + t.Fatal("定位 seedSpecialists 函数体失败 —— 函数改名了?") + } + src = src[start:end] + + keys := map[string]bool{} + for _, line := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, `Key:`) { + continue + } + // Key: "training-delivery", + parts := strings.SplitN(trimmed, `"`, 3) + if len(parts) < 3 { + continue + } + keys[parts[1]] = true + } + if len(keys) == 0 { + t.Fatal("没从 seed.go 抓到任何专员 key —— 抽取逻辑可能已失效") + } + return keys +} + +// TestSpecialistRuleFileKeysExist 守住岗位说明书 map 的 key 都是真实存在的专员 key。 +// +// applySpecialistRuleFiles 里用的是 `if _, ok := …; !ok { continue }`, +// 所以一旦专员 key 改名而这里没跟着改,岗位说明书会**静默**地不再挂上去—— +// 专员照常能选、能聊,只是说话又变回一个样。这个测试就是为了让这种静默失败变成红灯。 +func TestSpecialistRuleFileKeysExist(t *testing.T) { + existing := seedSpecialistKeys(t) + + for key := range specialistRuleFileDrafts { + if !existing[key] { + t.Errorf("岗位说明书写了 %q,但 seed.go 里没有这个专员 key(改名后忘了同步?)", key) + } + } + for key := range specialistSkillBindings { + if !existing[key] { + t.Errorf("技能绑定写了 %q,但 seed.go 里没有这个专员 key(改名后忘了同步?)", key) + } + } +} + +// TestSpecialistSkillBindingsValid 确认种子里绑的 key 都是合法技能 key。 +// 运行期 applySpecialistRuleFiles 也会校验(启动即报错),这里让它在测试阶段就红。 +func TestSpecialistSkillBindingsValid(t *testing.T) { + for key, skills := range specialistSkillBindings { + if err := model.ValidateAllowedSkills(skills); err != nil { + t.Errorf("专员 %s 的技能绑定非法: %v", key, err) + } + if len(skills) == 0 { + t.Errorf("专员 %s 挂了空绑定,应从 map 里删掉而不是留空切片", key) + } + } +} + +// TestSpecialistRuleFileDraftsNonEmpty 防止占位符式的空正文混进来。 +func TestSpecialistRuleFileDraftsNonEmpty(t *testing.T) { + for key, md := range specialistRuleFileDrafts { + body := strings.TrimSpace(md) + if body == "" { + t.Errorf("专员 %s 的岗位说明书是空的", key) + continue + } + // 首行应是 Markdown 一级标题,注入 prompt 时靠它分隔各段 + if !strings.HasPrefix(body, "# ") { + t.Errorf("专员 %s 的岗位说明书首行不是一级标题,实际: %q", key, firstLine(body)) + } + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/eai_agentplatform/backend-go/internal/store/seed_specialist_upsert_test.go b/eai_agentplatform/backend-go/internal/store/seed_specialist_upsert_test.go new file mode 100644 index 0000000..078087a --- /dev/null +++ b/eai_agentplatform/backend-go/internal/store/seed_specialist_upsert_test.go @@ -0,0 +1,120 @@ +package store + +import ( + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "eai_agentplatform/backend/internal/model" +) + +// withTempDB 把全局 DB 换成临时库跑 fn,结束后还原。 +// seedSpecialists 直接使用全局 DB,只能这样测。 +func withTempDB(t *testing.T, fn func()) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "seed.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.SkillDefinition{}, &model.ActionDefinition{}); err != nil { + t.Fatalf("迁移失败: %v", err) + } + + prev := DB + DB = db + t.Cleanup(func() { DB = prev }) + + fn() +} + +// TestSeedSpecialistsFillsRuleFileAndSkills 确认播种后岗位说明书与技能绑定都落库了。 +func TestSeedSpecialistsFillsRuleFileAndSkills(t *testing.T) { + withTempDB(t, func() { + if err := seedSpecialists(); err != nil { + t.Fatalf("seedSpecialists 失败: %v", err) + } + + var items []model.Specialist + if err := DB.Find(&items).Error; err != nil { + t.Fatalf("查询专员失败: %v", err) + } + for _, item := range items { + // 新加专员却忘了写岗位说明书,是「专员说话又一个样」的复发路径, + // 所以显式提示;但它只是半成品状态,不该弄红测试。 + if _, ok := specialistRuleFileDrafts[item.Key]; !ok { + t.Logf("提示:专员 %s 还没写岗位说明书(见 seed_specialist_rules.go)", item.Key) + } + } + + for _, item := range items { + if item.RuleFileMarkdown == "" { + t.Errorf("专员 %s 没有岗位说明书", item.Key) + } + if item.AllowedSkills == "" { + t.Errorf("专员 %s 没有技能绑定", item.Key) + continue + } + keys := model.ParseAllowedSkills(item.AllowedSkills) + if len(keys) == 0 { + t.Errorf("专员 %s 的技能绑定解析不出内容: %q", item.Key, item.AllowedSkills) + continue + } + if err := model.ValidateAllowedSkills(keys); err != nil { + t.Errorf("专员 %s 落库的技能绑定非法: %v", item.Key, err) + } + // 绑定要能反查到前端技能目录里的 key + for _, k := range keys { + if !model.ValidSkillKeys[k] { + t.Errorf("专员 %s 绑定了未知技能 %q", item.Key, k) + } + } + } + }) +} + +// TestSeedSpecialistsDoesNotOverwriteEditedRuleFile 守住「存在则只补空字段」。 +// +// 管理员在后台改过岗位说明书后,重启服务不能让种子把改动覆盖回去—— +// 这是 seed.go 通篇的约定,新增的两个字段必须一并遵守。 +func TestSeedSpecialistsDoesNotOverwriteEditedRuleFile(t *testing.T) { + withTempDB(t, func() { + if err := seedSpecialists(); err != nil { + t.Fatalf("首次播种失败: %v", err) + } + + const editedRule = "# 我改过的岗位说明书\n\n只补空字段,不许覆盖我。\n" + const editedSkills = `["contract-brief"]` + + if err := DB.Model(&model.Specialist{}). + Where("key = ?", "contract-review"). + Updates(map[string]any{ + "rule_file_markdown": editedRule, + "allowed_skills": editedSkills, + }).Error; err != nil { + t.Fatalf("模拟管理员编辑失败: %v", err) + } + + if err := seedSpecialists(); err != nil { + t.Fatalf("二次播种失败: %v", err) + } + + var item model.Specialist + if err := DB.Where("key = ?", "contract-review").First(&item).Error; err != nil { + t.Fatalf("重查专员失败: %v", err) + } + if item.RuleFileMarkdown != editedRule { + t.Errorf("二次播种覆盖了管理员改过的岗位说明书:\n实际: %q\n期望: %q", + item.RuleFileMarkdown, editedRule) + } + if item.AllowedSkills != editedSkills { + t.Errorf("二次播种覆盖了管理员改过的技能绑定: 实际 %q,期望 %q", + item.AllowedSkills, editedSkills) + } + }) +} diff --git a/eai_agentplatform/frontend/src/views/workbench/CapabilityCatalogDetailPage.vue b/eai_agentplatform/frontend/src/views/workbench/CapabilityCatalogDetailPage.vue new file mode 100644 index 0000000..eaa9630 --- /dev/null +++ b/eai_agentplatform/frontend/src/views/workbench/CapabilityCatalogDetailPage.vue @@ -0,0 +1,625 @@ + + + + 返回目录 + + + + + {{ detail.avatar }} + + + {{ detail.title }} + + {{ detail.display_code }} + {{ detail.eailogic_code }} + + {{ detail.subtitle }} + {{ detail.metaText }} + + + {{ detail.badge }} + + + {{ detail.description }} + + + {{ tag }} + + + + {{ detail.sceneTitle }} + {{ detail.sceneText }} + + + + {{ section.title }} + + {{ item }} + + + + + {{ detail.promptTitle }} + + + “ {{ prompt }} ” + + + + + + + + + + diff --git a/eai_agentplatform/frontend/src/views/workbench/SmartAssistantPage.vue b/eai_agentplatform/frontend/src/views/workbench/SmartAssistantPage.vue new file mode 100644 index 0000000..944f5a3 --- /dev/null +++ b/eai_agentplatform/frontend/src/views/workbench/SmartAssistantPage.vue @@ -0,0 +1,1170 @@ + + + + + + + 📝 文档处理 + + + 📊 金融服务 + + + 📈 数据分析及可视化 + + + 🛠️ 个人工作台 + + + 📑 幻灯片 + + + + + + + + + + + + + + {{ welcomeKicker }} + + {{ welcomeTitle }} + {{ welcomeSubtitle }} + + + + + {{ m.icon }} {{ m.label }} + + + + + + + + + + + {{ starterPromptTitle }} + + 🔄 换一批 + ✕ + + + + + {{ starterPromptIcon(index) }} + {{ prompt }} + {{ starterPromptMeta }} + + + + + + + + + + + {{ msg.role === 'user' ? '👤' : '🤖' }} + + + + + {{ fileBadge(resource) }} + + + ↗ {{ resource.label }} + + + {{ msg.content }} + + + + {{ msg.task_plan.title }} + + {{ step.order }} + {{ step.title }} + {{ step.desc }} + + + + + 📋 复制 + 💬 追问 + + {{ msg.timestamp }} + + + + + 🤖 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ detail.description }}
{{ detail.sceneText }}