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
@@ -0,0 +1,625 @@
<template>
<div class="catalog-detail-page">
<section v-if="showBackButton" class="detail-toolbar">
<button class="back-btn" type="button" @click="emit('close')">返回目录</button>
</section>
<section class="detail-modal-card" :style="{ '--accent': detail.color || '#409eff' }">
<div class="detail-header">
<div class="detail-avatar">{{ detail.avatar }}</div>
<div class="detail-header-main">
<div class="detail-title">{{ detail.title }}</div>
<div v-if="detail.display_code || detail.eailogic_code" class="detail-code-row">
<span v-if="detail.display_code" class="detail-code-chip">{{ detail.display_code }}</span>
<span v-if="detail.eailogic_code" class="detail-code-chip subtle">{{ detail.eailogic_code }}</span>
</div>
<div v-if="detail.subtitle" class="detail-subtitle">{{ detail.subtitle }}</div>
<div v-if="detail.metaText" class="detail-meta-text">{{ detail.metaText }}</div>
</div>
<span v-if="detail.badge" class="detail-badge">{{ detail.badge }}</span>
</div>
<p class="detail-description">{{ detail.description }}</p>
<div v-if="detail.tags.length" class="detail-tags">
<span v-for="tag in detail.tags" :key="tag" class="detail-tag">{{ tag }}</span>
</div>
<section v-if="detail.sceneText" class="detail-section">
<div class="detail-section-title">{{ detail.sceneTitle }}</div>
<p class="detail-section-text">{{ detail.sceneText }}</p>
</section>
<section
v-for="section in detail.extraSections"
:key="section.title"
class="detail-section"
>
<div class="detail-section-title">{{ section.title }}</div>
<div class="detail-bullet-list">
<div v-for="item in section.items" :key="item" class="detail-bullet-item">{{ item }}</div>
</div>
</section>
<section v-if="detail.prompts.length" class="detail-section">
<div class="detail-section-title">{{ detail.promptTitle }}</div>
<div class="detail-prompt-list">
<button
v-for="prompt in detail.prompts"
:key="prompt"
class="detail-prompt-button"
type="button"
>
“ {{ prompt }} ”
</button>
</div>
</section>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { listConnectors } from '@/api/connector'
import { getSpecialistByKey } from '@/api/specialist'
import { availableSkills, businessApps } from '@/config/workbench'
import { productizedApps } from '@/config/productizedApps'
const props = defineProps({
catalogType: {
type: String,
required: true,
},
objectKey: {
type: String,
required: true,
},
showBackButton: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close'])
const remoteConnectors = ref([])
// 专员的技能绑定与信源只有后端有(前端 businessApps 是静态配置)。
// 拉不到就退回只显示前端已知的内容,页面不会因此空掉或报错。
const remoteSpecialist = ref(null)
const catalogApps = [
{
key: 'knowledge-hub',
display_code: '应01',
eailogic_code: 'EAI-A-01',
label: '知识库',
marketTag: '内建 APP',
workerType: 'adw',
tier: 'platform',
color: '#5b7cff',
summary: '组织级默认存在的知识底座 APP,为专家、技能与其它 APP 提供知识支持。',
description: '知识库是平台里的知识底座,用来沉淀资料、规则和证据,并为专家、技能和 APP 提供统一知识支持。',
prompts: ['我想先整理这批知识资料', '帮我看看这部分内容适合怎么入库', '知识库更适合承接哪些任务'],
tags: ['知识沉淀', '证据引用', '组织底座'],
},
{
key: 'internal-exam',
display_code: '应02',
eailogic_code: 'EAI-A-02',
label: '内部考试APP',
marketTag: '长程 APP',
workerType: 'adw',
tier: 'business',
color: '#29b36a',
summary: '围绕练习、自测、正式考试、记录、证书和岗位应学组织的内部考试 APP。',
description: '内部考试 APP 用来承接训练、练习、考试、成绩记录和证书结果,适合需要完整过程管理的学习场景。',
prompts: ['我想组织一轮内部考试', '帮我看看这个考试流程怎么搭', '这个场景适合放到内部考试 APP 吗'],
tags: ['训练流程', '考试记录', '结果沉淀'],
},
{
key: 'internal-training',
display_code: '应03',
eailogic_code: 'EAI-A-03',
label: '内部培训APP',
marketTag: '长程 APP',
workerType: 'worker',
tier: 'business',
color: '#9b6bff',
summary: '统一承接销售培训、产品知识和公司介绍三个培训模块的内部培训 APP。',
description: '内部培训 APP 用来把销售培训、产品知识和公司介绍收在同一个对象下面,避免把同一条培训链拆成多个 APP。',
prompts: ['我想搭一个内部培训 APP', '帮我梳理销售培训、产品知识和公司介绍的关系', '这个培训主题应该挂到内部培训 APP 吗'],
tags: ['课程组织', '产品知识', '组织导览'],
},
].concat(productizedApps.map((item) => ({
key: item.key,
display_code: item.display_code,
eailogic_code: item.eailogic_code,
label: item.label,
marketTag: item.badge,
workerType: item.skillKey ? 'worker' : 'adw',
tier: 'business',
color: item.color,
summary: item.summary,
description: item.description,
prompts: item.prompts,
tags: item.tags,
})))
const resolvedCatalogType = computed(() => String(props.catalogType || 'specialists'))
const resolvedObjectKey = computed(() => decodeURIComponent(String(props.objectKey || '')))
function withFallbackCodes(items, { displayPrefix, logicPrefix, start = 1 }) {
return items.map((item, index) => {
const sequence = String(start + index).padStart(2, '0')
return {
...item,
display_code: item.display_code || `${displayPrefix}${sequence}`,
eailogic_code: item.eailogic_code || `EAI-${logicPrefix}-${sequence}`,
}
})
}
const detail = computed(() => {
if (resolvedCatalogType.value === 'skills') {
const skill = availableSkills.find((item) => item.key === resolvedObjectKey.value)
if (!skill) return notFoundDetail('技能')
return {
title: skill.label,
display_code: skill.display_code,
eailogic_code: skill.eailogic_code,
subtitle: skill.roleCard?.relationshipToUser || '你的技能助手',
metaText: '已安装 · 在对话里直接挂载',
badge: '技能',
avatar: skill.label.slice(0, 1),
color: skill.color || '#409eff',
description:
skill.roleCard?.greeting ||
`这个技能主要负责 ${skill.caption || skill.label},适合你已经知道目标、希望直接开工的时候使用。`,
tags: normalizeArray(skill.capabilityTags).length
? normalizeArray(skill.capabilityTags)
: buildTags(skill.caption, skill.roleCard?.tagline),
sceneTitle: '适合什么时候用',
sceneText: buildSkillUseCase(skill),
extraSections: buildSkillExtraSections(skill),
promptTitle: '你可以直接这样发起',
prompts: normalizeArray(skill.roleCard?.starterPrompts),
}
}
if (resolvedCatalogType.value === 'apps') {
const app = catalogApps.find((item) => item.key === resolvedObjectKey.value)
if (!app) return notFoundDetail('APP')
return {
title: app.label,
display_code: app.display_code,
eailogic_code: app.eailogic_code,
subtitle: app.workerType === 'adw' ? '更偏向组织流程与协同推进' : '更偏向直接执行具体内容',
metaText: `${app.marketTag} · ${app.workerType === 'adw' ? '流程推进型' : '执行处理型'}`,
badge: 'APP',
avatar: app.label.slice(0, 1),
color: app.color || '#7c8cff',
description: app.description,
tags: normalizeArray(app.tags),
sceneTitle: '适合什么时候用',
sceneText: app.summary,
promptTitle: 'APP 帮你做',
prompts: normalizeArray(app.prompts),
}
}
if (resolvedCatalogType.value === 'connectors') {
const connector = withFallbackCodes(remoteConnectors.value, { displayPrefix: '连', logicPrefix: 'C' })
.find((item) => item.key === resolvedObjectKey.value)
if (!connector) return notFoundDetail('连接器')
return {
title: connector.label || connector.key,
display_code: connector.display_code,
eailogic_code: connector.eailogic_code,
subtitle: connector.mode === 'live' ? '已经接入真实实例' : '当前还是目录项',
metaText: `${connector.mode === 'live' ? '已接入' : '待接入'} · ${connectorKind(connector.direction)}`,
badge: '连接器',
avatar: (connector.label || connector.key || '连').slice(0, 1),
color: connector.mode === 'live' ? '#67c23a' : '#909399',
description:
connector.mode === 'live'
? '这个连接器已经接入真实系统,可以把外部对象或数据源带进平台里使用。'
: '这个连接器现在还是目录项,用来说明未来可以接入什么系统、访问哪些对象。',
tags: normalizeArray(connector.objects).slice(0, 6),
sceneTitle: '适合什么时候看',
sceneText:
connector.mode === 'live'
? '当你关心它能访问哪些对象、数据往哪个方向流,以及是否已经能被任务链路调用时,就适合来看这个连接器。'
: '当你在做目录规划或接入设计,想确认未来可连接什么系统时,就适合来看这个连接器。',
promptTitle: '你可以这样查看',
extraSections: [],
prompts: normalizeArray([
`这个连接器现在能访问哪些对象`,
`它适合连接什么系统`,
`它现在是已接入还是待接入`,
]),
}
}
const specialist = businessApps.find((item) => item.key === resolvedObjectKey.value)
if (!specialist) return notFoundDetail('专员')
return {
title: specialist.label,
display_code: specialist.display_code,
eailogic_code: specialist.eailogic_code,
subtitle: specialist.roleCard?.relationshipToUser || '你的协作搭档',
metaText: `${specialist.tier === 'industry' ? '行业专员' : '通用专员'} · ${specialist.workerType === 'adw' ? '流程推进型' : '执行处理型'} · ${specialist.marketTag || '可用'}`,
badge: '专员',
avatar: specialist.label.slice(0, 1),
color: specialist.color || '#409eff',
description:
specialist.roleCard?.greeting ||
`这个专员主要负责 ${specialist.roleCard?.tagline || specialist.label},可以陪你把事情往前推进。`,
tags: buildTags(specialist.roleCard?.tagline, specialist.summary),
sceneTitle: '适合什么时候用',
sceneText: buildSpecialistUseCase(specialist),
extraSections: buildSpecialistExtraSections(remoteSpecialist.value),
promptTitle: '专员帮你做',
prompts: normalizeArray(specialist.roleCard?.starterPrompts),
}
})
onMounted(async () => {
if (resolvedCatalogType.value === 'connectors') {
try {
const res = await listConnectors()
remoteConnectors.value = Array.isArray(res?.data) ? res.data : []
} catch (error) {
console.warn('load connector detail failed', error)
}
return
}
if (resolvedCatalogType.value === 'specialists') {
const key = resolvedObjectKey.value.trim()
if (!key) return
try {
const res = await getSpecialistByKey(key)
remoteSpecialist.value = res?.data || null
} catch (error) {
console.warn('load specialist detail failed', error)
}
}
})
function connectorKind(direction) {
if (direction === 'output') return '输出连接器'
if (direction === 'input') return '输入连接器'
return '双向连接器'
}
function notFoundDetail(typeLabel) {
return {
title: `${typeLabel}不存在`,
subtitle: '',
metaText: '',
badge: '未找到',
avatar: '?',
color: '#909399',
description: `当前目录中不存在 key = ${resolvedObjectKey.value} 的${typeLabel}对象。`,
tags: [],
sceneTitle: '',
sceneText: '',
extraSections: [],
promptTitle: '',
prompts: [],
}
}
function normalizeArray(values) {
return (Array.isArray(values) ? values : [])
.map((item) => String(item || '').trim())
.filter(Boolean)
}
function buildTags(...sources) {
const collected = sources
.flatMap((source) => splitKeywords(source))
.filter(Boolean)
return [...new Set(collected)].slice(0, 6)
}
function splitKeywords(value) {
return String(value || '')
.split(/[\/、,,和与·]/)
.map((item) => item.trim())
.filter((item) => item && item.length <= 12)
}
function buildSpecialistUseCase(specialist) {
const openingPrompt = String(specialist.roleCard?.openingPrompt || '').trim()
if (openingPrompt) {
return `当你已经有目标、材料,或者已经知道当前卡点时,就适合找它介入。${openingPrompt}`
}
return '当你想找一个专门的对象来协助推进、整理和交付时,就适合让它介入。'
}
function buildSkillUseCase(skill) {
const openingPrompt = String(skill.roleCard?.openingPrompt || '').trim()
const caption = String(skill.caption || skill.label || '').trim()
if (openingPrompt) {
return `当你已经明确想做“${caption}”,并且希望它直接开工、尽快给出产物时,就适合挂载这个技能。${openingPrompt}`
}
return `当你希望围绕“${caption}”直接进入执行,而不是先做大量讨论时,就适合挂载这个技能。`
}
// buildSpecialistExtraSections 组装专员详情页的补充段落。
// 数据全部来自后端 specialist 记录(技能绑定 / 信源 / 权限),
// 拉不到就返回空数组 —— 与改动前表现一致,不会因为接口失败而露出半个页面。
function buildSpecialistExtraSections(remote) {
if (!remote) return []
const sections = []
// 它会调用的技能:key 是后端绑定,label 从前端技能目录取。
// 取不到 label 就直接显示 key —— 宁可露出一个生 key,也不要静默少一项,
// 少一项会让「专员为什么没做这件事」变得无法解释。
const skillKeys = parseAllowedSkills(remote.allowed_skills)
if (skillKeys.length) {
sections.push({
title: '它会调用的技能',
items: skillKeys.map((key) => {
const label = availableSkills.find((item) => item.key === key)?.label
return label ? `${label}(${key})` : key
}),
})
}
// 信源与权限在库里是「、」分隔的整串
const infoSources = splitChineseList(remote.info_sources)
if (infoSources.length) {
sections.push({ title: '它读哪些信源', items: infoSources })
}
const permissions = splitChineseList(remote.permission_scope)
if (permissions.length) {
sections.push({ title: '它可以动什么', items: permissions })
}
return sections
}
function parseAllowedSkills(raw) {
if (Array.isArray(raw)) return raw.map((item) => String(item || '').trim()).filter(Boolean)
const text = String(raw || '').trim()
if (!text) return []
try {
const parsed = JSON.parse(text)
return Array.isArray(parsed)
? parsed.map((item) => String(item || '').trim()).filter(Boolean)
: []
} catch {
// 后端存的是 JSON 数组;解析不了说明数据坏了,按「没绑定」处理,
// 不要在详情页抛错。
return []
}
}
function splitChineseList(raw) {
return String(raw || '')
.split(/[、,,\n]/)
.map((item) => item.trim())
.filter(Boolean)
}
function buildSkillExtraSections(skill) {
const workflow = normalizeArray((skill.workflowSchema || []).map((item) => item.title))
const outputs = normalizeArray((skill.artifactSchema || []).map((item) => {
const title = String(item.title || '').trim()
const fileFormat = String(item.fileFormat || '').trim()
return fileFormat ? `${title}(.${fileFormat})` : title
}))
const sections = []
if (workflow.length) sections.push({ title: '它会这样推进', items: workflow })
if (outputs.length) sections.push({ title: '它会优先产出', items: outputs })
return sections
}
</script>
<style scoped>
.catalog-detail-page {
padding: 11px;
}
.detail-toolbar {
margin-bottom: 7px;
}
.back-btn {
border: none;
background: transparent;
color: #2b63d9;
border-radius: 999px;
padding: 6px 11px;
cursor: pointer;
font-size: 12px;
}
.detail-modal-card {
padding: 15px;
border-radius: 18px;
background: #fff;
}
.detail-header {
display: flex;
align-items: flex-start;
gap: 10px;
padding-bottom: 11px;
border-bottom: 1px solid #eef2f7;
}
.detail-avatar {
width: 42px;
height: 42px;
flex: none;
border-radius: 14px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 14%, white);
color: var(--accent);
font-size: 18px;
font-weight: 700;
}
.detail-header-main {
min-width: 0;
flex: 1;
}
.detail-title {
font-size: 20px;
line-height: 1.25;
font-weight: 700;
color: #1f2329;
}
.detail-subtitle {
margin-top: 3px;
font-size: 12px;
color: #4f5d6b;
}
.detail-code-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.detail-code-chip {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #f3f6fb;
color: #344054;
font-size: 11px;
font-weight: 600;
}
.detail-code-chip.subtle {
background: #f7f8fa;
color: #667085;
font-weight: 500;
}
.detail-meta-text {
margin-top: 4px;
font-size: 11px;
color: #8a96a3;
}
.detail-badge {
flex: none;
border-radius: 999px;
padding: 4px 9px;
background: color-mix(in srgb, var(--accent) 12%, white);
color: var(--accent);
font-size: 11px;
font-weight: 600;
margin-top: 2px;
}
.detail-description {
margin: 11px 0 0;
font-size: 12px;
line-height: 1.65;
color: #3f4b57;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.detail-tag {
border-radius: 999px;
padding: 4px 9px;
border: 1px solid #e7ecf5;
background: transparent;
color: #58677a;
font-size: 11px;
}
.detail-section {
margin-top: 14px;
}
.detail-section-title {
font-size: 13px;
font-weight: 600;
color: #1f2329;
}
.detail-section-text {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.65;
color: #4f5d6b;
}
.detail-prompt-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.detail-bullet-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.detail-bullet-item {
font-size: 12px;
line-height: 1.6;
color: #4f5d6b;
}
.detail-bullet-item::before {
content: '· ';
color: #8a96a3;
}
.detail-prompt-button {
width: 100%;
text-align: left;
border: 1px solid #e7ecf5;
border-radius: 12px;
padding: 8px 10px;
background: transparent;
color: #2f3a45;
font-size: 12px;
line-height: 1.55;
cursor: pointer;
transition: border-color 0.16s ease, transform 0.16s ease;
}
.detail-prompt-button:hover {
border-color: color-mix(in srgb, var(--accent) 28%, #d9e3ef);
transform: translateY(-1px);
}
@media (max-width: 640px) {
.catalog-detail-page {
padding: 8px;
}
.detail-header {
flex-wrap: wrap;
}
.detail-modal-card {
padding: 12px;
}
}
</style>
File diff suppressed because it is too large Load Diff