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