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:
@@ -3,17 +3,19 @@ package model
|
||||
import "time"
|
||||
|
||||
// AiCallLog AI 调用日志(审计 + 按用户算力点计费)
|
||||
// 精简自 pj034 ai_call_logs:去掉 company_id / input_asset / cost_cny / billing_mode 等电商与多租户字段。
|
||||
type AiCallLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Capability string `gorm:"size:32;not null;index" json:"capability"` // ai_chat / text_gen / embed
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Capability string `gorm:"size:32;not null;index" json:"capability"` // ai_chat / text_gen / embed
|
||||
// SpecialistKey 本次调用以哪个专员的身份进行;空 = 通用助手。
|
||||
// 用来回答「这条回答是谁说的」,也是排查「专员没生效」的第一手线索。
|
||||
SpecialistKey string `gorm:"size:64;index" json:"specialist_key"`
|
||||
Provider string `gorm:"size:32" json:"provider"`
|
||||
RouteID string `gorm:"size:64" json:"route_id"`
|
||||
AIRouteID string `gorm:"size:64" json:"ai_route_id"`
|
||||
Model string `gorm:"size:64" json:"model"`
|
||||
TokensInput int `gorm:"default:0" json:"tokens_input"`
|
||||
TokensOutput int `gorm:"default:0" json:"tokens_output"`
|
||||
CreditsCharged int `gorm:"default:0" json:"credits_charged"` // 实扣点数(0 = 未扣)
|
||||
CreditsCharged int `gorm:"default:0" json:"credits_charged"` // 实扣点数(0 = 未扣)
|
||||
Status string `gorm:"size:16;not null;index" json:"status"` // success / failed
|
||||
ErrorMessage string `gorm:"size:512" json:"error_message,omitempty"`
|
||||
LatencyMs int `gorm:"default:0" json:"latency_ms"`
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidSkillKeys 技能 key 全集,用于校验 Specialist.AllowedSkills。
|
||||
//
|
||||
// 权威来源是前端 frontend/src/config/workbench.js 的 availableSkills ——
|
||||
// 技能实现仍归前端,后端只持有 key 清单,用于录入时挡住拼写错误。
|
||||
// 两个清单的一致性由 skill_keys_test.go 守着,改动任一侧都会让测试失败。
|
||||
//
|
||||
// 背景:前端 22 个技能 vs 后端 skill_definition 7 行,长期无同步机制;
|
||||
// 本清单是 SY23 §2.4 的「单向校验」兜底,不解决同步问题本身。
|
||||
var ValidSkillKeys = map[string]bool{
|
||||
"smart-assistant": true, // 通用助手(默认挂载)
|
||||
"document-translate": true,
|
||||
"copy-proofreading": true,
|
||||
"audio-transcribe": true,
|
||||
"batch-extract": true,
|
||||
"contract-review": true, // 注意:与专员 key contract-review 同名,日志里请带类型前缀
|
||||
"report-generation": true, // 同上,与专员 key report-generation 同名
|
||||
"ppt-generation": true,
|
||||
"mind-map": true,
|
||||
"longform-writing": true,
|
||||
"text-toolkit": true,
|
||||
"ocr-understanding": true,
|
||||
"meeting-minutes": true,
|
||||
"project-planning": true,
|
||||
"email-drafting": true,
|
||||
"table-cleanup": true,
|
||||
"proposal-summary": true,
|
||||
"progress-report": true,
|
||||
"contract-brief": true,
|
||||
"interview-summary": true,
|
||||
"policy-rewrite": true,
|
||||
"survey-summary": true,
|
||||
}
|
||||
|
||||
// SortedValidSkillKeys 返回排序后的 key 清单,用于报错文案与文档生成
|
||||
func SortedValidSkillKeys() []string {
|
||||
keys := make([]string, 0, len(ValidSkillKeys))
|
||||
for k := range ValidSkillKeys {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// ParseAllowedSkills 解析 AllowedSkills JSON 数组。
|
||||
// 空串、非法 JSON 一律返回 nil —— 读路径不报错,拿不到绑定就退回通用助手行为。
|
||||
func ParseAllowedSkills(raw string) []string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
var keys []string
|
||||
if err := json.Unmarshal([]byte(trimmed), &keys); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k = strings.TrimSpace(k); k != "" {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MarshalAllowedSkills 把 key 列表序列化为 AllowedSkills 的存储形式。
|
||||
// 空列表存空串(而不是 "[]"),与「未绑定」保持同一种表示。
|
||||
func MarshalAllowedSkills(keys []string) (string, error) {
|
||||
if len(keys) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := json.Marshal(keys)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// ValidateAllowedSkills 校验 key 列表,返回第一个非法 key 的错误。
|
||||
// 同时挡掉重复项——重复会让「首个为主技能」的语义变得不确定。
|
||||
func ValidateAllowedSkills(keys []string) error {
|
||||
seen := make(map[string]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if !ValidSkillKeys[k] {
|
||||
return fmt.Errorf("技能 key %q 非法,可选值:%s", k, strings.Join(SortedValidSkillKeys(), " / "))
|
||||
}
|
||||
if seen[k] {
|
||||
return fmt.Errorf("技能 key %q 重复绑定", k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// workbenchJSRelPath 前端技能目录的相对路径(相对本包目录)。
|
||||
const workbenchJSRelPath = "../../../frontend/src/config/workbench.js"
|
||||
|
||||
// createSkillDefinitionRe 抓 availableSkills 数组里每个 createSkillDefinition 的字面量 key。
|
||||
// 依赖 workbench.js 里「createSkillDefinition({ 紧跟 key: '…'」这一写法。
|
||||
var createSkillDefinitionRe = regexp.MustCompile(`createSkillDefinition\(\{\s*key:\s*'([^']+)'`)
|
||||
|
||||
// TestValidSkillKeysMatchFrontend 守住 backend 的 ValidSkillKeys 与前端 availableSkills 一致。
|
||||
//
|
||||
// 这两份清单天然会漂移:技能实现在前端,后端只持有 key 做录入校验。任何一侧增删技能
|
||||
// 而忘了另一侧,这里就会红——这正是本测试存在的唯一理由。
|
||||
//
|
||||
// 交付形态是没有前端源码的(单二进制 + 整盘克隆),找不到文件时跳过而不是失败。
|
||||
func TestValidSkillKeysMatchFrontend(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Clean(workbenchJSRelPath))
|
||||
if err != nil {
|
||||
t.Skipf("跳过:读不到前端 %s(交付形态无前端源码): %v", workbenchJSRelPath, err)
|
||||
}
|
||||
|
||||
matches := createSkillDefinitionRe.FindAllStringSubmatch(string(raw), -1)
|
||||
if len(matches) == 0 {
|
||||
t.Fatalf("在前端 %s 里没抓到任何 createSkillDefinition key —— "+
|
||||
"可能是正则失效(写法改了),请同步更新 createSkillDefinitionRe", workbenchJSRelPath)
|
||||
}
|
||||
|
||||
frontendKeys := make(map[string]bool, len(matches))
|
||||
for _, m := range matches {
|
||||
frontendKeys[m[1]] = true
|
||||
}
|
||||
|
||||
var onlyBackend, onlyFrontend []string
|
||||
for k := range ValidSkillKeys {
|
||||
if !frontendKeys[k] {
|
||||
onlyBackend = append(onlyBackend, k)
|
||||
}
|
||||
}
|
||||
for k := range frontendKeys {
|
||||
if !ValidSkillKeys[k] {
|
||||
onlyFrontend = append(onlyFrontend, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(onlyBackend)
|
||||
sort.Strings(onlyFrontend)
|
||||
|
||||
if len(onlyBackend) > 0 {
|
||||
t.Errorf("ValidSkillKeys 里有、前端 availableSkills 里没有的 key(后端多写或前端已删):\n %s",
|
||||
strings.Join(onlyBackend, "\n "))
|
||||
}
|
||||
if len(onlyFrontend) > 0 {
|
||||
t.Errorf("前端 availableSkills 里有、ValidSkillKeys 里没有的 key(新增技能后忘了补后端清单):\n %s",
|
||||
strings.Join(onlyFrontend, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAllowedSkills(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want []string
|
||||
}{
|
||||
{"空串返回 nil", "", nil},
|
||||
{"纯空白返回 nil", " ", nil},
|
||||
{"非法 JSON 返回 nil(读路径不报错)", `{"not":"array"}`, nil},
|
||||
{"正常数组", `["contract-review","batch-extract"]`, []string{"contract-review", "batch-extract"}},
|
||||
{"跳过多余空白", `[" contract-review "]`, []string{"contract-review"}},
|
||||
{"空数组返回空切片", `[]`, []string{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ParseAllowedSkills(tc.raw)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("ParseAllowedSkills(%q) = %v,期望 %v", tc.raw, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("ParseAllowedSkills(%q) = %v,期望 %v", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalAllowedSkills(t *testing.T) {
|
||||
t.Run("空列表存空串而非 []", func(t *testing.T) {
|
||||
got, err := MarshalAllowedSkills(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("期望空串,实际 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("往返一致", func(t *testing.T) {
|
||||
in := []string{"contract-review", "contract-brief"}
|
||||
raw, err := MarshalAllowedSkills(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := ParseAllowedSkills(raw)
|
||||
if len(out) != len(in) {
|
||||
t.Fatalf("往返后长度变了: %v -> %v", in, out)
|
||||
}
|
||||
for i := range in {
|
||||
if in[i] != out[i] {
|
||||
t.Errorf("往返后顺序变了: %v -> %v(顺序即优先级,必须保持)", in, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateAllowedSkills(t *testing.T) {
|
||||
t.Run("合法列表通过", func(t *testing.T) {
|
||||
if err := ValidateAllowedSkills([]string{"contract-review", "batch-extract"}); err != nil {
|
||||
t.Errorf("不应报错: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空列表通过", func(t *testing.T) {
|
||||
if err := ValidateAllowedSkills(nil); err != nil {
|
||||
t.Errorf("未绑定技能应合法: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非法 key 报错", func(t *testing.T) {
|
||||
err := ValidateAllowedSkills([]string{"contract-review", "typo-skill"})
|
||||
if err == nil {
|
||||
t.Fatal("非法 key 应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "typo-skill") {
|
||||
t.Errorf("错误信息应点出非法 key,实际: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("重复 key 报错", func(t *testing.T) {
|
||||
err := ValidateAllowedSkills([]string{"contract-review", "contract-review"})
|
||||
if err == nil {
|
||||
t.Fatal("重复绑定应报错(首个为主技能的语义会变得不确定)")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,27 +4,38 @@ import "time"
|
||||
|
||||
// Specialist 数字员工专员目录
|
||||
type Specialist struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
|
||||
Label string `gorm:"size:128;not null" json:"label"`
|
||||
Tier string `gorm:"size:16;not null;index" json:"tier"` // generic / industry
|
||||
WorkerType string `gorm:"size:16;not null;default:dw;index" json:"worker_type"`
|
||||
Route string `gorm:"size:128;not null" json:"route"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
WorkStatus string `gorm:"size:64;default:''" json:"work_status"`
|
||||
RiskLabel string `gorm:"size:64;default:''" json:"risk_label"`
|
||||
Color string `gorm:"size:16;default:''" json:"color"`
|
||||
Stage string `gorm:"size:64;default:''" json:"stage"`
|
||||
Progress int `gorm:"not null;default:0" json:"progress"`
|
||||
MarketTag string `gorm:"size:32;not null;default:installed;index" json:"market_tag"` // 已安装 / 可升级 / 试用
|
||||
Version string `gorm:"size:32;default:''" json:"version"`
|
||||
ConnectorScope string `gorm:"type:text" json:"connector_scope"`
|
||||
PermissionScope string `gorm:"type:text" json:"permission_scope"`
|
||||
ResourceBindings string `gorm:"type:text" json:"resource_bindings"`
|
||||
InfoSources string `gorm:"type:text" json:"info_sources"`
|
||||
BaseSkills string `gorm:"type:text" json:"base_skills"`
|
||||
AIAssistance string `gorm:"type:text" json:"ai_assistance"`
|
||||
GeneratedSkills string `gorm:"type:text" json:"generated_skills"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
|
||||
Label string `gorm:"size:128;not null" json:"label"`
|
||||
DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"`
|
||||
EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"`
|
||||
Tier string `gorm:"size:16;not null;index" json:"tier"` // generic / industry
|
||||
WorkerType string `gorm:"size:16;not null;default:dw;index" json:"worker_type"`
|
||||
ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;not null;default:''" json:"object_entry_route"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
RoleCardJSON string `gorm:"type:text" json:"role_card_json"`
|
||||
WorkStatus string `gorm:"size:64;default:''" json:"work_status"`
|
||||
RiskLabel string `gorm:"size:64;default:''" json:"risk_label"`
|
||||
Color string `gorm:"size:16;default:''" json:"color"`
|
||||
Stage string `gorm:"size:64;default:''" json:"stage"`
|
||||
Progress int `gorm:"not null;default:0" json:"progress"`
|
||||
MarketTag string `gorm:"size:32;not null;default:installed;index" json:"market_tag"` // 已安装 / 可升级 / 试用
|
||||
Version string `gorm:"size:32;default:''" json:"version"`
|
||||
ConnectorScope string `gorm:"type:text" json:"connector_scope"`
|
||||
PermissionScope string `gorm:"type:text" json:"permission_scope"`
|
||||
ResourceBindings string `gorm:"type:text" json:"resource_bindings"`
|
||||
InfoSources string `gorm:"type:text" json:"info_sources"`
|
||||
BaseSkills string `gorm:"type:text" json:"base_skills"`
|
||||
AIAssistance string `gorm:"type:text" json:"ai_assistance"`
|
||||
GeneratedSkills string `gorm:"type:text" json:"generated_skills"`
|
||||
// RuleFileMarkdown 岗位说明书正文(Markdown 全文)。
|
||||
// 会话创建时注入 System Prompt,决定这个专员怎么说话、怎么推进、什么时候调用哪个技能。
|
||||
// 参考 AionCore builtin-assistants 的 rule_file;命名对齐 SY23。
|
||||
RuleFileMarkdown string `gorm:"type:text" json:"rule_file_markdown"`
|
||||
// AllowedSkills 绑定技能 key 列表,JSON 字符串数组,顺序即优先级(首个为主技能)。
|
||||
// 元素取值须通过 model.ValidSkillKeys 校验,对齐前端 availableSkills[].key。
|
||||
// 字段名对齐 SY22 §6.1 对象 manifest 的 allowed_skills。
|
||||
AllowedSkills string `gorm:"type:text" json:"allowed_skills"`
|
||||
InputsRecordsJSON string `gorm:"column:source_records_json;type:text" json:"inputs_records_json"`
|
||||
PermissionRecordsJSON string `gorm:"type:text" json:"permission_records_json"`
|
||||
ActionRecordsJSON string `gorm:"type:text" json:"action_records_json"`
|
||||
|
||||
Reference in New Issue
Block a user