feat: 专员岗位说明书与技能绑定打通(SY23 P0–P4)

工作台对话此前根本不读专员表:smart_assistant.go 里两条写死的 prompt,
专员表零参与,所以「选哪个专员说话都一样」。本次把
「专员 → 岗位说明书 → 技能」接进 system prompt。

P0 修 bug
  batch/extract 与 contract/review 直接把 nil 当路由传给 AI 层,
  取 primary.RouteID 时空指针,这两个接口必 500。
  llm.go 抽出 buildRouteChain,nil 主路由改为显式报错。

P1 数据层
  specialist 表加 rule_file_markdown(岗位说明书正文)与 allowed_skills
  (绑定技能,顺序即优先级)。写入侧用 model.ValidSkillKeys 校验,
  非法 key / 重复项直接 400,并规范化成紧凑 JSON 落库。
  播种 11 份岗位说明书初稿与技能绑定,沿用「存在则只补空字段」,
  管理员改过的内容不会被重启覆盖。

P2 打通链路
  请求体加 task_id 与 specialist_key。后端按任务反查专员(专员是任务的
  字段,任务优先),查不到安静退回通用助手而不是报错打断对话。
  ai_call_log 加 specialist_key,用来回答「这条回答是谁说的」。

P3 注入 prompt
  system prompt 改为 基础角色 + 【当前专员】+【可用技能】+【岗位说明书】,
  说明书放最后(离用户消息最近,优先级最高)。选了专员就不再自称
  「通用助手」——身份冲突正是老毛病的成因。

P4 前端
  对话带上当前任务与专员;专员详情页显示绑定的技能、读哪些信源、
  可以动什么。

验证
  go build / go vet / go test 全绿,45 项测试通过;前端构建通过。
  另在开发库副本上验过真实升级路径(AutoMigrate 补三列 + 播种补齐
  11 个专员),源库未被改动。
  新增防漂移测试:后端技能清单与前端 availableSkills 不一致即红灯;
  专员改名而说明书没同步也会红灯(这类静默失效最难查)。

