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 ( const (
CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点) CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点) CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
CapabilityImageGen = "image_gen" // 文生图(按次扣点) CapabilityImageGen = "image_gen" // 文生图(按次扣点)
CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计) CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计) CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
) )
@@ -26,7 +26,7 @@ const (
var CapabilityCredits = map[string]int{ var CapabilityCredits = map[string]int{
CapabilityAIChat: 1, CapabilityAIChat: 1,
CapabilityTextGen: 1, CapabilityTextGen: 1,
CapabilityImageGen: 1, CapabilityImageGen: 1,
CapabilityEmbed: 0, CapabilityEmbed: 0,
CapabilityEssayGrade: 0, CapabilityEssayGrade: 0,
} }
@@ -41,16 +41,18 @@ func ComputeCredits(capability string, success bool) int {
// LogEntry 一次 AI 调用的审计入参 // LogEntry 一次 AI 调用的审计入参
type LogEntry struct { type LogEntry struct {
UserID uint UserID uint
Capability string Capability string
Provider string // SpecialistKey 本次调用以哪个专员的身份进行;空 = 通用助手
RouteID string SpecialistKey string
Model string Provider string
TokensInput int AIRouteID string
TokensOutput int Model string
Success bool TokensInput int
ErrorMessage string TokensOutput int
LatencyMs int Success bool
ErrorMessage string
LatencyMs int
} }
// LogCall 写 ai_call_log;成功且需扣点时从用户余额扣点。审计写入失败不阻断主流程。 // LogCall 写 ai_call_log;成功且需扣点时从用户余额扣点。审计写入失败不阻断主流程。
@@ -63,8 +65,9 @@ func LogCall(e LogEntry) {
rec := model.AiCallLog{ rec := model.AiCallLog{
UserID: e.UserID, UserID: e.UserID,
Capability: e.Capability, Capability: e.Capability,
SpecialistKey: e.SpecialistKey,
Provider: e.Provider, Provider: e.Provider,
RouteID: e.RouteID, AIRouteID: e.AIRouteID,
Model: e.Model, Model: e.Model,
TokensInput: e.TokensInput, TokensInput: e.TokensInput,
TokensOutput: e.TokensOutput, TokensOutput: e.TokensOutput,
+113 -89
View File
@@ -53,13 +53,13 @@ type Client struct {
} }
// NewClient 从 RouteConfig 创建客户端 // NewClient 从 RouteConfig 创建客户端
func NewClient(route *config.RouteConfig) *Client { func NewClient(aiRoute *config.RouteConfig) *Client {
return &Client{ return &Client{
baseURL: strings.TrimRight(route.BaseURL, "/"), baseURL: strings.TrimRight(aiRoute.BaseURL, "/"),
apiKey: route.APIKey, apiKey: aiRoute.APIKey,
model: route.Model, model: aiRoute.Model,
maxTokens: route.MaxTokens, maxTokens: aiRoute.MaxTokens,
temperature: route.Temperature, temperature: aiRoute.Temperature,
hc: &http.Client{Timeout: 120 * time.Second}, 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() { for k, v := range c.headers() {
req.Header.Set(k, v) 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) resp, err := c.hc.Do(req)
if err != nil { if err != nil {
return nil, err 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 return resp, nil
} }
@@ -260,24 +226,6 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body) 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)) 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 依次尝试主路由 + 回退链,首个成功返回 // GenerateWithFallback 依次尝试主路由 + 回退链,首个成功返回
func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (string, error) { func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (string, error) {
// 构建路由链 aiRouteChain, err := buildRouteChain(primary)
chain := []*config.RouteConfig{primary} if err != nil {
fallbacks, err := config.GetFallbackRoutes(primary.RouteID) return "", err
if err == nil && len(fallbacks) > 0 {
chain = append(chain, fallbacks...)
} }
var lastErr error var lastErr error
for _, route := range chain { for _, aiRoute := range aiRouteChain {
client := NewClient(route) client := NewClient(aiRoute)
start := time.Now()
content, err := client.Generate(messages) content, err := client.Generate(messages)
if err == nil { 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 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) return "", fmt.Errorf("所有路由均失败: %w", lastErr)
} }
// GenerateFullWithFallback 非流式 + 回退链:返回结果与「实际命中」的路由(用于审计) // GenerateFullWithFallback 非流式 + 回退链:返回结果与「实际命中」的路由(用于审计)
func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (*ChatResult, *config.RouteConfig, error) { func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (*ChatResult, *config.RouteConfig, error) {
chain := []*config.RouteConfig{primary} aiRouteChain, err := buildRouteChain(primary)
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 { if err != nil {
chain = append(chain, fallbacks...) return nil, nil, err
} }
var lastErr error var lastErr error
for _, route := range chain { for _, aiRoute := range aiRouteChain {
client := NewClient(route) client := NewClient(aiRoute)
start := time.Now()
res, err := client.GenerateFull(messages) res, err := client.GenerateFull(messages)
if err == nil { if err == nil {
res.Provider = route.Provider config.ReportRouteHealth(config.RouteHealth{
return res, route, nil 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) return nil, nil, fmt.Errorf("所有路由均失败: %w", lastErr)
} }
// GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由 // GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由
func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) { func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) {
chain := []*config.RouteConfig{primary} aiRouteChain, err := buildRouteChain(primary)
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 { if err != nil {
chain = append(chain, fallbacks...) return nil, err
} }
var lastErr error var lastErr error
for _, route := range chain { for _, aiRoute := range aiRouteChain {
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" { if requiresAPIKey(aiRoute) && strings.TrimSpace(aiRoute.APIKey) == "" {
lastErr = fmt.Errorf("[%s] 未配置 API Key", route.RouteID) 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 continue
} }
client := NewClient(route) client := NewClient(aiRoute)
start := time.Now()
if err := client.GenerateStream(messages, onChunk); err == nil { 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 { } 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) return nil, fmt.Errorf("所有路由均失败: %w", lastErr)
} }
func requiresAPIKey(route *config.RouteConfig) bool { func requiresAPIKey(aiRoute *config.RouteConfig) bool {
if route == nil { if aiRoute == nil {
return false 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") { if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true return true
} }
provider := strings.ToLower(strings.TrimSpace(route.Provider)) provider := strings.ToLower(strings.TrimSpace(aiRoute.Provider))
return provider == "openrouter" || provider == "openai" return provider == "openrouter" || provider == "openai"
} }
@@ -455,7 +479,7 @@ func ResolveLLM(cfg *config.Config) (LLMConfig, bool) {
modelName := get("llm_model", cfg.LLMModel) modelName := get("llm_model", cfg.LLMModel)
embedModel := get("embed_model", cfg.EmbedModel) embedModel := get("embed_model", cfg.EmbedModel)
// DB 空时降级到 JSON secrets(flat 格式,pj034 兼容) // DB 空时降级到 JSON secrets(flat 格式)
if baseURL == "" || apiKey == "" { if baseURL == "" || apiKey == "" {
if baseURL == "" { if baseURL == "" {
baseURL = config.GetProviderBaseURL("ollama") 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) fieldDesc := buildFieldDescription(req.Fields)
// 调用 AI 提取 // 调用 AI 提取(此前传 nil 会让 AI 层空指针,见 helpers.generateWithDefaultRoute)
content, err := ai.GenerateWithFallback(nil, []ai.Message{ content, err := generateWithDefaultRoute([]ai.Message{
{Role: "system", Content: "你是一个字段提取专家。请从用户输入的内容中提取以下字段信息,以JSON格式返回:\n" + fieldDesc}, {Role: "system", Content: "你是一个字段提取专家。请从用户输入的内容中提取以下字段信息,以JSON格式返回:\n" + fieldDesc},
{Role: "user", Content: req.Content}, {Role: "user", Content: req.Content},
}) })
@@ -66,7 +66,6 @@ func ReviewContract(c *gin.Context) {
web.Fail(c, web.NewBadRequest("content 必填")) web.Fail(c, web.NewBadRequest("content 必填"))
return return
} }
req.RiskStandard = req.RiskStandard
if req.RiskStandard == "" { if req.RiskStandard == "" {
req.RiskStandard = "standard" req.RiskStandard = "standard"
} }
@@ -357,7 +356,9 @@ func checkRisks(sections []ContractSection) []RiskItem {
// aiDeepReview AI 深度审查 // aiDeepReview AI 深度审查
func aiDeepReview(req ContractReviewRequest) []RiskItem { 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: "system", Content: "你是一位资深法务专家。请审查以下合同文本,识别法律风险。" +
"重点关注:条款完整性、权责对等性、违约责任、争议解决、不可抗力、保密条款、知识产权。"}, "重点关注:条款完整性、权责对等性、违约责任、争议解决、不可抗力、保密条款、知识产权。"},
{Role: "user", Content: req.Content}, {Role: "user", Content: req.Content},
@@ -1,10 +1,13 @@
package api package api
import ( import (
"fmt"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/web" "eai_agentplatform/backend/internal/web"
) )
@@ -17,3 +20,19 @@ func parseID(c *gin.Context, name string) (uint, bool) {
} }
return uint(id), true 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" "github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/ai" "eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model" "eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web" "eai_agentplatform/backend/internal/web"
) )
// SmartAssistantRequest POST /api/assistant/chat —— 智能助手请求 // SmartAssistantRequest POST /api/assistant/chat —— 通用助手请求
type SmartAssistantRequest struct { type SmartAssistantRequest struct {
Message string `json:"message"` Message string `json:"message"`
Context string `json:"context"` Context string `json:"context"`
Mode string `json:"mode"` Mode string `json:"mode"`
TaskID uint `json:"task_id"` AIRouteID string `json:"ai_route_id"`
Temperature float64 `json:"temperature"` // TaskID 当前会话挂在哪条事项上。带上它就能反查事项的专员,
Stream bool `json:"stream"` // 让这次回答以那个专员的身份说出来(见 resolveSpecialist)。
TaskID uint `json:"task_id"`
// SpecialistKey 直接指定专员,用于还没有事项的新会话。
// 与 TaskID 同时给出时以事项上的专员为准。
SpecialistKey string `json:"specialist_key"`
Temperature float64 `json:"temperature"`
Stream bool `json:"stream"`
} }
// AssistantMessage 助手消息 // AssistantMessage 助手消息
type AssistantMessage struct { type AssistantMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
Type string `json:"type"` Type string `json:"type"`
} }
// TaskPlan 任务拆解结果 // TaskPlan 任务拆解结果
type TaskPlan struct { type TaskPlan struct {
TaskID uint `json:"task_id"` TaskID uint `json:"task_id"`
Title string `json:"title"` Title string `json:"title"`
Steps []Step `json:"steps"` Steps []Step `json:"steps"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
// Step 任务步骤 // Step 任务步骤
@@ -48,17 +55,16 @@ type Step struct {
Order int `json:"order"` Order int `json:"order"`
} }
// SmartAssistantResult 智能助手响应 // SmartAssistantResult 通用助手响应
type SmartAssistantResult struct { type SmartAssistantResult struct {
Message AssistantMessage `json:"message"` Message AssistantMessage `json:"message"`
TaskPlan *TaskPlan `json:"task_plan,omitempty"` TaskPlan *TaskPlan `json:"task_plan,omitempty"`
IsPlan bool `json:"is_plan"` IsPlan bool `json:"is_plan"`
IsCopy bool `json:"is_copy"` IsExpert bool `json:"is_expert"`
IsExtract bool `json:"is_extract"` Stream bool `json:"stream"`
Stream bool `json:"stream"`
} }
// Chat POST /api/assistant/chat —— 智能助手对话入口 // Chat POST /api/assistant/chat —— 通用助手对话入口
func Chat(c *gin.Context) { func Chat(c *gin.Context) {
user := middleware.CurrentUser(c) user := middleware.CurrentUser(c)
if user == nil { if user == nil {
@@ -70,39 +76,36 @@ func Chat(c *gin.Context) {
web.Fail(c, web.NewBadRequest("message 必填")) web.Fail(c, web.NewBadRequest("message 必填"))
return return
} }
req.Mode = req.Mode
if req.Mode == "" { if req.Mode == "" {
req.Mode = "chat" req.Mode = "quick"
} }
req.Temperature = req.Temperature
if req.Temperature == 0 { if req.Temperature == 0 {
req.Temperature = 0.7 req.Temperature = 0.7
} }
result := processAssistantMessage(user.ID, req) // 专员只解析一次,供 prompt 组装与审计日志共用
logAssistantMessage(user.ID, req.Message, result) specialist := resolveSpecialist(req)
result := processAssistantMessage(user.ID, req, specialist)
logAssistantMessage(user.ID, req.Message, result, specialist)
web.OK(c, result) web.OK(c, result)
} }
// processAssistantMessage 处理智能助手消息 // processAssistantMessage 处理通用助手消息
func processAssistantMessage(userID uint, req SmartAssistantRequest) SmartAssistantResult { func processAssistantMessage(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
switch req.Mode { switch req.Mode {
case "chat": case "quick":
return handleChat(userID, req) return handleQuick(userID, req, specialist)
case "task": case "expert":
return handleTaskGeneration(userID, req) return handleExpert(userID, req, specialist)
case "copy":
return handleCopyWriting(userID, req)
case "task_plan":
return handleTaskPlan(userID, req)
default: default:
return handleChat(userID, req) return handleQuick(userID, req, specialist)
} }
} }
// handleChat 普通对话模式 // handleQuick 快速模式:直出回答,不开启 thinking 和多步规划
func handleChat(userID uint, req SmartAssistantRequest) SmartAssistantResult { func handleQuick(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
aiResponse := callAssistantAI(userID, req) aiResponse := callAssistantAI(userID, req, specialist, false)
return SmartAssistantResult{ return SmartAssistantResult{
Message: AssistantMessage{ Message: AssistantMessage{
Role: "assistant", Role: "assistant",
@@ -110,70 +113,49 @@ func handleChat(userID uint, req SmartAssistantRequest) SmartAssistantResult {
Timestamp: time.Now().Format("2006-01-02 15:04:05"), Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "text", Type: "text",
}, },
IsPlan: false, IsPlan: false,
IsCopy: false, IsExpert: 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,
Stream: req.Stream, Stream: req.Stream,
} }
} }
// callAssistantAI 调用AI生成对话回复 // handleExpert 专家模式:开启 thinking,进行多步智能体规划
func callAssistantAI(userID uint, req SmartAssistantRequest) string { func handleExpert(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
content, err := ai.GenerateWithFallback(nil, []ai.Message{ // 先用 expert system prompt 调用 LLM 生成多步规划和回复
{Role: "system", Content: "你是一位专业的AI智能助手,能够提供各类办公场景的辅助,包括文档翻译、文案校对、文案生成、任务拆解等。请根据用户的问题提供专业、简洁的回答。"}, 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}, {Role: "user", Content: req.Message},
}) })
if err != nil { if err != nil {
@@ -183,8 +165,7 @@ func callAssistantAI(userID uint, req SmartAssistantRequest) string {
} }
// generateTaskPlan 生成任务拆解 // generateTaskPlan 生成任务拆解
func generateTaskPlan(userID uint, prompt string) *TaskPlan { func generateTaskPlan(prompt string) *TaskPlan {
_ = userID
plan := &TaskPlan{ plan := &TaskPlan{
Title: prompt, Title: prompt,
Status: "pending", Status: "pending",
@@ -198,24 +179,16 @@ func generateTaskPlan(userID uint, prompt string) *TaskPlan {
return plan 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 记录助手消息 // 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{ store.DB.Create(&model.AiCallLog{
UserID: userID, UserID: userID,
Capability: "assistant_chat", Capability: "assistant_chat",
Status: "success", SpecialistKey: specialistKey,
Status: "success",
}) })
} }
@@ -34,7 +34,7 @@ func ListSpecialists(c *gin.Context) {
default: default:
q = q.Where("state = ?", state) q = q.Where("state = ?", state)
} }
// state = system 的内置记录(通用智能助手)是任务的默认归属,不是可安装的专员, // state = system 的内置记录(通用助手)是任务的默认归属,不是可安装的专员,
// 任何常规列表都不该带出它 —— 只有显式 state=system 才查得到。 // 任何常规列表都不该带出它 —— 只有显式 state=system 才查得到。
if c.Query("state") != "system" { if c.Query("state") != "system" {
q = q.Where("state <> ?", "system") q = q.Where("state <> ?", "system")
@@ -110,26 +110,34 @@ func SpecialistSummary(c *gin.Context) {
} }
type specialistReq struct { type specialistReq struct {
Key string `json:"key"` Key string `json:"key"`
Label string `json:"label"` Label string `json:"label"`
Tier string `json:"tier"` DisplayCode string `json:"display_code"`
WorkerType string `json:"worker_type"` EAILogicCode string `json:"eailogic_code"`
Route string `json:"route"` Tier string `json:"tier"`
Summary string `json:"summary"` WorkerType string `json:"worker_type"`
WorkStatus string `json:"work_status"` ObjectEntryRoute string `json:"object_entry_route"`
RiskLabel string `json:"risk_label"` Summary string `json:"summary"`
Color string `json:"color"` RoleCardJSON string `json:"role_card_json"`
Stage string `json:"stage"` WorkStatus string `json:"work_status"`
Progress int `json:"progress"` RiskLabel string `json:"risk_label"`
MarketTag string `json:"market_tag"` Color string `json:"color"`
Version string `json:"version"` Stage string `json:"stage"`
ConnectorScope string `json:"connector_scope"` Progress int `json:"progress"`
PermissionScope string `json:"permission_scope"` MarketTag string `json:"market_tag"`
ResourceBindings string `json:"resource_bindings"` Version string `json:"version"`
InfoSources string `json:"info_sources"` ConnectorScope string `json:"connector_scope"`
BaseSkills string `json:"base_skills"` PermissionScope string `json:"permission_scope"`
AIAssistance string `json:"ai_assistance"` ResourceBindings string `json:"resource_bindings"`
GeneratedSkills string `json:"generated_skills"` 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"` InputsRecordsJSON string `json:"inputs_records_json"`
LegacySourceRecordsJSON string `json:"source_records_json"` LegacySourceRecordsJSON string `json:"source_records_json"`
PermissionRecordsJSON string `json:"permission_records_json"` PermissionRecordsJSON string `json:"permission_records_json"`
@@ -142,10 +150,13 @@ type specialistReq struct {
func normalizeSpecialistReq(req *specialistReq) { func normalizeSpecialistReq(req *specialistReq) {
req.Key = strings.TrimSpace(req.Key) req.Key = strings.TrimSpace(req.Key)
req.Label = strings.TrimSpace(req.Label) 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.Tier = strings.TrimSpace(req.Tier)
req.WorkerType = strings.TrimSpace(req.WorkerType) 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.Summary = strings.TrimSpace(req.Summary)
req.RoleCardJSON = strings.TrimSpace(req.RoleCardJSON)
req.WorkStatus = strings.TrimSpace(req.WorkStatus) req.WorkStatus = strings.TrimSpace(req.WorkStatus)
req.RiskLabel = strings.TrimSpace(req.RiskLabel) req.RiskLabel = strings.TrimSpace(req.RiskLabel)
req.Color = strings.TrimSpace(req.Color) req.Color = strings.TrimSpace(req.Color)
@@ -159,6 +170,8 @@ func normalizeSpecialistReq(req *specialistReq) {
req.BaseSkills = strings.TrimSpace(req.BaseSkills) req.BaseSkills = strings.TrimSpace(req.BaseSkills)
req.AIAssistance = strings.TrimSpace(req.AIAssistance) req.AIAssistance = strings.TrimSpace(req.AIAssistance)
req.GeneratedSkills = strings.TrimSpace(req.GeneratedSkills) 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.InputsRecordsJSON = strings.TrimSpace(req.InputsRecordsJSON)
req.LegacySourceRecordsJSON = strings.TrimSpace(req.LegacySourceRecordsJSON) req.LegacySourceRecordsJSON = strings.TrimSpace(req.LegacySourceRecordsJSON)
if req.InputsRecordsJSON == "" { if req.InputsRecordsJSON == "" {
@@ -172,8 +185,8 @@ func normalizeSpecialistReq(req *specialistReq) {
func validateSpecialistReq(req *specialistReq) *web.AppError { func validateSpecialistReq(req *specialistReq) *web.AppError {
normalizeSpecialistReq(req) normalizeSpecialistReq(req)
if req.Key == "" || req.Label == "" || req.Tier == "" || req.Route == "" { if req.Key == "" || req.Label == "" || req.Tier == "" || req.ObjectEntryRoute == "" {
return web.NewBadRequest("key、label、tier、route 为必填") return web.NewBadRequest("key、label、tier、object_entry_route 为必填")
} }
if req.Tier != "generic" && req.Tier != "industry" { if req.Tier != "generic" && req.Tier != "industry" {
return web.NewBadRequest("tier 只能是 generic 或 industry") return web.NewBadRequest("tier 只能是 generic 或 industry")
@@ -199,6 +212,9 @@ func validateSpecialistReq(req *specialistReq) *web.AppError {
if !store.ValidateStructuredRecordsJSON(req.InputsRecordsJSON) { if !store.ValidateStructuredRecordsJSON(req.InputsRecordsJSON) {
return web.NewBadRequest("inputs_records_json 必须是 JSON 数组") return web.NewBadRequest("inputs_records_json 必须是 JSON 数组")
} }
if !store.ValidateJSONObjectJSON(req.RoleCardJSON) {
return web.NewBadRequest("role_card_json 必须是 JSON 对象")
}
if !store.ValidateStructuredRecordsJSON(req.PermissionRecordsJSON) { if !store.ValidateStructuredRecordsJSON(req.PermissionRecordsJSON) {
return web.NewBadRequest("permission_records_json 必须是 JSON 数组") return web.NewBadRequest("permission_records_json 必须是 JSON 数组")
} }
@@ -208,9 +224,36 @@ func validateSpecialistReq(req *specialistReq) *web.AppError {
if !store.ValidateStructuredRecordsJSON(req.ResultRecordsJSON) { if !store.ValidateStructuredRecordsJSON(req.ResultRecordsJSON) {
return web.NewBadRequest("result_records_json 必须是 JSON 数组") 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 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) // CreateSpecialist POST /api/specialists (admin)
func CreateSpecialist(c *gin.Context) { func CreateSpecialist(c *gin.Context) {
var req specialistReq var req specialistReq
@@ -233,10 +276,13 @@ func CreateSpecialist(c *gin.Context) {
item := model.Specialist{ item := model.Specialist{
Key: req.Key, Key: req.Key,
Label: req.Label, Label: req.Label,
DisplayCode: req.DisplayCode,
EAILogicCode: req.EAILogicCode,
Tier: req.Tier, Tier: req.Tier,
WorkerType: req.WorkerType, WorkerType: req.WorkerType,
Route: req.Route, ObjectEntryRoute: req.ObjectEntryRoute,
Summary: req.Summary, Summary: req.Summary,
RoleCardJSON: req.RoleCardJSON,
WorkStatus: req.WorkStatus, WorkStatus: req.WorkStatus,
RiskLabel: req.RiskLabel, RiskLabel: req.RiskLabel,
Color: req.Color, Color: req.Color,
@@ -251,6 +297,8 @@ func CreateSpecialist(c *gin.Context) {
BaseSkills: req.BaseSkills, BaseSkills: req.BaseSkills,
AIAssistance: req.AIAssistance, AIAssistance: req.AIAssistance,
GeneratedSkills: req.GeneratedSkills, GeneratedSkills: req.GeneratedSkills,
RuleFileMarkdown: req.RuleFileMarkdown,
AllowedSkills: req.AllowedSkills,
InputsRecordsJSON: req.InputsRecordsJSON, InputsRecordsJSON: req.InputsRecordsJSON,
PermissionRecordsJSON: req.PermissionRecordsJSON, PermissionRecordsJSON: req.PermissionRecordsJSON,
ActionRecordsJSON: req.ActionRecordsJSON, ActionRecordsJSON: req.ActionRecordsJSON,
@@ -297,10 +345,13 @@ func UpdateSpecialist(c *gin.Context) {
item.Key = req.Key item.Key = req.Key
item.Label = req.Label item.Label = req.Label
item.DisplayCode = req.DisplayCode
item.EAILogicCode = req.EAILogicCode
item.Tier = req.Tier item.Tier = req.Tier
item.WorkerType = req.WorkerType item.WorkerType = req.WorkerType
item.Route = req.Route item.ObjectEntryRoute = req.ObjectEntryRoute
item.Summary = req.Summary item.Summary = req.Summary
item.RoleCardJSON = req.RoleCardJSON
item.WorkStatus = req.WorkStatus item.WorkStatus = req.WorkStatus
item.RiskLabel = req.RiskLabel item.RiskLabel = req.RiskLabel
item.Color = req.Color item.Color = req.Color
@@ -315,6 +366,8 @@ func UpdateSpecialist(c *gin.Context) {
item.BaseSkills = req.BaseSkills item.BaseSkills = req.BaseSkills
item.AIAssistance = req.AIAssistance item.AIAssistance = req.AIAssistance
item.GeneratedSkills = req.GeneratedSkills item.GeneratedSkills = req.GeneratedSkills
item.RuleFileMarkdown = req.RuleFileMarkdown
item.AllowedSkills = req.AllowedSkills
item.InputsRecordsJSON = req.InputsRecordsJSON item.InputsRecordsJSON = req.InputsRecordsJSON
item.PermissionRecordsJSON = req.PermissionRecordsJSON item.PermissionRecordsJSON = req.PermissionRecordsJSON
item.ActionRecordsJSON = req.ActionRecordsJSON 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" import "time"
// AiCallLog AI 调用日志(审计 + 按用户算力点计费) // AiCallLog AI 调用日志(审计 + 按用户算力点计费)
// 精简自 pj034 ai_call_logs:去掉 company_id / input_asset / cost_cny / billing_mode 等电商与多租户字段。
type AiCallLog struct { type AiCallLog struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_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 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"` 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"` Model string `gorm:"size:64" json:"model"`
TokensInput int `gorm:"default:0" json:"tokens_input"` TokensInput int `gorm:"default:0" json:"tokens_input"`
TokensOutput int `gorm:"default:0" json:"tokens_output"` 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 Status string `gorm:"size:16;not null;index" json:"status"` // success / failed
ErrorMessage string `gorm:"size:512" json:"error_message,omitempty"` ErrorMessage string `gorm:"size:512" json:"error_message,omitempty"`
LatencyMs int `gorm:"default:0" json:"latency_ms"` 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 数字员工专员目录 // Specialist 数字员工专员目录
type Specialist struct { type Specialist struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"` Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
Label string `gorm:"size:128;not null" json:"label"` Label string `gorm:"size:128;not null" json:"label"`
Tier string `gorm:"size:16;not null;index" json:"tier"` // generic / industry DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"`
WorkerType string `gorm:"size:16;not null;default:dw;index" json:"worker_type"` EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"`
Route string `gorm:"size:128;not null" json:"route"` Tier string `gorm:"size:16;not null;index" json:"tier"` // generic / industry
Summary string `gorm:"type:text" json:"summary"` WorkerType string `gorm:"size:16;not null;default:dw;index" json:"worker_type"`
WorkStatus string `gorm:"size:64;default:''" json:"work_status"` ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;not null;default:''" json:"object_entry_route"`
RiskLabel string `gorm:"size:64;default:''" json:"risk_label"` Summary string `gorm:"type:text" json:"summary"`
Color string `gorm:"size:16;default:''" json:"color"` RoleCardJSON string `gorm:"type:text" json:"role_card_json"`
Stage string `gorm:"size:64;default:''" json:"stage"` WorkStatus string `gorm:"size:64;default:''" json:"work_status"`
Progress int `gorm:"not null;default:0" json:"progress"` RiskLabel string `gorm:"size:64;default:''" json:"risk_label"`
MarketTag string `gorm:"size:32;not null;default:installed;index" json:"market_tag"` // 已安装 / 可升级 / 试用 Color string `gorm:"size:16;default:''" json:"color"`
Version string `gorm:"size:32;default:''" json:"version"` Stage string `gorm:"size:64;default:''" json:"stage"`
ConnectorScope string `gorm:"type:text" json:"connector_scope"` Progress int `gorm:"not null;default:0" json:"progress"`
PermissionScope string `gorm:"type:text" json:"permission_scope"` MarketTag string `gorm:"size:32;not null;default:installed;index" json:"market_tag"` // 已安装 / 可升级 / 试用
ResourceBindings string `gorm:"type:text" json:"resource_bindings"` Version string `gorm:"size:32;default:''" json:"version"`
InfoSources string `gorm:"type:text" json:"info_sources"` ConnectorScope string `gorm:"type:text" json:"connector_scope"`
BaseSkills string `gorm:"type:text" json:"base_skills"` PermissionScope string `gorm:"type:text" json:"permission_scope"`
AIAssistance string `gorm:"type:text" json:"ai_assistance"` ResourceBindings string `gorm:"type:text" json:"resource_bindings"`
GeneratedSkills string `gorm:"type:text" json:"generated_skills"` 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"` InputsRecordsJSON string `gorm:"column:source_records_json;type:text" json:"inputs_records_json"`
PermissionRecordsJSON string `gorm:"type:text" json:"permission_records_json"` PermissionRecordsJSON string `gorm:"type:text" json:"permission_records_json"`
ActionRecordsJSON string `gorm:"type:text" json:"action_records_json"` ActionRecordsJSON string `gorm:"type:text" json:"action_records_json"`
@@ -2,6 +2,7 @@ package store
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log" "log"
"os" "os"
@@ -90,6 +91,12 @@ func SeedDefaults() error {
if err := seedSpecialists(); err != nil { if err := seedSpecialists(); err != nil {
return err return err
} }
if err := seedSkillDefinitions(); err != nil {
return err
}
if err := seedActionDefinitions(); err != nil {
return err
}
return nil return nil
} }
@@ -145,7 +152,7 @@ func seedCoursesFromJSON() error {
path := firstExistingPath( path := firstExistingPath(
"training_materials/work/courses.json", "training_materials/work/courses.json",
"../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 == "" { if path == "" {
return nil return nil
@@ -217,47 +224,47 @@ func seedTrainingMedia() error {
seeds := []mediaSeed{ seeds := []mediaSeed{
{ {
filename: "01_公司介绍.mp4", 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", storedName: "seed_company_intro_video.mp4",
bindType: "company", bindType: "company",
}, },
{ {
filename: "01_公司介绍.pdf", 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", storedName: "seed_company_intro_manual.pdf",
bindType: "company", bindType: "company",
}, },
{ {
filename: "05_资本咨询销售要点.mp4", 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", storedName: "seed_course_co001_video.mp4",
bindType: "course", bindType: "course",
bindCode: "CO-001", bindCode: "CO-001",
}, },
{ {
filename: "03_高企认定八大条件.mp4", 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", storedName: "seed_course_co002_video.mp4",
bindType: "course", bindType: "course",
bindCode: "CO-002", bindCode: "CO-002",
}, },
{ {
filename: "06_企业AI落地方法论.mp4", 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", storedName: "seed_course_co003_video.mp4",
bindType: "course", bindType: "course",
bindCode: "CO-003", bindCode: "CO-003",
}, },
{ {
filename: "03_资本咨询类产品手册.pdf", 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", storedName: "seed_course_co001_manual.pdf",
bindType: "course", bindType: "course",
bindCode: "CO-001", bindCode: "CO-001",
}, },
{ {
filename: "05_AI咨询类产品手册.pdf", 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", storedName: "seed_course_co003_manual.pdf",
bindType: "course", bindType: "course",
bindCode: "CO-003", bindCode: "CO-003",
@@ -283,32 +290,64 @@ func seedTrainingMedia() error {
return nil 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 { func seedSpecialists() error {
items := []model.Specialist{ items := applySpecialistCodes([]model.Specialist{
{ {
// 「新建任务」在还没选专员/工具之前,任务先落在这里。 // 「新建任务」在还没选专员/工具之前,任务先落在这里。
// state = system:它不是一个能装到工作区的专员,只是任务的默认归属, // state = system:它不是一个能装到工作区的专员,只是任务的默认归属,
// 所以不出现在专员市场、我的专员和 + 菜单里(ListSpecialists 会挡掉)。 // 所以不出现在专员市场、我的专员和 + 菜单里(ListSpecialists 会挡掉)。
Key: "general-assistant", Key: "general-assistant",
Label: "通用智能助手", Label: "通用助手",
Tier: "generic", Tier: "generic",
WorkerType: "dw", WorkerType: "dw",
Route: "/home", ObjectEntryRoute: "/home",
Summary: "还没指定专员的任务默认落在这里,用输入框的 + 挂上专员或工具", Summary: "还没指定专员的任务默认落在这里,用输入框的 + 挂上专员或工具",
Stage: "待处理", Stage: "待处理",
Color: "#409eff", Color: "#409eff",
MarketTag: "内置", MarketTag: "内置",
Version: "v1.0", Version: "v1.0",
BaseSkills: "对话、任务拆解、按 + 选定的专员或工具继续", BaseSkills: "对话、任务拆解、按 + 选定的专员或工具继续",
State: "system", State: "system",
SortOrder: 0, SortOrder: 0,
}, },
{ {
Key: "training-delivery", Key: "training-delivery",
Label: "培训交付专员", Label: "培训交付专员",
Tier: "generic", Tier: "generic",
WorkerType: "dw", WorkerType: "dw",
Route: "/apps/training-delivery", ObjectEntryRoute: "/apps/training-delivery",
Summary: "新人销售训练营第 3 周", Summary: "新人销售训练营第 3 周",
WorkStatus: "5 个进行中", WorkStatus: "5 个进行中",
RiskLabel: "0 个异常", RiskLabel: "0 个异常",
@@ -332,7 +371,7 @@ func seedSpecialists() error {
Label: "知识运营专员", Label: "知识运营专员",
Tier: "generic", Tier: "generic",
WorkerType: "dw", WorkerType: "dw",
Route: "/apps/knowledge-operations", ObjectEntryRoute: "/apps/knowledge-operations",
Summary: "对象字典、证据索引与规则同步", Summary: "对象字典、证据索引与规则同步",
WorkStatus: "4 个待整理", WorkStatus: "4 个待整理",
RiskLabel: "1 个映射待确认", RiskLabel: "1 个映射待确认",
@@ -356,7 +395,7 @@ func seedSpecialists() error {
Label: "流程推进专员", Label: "流程推进专员",
Tier: "generic", Tier: "generic",
WorkerType: "adw", WorkerType: "adw",
Route: "/apps/process-coordination", ObjectEntryRoute: "/apps/process-coordination",
Summary: "跨部门催办、checkpoint 跟进与阻塞升级", Summary: "跨部门催办、checkpoint 跟进与阻塞升级",
WorkStatus: "6 个待跟进", WorkStatus: "6 个待跟进",
RiskLabel: "2 个阻塞", RiskLabel: "2 个阻塞",
@@ -380,7 +419,7 @@ func seedSpecialists() error {
Label: "报告生成专员", Label: "报告生成专员",
Tier: "generic", Tier: "generic",
WorkerType: "dw", WorkerType: "dw",
Route: "/apps/report-generation", ObjectEntryRoute: "/apps/report-generation",
Summary: "日报、周报、复盘和交付说明自动生成", Summary: "日报、周报、复盘和交付说明自动生成",
WorkStatus: "3 个待输出", WorkStatus: "3 个待输出",
RiskLabel: "0 个异常", RiskLabel: "0 个异常",
@@ -404,7 +443,7 @@ func seedSpecialists() error {
Label: "公众号助手", Label: "公众号助手",
Tier: "generic", Tier: "generic",
WorkerType: "dw", WorkerType: "dw",
Route: "/apps/wechat-official-account", ObjectEntryRoute: "/apps/wechat-official-account",
Summary: "热点选题、标题、提纲、正文四步创作工作流", Summary: "热点选题、标题、提纲、正文四步创作工作流",
WorkStatus: "4 个节点可执行", WorkStatus: "4 个节点可执行",
RiskLabel: "0 个异常", RiskLabel: "0 个异常",
@@ -428,7 +467,7 @@ func seedSpecialists() error {
Label: "合同审查专员", Label: "合同审查专员",
Tier: "industry", Tier: "industry",
WorkerType: "adw", WorkerType: "adw",
Route: "/apps/contract-review", ObjectEntryRoute: "/apps/contract-review",
Summary: "华东客户主协议修订", Summary: "华东客户主协议修订",
WorkStatus: "2 个待确认", WorkStatus: "2 个待确认",
RiskLabel: "1 个高风险", RiskLabel: "1 个高风险",
@@ -452,7 +491,7 @@ func seedSpecialists() error {
Label: "售前方案专员", Label: "售前方案专员",
Tier: "industry", Tier: "industry",
WorkerType: "adw", WorkerType: "adw",
Route: "/apps/solution-proposal", ObjectEntryRoute: "/apps/solution-proposal",
Summary: "A 客户智能培训升级方案", Summary: "A 客户智能培训升级方案",
WorkStatus: "3 个待办", WorkStatus: "3 个待办",
RiskLabel: "1 个依赖阻塞", RiskLabel: "1 个依赖阻塞",
@@ -476,7 +515,7 @@ func seedSpecialists() error {
Label: "履约跟单专员", Label: "履约跟单专员",
Tier: "industry", Tier: "industry",
WorkerType: "adw", WorkerType: "adw",
Route: "/apps/logistics-fulfillment", ObjectEntryRoute: "/apps/logistics-fulfillment",
Summary: "美西航线本周履约看板", Summary: "美西航线本周履约看板",
WorkStatus: "1 个异常", WorkStatus: "1 个异常",
RiskLabel: "2 个节点延迟", RiskLabel: "2 个节点延迟",
@@ -500,7 +539,7 @@ func seedSpecialists() error {
Label: "HR 邮件整理专员", Label: "HR 邮件整理专员",
Tier: "industry", Tier: "industry",
WorkerType: "dw", WorkerType: "dw",
Route: "/apps/hr-email-sorter", ObjectEntryRoute: "/apps/hr-email-sorter",
Summary: "识别并整理招聘邮箱中的简历邮件", Summary: "识别并整理招聘邮箱中的简历邮件",
WorkStatus: "3 封待处理", WorkStatus: "3 封待处理",
RiskLabel: "0 个异常", RiskLabel: "0 个异常",
@@ -524,7 +563,7 @@ func seedSpecialists() error {
Label: "简历处理专员", Label: "简历处理专员",
Tier: "industry", Tier: "industry",
WorkerType: "dw", WorkerType: "dw",
Route: "/apps/resume-processor", ObjectEntryRoute: "/apps/resume-processor",
Summary: "筛选候选人并安排面试", Summary: "筛选候选人并安排面试",
WorkStatus: "5 份待筛选", WorkStatus: "5 份待筛选",
RiskLabel: "1 个匹配待确认", RiskLabel: "1 个匹配待确认",
@@ -543,6 +582,10 @@ func seedSpecialists() error {
State: "active", State: "active",
SortOrder: 90, SortOrder: 90,
}, },
})
// 岗位说明书与技能绑定按 key 挂载(见 seed_specialist_rules.go)
if err := applySpecialistRuleFiles(items); err != nil {
return err
} }
for _, item := range items { for _, item := range items {
@@ -556,9 +599,18 @@ func seedSpecialists() error {
} }
updates := map[string]any{} updates := map[string]any{}
if existing.DisplayCode == "" {
updates["display_code"] = item.DisplayCode
}
if existing.EAILogicCode == "" {
updates["eailogic_code"] = item.EAILogicCode
}
if existing.WorkerType == "" { if existing.WorkerType == "" {
updates["worker_type"] = item.WorkerType updates["worker_type"] = item.WorkerType
} }
if existing.RoleCardJSON == "" {
updates["role_card_json"] = item.RoleCardJSON
}
if existing.PermissionScope == "" { if existing.PermissionScope == "" {
updates["permission_scope"] = item.PermissionScope updates["permission_scope"] = item.PermissionScope
} }
@@ -577,6 +629,13 @@ func seedSpecialists() error {
if existing.GeneratedSkills == "" { if existing.GeneratedSkills == "" {
updates["generated_skills"] = item.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 == "" { if existing.InputsRecordsJSON == "" {
updates["source_records_json"] = item.InputsRecordsJSON updates["source_records_json"] = item.InputsRecordsJSON
} }
@@ -596,10 +655,341 @@ func seedSpecialists() error {
} }
} }
logSpecialistRuleFileCoverage()
log.Println("[OK] 专员目录种子已导入") log.Println("[OK] 专员目录种子已导入")
return nil 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 { func ensureSeedMedia(adminID uint, approvedDir string, item mediaSeed, bindID *uint) error {
dst := filepath.Join(approvedDir, item.storedName) dst := filepath.Join(approvedDir, item.storedName)
if err := ensureMediaLink(item.sourcePath, dst); err != nil { 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