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