注:llm.go / credits.go / seed.go / smart_assistant.go / api/specialist.go
同时含有并行进行中的改动(路由健康上报、清理调试埋点、技能与动作种子),
与本方案交织在同一批行内,无法单独拆出,一并随本次提交。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-17 21:32:31 +08:00
co-authored by Claude Code
parent fa6c26ea41
commit 8b136d4a10
20 changed files with 3781 additions and 314 deletions
@@ -10,14 +10,14 @@ import (
)
// ──────────────────────────────────────────────
// 按用户算力点计费(对齐 pj034 router.py 的 compute_credits / log_ai_call)
// 按用户算力点计费
// ──────────────────────────────────────────────
// AI 能力常量(pj034 AiCapability 的精简子集)
// AI 能力常量
const (
CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
CapabilityImageGen = "image_gen" // 文生图(按次扣点)
CapabilityImageGen = "image_gen" // 文生图(按次扣点)
CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
)
@@ -26,7 +26,7 @@ const (
var CapabilityCredits = map[string]int{
CapabilityAIChat: 1,
CapabilityTextGen: 1,
CapabilityImageGen: 1,
CapabilityImageGen: 1,
CapabilityEmbed: 0,
CapabilityEssayGrade: 0,
}
@@ -41,16 +41,18 @@ func ComputeCredits(capability string, success bool) int {
// LogEntry 一次 AI 调用的审计入参
type LogEntry struct {
UserID uint
Capability string
Provider string
RouteID string
Model string
TokensInput int
TokensOutput int
Success bool
ErrorMessage string
LatencyMs int
UserID uint
Capability string
// SpecialistKey 本次调用以哪个专员的身份进行;空 = 通用助手
SpecialistKey string
Provider string
AIRouteID string
Model string
TokensInput int
TokensOutput int
Success bool
ErrorMessage string
LatencyMs int
}
// LogCall 写 ai_call_log;成功且需扣点时从用户余额扣点。审计写入失败不阻断主流程。
@@ -63,8 +65,9 @@ func LogCall(e LogEntry) {
rec := model.AiCallLog{
UserID: e.UserID,
Capability: e.Capability,
SpecialistKey: e.SpecialistKey,
Provider: e.Provider,
RouteID: e.RouteID,
AIRouteID: e.AIRouteID,
Model: e.Model,
TokensInput: e.TokensInput,
TokensOutput: e.TokensOutput,
+113 -89
View File
@@ -53,13 +53,13 @@ type Client struct {
}
// NewClient 从 RouteConfig 创建客户端
func NewClient(route *config.RouteConfig) *Client {
func NewClient(aiRoute *config.RouteConfig) *Client {
return &Client{
baseURL: strings.TrimRight(route.BaseURL, "/"),
apiKey: route.APIKey,
model: route.Model,
maxTokens: route.MaxTokens,
temperature: route.Temperature,
baseURL: strings.TrimRight(aiRoute.BaseURL, "/"),
apiKey: aiRoute.APIKey,
model: aiRoute.Model,
maxTokens: aiRoute.MaxTokens,
temperature: aiRoute.Temperature,
hc: &http.Client{Timeout: 120 * time.Second},
}
}
@@ -100,44 +100,10 @@ func (c *Client) post(path string, body any) (*http.Response, error) {
for k, v := range c.headers() {
req.Header.Set(k, v)
}
// #region debug-point C:llm-post
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "C",
"location": "backend-go/internal/ai/llm.go:Client.post:request",
"msg": "[DEBUG] llm client request",
"data": map[string]any{
"url": c.url(path),
"model": c.model,
"hasAPIKey": c.apiKey != "",
"authHeader": req.Header.Get("Authorization") != "",
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
// #region debug-point C:llm-response
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "C",
"location": "backend-go/internal/ai/llm.go:Client.post:response",
"msg": "[DEBUG] llm client response",
"data": map[string]any{
"url": c.url(path),
"status": resp.StatusCode,
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
return resp, nil
}
@@ -260,24 +226,6 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
// #region debug-point C:llm-non200
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "C",
"location": "backend-go/internal/ai/llm.go:GenerateStream:non200",
"msg": "[DEBUG] llm stream non-200",
"data": map[string]any{
"status": resp.StatusCode,
"bodyPreview": truncate(string(data), 240),
"model": c.model,
"baseURL": c.baseURL,
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
return fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
}
@@ -347,82 +295,158 @@ func (c *Client) Embed(inputs []string) ([][]float64, error) {
}
// ──────────────────────────────────────────────
// Fallback 链调用(参考 pj034 chat_completion_with_fallback)
// Fallback 链调用
// ──────────────────────────────────────────────
// buildRouteChain 构建「主路由 + 回退链」。
//
// primary 为 nil 时必须显式报错而不是继续:链里存 nil 会在循环里 NewClient(nil),
// 而取 primary.RouteID 更是直接空指针。历史上 batch_extract / contract_review
// 就是直接传了 nil,导致这两个接口必 500。调用方应先用 config.GetRoute 解析路由。
func buildRouteChain(primary *config.RouteConfig) ([]*config.RouteConfig, error) {
if primary == nil {
return nil, fmt.Errorf("主路由为 nil:调用方未解析 AI 路由,请先用 config.GetRoute 取路由")
}
aiRouteChain := []*config.RouteConfig{primary}
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 {
aiRouteChain = append(aiRouteChain, fallbacks...)
}
return aiRouteChain, nil
}
// GenerateWithFallback 依次尝试主路由 + 回退链,首个成功返回
func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (string, error) {
// 构建路由链
chain := []*config.RouteConfig{primary}
fallbacks, err := config.GetFallbackRoutes(primary.RouteID)
if err == nil && len(fallbacks) > 0 {
chain = append(chain, fallbacks...)
aiRouteChain, err := buildRouteChain(primary)
if err != nil {
return "", err
}
var lastErr error
for _, route := range chain {
client := NewClient(route)
for _, aiRoute := range aiRouteChain {
client := NewClient(aiRoute)
start := time.Now()
content, err := client.Generate(messages)
if err == nil {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
Healthy: true,
Checked: true,
LatencyMs: time.Since(start).Milliseconds(),
LastCheckedAt: time.Now(),
})
return content, nil
}
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
Healthy: false,
Checked: true,
LatencyMs: time.Since(start).Milliseconds(),
LastCheckedAt: time.Now(),
LastError: err.Error(),
})
lastErr = fmt.Errorf("[%s] %w", aiRoute.RouteID, err)
}
return "", fmt.Errorf("所有路由均失败: %w", lastErr)
}
// GenerateFullWithFallback 非流式 + 回退链:返回结果与「实际命中」的路由(用于审计)
func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (*ChatResult, *config.RouteConfig, error) {
chain := []*config.RouteConfig{primary}
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 {
chain = append(chain, fallbacks...)
aiRouteChain, err := buildRouteChain(primary)
if err != nil {
return nil, nil, err
}
var lastErr error
for _, route := range chain {
client := NewClient(route)
for _, aiRoute := range aiRouteChain {
client := NewClient(aiRoute)
start := time.Now()
res, err := client.GenerateFull(messages)
if err == nil {
res.Provider = route.Provider
return res, route, nil
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
Healthy: true,
Checked: true,
LatencyMs: time.Since(start).Milliseconds(),
LastCheckedAt: time.Now(),
})
res.Provider = aiRoute.Provider
return res, aiRoute, nil
}
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
Healthy: false,
Checked: true,
LatencyMs: time.Since(start).Milliseconds(),
LastCheckedAt: time.Now(),
LastError: err.Error(),
})
lastErr = fmt.Errorf("[%s] %w", aiRoute.RouteID, err)
}
return nil, nil, fmt.Errorf("所有路由均失败: %w", lastErr)
}
// GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由
func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) {
chain := []*config.RouteConfig{primary}
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 {
chain = append(chain, fallbacks...)
aiRouteChain, err := buildRouteChain(primary)
if err != nil {
return nil, err
}
var lastErr error
for _, route := range chain {
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
lastErr = fmt.Errorf("[%s] 未配置 API Key", route.RouteID)
for _, aiRoute := range aiRouteChain {
if requiresAPIKey(aiRoute) && strings.TrimSpace(aiRoute.APIKey) == "" {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
Healthy: false,
Checked: true,
LastCheckedAt: time.Now(),
LastError: "未配置 API Key",
})
lastErr = fmt.Errorf("[%s] 未配置 API Key", aiRoute.RouteID)
continue
}
client := NewClient(route)
client := NewClient(aiRoute)
start := time.Now()
if err := client.GenerateStream(messages, onChunk); err == nil {
return route, nil
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
Healthy: true,
Checked: true,
LatencyMs: time.Since(start).Milliseconds(),
LastCheckedAt: time.Now(),
})
return aiRoute, nil
} else {
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
Healthy: false,
Checked: true,
LatencyMs: time.Since(start).Milliseconds(),
LastCheckedAt: time.Now(),
LastError: err.Error(),
})
lastErr = fmt.Errorf("[%s] %w", aiRoute.RouteID, err)
}
}
return nil, fmt.Errorf("所有路由均失败: %w", lastErr)
}
func requiresAPIKey(route *config.RouteConfig) bool {
if route == nil {
func requiresAPIKey(aiRoute *config.RouteConfig) bool {
if aiRoute == nil {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
baseURL := strings.ToLower(strings.TrimSpace(aiRoute.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
}
provider := strings.ToLower(strings.TrimSpace(route.Provider))
provider := strings.ToLower(strings.TrimSpace(aiRoute.Provider))
return provider == "openrouter" || provider == "openai"
}
@@ -455,7 +479,7 @@ func ResolveLLM(cfg *config.Config) (LLMConfig, bool) {
modelName := get("llm_model", cfg.LLMModel)
embedModel := get("embed_model", cfg.EmbedModel)
// DB 空时降级到 JSON secrets(flat 格式,pj034 兼容)
// DB 空时降级到 JSON secrets(flat 格式)
if baseURL == "" || apiKey == "" {
if baseURL == "" {
baseURL = config.GetProviderBaseURL("ollama")
@@ -0,0 +1,70 @@
package ai
import (
"strings"
"testing"
"eai_agentplatform/backend/internal/config"
)
// TestBuildRouteChainNilPrimary 回归测试:主路由为 nil 时必须返回错误,不能 panic。
//
// 背景:internal/api/batch_extract.go 与 internal/api/contract_review.go 曾直接传
// ai.GenerateWithFallback(nil, …),旧实现第一行就取 primary.RouteID,导致
// POST /api/batch/extract 与 POST /api/contract/review 必然 500。
func TestBuildRouteChainNilPrimary(t *testing.T) {
chain, err := buildRouteChain(nil)
if err == nil {
t.Fatal("主路由为 nil 时应返回错误,实际返回 nil error")
}
if chain != nil {
t.Fatalf("主路由为 nil 时不应返回路由链,实际返回 %d 条", len(chain))
}
if !strings.Contains(err.Error(), "nil") {
t.Errorf("错误信息应点明 nil 主路由,实际为: %v", err)
}
}
// TestBuildRouteChainNonNilPrimary 确认正常路径仍是「主路由在链首」。
// 这里只校验链首,不依赖 ai_config.json 里是否配了回退链。
func TestBuildRouteChainNonNilPrimary(t *testing.T) {
primary := &config.RouteConfig{RouteID: "chat_route_does_not_exist_for_test"}
chain, err := buildRouteChain(primary)
if err != nil {
t.Fatalf("非 nil 主路由不应报错: %v", err)
}
if len(chain) == 0 {
t.Fatal("路由链不应为空")
}
if chain[0] != primary {
t.Errorf("链首应为传入的主路由,实际是 %v", chain[0])
}
}
// TestGenerateWithFallbackNilPrimary 三个导出入口都必须把 nil 转成错误而非 panic。
func TestGenerateWithFallbackNilPrimary(t *testing.T) {
msgs := []Message{{Role: "user", Content: "ping"}}
t.Run("GenerateWithFallback", func(t *testing.T) {
if _, err := GenerateWithFallback(nil, msgs); err == nil {
t.Error("期望返回错误,实际 nil")
}
})
t.Run("GenerateFullWithFallback", func(t *testing.T) {
res, route, err := GenerateFullWithFallback(nil, msgs)
if err == nil {
t.Error("期望返回错误,实际 nil")
}
if res != nil || route != nil {
t.Errorf("失败时结果与路由都应为 nil,实际 res=%v route=%v", res, route)
}
})
t.Run("GenerateStreamWithFallback", func(t *testing.T) {
if _, err := GenerateStreamWithFallback(nil, msgs, func(string) {}); err == nil {
t.Error("期望返回错误,实际 nil")
}
})
}
@@ -58,8 +58,8 @@ func runBatchExtract(req BatchExtractRequest) BatchExtractResult {
// 构建字段说明
fieldDesc := buildFieldDescription(req.Fields)
// 调用 AI 提取
content, err := ai.GenerateWithFallback(nil, []ai.Message{
// 调用 AI 提取(此前传 nil 会让 AI 层空指针,见 helpers.generateWithDefaultRoute)
content, err := generateWithDefaultRoute([]ai.Message{
{Role: "system", Content: "你是一个字段提取专家。请从用户输入的内容中提取以下字段信息,以JSON格式返回:\n" + fieldDesc},
{Role: "user", Content: req.Content},
})
@@ -66,7 +66,6 @@ func ReviewContract(c *gin.Context) {
web.Fail(c, web.NewBadRequest("content 必填"))
return
}
req.RiskStandard = req.RiskStandard
if req.RiskStandard == "" {
req.RiskStandard = "standard"
}
@@ -357,7 +356,9 @@ func checkRisks(sections []ContractSection) []RiskItem {
// aiDeepReview AI 深度审查
func aiDeepReview(req ContractReviewRequest) []RiskItem {
content, err := ai.GenerateWithFallback(nil, []ai.Message{
// 此前传 nil 会让 AI 层空指针(见 helpers.generateWithDefaultRoute);
// 失败时返回 nil,由调用方只保留规则引擎结果。
content, err := generateWithDefaultRoute([]ai.Message{
{Role: "system", Content: "你是一位资深法务专家。请审查以下合同文本,识别法律风险。" +
"重点关注:条款完整性、权责对等性、违约责任、争议解决、不可抗力、保密条款、知识产权。"},
{Role: "user", Content: req.Content},
@@ -1,10 +1,13 @@
package api
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/web"
)
@@ -17,3 +20,19 @@ func parseID(c *gin.Context, name string) (uint, bool) {
}
return uint(id), true
}
// generateWithDefaultRoute 取通用文本生成路由后调用 LLM(非流式 + 回退链)。
//
// 用于请求体里没有 ai_route_id、服务端自行挑路由的场景。路由名沿用代码里既有惯例
// "title_gen"(agent_routes 里指向通用 chat 模型);GetRoute 本身在查不到时会回落到
// ai_config.json 的 default_route,所以这里只可能在配置整体不可用时失败。
//
// 存在的意义:取代此前 ai.GenerateWithFallback(nil, …) 的写法。传 nil 会让
// ai.buildRouteChain 直接报错——历史上前者会让调用方必 500。
func generateWithDefaultRoute(messages []ai.Message) (string, error) {
aiRoute, err := config.GetRoute("title_gen")
if err != nil || aiRoute == nil {
return "", fmt.Errorf("通用文本生成路由不可用: %w", err)
}
return ai.GenerateWithFallback(aiRoute, messages)
}
@@ -6,37 +6,44 @@ import (
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
// SmartAssistantRequest POST /api/assistant/chat —— 智能助手请求
// SmartAssistantRequest POST /api/assistant/chat —— 通用助手请求
type SmartAssistantRequest struct {
Message string `json:"message"`
Context string `json:"context"`
Mode string `json:"mode"`
TaskID uint `json:"task_id"`
Temperature float64 `json:"temperature"`
Stream bool `json:"stream"`
Message string `json:"message"`
Context string `json:"context"`
Mode string `json:"mode"`
AIRouteID string `json:"ai_route_id"`
// TaskID 当前会话挂在哪条事项上。带上它就能反查事项的专员,
// 让这次回答以那个专员的身份说出来(见 resolveSpecialist)。
TaskID uint `json:"task_id"`
// SpecialistKey 直接指定专员,用于还没有事项的新会话。
// 与 TaskID 同时给出时以事项上的专员为准。
SpecialistKey string `json:"specialist_key"`
Temperature float64 `json:"temperature"`
Stream bool `json:"stream"`
}
// AssistantMessage 助手消息
type AssistantMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
Type string `json:"type"`
Type string `json:"type"`
}
// TaskPlan 任务拆解结果
type TaskPlan struct {
TaskID uint `json:"task_id"`
Title string `json:"title"`
Steps []Step `json:"steps"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
TaskID uint `json:"task_id"`
Title string `json:"title"`
Steps []Step `json:"steps"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
// Step 任务步骤
@@ -48,17 +55,16 @@ type Step struct {
Order int `json:"order"`
}
// SmartAssistantResult 智能助手响应
// SmartAssistantResult 通用助手响应
type SmartAssistantResult struct {
Message AssistantMessage `json:"message"`
TaskPlan *TaskPlan `json:"task_plan,omitempty"`
IsPlan bool `json:"is_plan"`
IsCopy bool `json:"is_copy"`
IsExtract bool `json:"is_extract"`
Stream bool `json:"stream"`
Message AssistantMessage `json:"message"`
TaskPlan *TaskPlan `json:"task_plan,omitempty"`
IsPlan bool `json:"is_plan"`
IsExpert bool `json:"is_expert"`
Stream bool `json:"stream"`
}
// Chat POST /api/assistant/chat —— 智能助手对话入口
// Chat POST /api/assistant/chat —— 通用助手对话入口
func Chat(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
@@ -70,39 +76,36 @@ func Chat(c *gin.Context) {
web.Fail(c, web.NewBadRequest("message 必填"))
return
}
req.Mode = req.Mode
if req.Mode == "" {
req.Mode = "chat"
req.Mode = "quick"
}
req.Temperature = req.Temperature
if req.Temperature == 0 {
req.Temperature = 0.7
}
result := processAssistantMessage(user.ID, req)
logAssistantMessage(user.ID, req.Message, result)
// 专员只解析一次,供 prompt 组装与审计日志共用
specialist := resolveSpecialist(req)
result := processAssistantMessage(user.ID, req, specialist)
logAssistantMessage(user.ID, req.Message, result, specialist)
web.OK(c, result)
}
// processAssistantMessage 处理智能助手消息
func processAssistantMessage(userID uint, req SmartAssistantRequest) SmartAssistantResult {
// processAssistantMessage 处理通用助手消息
func processAssistantMessage(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
switch req.Mode {
case "chat":
return handleChat(userID, req)
case "task":
return handleTaskGeneration(userID, req)
case "copy":
return handleCopyWriting(userID, req)
case "task_plan":
return handleTaskPlan(userID, req)
case "quick":
return handleQuick(userID, req, specialist)
case "expert":
return handleExpert(userID, req, specialist)
default:
return handleChat(userID, req)
return handleQuick(userID, req, specialist)
}
}
// handleChat 普通对话模式
func handleChat(userID uint, req SmartAssistantRequest) SmartAssistantResult {
aiResponse := callAssistantAI(userID, req)
// handleQuick 快速模式:直出回答,不开启 thinking 和多步规划
func handleQuick(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
aiResponse := callAssistantAI(userID, req, specialist, false)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
@@ -110,70 +113,49 @@ func handleChat(userID uint, req SmartAssistantRequest) SmartAssistantResult {
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "text",
},
IsPlan: false,
IsCopy: false,
IsExtract: false,
Stream: req.Stream,
}
}
// handleTaskGeneration 任务生成模式
func handleTaskGeneration(userID uint, req SmartAssistantRequest) SmartAssistantResult {
plan := generateTaskPlan(userID, req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: "已为您生成任务计划:" + plan.Title,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "plan",
},
TaskPlan: plan,
IsPlan: true,
IsCopy: false,
IsExtract: false,
Stream: req.Stream,
}
}
// handleCopyWriting 文案生成模式
func handleCopyWriting(userID uint, req SmartAssistantRequest) SmartAssistantResult {
copys := generateCopyWriting(userID, req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: copys,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "copy",
},
IsPlan: false,
IsCopy: true,
IsExtract: false,
Stream: req.Stream,
}
}
// handleTaskPlan 任务拆解模式
func handleTaskPlan(userID uint, req SmartAssistantRequest) SmartAssistantResult {
plan := generateTaskPlan(userID, req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: "任务已创建并拆解:" + plan.Title,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "plan",
},
TaskPlan: plan,
IsPlan: true,
IsCopy: false,
IsExtract: false,
IsPlan: false,
IsExpert: false,
Stream: req.Stream,
}
}
// callAssistantAI 调用AI生成对话回复
func callAssistantAI(userID uint, req SmartAssistantRequest) string {
content, err := ai.GenerateWithFallback(nil, []ai.Message{
{Role: "system", Content: "你是一位专业的AI智能助手,能够提供各类办公场景的辅助,包括文档翻译、文案校对、文案生成、任务拆解等。请根据用户的问题提供专业、简洁的回答。"},
// handleExpert 专家模式:开启 thinking,进行多步智能体规划
func handleExpert(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
// 先用 expert system prompt 调用 LLM 生成多步规划和回复
aiResponse := callAssistantAI(userID, req, specialist, true)
plan := generateTaskPlan(req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: aiResponse,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "text",
},
TaskPlan: plan,
IsPlan: true,
IsExpert: true,
Stream: req.Stream,
}
}
// callAssistantAI 调用AI生成对话回复,enableThinking 控制是否使用 expert prompt
//
// system prompt 由 buildAssistantSystemPrompt 组装:基础角色 + 专员岗位说明书。
// 没解析到专员时(未指定 / key 失效)与改动前逐字一致。
func callAssistantAI(userID uint, req SmartAssistantRequest, specialist *model.Specialist, enableThinking bool) string {
systemPrompt := buildAssistantSystemPrompt(specialist, enableThinking)
aiRouteID := req.AIRouteID
if aiRouteID == "" {
aiRouteID = "path_coach"
}
aiRoute, err := config.GetRoute(aiRouteID)
if err != nil {
return "抱歉,当前后台AI模型不可用,请检查 AI 路由配置。"
}
content, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: req.Message},
})
if err != nil {
@@ -183,8 +165,7 @@ func callAssistantAI(userID uint, req SmartAssistantRequest) string {
}
// generateTaskPlan 生成任务拆解
func generateTaskPlan(userID uint, prompt string) *TaskPlan {
_ = userID
func generateTaskPlan(prompt string) *TaskPlan {
plan := &TaskPlan{
Title: prompt,
Status: "pending",
@@ -198,24 +179,16 @@ func generateTaskPlan(userID uint, prompt string) *TaskPlan {
return plan
}
// generateCopyWriting 生成文案
func generateCopyWriting(userID uint, prompt string) string {
_ = userID
c, err := ai.GenerateWithFallback(nil, []ai.Message{
{Role: "system", Content: "你是一位文案专家,请根据以下要求生成文案。"},
{Role: "user", Content: prompt},
})
if err != nil {
return "生成失败,请稍后重试。"
}
return c
}
// logAssistantMessage 记录助手消息
func logAssistantMessage(userID uint, message string, result SmartAssistantResult) {
func logAssistantMessage(userID uint, message string, result SmartAssistantResult, specialist *model.Specialist) {
specialistKey := ""
if specialist != nil {
specialistKey = specialist.Key
}
store.DB.Create(&model.AiCallLog{
UserID: userID,
Capability: "assistant_chat",
Status: "success",
UserID: userID,
Capability: "assistant_chat",
SpecialistKey: specialistKey,
Status: "success",
})
}
@@ -34,7 +34,7 @@ func ListSpecialists(c *gin.Context) {
default:
q = q.Where("state = ?", state)
}
// state = system 的内置记录(通用智能助手)是任务的默认归属,不是可安装的专员,
// state = system 的内置记录(通用助手)是任务的默认归属,不是可安装的专员,
// 任何常规列表都不该带出它 —— 只有显式 state=system 才查得到。
if c.Query("state") != "system" {
q = q.Where("state <> ?", "system")
@@ -110,26 +110,34 @@ func SpecialistSummary(c *gin.Context) {
}
type specialistReq struct {
Key string `json:"key"`
Label string `json:"label"`
Tier string `json:"tier"`
WorkerType string `json:"worker_type"`
Route string `json:"route"`
Summary string `json:"summary"`
WorkStatus string `json:"work_status"`
RiskLabel string `json:"risk_label"`
Color string `json:"color"`
Stage string `json:"stage"`
Progress int `json:"progress"`
MarketTag string `json:"market_tag"`
Version string `json:"version"`
ConnectorScope string `json:"connector_scope"`
PermissionScope string `json:"permission_scope"`
ResourceBindings string `json:"resource_bindings"`
InfoSources string `json:"info_sources"`
BaseSkills string `json:"base_skills"`
AIAssistance string `json:"ai_assistance"`
GeneratedSkills string `json:"generated_skills"`
Key string `json:"key"`
Label string `json:"label"`
DisplayCode string `json:"display_code"`
EAILogicCode string `json:"eailogic_code"`
Tier string `json:"tier"`
WorkerType string `json:"worker_type"`
ObjectEntryRoute string `json:"object_entry_route"`
Summary string `json:"summary"`
RoleCardJSON string `json:"role_card_json"`
WorkStatus string `json:"work_status"`
RiskLabel string `json:"risk_label"`
Color string `json:"color"`
Stage string `json:"stage"`
Progress int `json:"progress"`
MarketTag string `json:"market_tag"`
Version string `json:"version"`
ConnectorScope string `json:"connector_scope"`
PermissionScope string `json:"permission_scope"`
ResourceBindings string `json:"resource_bindings"`
InfoSources string `json:"info_sources"`
BaseSkills string `json:"base_skills"`
AIAssistance string `json:"ai_assistance"`
GeneratedSkills string `json:"generated_skills"`
// RuleFileMarkdown 岗位说明书正文(Markdown),会话创建时注入 System Prompt
RuleFileMarkdown string `json:"rule_file_markdown"`
// AllowedSkills 绑定技能 key 的 JSON 数组字符串,顺序即优先级(首个为主技能)。
// 元素须在 model.ValidSkillKeys 内;写入前会被规范化成紧凑 JSON。
AllowedSkills string `json:"allowed_skills"`
InputsRecordsJSON string `json:"inputs_records_json"`
LegacySourceRecordsJSON string `json:"source_records_json"`
PermissionRecordsJSON string `json:"permission_records_json"`
@@ -142,10 +150,13 @@ type specialistReq struct {
func normalizeSpecialistReq(req *specialistReq) {
req.Key = strings.TrimSpace(req.Key)
req.Label = strings.TrimSpace(req.Label)
req.DisplayCode = strings.TrimSpace(req.DisplayCode)
req.EAILogicCode = strings.TrimSpace(req.EAILogicCode)
req.Tier = strings.TrimSpace(req.Tier)
req.WorkerType = strings.TrimSpace(req.WorkerType)
req.Route = strings.TrimSpace(req.Route)
req.ObjectEntryRoute = strings.TrimSpace(req.ObjectEntryRoute)
req.Summary = strings.TrimSpace(req.Summary)
req.RoleCardJSON = strings.TrimSpace(req.RoleCardJSON)
req.WorkStatus = strings.TrimSpace(req.WorkStatus)
req.RiskLabel = strings.TrimSpace(req.RiskLabel)
req.Color = strings.TrimSpace(req.Color)
@@ -159,6 +170,8 @@ func normalizeSpecialistReq(req *specialistReq) {
req.BaseSkills = strings.TrimSpace(req.BaseSkills)
req.AIAssistance = strings.TrimSpace(req.AIAssistance)
req.GeneratedSkills = strings.TrimSpace(req.GeneratedSkills)
req.RuleFileMarkdown = strings.TrimSpace(req.RuleFileMarkdown)
req.AllowedSkills = strings.TrimSpace(req.AllowedSkills)
req.InputsRecordsJSON = strings.TrimSpace(req.InputsRecordsJSON)
req.LegacySourceRecordsJSON = strings.TrimSpace(req.LegacySourceRecordsJSON)
if req.InputsRecordsJSON == "" {
@@ -172,8 +185,8 @@ func normalizeSpecialistReq(req *specialistReq) {
func validateSpecialistReq(req *specialistReq) *web.AppError {
normalizeSpecialistReq(req)
if req.Key == "" || req.Label == "" || req.Tier == "" || req.Route == "" {
return web.NewBadRequest("key、label、tier、route 为必填")
if req.Key == "" || req.Label == "" || req.Tier == "" || req.ObjectEntryRoute == "" {
return web.NewBadRequest("key、label、tier、object_entry_route 为必填")
}
if req.Tier != "generic" && req.Tier != "industry" {
return web.NewBadRequest("tier 只能是 generic 或 industry")
@@ -199,6 +212,9 @@ func validateSpecialistReq(req *specialistReq) *web.AppError {
if !store.ValidateStructuredRecordsJSON(req.InputsRecordsJSON) {
return web.NewBadRequest("inputs_records_json 必须是 JSON 数组")
}
if !store.ValidateJSONObjectJSON(req.RoleCardJSON) {
return web.NewBadRequest("role_card_json 必须是 JSON 对象")
}
if !store.ValidateStructuredRecordsJSON(req.PermissionRecordsJSON) {
return web.NewBadRequest("permission_records_json 必须是 JSON 数组")
}
@@ -208,9 +224,36 @@ func validateSpecialistReq(req *specialistReq) *web.AppError {
if !store.ValidateStructuredRecordsJSON(req.ResultRecordsJSON) {
return web.NewBadRequest("result_records_json 必须是 JSON 数组")
}
// 绑定技能:非法 key / 重复项在录入侧就挡住(见 SY23 §2.4)。
// 顺带把值规范化成紧凑 JSON,避免 "[ \"a\" , \"b\" ]" 这类写法进库后
// 让 ParseAllowedSkills 之外的下游(如直接字符串比较)踩坑。
if normalized, appErr := normalizeAllowedSkills(req.AllowedSkills); appErr != nil {
return appErr
} else {
req.AllowedSkills = normalized
}
return nil
}
// normalizeAllowedSkills 校验绑定技能列表并回写规范化后的 JSON 字符串。
// 空值合法(表示未绑定技能),统一存空串而不是 "[]"。
func normalizeAllowedSkills(raw string) (string, *web.AppError) {
keys := model.ParseAllowedSkills(raw)
// ParseAllowedSkills 对非法 JSON 返回 nil,这里要与「空数组」区分开:
// 传了内容但解析不出来,说明格式错了,应报错而不是静默丢成未绑定。
if strings.TrimSpace(raw) != "" && keys == nil {
return "", web.NewBadRequest(`allowed_skills 必须是 JSON 字符串数组,例如 ["contract-review","batch-extract"]`)
}
if err := model.ValidateAllowedSkills(keys); err != nil {
return "", web.NewBadRequest(err.Error())
}
normalized, err := model.MarshalAllowedSkills(keys)
if err != nil {
return "", web.NewBadRequest("allowed_skills 序列化失败")
}
return normalized, nil
}
// CreateSpecialist POST /api/specialists (admin)
func CreateSpecialist(c *gin.Context) {
var req specialistReq
@@ -233,10 +276,13 @@ func CreateSpecialist(c *gin.Context) {
item := model.Specialist{
Key: req.Key,
Label: req.Label,
DisplayCode: req.DisplayCode,
EAILogicCode: req.EAILogicCode,
Tier: req.Tier,
WorkerType: req.WorkerType,
Route: req.Route,
ObjectEntryRoute: req.ObjectEntryRoute,
Summary: req.Summary,
RoleCardJSON: req.RoleCardJSON,
WorkStatus: req.WorkStatus,
RiskLabel: req.RiskLabel,
Color: req.Color,
@@ -251,6 +297,8 @@ func CreateSpecialist(c *gin.Context) {
BaseSkills: req.BaseSkills,
AIAssistance: req.AIAssistance,
GeneratedSkills: req.GeneratedSkills,
RuleFileMarkdown: req.RuleFileMarkdown,
AllowedSkills: req.AllowedSkills,
InputsRecordsJSON: req.InputsRecordsJSON,
PermissionRecordsJSON: req.PermissionRecordsJSON,
ActionRecordsJSON: req.ActionRecordsJSON,
@@ -297,10 +345,13 @@ func UpdateSpecialist(c *gin.Context) {
item.Key = req.Key
item.Label = req.Label
item.DisplayCode = req.DisplayCode
item.EAILogicCode = req.EAILogicCode
item.Tier = req.Tier
item.WorkerType = req.WorkerType
item.Route = req.Route
item.ObjectEntryRoute = req.ObjectEntryRoute
item.Summary = req.Summary
item.RoleCardJSON = req.RoleCardJSON
item.WorkStatus = req.WorkStatus
item.RiskLabel = req.RiskLabel
item.Color = req.Color
@@ -315,6 +366,8 @@ func UpdateSpecialist(c *gin.Context) {
item.BaseSkills = req.BaseSkills
item.AIAssistance = req.AIAssistance
item.GeneratedSkills = req.GeneratedSkills
item.RuleFileMarkdown = req.RuleFileMarkdown
item.AllowedSkills = req.AllowedSkills
item.InputsRecordsJSON = req.InputsRecordsJSON
item.PermissionRecordsJSON = req.PermissionRecordsJSON
item.ActionRecordsJSON = req.ActionRecordsJSON
@@ -0,0 +1,127 @@
package api
import (
"fmt"
"strings"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
)
// 本文件回答一个问题:一次对话/一次动作,到底该以哪个专员的身份说话。
//
// 在此之前,工作台对话走的是 smart_assistant.go 里两条写死的 prompt,
// 专员表根本不参与——所以选谁都是同一个声音。这里把「专员 → 岗位说明书 → 技能」
// 接进 system prompt,让专员真正决定怎么说话、怎么推进。
// loadSpecialistByKey 按 key 取专员;取不到返回 nil。
//
// 刻意不报错:专员查不到(key 写错、被下线、删了)应当退化成通用助手,
// 而不是把用户的对话打断成 500。
func loadSpecialistByKey(key string) *model.Specialist {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
var s model.Specialist
if err := store.DB.Where("key = ?", key).First(&s).Error; err != nil {
return nil
}
// state = inactive 的专员不再接新会话,但 system(通用助手)要能查到。
if s.State == "inactive" {
return nil
}
return &s
}
// resolveSpecialist 解析本次请求该用的专员。
//
// 优先级:
// 1. task_id 指向的任务上的 specialist_key —— 专员是任务的字段,任务才是权威来源,
// 前端的 attachSpecialistToCurrentTask 也是把专员写到任务上而不是写到会话上。
// 2. 请求体里的 specialist_key —— 还没有任务时(新建会话早期)用这个。
//
// 都取不到返回 nil,调用方退回通用助手。
func resolveSpecialist(req SmartAssistantRequest) *model.Specialist {
if req.TaskID > 0 {
// 与 worker_task.go 其余读路径一致,不按 created_by 收窄:
// 专员目录本身就是所有登录用户可读的,这里不构成新的信息暴露。
var task model.WorkerTask
if err := store.DB.First(&task, req.TaskID).Error; err == nil {
if s := loadSpecialistByKey(task.SpecialistKey); s != nil {
return s
}
}
}
return loadSpecialistByKey(req.SpecialistKey)
}
// specialistPromptSection 生成要拼进 system prompt 的专员段落。
//
// 三块内容,顺序有讲究:
// - 身份:让模型知道自己现在是谁
// - 可用技能:告诉它能调用什么,避免它承诺做不到的事
// - 岗位说明书:放在最后,离用户消息最近,优先级最高
//
// 专员为 nil 或什么都没配时返回空串,调用方不做任何拼接——
// 这样「没选专员」与「选了但没配说明书」都不改变原有行为。
func specialistPromptSection(s *model.Specialist) string {
if s == nil {
return ""
}
var b strings.Builder
// 身份
b.WriteString("【当前专员】\n")
label := strings.TrimSpace(s.Label)
if label == "" {
label = s.Key
}
fmt.Fprintf(&b, "%s(%s)\n", label, s.Key)
if summary := strings.TrimSpace(s.Summary); summary != "" {
b.WriteString(summary + "\n")
}
if workStatus := strings.TrimSpace(s.WorkStatus); workStatus != "" {
b.WriteString("当前状态:" + workStatus + "\n")
}
// 可用技能
if skills := model.ParseAllowedSkills(s.AllowedSkills); len(skills) > 0 {
b.WriteString("\n【可用技能】\n")
b.WriteString(strings.Join(skills, "、"))
b.WriteString("\n首个是主技能。只在这些技能范围内承诺能力,范围外的需求如实说做不了。\n")
}
// 岗位说明书
if rule := strings.TrimSpace(s.RuleFileMarkdown); rule != "" {
b.WriteString("\n【岗位说明书】\n")
b.WriteString(rule)
b.WriteString("\n")
}
return strings.TrimSpace(b.String())
}
// buildAssistantSystemPrompt 组装工作台对话的 system prompt。
//
// 结构:基础角色说明(随模式变化) + 专员段落。
// 没有专员时结果与改动前完全一致,保证这是纯增量。
func buildAssistantSystemPrompt(s *model.Specialist, enableThinking bool) string {
var base string
if enableThinking {
base = "你是一位专家级AI数字员工,使用 thinking 模式。在回答前先进行多步思考,将复杂任务拆解为可执行的步骤,给出深度分析。回答要专业、结构化、有洞察力。"
} else {
base = "你是一位专业的AI通用助手,回答简洁直接,不展开思考过程,快速响应用户问题。"
}
section := specialistPromptSection(s)
if section == "" {
return base
}
// 选了专员就不再自称「通用助手」——身份冲突会让模型按通用助手的方式答话,
// 这正是「选谁说话都一样」的老毛病。
base = strings.Replace(base, "AI通用助手", "AI数字员工", 1)
return base + "\n\n" + section
}
@@ -0,0 +1,227 @@
package api
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
)
func sampleSpecialist() *model.Specialist {
return &model.Specialist{
Key: "contract-review",
Label: "合同审查专员",
Summary: "审合同、找风险、给红线",
WorkStatus: "3 份待审",
AllowedSkills: `["contract-review","contract-brief"]`,
RuleFileMarkdown: "# 合同审查专员\n\n## 你是谁\n你面对的是要签字的人。",
}
}
// TestSpecialistPromptSectionNil 没有专员时必须返回空串,
// 让调用方能靠「空串 = 不拼接」保持改动前的行为。
func TestSpecialistPromptSectionNil(t *testing.T) {
if got := specialistPromptSection(nil); got != "" {
t.Errorf("专员为 nil 时应返回空串,实际: %q", got)
}
}
// TestSpecialistPromptSectionEmptySpecialist 专员存在但什么都没配,同样不该产出段落,
// 否则 prompt 里会多出一个空壳的【当前专员】把模型带偏。
func TestSpecialistPromptSectionEmptySpecialist(t *testing.T) {
empty := &model.Specialist{Key: "bare", Label: "空壳专员"}
got := specialistPromptSection(empty)
// 身份块本身是有内容的(key + label),所以这里只要求不含技能/说明书两节
if strings.Contains(got, "【可用技能】") {
t.Errorf("没绑技能不该出现【可用技能】: %q", got)
}
if strings.Contains(got, "【岗位说明书】") {
t.Errorf("没写说明书不该出现【岗位说明书】: %q", got)
}
}
// TestSpecialistPromptSectionOrdering 岗位说明书必须排在最后 —— 离用户消息最近,
// 优先级最高,否则模型会按基础角色说明(简洁直接)而不是说明书里的流程来答。
func TestSpecialistPromptSectionOrdering(t *testing.T) {
got := specialistPromptSection(sampleSpecialist())
iIdentity := strings.Index(got, "【当前专员】")
iSkills := strings.Index(got, "【可用技能】")
iRule := strings.Index(got, "【岗位说明书】")
if iIdentity < 0 || iSkills < 0 || iRule < 0 {
t.Fatalf("三段都应出现,实际: %q", got)
}
if !(iIdentity < iSkills && iSkills < iRule) {
t.Errorf("顺序应为 身份 < 技能 < 说明书,实际位置 %d/%d/%d", iIdentity, iSkills, iRule)
}
}
// TestSpecialistPromptSectionCarriesContent 段落里要真的带上说明书正文与技能 key。
func TestSpecialistPromptSectionCarriesContent(t *testing.T) {
s := sampleSpecialist()
got := specialistPromptSection(s)
for _, want := range []string{s.Label, s.Key, s.Summary, "contract-review", "contract-brief", "你面对的是要签字的人"} {
if !strings.Contains(got, want) {
t.Errorf("专员段落里缺少 %q\n实际内容:\n%s", want, got)
}
}
}
// TestBuildAssistantSystemPromptWithoutSpecialistIsUnchanged 守住「纯增量」:
// 没有专员时,system prompt 必须与改动前逐字一致(只有基础角色说明)。
func TestBuildAssistantSystemPromptWithoutSpecialistIsUnchanged(t *testing.T) {
const quickBase = "你是一位专业的AI通用助手,回答简洁直接,不展开思考过程,快速响应用户问题。"
const expertBase = "你是一位专家级AI数字员工,使用 thinking 模式。在回答前先进行多步思考,将复杂任务拆解为可执行的步骤,给出深度分析。回答要专业、结构化、有洞察力。"
if got := buildAssistantSystemPrompt(nil, false); got != quickBase {
t.Errorf("快速模式无专员时 prompt 被改动了:\n实际: %q\n期望: %q", got, quickBase)
}
if got := buildAssistantSystemPrompt(nil, true); got != expertBase {
t.Errorf("专家模式无专员时 prompt 被改动了:\n实际: %q\n期望: %q", got, expertBase)
}
}
// TestBuildAssistantSystemPromptWithSpecialistDropsGenericIdentity
// 选了专员就不该再自称「通用助手」——身份冲突正是「选谁说话都一样」的成因。
func TestBuildAssistantSystemPromptWithSpecialistDropsGenericIdentity(t *testing.T) {
got := buildAssistantSystemPrompt(sampleSpecialist(), false)
if strings.Contains(got, "AI通用助手") {
t.Errorf("选了专员后仍在自称通用助手: %q", got)
}
if !strings.HasPrefix(got, "你是一位专业的AI数字员工") {
t.Errorf("基础角色说明应被换成数字员工,实际开头: %q", got)
}
if !strings.Contains(got, "【当前专员】") {
t.Errorf("缺少专员段落: %q", got)
}
}
// --- 解析专员(需要 DB)---
func setupAPITestDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "api.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
if err := db.AutoMigrate(&model.Specialist{}, &model.WorkerTask{}); err != nil {
t.Fatalf("迁移失败: %v", err)
}
prev := store.DB
store.DB = db
t.Cleanup(func() { store.DB = prev })
for _, s := range []model.Specialist{
{Key: "contract-review", Label: "合同审查专员", State: "active", Tier: "industry", ObjectEntryRoute: "/apps/contract-review"},
{Key: "report-generation", Label: "报告生成专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/report-generation"},
{Key: "retired-one", Label: "已下线专员", State: "inactive", Tier: "generic", ObjectEntryRoute: "/apps/retired"},
{Key: "general-assistant", Label: "通用助手", State: "system", Tier: "generic", ObjectEntryRoute: "/home"},
} {
item := s
if err := store.DB.Create(&item).Error; err != nil {
t.Fatalf("建专员 %s 失败: %v", s.Key, err)
}
}
}
// TestResolveSpecialistFromTask 任务上的专员是权威来源(专员是任务的字段)。
func TestResolveSpecialistFromTask(t *testing.T) {
setupAPITestDB(t)
task := model.WorkerTask{SpecialistKey: "contract-review", Title: "审一份采购合同", Status: "待处理"}
if err := store.DB.Create(&task).Error; err != nil {
t.Fatalf("建任务失败: %v", err)
}
got := resolveSpecialist(SmartAssistantRequest{TaskID: task.ID})
if got == nil || got.Key != "contract-review" {
t.Fatalf("应从任务解析出 contract-review,实际 %v", got)
}
}
// TestResolveSpecialistTaskWinsOverRequest 两者都给且不一致时,以任务上的为准。
func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) {
setupAPITestDB(t)
task := model.WorkerTask{SpecialistKey: "contract-review", Title: "审一份采购合同", Status: "待处理"}
if err := store.DB.Create(&task).Error; err != nil {
t.Fatalf("建任务失败: %v", err)
}
got := resolveSpecialist(SmartAssistantRequest{TaskID: task.ID, SpecialistKey: "report-generation"})
if got == nil || got.Key != "contract-review" {
t.Fatalf("任务上的专员应优先,实际 %v", got)
}
}
// TestResolveSpecialistFallsBackToRequest 还没有任务时用请求里带的 key。
func TestResolveSpecialistFallsBackToRequest(t *testing.T) {
setupAPITestDB(t)
got := resolveSpecialist(SmartAssistantRequest{SpecialistKey: "report-generation"})
if got == nil || got.Key != "report-generation" {
t.Fatalf("应回退到请求里的 specialist_key,实际 %v", got)
}
}
// TestResolveSpecialistUnknownKeyIsNotFatal 未知 key / 已下线专员都不能报错,
// 只能退化成 nil(通用助手),否则用户会把对话失败归咎于平台坏了。
func TestResolveSpecialistUnknownKeyIsNotFatal(t *testing.T) {
setupAPITestDB(t)
cases := []struct {
name string
req SmartAssistantRequest
}{
{"不存在的 key", SmartAssistantRequest{SpecialistKey: "no-such-specialist"}},
{"已下线专员", SmartAssistantRequest{SpecialistKey: "retired-one"}},
{"任务不存在", SmartAssistantRequest{TaskID: 999999}},
{"什么都没给", SmartAssistantRequest{}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := resolveSpecialist(c.req); got != nil {
t.Errorf("应返回 nil(退回通用助手),实际 %s", got.Key)
}
})
}
}
// TestResolveSpecialistAllowsSystemAssistant 通用助手是 state=system,
// 它必须能被解析到(否则默认入口没有岗位说明书)。
func TestResolveSpecialistAllowsSystemAssistant(t *testing.T) {
setupAPITestDB(t)
got := resolveSpecialist(SmartAssistantRequest{SpecialistKey: "general-assistant"})
if got == nil || got.Key != "general-assistant" {
t.Fatalf("state=system 的通用助手应可解析,实际 %v", got)
}
}
// TestResolveSpecialistTaskWithoutSpecialistFallsBack 任务存在但没挂专员时,
// 仍应回退到请求里的 key,而不是直接返回 nil。
func TestResolveSpecialistTaskWithoutSpecialistFallsBack(t *testing.T) {
setupAPITestDB(t)
task := model.WorkerTask{SpecialistKey: "no-such-specialist", Title: "孤儿任务", Status: "待处理"}
if err := store.DB.Create(&task).Error; err != nil {
t.Fatalf("建任务失败: %v", err)
}
got := resolveSpecialist(SmartAssistantRequest{TaskID: task.ID, SpecialistKey: "report-generation"})
if got == nil || got.Key != "report-generation" {
t.Fatalf("任务专员失效时应回退到请求里的 key,实际 %v", got)
}
}
@@ -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"`
@@ -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
}
@@ -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("重复绑定应报错(首个为主技能的语义会变得不确定)")
}
})
}
@@ -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"`
@@ -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 {
@@ -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)
}
@@ -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
}
@@ -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)
}
})
}
@@ -0,0 +1,625 @@
<template>
<div class="catalog-detail-page">
<section v-if="showBackButton" class="detail-toolbar">
<button class="back-btn" type="button" @click="emit('close')">返回目录</button>
</section>
<section class="detail-modal-card" :style="{ '--accent': detail.color || '#409eff' }">
<div class="detail-header">
<div class="detail-avatar">{{ detail.avatar }}</div>
<div class="detail-header-main">
<div class="detail-title">{{ detail.title }}</div>
<div v-if="detail.display_code || detail.eailogic_code" class="detail-code-row">
<span v-if="detail.display_code" class="detail-code-chip">{{ detail.display_code }}</span>
<span v-if="detail.eailogic_code" class="detail-code-chip subtle">{{ detail.eailogic_code }}</span>
</div>
<div v-if="detail.subtitle" class="detail-subtitle">{{ detail.subtitle }}</div>
<div v-if="detail.metaText" class="detail-meta-text">{{ detail.metaText }}</div>
</div>
<span v-if="detail.badge" class="detail-badge">{{ detail.badge }}</span>
</div>
<p class="detail-description">{{ detail.description }}</p>
<div v-if="detail.tags.length" class="detail-tags">
<span v-for="tag in detail.tags" :key="tag" class="detail-tag">{{ tag }}</span>
</div>
<section v-if="detail.sceneText" class="detail-section">
<div class="detail-section-title">{{ detail.sceneTitle }}</div>
<p class="detail-section-text">{{ detail.sceneText }}</p>
</section>
<section
v-for="section in detail.extraSections"
:key="section.title"
class="detail-section"
>
<div class="detail-section-title">{{ section.title }}</div>
<div class="detail-bullet-list">
<div v-for="item in section.items" :key="item" class="detail-bullet-item">{{ item }}</div>
</div>
</section>
<section v-if="detail.prompts.length" class="detail-section">
<div class="detail-section-title">{{ detail.promptTitle }}</div>
<div class="detail-prompt-list">
<button
v-for="prompt in detail.prompts"
:key="prompt"
class="detail-prompt-button"
type="button"
>
“ {{ prompt }} ”
</button>
</div>
</section>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { listConnectors } from '@/api/connector'
import { getSpecialistByKey } from '@/api/specialist'
import { availableSkills, businessApps } from '@/config/workbench'
import { productizedApps } from '@/config/productizedApps'
const props = defineProps({
catalogType: {
type: String,
required: true,
},
objectKey: {
type: String,
required: true,
},
showBackButton: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close'])
const remoteConnectors = ref([])
// 专员的技能绑定与信源只有后端有(前端 businessApps 是静态配置)。
// 拉不到就退回只显示前端已知的内容,页面不会因此空掉或报错。
const remoteSpecialist = ref(null)
const catalogApps = [
{
key: 'knowledge-hub',
display_code: '应01',
eailogic_code: 'EAI-A-01',
label: '知识库',
marketTag: '内建 APP',
workerType: 'adw',
tier: 'platform',
color: '#5b7cff',
summary: '组织级默认存在的知识底座 APP,为专家、技能与其它 APP 提供知识支持。',
description: '知识库是平台里的知识底座,用来沉淀资料、规则和证据,并为专家、技能和 APP 提供统一知识支持。',
prompts: ['我想先整理这批知识资料', '帮我看看这部分内容适合怎么入库', '知识库更适合承接哪些任务'],
tags: ['知识沉淀', '证据引用', '组织底座'],
},
{
key: 'internal-exam',
display_code: '应02',
eailogic_code: 'EAI-A-02',
label: '内部考试APP',
marketTag: '长程 APP',
workerType: 'adw',
tier: 'business',
color: '#29b36a',
summary: '围绕练习、自测、正式考试、记录、证书和岗位应学组织的内部考试 APP。',
description: '内部考试 APP 用来承接训练、练习、考试、成绩记录和证书结果,适合需要完整过程管理的学习场景。',
prompts: ['我想组织一轮内部考试', '帮我看看这个考试流程怎么搭', '这个场景适合放到内部考试 APP 吗'],
tags: ['训练流程', '考试记录', '结果沉淀'],
},
{
key: 'internal-training',
display_code: '应03',
eailogic_code: 'EAI-A-03',
label: '内部培训APP',
marketTag: '长程 APP',
workerType: 'worker',
tier: 'business',
color: '#9b6bff',
summary: '统一承接销售培训、产品知识和公司介绍三个培训模块的内部培训 APP。',
description: '内部培训 APP 用来把销售培训、产品知识和公司介绍收在同一个对象下面,避免把同一条培训链拆成多个 APP。',
prompts: ['我想搭一个内部培训 APP', '帮我梳理销售培训、产品知识和公司介绍的关系', '这个培训主题应该挂到内部培训 APP 吗'],
tags: ['课程组织', '产品知识', '组织导览'],
},
].concat(productizedApps.map((item) => ({
key: item.key,
display_code: item.display_code,
eailogic_code: item.eailogic_code,
label: item.label,
marketTag: item.badge,
workerType: item.skillKey ? 'worker' : 'adw',
tier: 'business',
color: item.color,
summary: item.summary,
description: item.description,
prompts: item.prompts,
tags: item.tags,
})))
const resolvedCatalogType = computed(() => String(props.catalogType || 'specialists'))
const resolvedObjectKey = computed(() => decodeURIComponent(String(props.objectKey || '')))
function withFallbackCodes(items, { displayPrefix, logicPrefix, start = 1 }) {
return items.map((item, index) => {
const sequence = String(start + index).padStart(2, '0')
return {
...item,
display_code: item.display_code || `${displayPrefix}${sequence}`,
eailogic_code: item.eailogic_code || `EAI-${logicPrefix}-${sequence}`,
}
})
}
const detail = computed(() => {
if (resolvedCatalogType.value === 'skills') {
const skill = availableSkills.find((item) => item.key === resolvedObjectKey.value)
if (!skill) return notFoundDetail('技能')
return {
title: skill.label,
display_code: skill.display_code,
eailogic_code: skill.eailogic_code,
subtitle: skill.roleCard?.relationshipToUser || '你的技能助手',
metaText: '已安装 · 在对话里直接挂载',
badge: '技能',
avatar: skill.label.slice(0, 1),
color: skill.color || '#409eff',
description:
skill.roleCard?.greeting ||
`这个技能主要负责 ${skill.caption || skill.label},适合你已经知道目标、希望直接开工的时候使用。`,
tags: normalizeArray(skill.capabilityTags).length
? normalizeArray(skill.capabilityTags)
: buildTags(skill.caption, skill.roleCard?.tagline),
sceneTitle: '适合什么时候用',
sceneText: buildSkillUseCase(skill),
extraSections: buildSkillExtraSections(skill),
promptTitle: '你可以直接这样发起',
prompts: normalizeArray(skill.roleCard?.starterPrompts),
}
}
if (resolvedCatalogType.value === 'apps') {
const app = catalogApps.find((item) => item.key === resolvedObjectKey.value)
if (!app) return notFoundDetail('APP')
return {
title: app.label,
display_code: app.display_code,
eailogic_code: app.eailogic_code,
subtitle: app.workerType === 'adw' ? '更偏向组织流程与协同推进' : '更偏向直接执行具体内容',
metaText: `${app.marketTag} · ${app.workerType === 'adw' ? '流程推进型' : '执行处理型'}`,
badge: 'APP',
avatar: app.label.slice(0, 1),
color: app.color || '#7c8cff',
description: app.description,
tags: normalizeArray(app.tags),
sceneTitle: '适合什么时候用',
sceneText: app.summary,
promptTitle: 'APP 帮你做',
prompts: normalizeArray(app.prompts),
}
}
if (resolvedCatalogType.value === 'connectors') {
const connector = withFallbackCodes(remoteConnectors.value, { displayPrefix: '连', logicPrefix: 'C' })
.find((item) => item.key === resolvedObjectKey.value)
if (!connector) return notFoundDetail('连接器')
return {
title: connector.label || connector.key,
display_code: connector.display_code,
eailogic_code: connector.eailogic_code,
subtitle: connector.mode === 'live' ? '已经接入真实实例' : '当前还是目录项',
metaText: `${connector.mode === 'live' ? '已接入' : '待接入'} · ${connectorKind(connector.direction)}`,
badge: '连接器',
avatar: (connector.label || connector.key || '连').slice(0, 1),
color: connector.mode === 'live' ? '#67c23a' : '#909399',
description:
connector.mode === 'live'
? '这个连接器已经接入真实系统,可以把外部对象或数据源带进平台里使用。'
: '这个连接器现在还是目录项,用来说明未来可以接入什么系统、访问哪些对象。',
tags: normalizeArray(connector.objects).slice(0, 6),
sceneTitle: '适合什么时候看',
sceneText:
connector.mode === 'live'
? '当你关心它能访问哪些对象、数据往哪个方向流,以及是否已经能被任务链路调用时,就适合来看这个连接器。'
: '当你在做目录规划或接入设计,想确认未来可连接什么系统时,就适合来看这个连接器。',
promptTitle: '你可以这样查看',
extraSections: [],
prompts: normalizeArray([
`这个连接器现在能访问哪些对象`,
`它适合连接什么系统`,
`它现在是已接入还是待接入`,
]),
}
}
const specialist = businessApps.find((item) => item.key === resolvedObjectKey.value)
if (!specialist) return notFoundDetail('专员')
return {
title: specialist.label,
display_code: specialist.display_code,
eailogic_code: specialist.eailogic_code,
subtitle: specialist.roleCard?.relationshipToUser || '你的协作搭档',
metaText: `${specialist.tier === 'industry' ? '行业专员' : '通用专员'} · ${specialist.workerType === 'adw' ? '流程推进型' : '执行处理型'} · ${specialist.marketTag || '可用'}`,
badge: '专员',
avatar: specialist.label.slice(0, 1),
color: specialist.color || '#409eff',
description:
specialist.roleCard?.greeting ||
`这个专员主要负责 ${specialist.roleCard?.tagline || specialist.label},可以陪你把事情往前推进。`,
tags: buildTags(specialist.roleCard?.tagline, specialist.summary),
sceneTitle: '适合什么时候用',
sceneText: buildSpecialistUseCase(specialist),
extraSections: buildSpecialistExtraSections(remoteSpecialist.value),
promptTitle: '专员帮你做',
prompts: normalizeArray(specialist.roleCard?.starterPrompts),
}
})
onMounted(async () => {
if (resolvedCatalogType.value === 'connectors') {
try {
const res = await listConnectors()
remoteConnectors.value = Array.isArray(res?.data) ? res.data : []
} catch (error) {
console.warn('load connector detail failed', error)
}
return
}
if (resolvedCatalogType.value === 'specialists') {
const key = resolvedObjectKey.value.trim()
if (!key) return
try {
const res = await getSpecialistByKey(key)
remoteSpecialist.value = res?.data || null
} catch (error) {
console.warn('load specialist detail failed', error)
}
}
})
function connectorKind(direction) {
if (direction === 'output') return '输出连接器'
if (direction === 'input') return '输入连接器'
return '双向连接器'
}
function notFoundDetail(typeLabel) {
return {
title: `${typeLabel}不存在`,
subtitle: '',
metaText: '',
badge: '未找到',
avatar: '?',
color: '#909399',
description: `当前目录中不存在 key = ${resolvedObjectKey.value} 的${typeLabel}对象。`,
tags: [],
sceneTitle: '',
sceneText: '',
extraSections: [],
promptTitle: '',
prompts: [],
}
}
function normalizeArray(values) {
return (Array.isArray(values) ? values : [])
.map((item) => String(item || '').trim())
.filter(Boolean)
}
function buildTags(...sources) {
const collected = sources
.flatMap((source) => splitKeywords(source))
.filter(Boolean)
return [...new Set(collected)].slice(0, 6)
}
function splitKeywords(value) {
return String(value || '')
.split(/[\/、,,和与·]/)
.map((item) => item.trim())
.filter((item) => item && item.length <= 12)
}
function buildSpecialistUseCase(specialist) {
const openingPrompt = String(specialist.roleCard?.openingPrompt || '').trim()
if (openingPrompt) {
return `当你已经有目标、材料,或者已经知道当前卡点时,就适合找它介入。${openingPrompt}`
}
return '当你想找一个专门的对象来协助推进、整理和交付时,就适合让它介入。'
}
function buildSkillUseCase(skill) {
const openingPrompt = String(skill.roleCard?.openingPrompt || '').trim()
const caption = String(skill.caption || skill.label || '').trim()
if (openingPrompt) {
return `当你已经明确想做“${caption}”,并且希望它直接开工、尽快给出产物时,就适合挂载这个技能。${openingPrompt}`
}
return `当你希望围绕“${caption}”直接进入执行,而不是先做大量讨论时,就适合挂载这个技能。`
}
// buildSpecialistExtraSections 组装专员详情页的补充段落。
// 数据全部来自后端 specialist 记录(技能绑定 / 信源 / 权限),
// 拉不到就返回空数组 —— 与改动前表现一致,不会因为接口失败而露出半个页面。
function buildSpecialistExtraSections(remote) {
if (!remote) return []
const sections = []
// 它会调用的技能:key 是后端绑定,label 从前端技能目录取。
// 取不到 label 就直接显示 key —— 宁可露出一个生 key,也不要静默少一项,
// 少一项会让「专员为什么没做这件事」变得无法解释。
const skillKeys = parseAllowedSkills(remote.allowed_skills)
if (skillKeys.length) {
sections.push({
title: '它会调用的技能',
items: skillKeys.map((key) => {
const label = availableSkills.find((item) => item.key === key)?.label
return label ? `${label}(${key})` : key
}),
})
}
// 信源与权限在库里是「、」分隔的整串
const infoSources = splitChineseList(remote.info_sources)
if (infoSources.length) {
sections.push({ title: '它读哪些信源', items: infoSources })
}
const permissions = splitChineseList(remote.permission_scope)
if (permissions.length) {
sections.push({ title: '它可以动什么', items: permissions })
}
return sections
}
function parseAllowedSkills(raw) {
if (Array.isArray(raw)) return raw.map((item) => String(item || '').trim()).filter(Boolean)
const text = String(raw || '').trim()
if (!text) return []
try {
const parsed = JSON.parse(text)
return Array.isArray(parsed)
? parsed.map((item) => String(item || '').trim()).filter(Boolean)
: []
} catch {
// 后端存的是 JSON 数组;解析不了说明数据坏了,按「没绑定」处理,
// 不要在详情页抛错。
return []
}
}
function splitChineseList(raw) {
return String(raw || '')
.split(/[、,,\n]/)
.map((item) => item.trim())
.filter(Boolean)
}
function buildSkillExtraSections(skill) {
const workflow = normalizeArray((skill.workflowSchema || []).map((item) => item.title))
const outputs = normalizeArray((skill.artifactSchema || []).map((item) => {
const title = String(item.title || '').trim()
const fileFormat = String(item.fileFormat || '').trim()
return fileFormat ? `${title}(.${fileFormat})` : title
}))
const sections = []
if (workflow.length) sections.push({ title: '它会这样推进', items: workflow })
if (outputs.length) sections.push({ title: '它会优先产出', items: outputs })
return sections
}
</script>
<style scoped>
.catalog-detail-page {
padding: 11px;
}
.detail-toolbar {
margin-bottom: 7px;
}
.back-btn {
border: none;
background: transparent;
color: #2b63d9;
border-radius: 999px;
padding: 6px 11px;
cursor: pointer;
font-size: 12px;
}
.detail-modal-card {
padding: 15px;
border-radius: 18px;
background: #fff;
}
.detail-header {
display: flex;
align-items: flex-start;
gap: 10px;
padding-bottom: 11px;
border-bottom: 1px solid #eef2f7;
}
.detail-avatar {
width: 42px;
height: 42px;
flex: none;
border-radius: 14px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 14%, white);
color: var(--accent);
font-size: 18px;
font-weight: 700;
}
.detail-header-main {
min-width: 0;
flex: 1;
}
.detail-title {
font-size: 20px;
line-height: 1.25;
font-weight: 700;
color: #1f2329;
}
.detail-subtitle {
margin-top: 3px;
font-size: 12px;
color: #4f5d6b;
}
.detail-code-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.detail-code-chip {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #f3f6fb;
color: #344054;
font-size: 11px;
font-weight: 600;
}
.detail-code-chip.subtle {
background: #f7f8fa;
color: #667085;
font-weight: 500;
}
.detail-meta-text {
margin-top: 4px;
font-size: 11px;
color: #8a96a3;
}
.detail-badge {
flex: none;
border-radius: 999px;
padding: 4px 9px;
background: color-mix(in srgb, var(--accent) 12%, white);
color: var(--accent);
font-size: 11px;
font-weight: 600;
margin-top: 2px;
}
.detail-description {
margin: 11px 0 0;
font-size: 12px;
line-height: 1.65;
color: #3f4b57;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.detail-tag {
border-radius: 999px;
padding: 4px 9px;
border: 1px solid #e7ecf5;
background: transparent;
color: #58677a;
font-size: 11px;
}
.detail-section {
margin-top: 14px;
}
.detail-section-title {
font-size: 13px;
font-weight: 600;
color: #1f2329;
}
.detail-section-text {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.65;
color: #4f5d6b;
}
.detail-prompt-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.detail-bullet-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.detail-bullet-item {
font-size: 12px;
line-height: 1.6;
color: #4f5d6b;
}
.detail-bullet-item::before {
content: '· ';
color: #8a96a3;
}
.detail-prompt-button {
width: 100%;
text-align: left;
border: 1px solid #e7ecf5;
border-radius: 12px;
padding: 8px 10px;
background: transparent;
color: #2f3a45;
font-size: 12px;
line-height: 1.55;
cursor: pointer;
transition: border-color 0.16s ease, transform 0.16s ease;
}
.detail-prompt-button:hover {
border-color: color-mix(in srgb, var(--accent) 28%, #d9e3ef);
transform: translateY(-1px);
}
@media (max-width: 640px) {
.catalog-detail-page {
padding: 8px;
}
.detail-header {
flex-wrap: wrap;
}
.detail-modal-card {
padding: 12px;
}
}
</style>
File diff suppressed because it is too large Load Diff