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
@@ -2,6 +2,7 @@ package store
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
@@ -90,6 +91,12 @@ func SeedDefaults() error {
if err := seedSpecialists(); err != nil {
return err
}
if err := seedSkillDefinitions(); err != nil {
return err
}
if err := seedActionDefinitions(); err != nil {
return err
}
return nil
}
@@ -145,7 +152,7 @@ func seedCoursesFromJSON() error {
path := firstExistingPath(
"training_materials/work/courses.json",
"../training_materials/work/courses.json",
"eai_agentplatform_app/training_materials/work/courses.json",
"eai_agentplatform/training_materials/work/courses.json",
)
if path == "" {
return nil
@@ -217,47 +224,47 @@ func seedTrainingMedia() error {
seeds := []mediaSeed{
{
filename: "01_公司介绍.mp4",
sourcePath: firstExistingPath("training_materials/mp4/01_公司介绍.mp4", "../training_materials/mp4/01_公司介绍.mp4", "eai_agentplatform_app/training_materials/mp4/01_公司介绍.mp4"),
sourcePath: firstExistingPath("training_materials/mp4/01_公司介绍.mp4", "../training_materials/mp4/01_公司介绍.mp4", "eai_agentplatform/training_materials/mp4/01_公司介绍.mp4"),
storedName: "seed_company_intro_video.mp4",
bindType: "company",
},
{
filename: "01_公司介绍.pdf",
sourcePath: firstExistingPath("training_materials/pdf/01_公司介绍.pdf", "../training_materials/pdf/01_公司介绍.pdf", "eai_agentplatform_app/training_materials/pdf/01_公司介绍.pdf"),
sourcePath: firstExistingPath("training_materials/pdf/01_公司介绍.pdf", "../training_materials/pdf/01_公司介绍.pdf", "eai_agentplatform/training_materials/pdf/01_公司介绍.pdf"),
storedName: "seed_company_intro_manual.pdf",
bindType: "company",
},
{
filename: "05_资本咨询销售要点.mp4",
sourcePath: firstExistingPath("training_materials/mp4/05_资本咨询销售要点.mp4", "../training_materials/mp4/05_资本咨询销售要点.mp4", "eai_agentplatform_app/training_materials/mp4/05_资本咨询销售要点.mp4"),
sourcePath: firstExistingPath("training_materials/mp4/05_资本咨询销售要点.mp4", "../training_materials/mp4/05_资本咨询销售要点.mp4", "eai_agentplatform/training_materials/mp4/05_资本咨询销售要点.mp4"),
storedName: "seed_course_co001_video.mp4",
bindType: "course",
bindCode: "CO-001",
},
{
filename: "03_高企认定八大条件.mp4",
sourcePath: firstExistingPath("training_materials/mp4/03_高企认定八大条件.mp4", "../training_materials/mp4/03_高企认定八大条件.mp4", "eai_agentplatform_app/training_materials/mp4/03_高企认定八大条件.mp4"),
sourcePath: firstExistingPath("training_materials/mp4/03_高企认定八大条件.mp4", "../training_materials/mp4/03_高企认定八大条件.mp4", "eai_agentplatform/training_materials/mp4/03_高企认定八大条件.mp4"),
storedName: "seed_course_co002_video.mp4",
bindType: "course",
bindCode: "CO-002",
},
{
filename: "06_企业AI落地方法论.mp4",
sourcePath: firstExistingPath("training_materials/mp4/06_企业AI落地方法论.mp4", "../training_materials/mp4/06_企业AI落地方法论.mp4", "eai_agentplatform_app/training_materials/mp4/06_企业AI落地方法论.mp4"),
sourcePath: firstExistingPath("training_materials/mp4/06_企业AI落地方法论.mp4", "../training_materials/mp4/06_企业AI落地方法论.mp4", "eai_agentplatform/training_materials/mp4/06_企业AI落地方法论.mp4"),
storedName: "seed_course_co003_video.mp4",
bindType: "course",
bindCode: "CO-003",
},
{
filename: "03_资本咨询类产品手册.pdf",
sourcePath: firstExistingPath("training_materials/pdf/03_资本咨询类产品手册.pdf", "../training_materials/pdf/03_资本咨询类产品手册.pdf", "eai_agentplatform_app/training_materials/pdf/03_资本咨询类产品手册.pdf"),
sourcePath: firstExistingPath("training_materials/pdf/03_资本咨询类产品手册.pdf", "../training_materials/pdf/03_资本咨询类产品手册.pdf", "eai_agentplatform/training_materials/pdf/03_资本咨询类产品手册.pdf"),
storedName: "seed_course_co001_manual.pdf",
bindType: "course",
bindCode: "CO-001",
},
{
filename: "05_AI咨询类产品手册.pdf",
sourcePath: firstExistingPath("training_materials/pdf/05_AI咨询类产品手册.pdf", "../training_materials/pdf/05_AI咨询类产品手册.pdf", "eai_agentplatform_app/training_materials/pdf/05_AI咨询类产品手册.pdf"),
sourcePath: firstExistingPath("training_materials/pdf/05_AI咨询类产品手册.pdf", "../training_materials/pdf/05_AI咨询类产品手册.pdf", "eai_agentplatform/training_materials/pdf/05_AI咨询类产品手册.pdf"),
storedName: "seed_course_co003_manual.pdf",
bindType: "course",
bindCode: "CO-003",
@@ -283,32 +290,64 @@ func seedTrainingMedia() error {
return nil
}
func applySpecialistCodes(items []model.Specialist) []model.Specialist {
visibleIndex := 1
for i := range items {
codeIndex := 0
if items[i].State != "system" {
codeIndex = visibleIndex
visibleIndex++
}
suffix := fmt.Sprintf("%02d", codeIndex)
if strings.TrimSpace(items[i].DisplayCode) == "" {
items[i].DisplayCode = "专" + suffix
}
if strings.TrimSpace(items[i].EAILogicCode) == "" {
items[i].EAILogicCode = "EAI-S-" + suffix
}
}
return items
}
func applySkillCodes(items []model.SkillDefinition) []model.SkillDefinition {
for i := range items {
suffix := fmt.Sprintf("%02d", i+1)
if strings.TrimSpace(items[i].DisplayCode) == "" {
items[i].DisplayCode = "能" + suffix
}
if strings.TrimSpace(items[i].EAILogicCode) == "" {
items[i].EAILogicCode = "EAI-K-" + suffix
}
}
return items
}
func seedSpecialists() error {
items := []model.Specialist{
items := applySpecialistCodes([]model.Specialist{
{
// 「新建任务」在还没选专员/工具之前,任务先落在这里。
// state = system:它不是一个能装到工作区的专员,只是任务的默认归属,
// 所以不出现在专员市场、我的专员和 + 菜单里(ListSpecialists 会挡掉)。
Key: "general-assistant",
Label: "通用智能助手",
Tier: "generic",
WorkerType: "dw",
Route: "/home",
Summary: "还没指定专员的任务默认落在这里,用输入框的 + 挂上专员或工具",
Stage: "待处理",
Color: "#409eff",
MarketTag: "内置",
Version: "v1.0",
BaseSkills: "对话、任务拆解、按 + 选定的专员或工具继续",
State: "system",
SortOrder: 0,
Key: "general-assistant",
Label: "通用助手",
Tier: "generic",
WorkerType: "dw",
ObjectEntryRoute: "/home",
Summary: "还没指定专员的任务默认落在这里,用输入框的 + 挂上专员或工具",
Stage: "待处理",
Color: "#409eff",
MarketTag: "内置",
Version: "v1.0",
BaseSkills: "对话、任务拆解、按 + 选定的专员或工具继续",
State: "system",
SortOrder: 0,
},
{
Key: "training-delivery",
Label: "培训交付专员",
Tier: "generic",
WorkerType: "dw",
Route: "/apps/training-delivery",
ObjectEntryRoute: "/apps/training-delivery",
Summary: "新人销售训练营第 3 周",
WorkStatus: "5 个进行中",
RiskLabel: "0 个异常",
@@ -332,7 +371,7 @@ func seedSpecialists() error {
Label: "知识运营专员",
Tier: "generic",
WorkerType: "dw",
Route: "/apps/knowledge-operations",
ObjectEntryRoute: "/apps/knowledge-operations",
Summary: "对象字典、证据索引与规则同步",
WorkStatus: "4 个待整理",
RiskLabel: "1 个映射待确认",
@@ -356,7 +395,7 @@ func seedSpecialists() error {
Label: "流程推进专员",
Tier: "generic",
WorkerType: "adw",
Route: "/apps/process-coordination",
ObjectEntryRoute: "/apps/process-coordination",
Summary: "跨部门催办、checkpoint 跟进与阻塞升级",
WorkStatus: "6 个待跟进",
RiskLabel: "2 个阻塞",
@@ -380,7 +419,7 @@ func seedSpecialists() error {
Label: "报告生成专员",
Tier: "generic",
WorkerType: "dw",
Route: "/apps/report-generation",
ObjectEntryRoute: "/apps/report-generation",
Summary: "日报、周报、复盘和交付说明自动生成",
WorkStatus: "3 个待输出",
RiskLabel: "0 个异常",
@@ -404,7 +443,7 @@ func seedSpecialists() error {
Label: "公众号助手",
Tier: "generic",
WorkerType: "dw",
Route: "/apps/wechat-official-account",
ObjectEntryRoute: "/apps/wechat-official-account",
Summary: "热点选题、标题、提纲、正文四步创作工作流",
WorkStatus: "4 个节点可执行",
RiskLabel: "0 个异常",
@@ -428,7 +467,7 @@ func seedSpecialists() error {
Label: "合同审查专员",
Tier: "industry",
WorkerType: "adw",
Route: "/apps/contract-review",
ObjectEntryRoute: "/apps/contract-review",
Summary: "华东客户主协议修订",
WorkStatus: "2 个待确认",
RiskLabel: "1 个高风险",
@@ -452,7 +491,7 @@ func seedSpecialists() error {
Label: "售前方案专员",
Tier: "industry",
WorkerType: "adw",
Route: "/apps/solution-proposal",
ObjectEntryRoute: "/apps/solution-proposal",
Summary: "A 客户智能培训升级方案",
WorkStatus: "3 个待办",
RiskLabel: "1 个依赖阻塞",
@@ -476,7 +515,7 @@ func seedSpecialists() error {
Label: "履约跟单专员",
Tier: "industry",
WorkerType: "adw",
Route: "/apps/logistics-fulfillment",
ObjectEntryRoute: "/apps/logistics-fulfillment",
Summary: "美西航线本周履约看板",
WorkStatus: "1 个异常",
RiskLabel: "2 个节点延迟",
@@ -500,7 +539,7 @@ func seedSpecialists() error {
Label: "HR 邮件整理专员",
Tier: "industry",
WorkerType: "dw",
Route: "/apps/hr-email-sorter",
ObjectEntryRoute: "/apps/hr-email-sorter",
Summary: "识别并整理招聘邮箱中的简历邮件",
WorkStatus: "3 封待处理",
RiskLabel: "0 个异常",
@@ -524,7 +563,7 @@ func seedSpecialists() error {
Label: "简历处理专员",
Tier: "industry",
WorkerType: "dw",
Route: "/apps/resume-processor",
ObjectEntryRoute: "/apps/resume-processor",
Summary: "筛选候选人并安排面试",
WorkStatus: "5 份待筛选",
RiskLabel: "1 个匹配待确认",
@@ -543,6 +582,10 @@ func seedSpecialists() error {
State: "active",
SortOrder: 90,
},
})
// 岗位说明书与技能绑定按 key 挂载(见 seed_specialist_rules.go)
if err := applySpecialistRuleFiles(items); err != nil {
return err
}
for _, item := range items {
@@ -556,9 +599,18 @@ func seedSpecialists() error {
}
updates := map[string]any{}
if existing.DisplayCode == "" {
updates["display_code"] = item.DisplayCode
}
if existing.EAILogicCode == "" {
updates["eailogic_code"] = item.EAILogicCode
}
if existing.WorkerType == "" {
updates["worker_type"] = item.WorkerType
}
if existing.RoleCardJSON == "" {
updates["role_card_json"] = item.RoleCardJSON
}
if existing.PermissionScope == "" {
updates["permission_scope"] = item.PermissionScope
}
@@ -577,6 +629,13 @@ func seedSpecialists() error {
if existing.GeneratedSkills == "" {
updates["generated_skills"] = item.GeneratedSkills
}
// 岗位说明书与技能绑定同样只补空字段:管理员在后台改过就不再覆盖。
if existing.RuleFileMarkdown == "" {
updates["rule_file_markdown"] = item.RuleFileMarkdown
}
if existing.AllowedSkills == "" {
updates["allowed_skills"] = item.AllowedSkills
}
if existing.InputsRecordsJSON == "" {
updates["source_records_json"] = item.InputsRecordsJSON
}
@@ -596,10 +655,341 @@ func seedSpecialists() error {
}
}
logSpecialistRuleFileCoverage()
log.Println("[OK] 专员目录种子已导入")
return nil
}
func seedSkillDefinitions() error {
items := applySkillCodes([]model.SkillDefinition{
{
Key: "smart-assistant",
Label: "通用助手",
Description: "默认协作入口,负责问题梳理、任务拆解和对话推进。",
RoleKind: "assistant",
Source: "eai",
ObjectEntryRoute: "/home",
ExposedToUser: true,
StarterPromptsJSON: mustJSON([]string{"帮我拆解这个任务,并列出下一步", "把这段产品介绍整理成正式文案", "先帮我判断这个问题该找哪个专员或技能"}),
PromptTemplate: "围绕用户当前目标先澄清问题,再给出执行建议或继续推进。",
ActionRefsJSON: mustJSON([]string{"assistant.chat"}),
InputSchemaJSON: mustJSON(map[string]any{"blocks": []string{"text", "resource_link"}}),
OutputSchemaJSON: mustJSON(map[string]any{"message": "assistant_reply"}),
ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"summary", "plan"}}),
OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"task", "knowledge_item"}, "action_types": []string{"analyze", "draft"}}),
State: "active",
SortOrder: 10,
},
{
Key: "document-translate",
Label: "文档翻译",
Description: "把文本和文档稳定翻成目标语言,并保留语气和结构。",
RoleKind: "skill",
Source: "eai",
ObjectEntryRoute: "/home",
LegacyObjectEntryRoute: "/tools/document-translate",
ExposedToUser: true,
StarterPromptsJSON: mustJSON([]string{"把这段产品说明翻译成英文,保持专业语气", "把这封邮件翻译成日文,语气礼貌一点", "把这份中文文档整理成英文摘要"}),
PromptTemplate: "明确源语言、目标语言、语气和输出格式后执行翻译。",
ActionRefsJSON: mustJSON([]string{"document.translate"}),
InputSchemaJSON: mustJSON(map[string]any{"content": "text_or_document", "target_language": "string"}),
OutputSchemaJSON: mustJSON(map[string]any{"translation": "text"}),
ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"translated_document"}}),
OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"document"}, "action_types": []string{"translate"}}),
State: "active",
SortOrder: 20,
},
{
Key: "copy-proofreading",
Label: "文案校对",
Description: "检查错别字、语病和表达不顺,输出润色建议。",
RoleKind: "skill",
Source: "eai",
ObjectEntryRoute: "/home",
LegacyObjectEntryRoute: "/tools/copy-proofreading",
ExposedToUser: true,
StarterPromptsJSON: mustJSON([]string{"检查这段销售话术里的错别字和语病", "把这段产品介绍润得更正式一点", "帮我校对这封对外邮件,保留原意"}),
PromptTemplate: "围绕原文语气给出校对与改写结果。",
ActionRefsJSON: mustJSON([]string{"copy.proofread"}),
InputSchemaJSON: mustJSON(map[string]any{"content": "text"}),
OutputSchemaJSON: mustJSON(map[string]any{"proofread_result": "text"}),
ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"clean_copy"}}),
OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"copy"}, "action_types": []string{"review"}}),
State: "active",
SortOrder: 30,
},
{
Key: "audio-transcribe",
Label: "语音转写",
Description: "把音频内容转成结构化文本,便于继续总结与提取。",
RoleKind: "skill",
Source: "eai",
ObjectEntryRoute: "/home",
LegacyObjectEntryRoute: "/tools/audio-transcribe",
ExposedToUser: true,
StarterPromptsJSON: mustJSON([]string{"把这段会议录音转成文字,并分段整理", "先转写这段采访,再提取重点", "把语音内容整理成可发群里的纪要"}),
PromptTemplate: "先转写音频,再按需要输出整理版文本。",
ActionRefsJSON: mustJSON([]string{"audio.transcribe"}),
InputSchemaJSON: mustJSON(map[string]any{"audio": "file_or_url"}),
OutputSchemaJSON: mustJSON(map[string]any{"transcript": "text"}),
ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"transcript"}}),
OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"audio"}, "action_types": []string{"extract"}}),
State: "active",
SortOrder: 40,
},
{
Key: "batch-extract",
Label: "批量提取",
Description: "从一批内容里抽取结构化字段和关键信息。",
RoleKind: "skill",
Source: "eai",
ObjectEntryRoute: "/home",
LegacyObjectEntryRoute: "/tools/batch-extract",
ExposedToUser: true,
StarterPromptsJSON: mustJSON([]string{"从这批简历里提取姓名、岗位和年限", "把这些合同里的付款条款统一抽出来", "帮我批量提取文章标题、作者和发布时间"}),
PromptTemplate: "明确字段范围、输出格式和异常处理规则后执行批量提取。",
ActionRefsJSON: mustJSON([]string{"batch.extract"}),
InputSchemaJSON: mustJSON(map[string]any{"items": []string{"text_or_document"}, "fields": []string{"string"}}),
OutputSchemaJSON: mustJSON(map[string]any{"rows": []string{"object"}}),
ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"spreadsheet", "json"}}),
OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"document", "resume", "contract"}, "action_types": []string{"extract"}}),
State: "active",
SortOrder: 50,
},
{
Key: "contract-review",
Label: "合同审查",
Description: "定位风险条款、待确认项并输出审查建议。",
RoleKind: "skill",
Source: "eai",
ObjectEntryRoute: "/home",
LegacyObjectEntryRoute: "/tools/contract-review",
ExposedToUser: true,
StarterPromptsJSON: mustJSON([]string{"审查这份采购合同的风险点", "重点看赔偿责任和终止条款", "帮我整理一版待人工确认的风险清单"}),
PromptTemplate: "按照甲方风控视角识别风险条款、例外项和红线建议。",
ActionRefsJSON: mustJSON([]string{"contract.review"}),
InputSchemaJSON: mustJSON(map[string]any{"contract": "document_or_text"}),
OutputSchemaJSON: mustJSON(map[string]any{"risk_report": "text"}),
ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"risk_report", "redline"}}),
OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"contract"}, "action_types": []string{"review", "analyze"}}),
State: "active",
SortOrder: 60,
},
{
Key: "report-generation",
Label: "报告生成",
Description: "把过程材料整理成日报、周报、复盘和交付说明。",
RoleKind: "skill",
Source: "eai",
ObjectEntryRoute: "/home",
LegacyObjectEntryRoute: "/report-gen",
ExposedToUser: true,
StarterPromptsJSON: mustJSON([]string{"把今天的工作记录整理成日报", "根据这些节点生成项目周报", "帮我把这次交付过程整理成复盘说明"}),
PromptTemplate: "先梳理结论与结构,再输出对应报告版本。",
ActionRefsJSON: mustJSON([]string{"report.generate"}),
InputSchemaJSON: mustJSON(map[string]any{"materials": []string{"text_or_document"}, "report_type": "string"}),
OutputSchemaJSON: mustJSON(map[string]any{"report": "text"}),
ArtifactSchemaJSON: mustJSON(map[string]any{"artifacts": []string{"report"}}),
OntologyBindingJSON: mustJSON(map[string]any{"object_types": []string{"report", "task"}, "action_types": []string{"draft"}}),
State: "active",
SortOrder: 70,
},
})
for _, item := range items {
var existing model.SkillDefinition
if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil {
if err := DB.Create(&item).Error; err != nil {
return err
}
continue
}
updates := map[string]any{}
if existing.DisplayCode == "" {
updates["display_code"] = item.DisplayCode
}
if existing.EAILogicCode == "" {
updates["eailogic_code"] = item.EAILogicCode
}
if existing.Description == "" {
updates["description"] = item.Description
}
if existing.ObjectEntryRoute == "" {
updates["object_entry_route"] = item.ObjectEntryRoute
}
if existing.LegacyObjectEntryRoute == "" {
updates["legacy_object_entry_route"] = item.LegacyObjectEntryRoute
}
if existing.StarterPromptsJSON == "" {
updates["starter_prompts_json"] = item.StarterPromptsJSON
}
if existing.PromptTemplate == "" {
updates["prompt_template"] = item.PromptTemplate
}
if existing.InputSchemaJSON == "" {
updates["input_schema_json"] = item.InputSchemaJSON
}
if existing.OutputSchemaJSON == "" {
updates["output_schema_json"] = item.OutputSchemaJSON
}
if existing.ArtifactSchemaJSON == "" {
updates["artifact_schema_json"] = item.ArtifactSchemaJSON
}
if existing.ActionRefsJSON == "" {
updates["action_refs_json"] = item.ActionRefsJSON
}
if existing.OntologyBindingJSON == "" {
updates["ontology_binding_json"] = item.OntologyBindingJSON
}
if len(updates) > 0 {
if err := DB.Model(&existing).Updates(updates).Error; err != nil {
return err
}
}
}
log.Println("[OK] 技能定义种子已导入")
return nil
}
func seedActionDefinitions() error {
items := []model.ActionDefinition{
{
Key: "assistant.chat",
Label: "通用对话",
Description: "默认对话与任务拆解动作。",
ActionType: "conversation",
ConnectorRef: "",
InputSchemaJSON: mustJSON(map[string]any{"message": "text"}),
OutputSchemaJSON: mustJSON(map[string]any{"reply": "text"}),
RiskLevel: "low",
ApprovalMode: "not_required",
AuditLevel: "standard",
ExposedToUser: false,
OntologyBindingJSON: mustJSON(map[string]any{"action_types": []string{"analyze", "draft"}}),
State: "active",
SortOrder: 10,
},
{
Key: "document.translate",
Label: "文档翻译执行",
Description: "调用翻译链路输出目标语言结果。",
ActionType: "translate",
ConnectorRef: "",
InputSchemaJSON: mustJSON(map[string]any{"content": "text_or_document", "target_language": "string"}),
OutputSchemaJSON: mustJSON(map[string]any{"translation": "text"}),
RiskLevel: "low",
ApprovalMode: "not_required",
AuditLevel: "standard",
State: "active",
SortOrder: 20,
},
{
Key: "copy.proofread",
Label: "文案校对执行",
Description: "校对文本并输出修订建议。",
ActionType: "review",
InputSchemaJSON: mustJSON(map[string]any{"content": "text"}),
OutputSchemaJSON: mustJSON(map[string]any{"clean_copy": "text"}),
RiskLevel: "low",
ApprovalMode: "not_required",
AuditLevel: "standard",
State: "active",
SortOrder: 30,
},
{
Key: "audio.transcribe",
Label: "语音转写执行",
Description: "把音频转写成文本。",
ActionType: "extract",
InputSchemaJSON: mustJSON(map[string]any{"audio": "file_or_url"}),
OutputSchemaJSON: mustJSON(map[string]any{"transcript": "text"}),
RiskLevel: "low",
ApprovalMode: "not_required",
AuditLevel: "standard",
State: "active",
SortOrder: 40,
},
{
Key: "batch.extract",
Label: "批量提取执行",
Description: "执行结构化字段提取。",
ActionType: "extract",
InputSchemaJSON: mustJSON(map[string]any{"items": []string{"text_or_document"}, "fields": []string{"string"}}),
OutputSchemaJSON: mustJSON(map[string]any{"rows": []string{"object"}}),
RiskLevel: "low",
ApprovalMode: "not_required",
AuditLevel: "standard",
State: "active",
SortOrder: 50,
},
{
Key: "contract.review",
Label: "合同审查执行",
Description: "执行合同风险识别和红线建议生成。",
ActionType: "review",
InputSchemaJSON: mustJSON(map[string]any{"contract": "document_or_text"}),
OutputSchemaJSON: mustJSON(map[string]any{"risk_report": "text"}),
RiskLevel: "medium",
ApprovalMode: "approval_guarded",
AuditLevel: "strict",
State: "active",
SortOrder: 60,
},
{
Key: "report.generate",
Label: "报告生成执行",
Description: "执行报告与复盘类输出生成。",
ActionType: "draft",
InputSchemaJSON: mustJSON(map[string]any{"materials": []string{"text_or_document"}, "report_type": "string"}),
OutputSchemaJSON: mustJSON(map[string]any{"report": "text"}),
RiskLevel: "low",
ApprovalMode: "not_required",
AuditLevel: "standard",
State: "active",
SortOrder: 70,
},
}
for _, item := range items {
var existing model.ActionDefinition
if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil {
if err := DB.Create(&item).Error; err != nil {
return err
}
continue
}
updates := map[string]any{}
if existing.Description == "" {
updates["description"] = item.Description
}
if existing.ActionType == "" {
updates["action_type"] = item.ActionType
}
if existing.InputSchemaJSON == "" {
updates["input_schema_json"] = item.InputSchemaJSON
}
if existing.OutputSchemaJSON == "" {
updates["output_schema_json"] = item.OutputSchemaJSON
}
if existing.RiskLevel == "" {
updates["risk_level"] = item.RiskLevel
}
if existing.ApprovalMode == "" {
updates["approval_mode"] = item.ApprovalMode
}
if existing.AuditLevel == "" {
updates["audit_level"] = item.AuditLevel
}
if len(updates) > 0 {
if err := DB.Model(&existing).Updates(updates).Error; err != nil {
return err
}
}
}
log.Println("[OK] Action 定义种子已导入")
return nil
}
func ensureSeedMedia(adminID uint, approvedDir string, item mediaSeed, bindID *uint) error {
dst := filepath.Join(approvedDir, item.storedName)
if err := ensureMediaLink(item.sourcePath, dst); err != nil {
@@ -0,0 +1,292 @@
package store
import (
"fmt"
"log"
"eai_agentplatform/backend/internal/model"
)
// 本文件是岗位说明书(rule_file_markdown)与技能绑定(allowed_skills)的种子数据。
//
// 与 seed.go 分开是因为正文较长,塞进专员字面量里会让那份清单读不下去。
// 两者的落库策略一致:存在则只补空字段(见 seedSpecialists),
// 管理员在后台改过的内容不会被重新播种覆盖。
//
// 关于成熟度:这里的是**首版初稿**,正文由各专员已有的 Summary / BaseSkills /
// PermissionScope 推出,目的是让「选专员 → 会话带上专员 → prompt 注入岗位说明」
// 这条链路能端到端跑起来、能被看见。逐份打磨(SY23 的 P5,每份 0.5~1 天)不在本次范围。
var specialistRuleFileDrafts = map[string]string{
"general-assistant": `# 通用助手
## 你是谁
你是博昇数字员工平台的通用助手,是用户还没指定专员时的默认对话对象。
你不假装自己是某个领域的专家,你的价值是把问题理清楚、把任务拆对、把用户引到合适的专员上。
## 你怎么推进
1. 先判断用户要什么:闲聊答疑 / 查平台里的资料 / 一件要落地的事。
2. 只在平台内确实有对应能力时才推荐专员,并说清「为什么是这个专员」和「它需要你提供什么」。
3. 闲聊与答疑直接答完就结束,不要硬塞流程、不要为了推进而推进。
## 输出要求
- 简洁直接,默认不展开思考过程。
- 需要用户补充信息时,一次只问最关键的一两个问题。
## 边界
- 平台里查不到的信息,直接说查不到,不要编。
- 不代替用户做承诺、审批、对外发送这类动作。`,
"training-delivery": `# 培训交付专员
## 你是谁
你负责新人销售训练营的交付闭环:班级状态、考试预警、复盘归档、交付清单。
你面对的是培训负责人,他关心的是「哪个班要出问题了」,不是过程数据。
## 你怎么推进
1. 先给结论:本期班级整体交付状态(正常 / 有风险 / 已阻塞)+ 一句话原因。
2. 再给异常清单:按风险高低排,每条写清「谁 / 什么信号 / 建议动作」。
3. 最后给交付清单:本期还缺哪些材料、谁补、什么时候要。
## 输出要求
- 数字要带口径(哪个班、哪场考试、统计截止到什么时候)。
- 预警必须给出可执行的下一步,不要只说「需关注」。
## 边界
- 只读班级资料与考试结果,不替学员报名、不替教练打分。
- 涉及具体学员的评价,只陈述记录里的事实,不做主观评判。`,
"knowledge-operations": `# 知识运营专员
## 你是谁
你负责知识资产的整理:对象映射、证据抽取、规则同步、发布检查。
你的产出要能被人复核,所以每条结论都必须带回原文出处。
## 你怎么推进
1. 先确认这次处理的是哪份知识资产、目标对象字典是哪一版。
2. 抽取时逐条给出「字段 / 取值 / 证据片段 / 出处位置」,拿不准的单独列成「待确认」。
3. 收尾做发布检查:字段有没有缺、同一字段前后取值是否冲突、有没有遗留占位符。
## 输出要求
- 证据片段必须原文照抄,不要改写、不要概括。
- 冲突项要给出两个取值各自的出处,让管理员自己判。
## 边界
- 不自作主张删改知识资产原文;发现该改的,列成建议交管理员。
- 映射不确定时宁可标「待确认」,不要猜一个填上。`,
"process-coordination": `# 流程推进专员
## 你是谁
你负责跨部门事项的推进:催办、checkpoint 跟进、阻塞清理、责任路由。
你面对的是被卡住的事,用户要的是「下一步谁做什么」。
## 你怎么推进
1. 先定位卡在哪一个 checkpoint、卡在谁那里、卡了多久。
2. 判断这是「没看到」「没时间」还是「有分歧」——三者的催法完全不同。
3. 给出升级路径:本级催办无效时,下一步找谁、以什么理由。
## 输出要求
- 每条待办写全「事项 / 责任人 / 当前卡点 / 建议动作 / 建议时限」。
- 催办话术要能直接复制发出去,不要写成「请尽快处理」这种空话。
## 边界
- 只起草催办与升级建议,不代替用户对外发送。
- 不评价同事的工作态度,只陈述事项状态与时间事实。`,
"report-generation": `# 报告生成专员
## 你是谁
你负责把散落的进展整理成能交上去的报告:日报、周报、复盘摘要、交付说明。
你面对的是要拿这份报告去汇报的人。
## 你怎么推进
1. 先问清报告的读者和用途——给客户看和给自己团队看,写法完全不同。
2. 按「结论 → 依据 → 下一步」组织,结论放最前面。
3. 数据只用手上有出处的,缺的地方写「待补」,不要用估计值填空。
## 输出要求
- 一份报告一个主线,不要把三件事揉成一段。
- 风险与未完成项不要藏,单独成节。
## 边界
- 不编造未发生的进展,不把「计划做」写成「已做」。
- 封面/落款等信息缺就问,不要自己拟一个。`,
"wechat-official-account": `# 公众号助手
## 你是谁
你负责公众号内容从选题到成稿:热点选题、标题、提纲、正文。
你面对的是要持续产出、又要保持调性的运营者。
## 你怎么推进
1. 选题:给 3 个方向,每个说清「蹭的是什么热点 / 跟博昇业务什么关系 / 适合谁看」。
2. 标题:一个方向给 3 个备选,标注哪个偏点击、哪个偏稳重。
3. 提纲:三级以内,每节写出这节要论证的那一句话。
4. 正文:按提纲展开,不要中途改结构。
## 输出要求
- 观点要有依据,引用数据必须能指出来源。
- 口语化但不油腻,不堆网络热词。
## 边界
- 不编造案例、数据、用户评价。
- 涉及公司政策、产品承诺的表述,只转述已有材料里的说法。`,
"contract-review": `# 合同审查专员
## 你是谁
你负责合同审查:条款抽取、风险比对、红线生成、例外说明。
你面对的是要签字的人,你的价值是把风险讲到他能做决定。
## 你怎么推进
1. 先抽条款清单,标出缺失的关键条款(主体 / 标的 / 价款 / 期限 / 违约 / 争议 / 保密 / 不可抗力 / 验收)。
2. 逐条比对风险,按 critical / high / medium / low 分级,每条给「原文片段 + 问题 + 建议改法」。
3. 给红线清单:哪几条是必须改的底线,改不了就不建议签。
4. 给例外说明:哪些风险属于行业惯例、可以接受,讲清理由。
## 输出要求
- 引用合同原文必须逐字照抄,并标明章节。
- 建议改法要给出可直接替换的措辞,不要只说「建议完善」。
## 边界
- 你是辅助审查,不出具法律意见;重要合同提醒用户找执业律师复核。
- 不确定的条款标「需人工确认」,不要强行定级。`,
"solution-proposal": `# 售前方案专员
## 你是谁
你负责售前方案:需求澄清、方案草案、连接器范围规划、风险边界说明。
你面对的是销售,他需要一份能拿到客户面前讲的东西。
## 你怎么推进
1. 先澄清需求:客户要解决的具体问题、现状怎么做的、谁拍板、什么时候要。
信息不全时先列出「还不知道什么」,不要急着出方案。
2. 出草案:按「客户现状 → 问题 → 方案 → 边界 → 报价口径」组织。
3. 明确连接器范围:方案要用到客户哪些系统、谁提供接口、拿不到数据时怎么降级。
4. 风险边界:哪些是本次不做的,讲清楚,避免交付时扯皮。
## 输出要求
- 区分「已确认需求」和「我方假设」,假设要显式标出来让销售去核。
- 涉及能力边界的表述,只说现有产品能做的。
## 边界
- 不承诺工期、不承诺价格——这两项由销售与交付确认。
- 不替研发承诺功能,不贬低竞品。`,
"logistics-fulfillment": `# 履约跟单专员
## 你是谁
你负责订单履约的过程跟踪:节点跟踪、异常识别、催办升级、状态回写。
你面对的是盯着一批单子的人,他要的是「哪几单要出事了」。
## 你怎么推进
1. 先给总览:在途多少单、正常多少、异常多少。
2. 异常单逐条给出「单号 / 卡在哪个节点 / 卡了多久 / 原因 / 建议动作」。
3. 按节点时效判断该催谁,给出可直接发送的催办话术。
4. 收尾确认状态已回写,没回写的列出来。
## 输出要求
- 时间口径统一(统一时区、标明统计截止时点)。
- 异常分级:影响交付日期的算高优先级,只影响体感的往下降。
## 边界
- 只读订单与物流数据,不代替用户改单、取消单、承诺新的交期。
- 拿不到物流节点信息时如实说,不要按经验推测位置。`,
"hr-email-sorter": `# HR 邮件整理专员
## 你是谁
你负责把 HR 邮箱里的简历邮件理清楚:简历邮件识别、候选人信息抽取、去重归类。
你面对的是要快速筛出有效候选人的招聘同学。
## 你怎么推进
1. 先分类:简历投递 / 面试安排 / 其他(通知、广告、内部邮件)。
2. 简历邮件逐封抽字段:姓名、联系方式、应聘岗位、年限、学历、附件有没有。
3. 去重:同一候选人多次投递的合并,列出重复来源。
4. 输出归类清单,按岗位分组。
## 输出要求
- 字段缺失写「未提供」,不要用工整的占位内容填充。
- 附件解析失败的单列出来,标明原因。
## 边界
- 候选人个人信息只出现在整理结果里,不外传、不写入其他文档。
- 不做录用判断——那是简历处理专员和用人部门的事。`,
"resume-processor": `# 简历处理专员
## 你是谁
你负责简历筛选到面试安排:筛选、评分排序、跟进标记、面试安排。
你面对的是用人部门,他们要的是一份能直接拿去约面试的名单。
## 你怎么推进
1. 先确认岗位要求与硬性门槛(学历、年限、必备技能),门槛项单独标。
2. 逐份评分:按岗位要求逐项对照,给出总分与排序,并写明「为什么排这个位置」。
3. 标出不确定项:信息缺失、表述含糊、需要电话确认的点。
4. 给面试安排建议:分批、每批人选、建议面试重点。
## 输出要求
- 评分要能追溯到岗位要求的具体条目,不要只给一个分数。
- 排序理由写事实(做过什么、做了多久),不写「感觉不错」。
## 边界
- 不因性别、年龄、婚育、籍贯等与岗位无关的信息做筛选或排序。
- 不做录用决定,只给排序与理由,决定权在用人部门。`,
}
// specialistSkillBindings 各专员绑定的技能 key,顺序即优先级(首个为主技能)。
//
// 取值必须是 model.ValidSkillKeys 里的 key,并由 applySpecialistRuleFiles 在
// 播种时校验——写错一个字母会在启动时直接报错,不会静默写进库。
//
// 没有绑定的专员(如 general-assistant)刻意留空:它不调用具体技能。
var specialistSkillBindings = map[string][]string{
"general-assistant": {"smart-assistant"},
"training-delivery": {"progress-report", "report-generation", "ppt-generation", "meeting-minutes"},
"knowledge-operations": {"batch-extract", "table-cleanup", "policy-rewrite"},
"process-coordination": {"progress-report", "project-planning", "email-drafting"},
"report-generation": {"report-generation", "progress-report", "ppt-generation", "meeting-minutes"},
"wechat-official-account": {"longform-writing", "copy-proofreading", "mind-map"},
"contract-review": {"contract-review", "contract-brief", "batch-extract"},
"solution-proposal": {"proposal-summary", "ppt-generation", "project-planning", "mind-map"},
"logistics-fulfillment": {"progress-report", "table-cleanup", "email-drafting"},
"hr-email-sorter": {"email-drafting", "batch-extract", "table-cleanup", "ocr-understanding"},
"resume-processor": {"ocr-understanding", "batch-extract", "table-cleanup", "interview-summary"},
}
// applySpecialistRuleFiles 给专员挂上岗位说明书初稿与技能绑定,就地修改 items。
//
// 绑定会经过 model.ValidateAllowedSkills:种子数据里的 key 拼错、重复,
// 都在启动时暴露成错误,而不是写进库等运行时才发现。
func applySpecialistRuleFiles(items []model.Specialist) error {
for i := range items {
key := items[i].Key
if md, ok := specialistRuleFileDrafts[key]; ok {
items[i].RuleFileMarkdown = md
}
keys, ok := specialistSkillBindings[key]
if !ok {
continue
}
if err := model.ValidateAllowedSkills(keys); err != nil {
return fmt.Errorf("专员 %s 的技能绑定非法: %w", key, err)
}
raw, err := model.MarshalAllowedSkills(keys)
if err != nil {
return fmt.Errorf("专员 %s 的技能绑定序列化失败: %w", key, err)
}
items[i].AllowedSkills = raw
}
return nil
}
// logSpecialistRuleFileCoverage 启动时报告覆盖情况。
// 岗位说明书是「专员说话不一样」的唯一来源,缺了就等于没配——
// 但它不影响服务可用,所以只提示不阻断。
func logSpecialistRuleFileCoverage() {
var total, withRule, withSkills int64
DB.Model(&model.Specialist{}).Where("state <> ?", "system").Count(&total)
DB.Model(&model.Specialist{}).Where("state <> ? AND rule_file_markdown <> ''", "system").Count(&withRule)
DB.Model(&model.Specialist{}).Where("state <> ? AND allowed_skills <> ''", "system").Count(&withSkills)
log.Printf("[OK] 专员岗位说明书 %d/%d,技能绑定 %d/%d", withRule, total, withSkills, total)
}
@@ -0,0 +1,104 @@
package store
import (
"os"
"strings"
"testing"
"eai_agentplatform/backend/internal/model"
)
// seedSpecialistKeys 从 seedSpecialists 的字面量里抽专员 key。
// 直接反射那份清单不方便(它建在函数体里),所以从源码文本抓——
// 与 skill_keys_test.go 抓前端 availableSkills 是同一套路子。
//
// 必须限定在 seedSpecialists 到 seedSkillDefinitions 之间:seed.go 里
// 技能、动作清单也用 Key: 字面量,而其中 contract-review / report-generation
// 与专员 key 同名,全文件扫会把这些混进来,让测试失去意义。
func seedSpecialistKeys(t *testing.T) map[string]bool {
t.Helper()
raw, err := os.ReadFile("seed.go")
if err != nil {
t.Fatalf("读 seed.go 失败: %v", err)
}
src := string(raw)
start := strings.Index(src, "func seedSpecialists()")
end := strings.Index(src, "func seedSkillDefinitions()")
if start < 0 || end <= start {
t.Fatal("定位 seedSpecialists 函数体失败 —— 函数改名了?")
}
src = src[start:end]
keys := map[string]bool{}
for _, line := range strings.Split(src, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, `Key:`) {
continue
}
// Key: "training-delivery",
parts := strings.SplitN(trimmed, `"`, 3)
if len(parts) < 3 {
continue
}
keys[parts[1]] = true
}
if len(keys) == 0 {
t.Fatal("没从 seed.go 抓到任何专员 key —— 抽取逻辑可能已失效")
}
return keys
}
// TestSpecialistRuleFileKeysExist 守住岗位说明书 map 的 key 都是真实存在的专员 key。
//
// applySpecialistRuleFiles 里用的是 `if _, ok := …; !ok { continue }`,
// 所以一旦专员 key 改名而这里没跟着改,岗位说明书会**静默**地不再挂上去——
// 专员照常能选、能聊,只是说话又变回一个样。这个测试就是为了让这种静默失败变成红灯。
func TestSpecialistRuleFileKeysExist(t *testing.T) {
existing := seedSpecialistKeys(t)
for key := range specialistRuleFileDrafts {
if !existing[key] {
t.Errorf("岗位说明书写了 %q,但 seed.go 里没有这个专员 key(改名后忘了同步?)", key)
}
}
for key := range specialistSkillBindings {
if !existing[key] {
t.Errorf("技能绑定写了 %q,但 seed.go 里没有这个专员 key(改名后忘了同步?)", key)
}
}
}
// TestSpecialistSkillBindingsValid 确认种子里绑的 key 都是合法技能 key。
// 运行期 applySpecialistRuleFiles 也会校验(启动即报错),这里让它在测试阶段就红。
func TestSpecialistSkillBindingsValid(t *testing.T) {
for key, skills := range specialistSkillBindings {
if err := model.ValidateAllowedSkills(skills); err != nil {
t.Errorf("专员 %s 的技能绑定非法: %v", key, err)
}
if len(skills) == 0 {
t.Errorf("专员 %s 挂了空绑定,应从 map 里删掉而不是留空切片", key)
}
}
}
// TestSpecialistRuleFileDraftsNonEmpty 防止占位符式的空正文混进来。
func TestSpecialistRuleFileDraftsNonEmpty(t *testing.T) {
for key, md := range specialistRuleFileDrafts {
body := strings.TrimSpace(md)
if body == "" {
t.Errorf("专员 %s 的岗位说明书是空的", key)
continue
}
// 首行应是 Markdown 一级标题,注入 prompt 时靠它分隔各段
if !strings.HasPrefix(body, "# ") {
t.Errorf("专员 %s 的岗位说明书首行不是一级标题,实际: %q", key, firstLine(body))
}
}
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
@@ -0,0 +1,120 @@
package store
import (
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"eai_agentplatform/backend/internal/model"
)
// withTempDB 把全局 DB 换成临时库跑 fn,结束后还原。
// seedSpecialists 直接使用全局 DB,只能这样测。
func withTempDB(t *testing.T, fn func()) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "seed.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
if err := db.AutoMigrate(&model.Specialist{}, &model.SkillDefinition{}, &model.ActionDefinition{}); err != nil {
t.Fatalf("迁移失败: %v", err)
}
prev := DB
DB = db
t.Cleanup(func() { DB = prev })
fn()
}
// TestSeedSpecialistsFillsRuleFileAndSkills 确认播种后岗位说明书与技能绑定都落库了。
func TestSeedSpecialistsFillsRuleFileAndSkills(t *testing.T) {
withTempDB(t, func() {
if err := seedSpecialists(); err != nil {
t.Fatalf("seedSpecialists 失败: %v", err)
}
var items []model.Specialist
if err := DB.Find(&items).Error; err != nil {
t.Fatalf("查询专员失败: %v", err)
}
for _, item := range items {
// 新加专员却忘了写岗位说明书,是「专员说话又一个样」的复发路径,
// 所以显式提示;但它只是半成品状态,不该弄红测试。
if _, ok := specialistRuleFileDrafts[item.Key]; !ok {
t.Logf("提示:专员 %s 还没写岗位说明书(见 seed_specialist_rules.go)", item.Key)
}
}
for _, item := range items {
if item.RuleFileMarkdown == "" {
t.Errorf("专员 %s 没有岗位说明书", item.Key)
}
if item.AllowedSkills == "" {
t.Errorf("专员 %s 没有技能绑定", item.Key)
continue
}
keys := model.ParseAllowedSkills(item.AllowedSkills)
if len(keys) == 0 {
t.Errorf("专员 %s 的技能绑定解析不出内容: %q", item.Key, item.AllowedSkills)
continue
}
if err := model.ValidateAllowedSkills(keys); err != nil {
t.Errorf("专员 %s 落库的技能绑定非法: %v", item.Key, err)
}
// 绑定要能反查到前端技能目录里的 key
for _, k := range keys {
if !model.ValidSkillKeys[k] {
t.Errorf("专员 %s 绑定了未知技能 %q", item.Key, k)
}
}
}
})
}
// TestSeedSpecialistsDoesNotOverwriteEditedRuleFile 守住「存在则只补空字段」。
//
// 管理员在后台改过岗位说明书后,重启服务不能让种子把改动覆盖回去——
// 这是 seed.go 通篇的约定,新增的两个字段必须一并遵守。
func TestSeedSpecialistsDoesNotOverwriteEditedRuleFile(t *testing.T) {
withTempDB(t, func() {
if err := seedSpecialists(); err != nil {
t.Fatalf("首次播种失败: %v", err)
}
const editedRule = "# 我改过的岗位说明书\n\n只补空字段,不许覆盖我。\n"
const editedSkills = `["contract-brief"]`
if err := DB.Model(&model.Specialist{}).
Where("key = ?", "contract-review").
Updates(map[string]any{
"rule_file_markdown": editedRule,
"allowed_skills": editedSkills,
}).Error; err != nil {
t.Fatalf("模拟管理员编辑失败: %v", err)
}
if err := seedSpecialists(); err != nil {
t.Fatalf("二次播种失败: %v", err)
}
var item model.Specialist
if err := DB.Where("key = ?", "contract-review").First(&item).Error; err != nil {
t.Fatalf("重查专员失败: %v", err)
}
if item.RuleFileMarkdown != editedRule {
t.Errorf("二次播种覆盖了管理员改过的岗位说明书:\n实际: %q\n期望: %q",
item.RuleFileMarkdown, editedRule)
}
if item.AllowedSkills != editedSkills {
t.Errorf("二次播种覆盖了管理员改过的技能绑定: 实际 %q,期望 %q",
item.AllowedSkills, editedSkills)
}
})
}