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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-17 21:32:31 +08:00
co-authored by Claude Code
parent fa6c26ea41
commit 8b136d4a10
20 changed files with 3781 additions and 314 deletions
@@ -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"`