feat: 新增语音转文字(ASR)功能
- 后端:新增 /api/audio/transcribe 接口,调用 Ollama whisper 进行语音识别 - 前端:新增 AudioTranscribePage.vue 页面,支持 MP3/WAV/M4A/OGG/FLAC 等格式 - 注册路由、工具卡片、智能助手欢迎语更新 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// DB 全局数据库句柄
|
||||
var DB *gorm.DB
|
||||
|
||||
// Init 打开 SQLite(pure-Go 驱动,CGO_ENABLED=0 可静态编译)并 AutoMigrate 全部表。
|
||||
// 通过 GORM 方言抽象,未来切换 MySQL 仅需替换驱动与 DSN。
|
||||
func Init(dbPath string) (*gorm.DB, error) {
|
||||
if dir := filepath.Dir(dbPath); dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.Product{},
|
||||
&model.Course{},
|
||||
&model.MediaFile{},
|
||||
&model.KnowledgeSpace{},
|
||||
&model.KnowledgeFAQ{},
|
||||
&model.KnowledgeChunk{},
|
||||
&model.Question{},
|
||||
&model.ExamPaper{},
|
||||
&model.ExamRecord{},
|
||||
&model.SystemConfig{},
|
||||
&model.KnowledgeSource{},
|
||||
&model.AiCallLog{},
|
||||
&model.LearningProgress{},
|
||||
&model.Position{},
|
||||
&model.PositionKnowledge{},
|
||||
&model.PositionExamBlueprint{},
|
||||
&model.MistakeRecord{},
|
||||
&model.PointEvent{},
|
||||
&model.Certificate{},
|
||||
&model.Department{},
|
||||
&model.Notification{},
|
||||
&model.StudyNote{},
|
||||
&model.Specialist{},
|
||||
&model.WorkerTask{},
|
||||
&model.WorkerArtifact{},
|
||||
&model.WorkerRun{},
|
||||
&model.OfficialAccountArticle{},
|
||||
&model.OfficialAccountHotspot{},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
DB = db
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"eai_agentplatform/backend/internal/auth"
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// SeedDefaults 插入默认管理员 + system_config 默认项(幂等,可重复执行)
|
||||
func SeedDefaults() error {
|
||||
// 默认管理员 admin / admin123(交付前 Clonezilla 清理时改密)
|
||||
var c int64
|
||||
DB.Model(&model.User{}).Where("username = ?", "admin").Count(&c)
|
||||
if c == 0 {
|
||||
hash, err := auth.HashPassword("admin123")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
admin := model.User{
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
FullName: "系统管理员",
|
||||
Role: "admin",
|
||||
Status: "active",
|
||||
AiPoints: 999999, // 管理员算力点默认不限
|
||||
}
|
||||
if err := DB.Create(&admin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println("[OK] 管理员账号已创建(admin / admin123)")
|
||||
}
|
||||
|
||||
if err := ensureDemoEmployee(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 管理员算力点兜底:历史管理员(自动迁移补列后为默认 100)抬到 999999 不限
|
||||
DB.Model(&model.User{}).Where("role = ? AND ai_points <= 100", "admin").Update("ai_points", 999999)
|
||||
|
||||
defaults := []struct{ key, value, desc string }{
|
||||
// 7.2.1 公司信息配置
|
||||
{"company_intro", "", "公司介绍培训页内容(HTML/文本)"},
|
||||
{"company_name", "博昇", "公司名称"},
|
||||
{"admin_email", "", "管理员联系邮箱"},
|
||||
// 7.2.2 文件与存储
|
||||
{"file_max_size_doc", "209715200", "文档最大字节(200MB)"},
|
||||
{"file_max_size_video", "2147483648", "视频最大字节(2GB)"},
|
||||
{"chunk_threshold", "104857600", "分片上传阈值(100MB)"},
|
||||
// 7.2.3 其他信息配置
|
||||
{"jwt_expire_minutes", "480", "Token 过期分钟数"},
|
||||
{"password_min_length", "6", "密码最小长度"},
|
||||
{"session_timeout_minutes", "480", "会话超时分钟数(不活动自动退出)"},
|
||||
// 7.2.4 AI 配置
|
||||
{"llm_base_url", "http://localhost:11434/v1", "LLM 服务地址"},
|
||||
{"llm_api_key", "sk-xxx", "LLM API Key(敏感信息,建议写入 ai_secrets.json)"},
|
||||
{"llm_model", "qwen2.5:7b", "LLM 模型名"},
|
||||
{"embed_model", "bge-m3", "Embedding 模型名"},
|
||||
{"llm_max_tokens", "2048", "LLM 最大输出 Token 数"},
|
||||
{"llm_temperature", "0.7", "LLM 温度参数(0.0~1.0)"},
|
||||
{"ai_points_default", "100", "新用户默认 AI 算力点"},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
var n int64
|
||||
DB.Model(&model.SystemConfig{}).Where("config_key = ?", d.key).Count(&n)
|
||||
if n == 0 {
|
||||
if err := DB.Create(&model.SystemConfig{
|
||||
ConfigKey: d.key,
|
||||
ConfigValue: d.value,
|
||||
Description: d.desc,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedCoursesFromJSON(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedTrainingMedia(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedSpecialists(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDemoEmployee() error {
|
||||
var c int64
|
||||
DB.Model(&model.User{}).Where("username = ?", "emp_test01").Count(&c)
|
||||
if c > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := auth.HashPassword("test123456")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
employee := model.User{
|
||||
Username: "emp_test01",
|
||||
PasswordHash: hash,
|
||||
FullName: "测试员工",
|
||||
Role: "employee",
|
||||
Status: "active",
|
||||
AiPoints: 100,
|
||||
}
|
||||
if err := DB.Create(&employee).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println("[OK] 演示员工账号已创建(emp_test01 / test123456)")
|
||||
return nil
|
||||
}
|
||||
|
||||
type courseSeedFile struct {
|
||||
Data []courseSeedItem `json:"data"`
|
||||
}
|
||||
|
||||
type courseSeedItem struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
TargetCustomers string `json:"target_customers"`
|
||||
ForbiddenCustomers string `json:"forbidden_customers"`
|
||||
Scripts string `json:"scripts"`
|
||||
SalesProcess string `json:"sales_process"`
|
||||
ObjectionHandling string `json:"objection_handling"`
|
||||
DeliveryPitfalls string `json:"delivery_pitfalls"`
|
||||
ReportRules string `json:"report_rules"`
|
||||
}
|
||||
|
||||
func seedCoursesFromJSON() error {
|
||||
var count int64
|
||||
DB.Model(&model.Course{}).Count(&count)
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := firstExistingPath(
|
||||
"training_materials/work/courses.json",
|
||||
"../training_materials/work/courses.json",
|
||||
"eai_agentplatform_app/training_materials/work/courses.json",
|
||||
)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var payload courseSeedFile
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range payload.Data {
|
||||
if item.Code == "" || item.Name == "" || item.Category == "" {
|
||||
continue
|
||||
}
|
||||
course := model.Course{
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Category: item.Category,
|
||||
TargetCustomers: item.TargetCustomers,
|
||||
ForbiddenCustomers: item.ForbiddenCustomers,
|
||||
Scripts: item.Scripts,
|
||||
SalesProcess: item.SalesProcess,
|
||||
ObjectionHandling: item.ObjectionHandling,
|
||||
DeliveryPitfalls: item.DeliveryPitfalls,
|
||||
ReportRules: item.ReportRules,
|
||||
Status: "active",
|
||||
}
|
||||
if err := DB.Create(&course).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("[OK] 销售课程种子已导入")
|
||||
return nil
|
||||
}
|
||||
|
||||
type mediaSeed struct {
|
||||
filename string
|
||||
sourcePath string
|
||||
storedName string
|
||||
bindType string
|
||||
bindCode string
|
||||
}
|
||||
|
||||
func seedTrainingMedia() error {
|
||||
cfg := config.Load()
|
||||
approvedDir := filepath.Join(cfg.KBDataDir, "approved")
|
||||
if err := os.MkdirAll(approvedDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var admin model.User
|
||||
if err := DB.Where("username = ?", "admin").First(&admin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
courseIDs := map[string]uint{}
|
||||
var courses []model.Course
|
||||
DB.Where("status = ?", "active").Find(&courses)
|
||||
for _, course := range courses {
|
||||
courseIDs[course.Code] = course.ID
|
||||
}
|
||||
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
storedName: "seed_course_co003_manual.pdf",
|
||||
bindType: "course",
|
||||
bindCode: "CO-003",
|
||||
},
|
||||
}
|
||||
|
||||
for _, item := range seeds {
|
||||
if item.sourcePath == "" {
|
||||
continue
|
||||
}
|
||||
var bindID *uint
|
||||
if item.bindType == "course" {
|
||||
id, ok := courseIDs[item.bindCode]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bindID = &id
|
||||
}
|
||||
if err := ensureSeedMedia(admin.ID, approvedDir, item, bindID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedSpecialists() error {
|
||||
items := []model.Specialist{
|
||||
{
|
||||
Key: "training-delivery",
|
||||
Label: "培训交付专员",
|
||||
Tier: "generic",
|
||||
WorkerType: "dw",
|
||||
Route: "/apps/training-delivery",
|
||||
Summary: "新人销售训练营第 3 周",
|
||||
WorkStatus: "5 个进行中",
|
||||
RiskLabel: "0 个异常",
|
||||
Color: "#67c23a",
|
||||
Stage: "交付归档",
|
||||
Progress: 84,
|
||||
MarketTag: "已安装",
|
||||
Version: "v1.1",
|
||||
ConnectorScope: "连接课程系统 / 知识库 / 考试模块",
|
||||
PermissionScope: "班级资料读取、考试结果读取、交付包生成",
|
||||
ResourceBindings: "课程系统、考试模块、知识库、交付模板中心",
|
||||
InfoSources: "班级排期、学员成绩、互动记录、教练点评",
|
||||
BaseSkills: "班级日报、考试预警、复盘归档、交付清单生成",
|
||||
AIAssistance: "自动总结班级状态并给出补练建议",
|
||||
GeneratedSkills: "基于优秀班级复盘沉淀新训练营模板",
|
||||
State: "active",
|
||||
SortOrder: 10,
|
||||
},
|
||||
{
|
||||
Key: "knowledge-operations",
|
||||
Label: "知识运营专员",
|
||||
Tier: "generic",
|
||||
WorkerType: "dw",
|
||||
Route: "/apps/knowledge-operations",
|
||||
Summary: "对象字典、证据索引与规则同步",
|
||||
WorkStatus: "4 个待整理",
|
||||
RiskLabel: "1 个映射待确认",
|
||||
Color: "#8e6cf2",
|
||||
Stage: "证据整理",
|
||||
Progress: 63,
|
||||
MarketTag: "已安装",
|
||||
Version: "v1.0",
|
||||
ConnectorScope: "连接知识资产 / 文档系统",
|
||||
PermissionScope: "知识资产读取、规则发布建议、证据索引维护",
|
||||
ResourceBindings: "知识库、文档系统、规则字典、对象映射表",
|
||||
InfoSources: "对象字典、规则文档、证据片段、变更记录",
|
||||
BaseSkills: "对象映射、证据抽取、规则同步、发布检查",
|
||||
AIAssistance: "自动识别字段冲突并给出映射建议",
|
||||
GeneratedSkills: "把高频清洗与映射动作沉淀为新规则技能",
|
||||
State: "active",
|
||||
SortOrder: 20,
|
||||
},
|
||||
{
|
||||
Key: "process-coordination",
|
||||
Label: "流程推进专员",
|
||||
Tier: "generic",
|
||||
WorkerType: "adw",
|
||||
Route: "/apps/process-coordination",
|
||||
Summary: "跨部门催办、checkpoint 跟进与阻塞升级",
|
||||
WorkStatus: "6 个待跟进",
|
||||
RiskLabel: "2 个阻塞",
|
||||
Color: "#14b8a6",
|
||||
Stage: "阻塞清理",
|
||||
Progress: 57,
|
||||
MarketTag: "可升级",
|
||||
Version: "v1.0",
|
||||
ConnectorScope: "连接审批流 / 企微 / 项目事项",
|
||||
PermissionScope: "事项读写、催办通知、阻塞升级、审批触发",
|
||||
ResourceBindings: "审批流、项目事项、企微、日程提醒",
|
||||
InfoSources: "任务看板、流程 checkpoint、阻塞日志、责任人状态",
|
||||
BaseSkills: "催办升级、checkpoint 跟进、阻塞清理、责任路由",
|
||||
AIAssistance: "自动判断升级路径并规划下一步协同动作",
|
||||
GeneratedSkills: "把成功的跨部门推进套路沉淀为自动推进技能",
|
||||
State: "active",
|
||||
SortOrder: 30,
|
||||
},
|
||||
{
|
||||
Key: "report-generation",
|
||||
Label: "报告生成专员",
|
||||
Tier: "generic",
|
||||
WorkerType: "dw",
|
||||
Route: "/apps/report-generation",
|
||||
Summary: "日报、周报、复盘和交付说明自动生成",
|
||||
WorkStatus: "3 个待输出",
|
||||
RiskLabel: "0 个异常",
|
||||
Color: "#ec4899",
|
||||
Stage: "报告汇总",
|
||||
Progress: 76,
|
||||
MarketTag: "可升级",
|
||||
Version: "v1.0",
|
||||
ConnectorScope: "连接交付物 / 知识库 / 模板中心",
|
||||
PermissionScope: "交付物读取、模板套用、报告产出",
|
||||
ResourceBindings: "交付模板、知识库、任务结果、证据引用",
|
||||
InfoSources: "日报数据、周进度、复盘纪要、交付清单",
|
||||
BaseSkills: "日报生成、周报汇总、复盘摘要、交付说明编排",
|
||||
AIAssistance: "自动抽取重点变化并生成适配受众的版本",
|
||||
GeneratedSkills: "把高质量报告结构沉淀为新模板技能",
|
||||
State: "active",
|
||||
SortOrder: 40,
|
||||
},
|
||||
{
|
||||
Key: "wechat-official-account",
|
||||
Label: "公众号助手",
|
||||
Tier: "generic",
|
||||
WorkerType: "dw",
|
||||
Route: "/apps/wechat-official-account",
|
||||
Summary: "热点选题、标题、提纲、正文四步创作工作流",
|
||||
WorkStatus: "4 个节点可执行",
|
||||
RiskLabel: "0 个异常",
|
||||
Color: "#2563eb",
|
||||
Stage: "正文创作",
|
||||
Progress: 0,
|
||||
MarketTag: "已安装",
|
||||
Version: "v1.0",
|
||||
ConnectorScope: "连接知识库 / 公众号工作区上下文",
|
||||
PermissionScope: "文章任务创建、步骤执行、草稿保存、人工确认",
|
||||
ResourceBindings: "公众号工作流、知识库、事项运行记录、文章草稿",
|
||||
InfoSources: "选题关键词、目标受众、文章目标、风格要求",
|
||||
BaseSkills: "推荐热点选题、标题生成、提纲生成、正文创作",
|
||||
AIAssistance: "按步骤生成中间结果,并展示进度与可追溯输出",
|
||||
GeneratedSkills: "公众号选题建议、标题建议、文章提纲、正文草稿",
|
||||
State: "active",
|
||||
SortOrder: 45,
|
||||
},
|
||||
{
|
||||
Key: "contract-review",
|
||||
Label: "合同审查专员",
|
||||
Tier: "industry",
|
||||
WorkerType: "adw",
|
||||
Route: "/apps/contract-review",
|
||||
Summary: "华东客户主协议修订",
|
||||
WorkStatus: "2 个待确认",
|
||||
RiskLabel: "1 个高风险",
|
||||
Color: "#f56c6c",
|
||||
Stage: "人工复核",
|
||||
Progress: 72,
|
||||
MarketTag: "已安装",
|
||||
Version: "v1.4",
|
||||
ConnectorScope: "连接 DMS / OA / 合同模板库",
|
||||
PermissionScope: "合同文本读取、红线建议、审批流提交、例外升级",
|
||||
ResourceBindings: "DMS、OA、模板库、法务规则库",
|
||||
InfoSources: "合同正文、历史条款、谈判纪要、法务规则",
|
||||
BaseSkills: "条款抽取、风险比对、红线生成、例外说明",
|
||||
AIAssistance: "自动分析条款冲突并规划复核重点",
|
||||
GeneratedSkills: "从人工复核结果中提炼新条款规则与红线模式",
|
||||
State: "active",
|
||||
SortOrder: 50,
|
||||
},
|
||||
{
|
||||
Key: "solution-proposal",
|
||||
Label: "售前方案专员",
|
||||
Tier: "industry",
|
||||
WorkerType: "adw",
|
||||
Route: "/apps/solution-proposal",
|
||||
Summary: "A 客户智能培训升级方案",
|
||||
WorkStatus: "3 个待办",
|
||||
RiskLabel: "1 个依赖阻塞",
|
||||
Color: "#409eff",
|
||||
Stage: "方案生成",
|
||||
Progress: 58,
|
||||
MarketTag: "可升级",
|
||||
Version: "v1.2",
|
||||
ConnectorScope: "连接 CRM / OA / 知识库",
|
||||
PermissionScope: "商机读取、方案草案生成、评审流发起、范围确认",
|
||||
ResourceBindings: "CRM、OA、知识库、方案模板中心",
|
||||
InfoSources: "商机信息、调研纪要、行业方案、交付案例",
|
||||
BaseSkills: "需求澄清、方案草案、连接器范围规划、风险边界说明",
|
||||
AIAssistance: "自动组合客户上下文并生成多版本方案草案",
|
||||
GeneratedSkills: "把高转化方案结构沉淀为可复用售前技能",
|
||||
State: "active",
|
||||
SortOrder: 60,
|
||||
},
|
||||
{
|
||||
Key: "logistics-fulfillment",
|
||||
Label: "履约跟单专员",
|
||||
Tier: "industry",
|
||||
WorkerType: "adw",
|
||||
Route: "/apps/logistics-fulfillment",
|
||||
Summary: "美西航线本周履约看板",
|
||||
WorkStatus: "1 个异常",
|
||||
RiskLabel: "2 个节点延迟",
|
||||
Color: "#e6a23c",
|
||||
Stage: "异常处置",
|
||||
Progress: 49,
|
||||
MarketTag: "试用",
|
||||
Version: "v0.9",
|
||||
ConnectorScope: "连接船司 / 邮件 / 企微",
|
||||
PermissionScope: "节点读取、异常通知、履约状态写回、升级协同",
|
||||
ResourceBindings: "船司系统、邮件、企微、履约看板",
|
||||
InfoSources: "节点状态、异常邮件、订舱资料、客户承诺时间",
|
||||
BaseSkills: "节点跟踪、异常识别、催办升级、状态回写",
|
||||
AIAssistance: "自动判断异常影响范围并生成处置建议",
|
||||
GeneratedSkills: "把高频异常处理流程沉淀为自动履约技能",
|
||||
State: "active",
|
||||
SortOrder: 70,
|
||||
},
|
||||
{
|
||||
Key: "hr-email-sorter",
|
||||
Label: "HR 邮件整理专员",
|
||||
Tier: "industry",
|
||||
WorkerType: "dw",
|
||||
Route: "/apps/hr-email-sorter",
|
||||
Summary: "识别并整理招聘邮箱中的简历邮件",
|
||||
WorkStatus: "3 封待处理",
|
||||
RiskLabel: "0 个异常",
|
||||
Color: "#409eff",
|
||||
Stage: "邮件处理",
|
||||
Progress: 0,
|
||||
MarketTag: "试用",
|
||||
Version: "v1.0",
|
||||
ConnectorScope: "连接 HR 招聘邮箱 / 邮件系统",
|
||||
PermissionScope: "招聘邮箱读取、候选人表写入",
|
||||
ResourceBindings: "HR 招聘邮箱、候选人管理表",
|
||||
InfoSources: "HR 招聘邮箱收件箱",
|
||||
BaseSkills: "简历邮件识别、候选人信息抽取、去重归类",
|
||||
AIAssistance: "自动识别简历邮件并抽取候选人字段",
|
||||
GeneratedSkills: "从历史招聘中沉淀简历筛选规则",
|
||||
State: "active",
|
||||
SortOrder: 80,
|
||||
},
|
||||
{
|
||||
Key: "resume-processor",
|
||||
Label: "简历处理专员",
|
||||
Tier: "industry",
|
||||
WorkerType: "dw",
|
||||
Route: "/apps/resume-processor",
|
||||
Summary: "筛选候选人并安排面试",
|
||||
WorkStatus: "5 份待筛选",
|
||||
RiskLabel: "1 个匹配待确认",
|
||||
Color: "#67c23a",
|
||||
Stage: "简历筛选",
|
||||
Progress: 0,
|
||||
MarketTag: "试用",
|
||||
Version: "v1.0",
|
||||
ConnectorScope: "连接候选人表 / 岗位需求表",
|
||||
PermissionScope: "候选人表读写、面试安排表写入、岗位需求表读取",
|
||||
ResourceBindings: "候选人表、岗位需求表、面试安排表",
|
||||
InfoSources: "候选人表、岗位需求表",
|
||||
BaseSkills: "简历筛选、评分排序、跟进标记、面试安排",
|
||||
AIAssistance: "自动匹配岗位要求并给出排序建议",
|
||||
GeneratedSkills: "从录用决策中沉淀筛选模型",
|
||||
State: "active",
|
||||
SortOrder: 90,
|
||||
},
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
EnsureSpecialistStructuredRecords(&item)
|
||||
var existing model.Specialist
|
||||
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.WorkerType == "" {
|
||||
updates["worker_type"] = item.WorkerType
|
||||
}
|
||||
if existing.PermissionScope == "" {
|
||||
updates["permission_scope"] = item.PermissionScope
|
||||
}
|
||||
if existing.ResourceBindings == "" {
|
||||
updates["resource_bindings"] = item.ResourceBindings
|
||||
}
|
||||
if existing.InfoSources == "" {
|
||||
updates["info_sources"] = item.InfoSources
|
||||
}
|
||||
if existing.BaseSkills == "" {
|
||||
updates["base_skills"] = item.BaseSkills
|
||||
}
|
||||
if existing.AIAssistance == "" {
|
||||
updates["ai_assistance"] = item.AIAssistance
|
||||
}
|
||||
if existing.GeneratedSkills == "" {
|
||||
updates["generated_skills"] = item.GeneratedSkills
|
||||
}
|
||||
if existing.InputsRecordsJSON == "" {
|
||||
updates["source_records_json"] = item.InputsRecordsJSON
|
||||
}
|
||||
if existing.PermissionRecordsJSON == "" {
|
||||
updates["permission_records_json"] = item.PermissionRecordsJSON
|
||||
}
|
||||
if existing.ActionRecordsJSON == "" {
|
||||
updates["action_records_json"] = item.ActionRecordsJSON
|
||||
}
|
||||
if existing.ResultRecordsJSON == "" {
|
||||
updates["result_records_json"] = item.ResultRecordsJSON
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := DB.Model(&existing).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("[OK] 专员目录种子已导入")
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
query := DB.Where("stored_name = ? AND bind_type = ?", item.storedName, item.bindType)
|
||||
if bindID == nil {
|
||||
query = query.Where("bind_id IS NULL")
|
||||
} else {
|
||||
query = query.Where("bind_id = ?", *bindID)
|
||||
}
|
||||
|
||||
var existing model.MediaFile
|
||||
if err := query.First(&existing).Error; err == nil {
|
||||
updates := map[string]any{
|
||||
"filename": item.filename,
|
||||
"stored_path": item.storedName,
|
||||
"status": "approved",
|
||||
"source": "admin",
|
||||
"submitter_id": adminID,
|
||||
"extracted": existing.FileExt != "pdf",
|
||||
}
|
||||
return DB.Model(&existing).Updates(updates).Error
|
||||
}
|
||||
|
||||
info, err := os.Stat(item.sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := model.MediaFile{
|
||||
Filename: item.filename,
|
||||
StoredName: item.storedName,
|
||||
StoredPath: item.storedName,
|
||||
FileExt: strings.TrimPrefix(strings.ToLower(filepath.Ext(item.filename)), "."),
|
||||
FileSize: info.Size(),
|
||||
Status: "approved",
|
||||
Source: "admin",
|
||||
SubmitterID: adminID,
|
||||
BindType: item.bindType,
|
||||
BindID: bindID,
|
||||
Extracted: strings.ToLower(filepath.Ext(item.filename)) != ".pdf",
|
||||
}
|
||||
return DB.Create(&record).Error
|
||||
}
|
||||
|
||||
func ensureMediaLink(src, dst string) error {
|
||||
absSrc, err := filepath.Abs(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi, err := os.Lstat(dst); err == nil {
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
target, err := os.Readlink(dst)
|
||||
if err == nil {
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(dst), target)
|
||||
}
|
||||
if absTarget, absErr := filepath.Abs(target); absErr == nil && absTarget == absSrc {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := os.Remove(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
} else if err := os.Remove(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(absSrc, dst); err == nil {
|
||||
return nil
|
||||
} else if shouldCopySeedMedia(err) {
|
||||
return copySeedMedia(absSrc, dst)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func shouldCopySeedMedia(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。
|
||||
return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege")
|
||||
}
|
||||
|
||||
func copySeedMedia(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func firstExistingPath(paths ...string) string {
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// EnsureSpecialistStructuredRecords 补齐四类结构化记录,供工作区 UI 直接消费。
|
||||
func EnsureSpecialistStructuredRecords(item *model.Specialist) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(item.InputsRecordsJSON) == "" {
|
||||
item.InputsRecordsJSON = mustJSON(buildInputsRecords(item))
|
||||
}
|
||||
if strings.TrimSpace(item.PermissionRecordsJSON) == "" {
|
||||
item.PermissionRecordsJSON = mustJSON(buildPermissionRecords(item))
|
||||
}
|
||||
if strings.TrimSpace(item.ActionRecordsJSON) == "" {
|
||||
item.ActionRecordsJSON = mustJSON(buildActionRecords(item))
|
||||
}
|
||||
if strings.TrimSpace(item.ResultRecordsJSON) == "" {
|
||||
item.ResultRecordsJSON = mustJSON(buildResultRecords(item))
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateStructuredRecordsJSON(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
var arr []map[string]any
|
||||
return json.Unmarshal([]byte(value), &arr) == nil
|
||||
}
|
||||
|
||||
func buildInputsRecords(item *model.Specialist) []map[string]any {
|
||||
records := make([]map[string]any, 0)
|
||||
for _, value := range splitText(item.ConnectorScope) {
|
||||
records = append(records, map[string]any{
|
||||
"input_name": value,
|
||||
"input_type": "connector",
|
||||
"connector_name": value,
|
||||
"object_type": "system_scope",
|
||||
"object_id": item.Key,
|
||||
"fetched_at": "",
|
||||
"last_sync_status": "ready",
|
||||
"confidence": 1,
|
||||
"citation": "来自连接范围定义",
|
||||
})
|
||||
}
|
||||
for _, value := range splitText(item.InfoSources) {
|
||||
records = append(records, map[string]any{
|
||||
"input_name": value,
|
||||
"input_type": "input",
|
||||
"connector_name": "workspace_context",
|
||||
"object_type": "context_input",
|
||||
"object_id": item.Key,
|
||||
"fetched_at": "",
|
||||
"last_sync_status": "ready",
|
||||
"confidence": 0.92,
|
||||
"citation": "来自输入定义",
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func buildPermissionRecords(item *model.Specialist) []map[string]any {
|
||||
approvalReason := "关键动作需要人工确认"
|
||||
forbidden := []string{"自动发布", "批量删除", "跨系统迁移"}
|
||||
if item.WorkerType == "adw" {
|
||||
approvalReason = "中高风险动作进入审批或人工复核"
|
||||
forbidden = []string{"无审批自动发布", "无审批跨系统写回", "高风险批量改写"}
|
||||
}
|
||||
return []map[string]any{
|
||||
{
|
||||
"title": "资源范围",
|
||||
"resource_scope": item.PermissionScope,
|
||||
"access_mode": "mixed",
|
||||
"approval_required": false,
|
||||
"approval_role": []string{},
|
||||
"approval_reason": "",
|
||||
"forbidden_actions": []string{},
|
||||
"delegation_allowed": item.WorkerType == "adw",
|
||||
},
|
||||
{
|
||||
"title": "访问边界",
|
||||
"resource_scope": firstNonEmpty(item.ResourceBindings, item.ConnectorScope),
|
||||
"access_mode": "read_write_scoped",
|
||||
"approval_required": false,
|
||||
"approval_role": []string{},
|
||||
"approval_reason": "",
|
||||
"forbidden_actions": []string{},
|
||||
"delegation_allowed": item.WorkerType == "adw",
|
||||
},
|
||||
{
|
||||
"title": "审批要求",
|
||||
"resource_scope": item.PermissionScope,
|
||||
"access_mode": "approval_guarded",
|
||||
"approval_required": true,
|
||||
"approval_role": []string{"管理员", "业务负责人"},
|
||||
"approval_reason": approvalReason,
|
||||
"forbidden_actions": []string{},
|
||||
"delegation_allowed": item.WorkerType == "adw",
|
||||
},
|
||||
{
|
||||
"title": "禁止动作",
|
||||
"resource_scope": "",
|
||||
"access_mode": "deny",
|
||||
"approval_required": false,
|
||||
"approval_role": []string{},
|
||||
"approval_reason": "",
|
||||
"forbidden_actions": forbidden,
|
||||
"delegation_allowed": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildActionRecords(item *model.Specialist) []map[string]any {
|
||||
records := make([]map[string]any, 0)
|
||||
skills := splitText(item.BaseSkills)
|
||||
if len(skills) == 0 {
|
||||
skills = []string{"事项处理"}
|
||||
}
|
||||
outputs := splitText(item.GeneratedSkills)
|
||||
infoSources := splitText(item.InfoSources)
|
||||
for i, value := range skills {
|
||||
records = append(records, map[string]any{
|
||||
"action_name": value,
|
||||
"action_type": inferActionType(value),
|
||||
"trigger_mode": triggerMode(item.WorkerType),
|
||||
"input_sources": infoSources,
|
||||
"expected_output": outputs,
|
||||
"risk_level": inferRiskLevel(value),
|
||||
"status": defaultActionStatus(i),
|
||||
"approval_state": approvalState(item.WorkerType, value),
|
||||
"operator": strings.ToUpper(firstNonEmpty(item.WorkerType, "dw")),
|
||||
"started_at": "",
|
||||
"finished_at": "",
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func buildResultRecords(item *model.Specialist) []map[string]any {
|
||||
results := make([]map[string]any, 0)
|
||||
derived := splitText(item.BaseSkills)
|
||||
for _, value := range splitText(item.GeneratedSkills) {
|
||||
results = append(results, map[string]any{
|
||||
"result_type": "skill",
|
||||
"result_title": value,
|
||||
"result_status": statusByMarketTag(item.MarketTag),
|
||||
"derived_from_actions": derived,
|
||||
"derived_from_sources": splitText(item.InfoSources),
|
||||
"artifact_url": "",
|
||||
"published_to": []string{"工作区"},
|
||||
"confirmed_by": "",
|
||||
"confirmed_at": "",
|
||||
"version": firstNonEmpty(item.Version, "v1.0"),
|
||||
})
|
||||
}
|
||||
if len(results) == 0 {
|
||||
results = append(results, map[string]any{
|
||||
"result_type": "summary",
|
||||
"result_title": firstNonEmpty(item.WorkStatus, item.Summary, item.Label+"结果"),
|
||||
"result_status": statusByMarketTag(item.MarketTag),
|
||||
"derived_from_actions": derived,
|
||||
"derived_from_sources": splitText(item.InfoSources),
|
||||
"artifact_url": "",
|
||||
"published_to": []string{"工作区"},
|
||||
"confirmed_by": "",
|
||||
"confirmed_at": "",
|
||||
"version": firstNonEmpty(item.Version, "v1.0"),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func splitText(value string) []string {
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||
switch r {
|
||||
case '、', ',', ',', '/', '|', ';', ';', '\n':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
items := make([]string, 0, len(parts))
|
||||
for _, item := range parts {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func inferActionType(value string) string {
|
||||
switch {
|
||||
case strings.Contains(value, "生成"), strings.Contains(value, "输出"), strings.Contains(value, "编排"):
|
||||
return "generation"
|
||||
case strings.Contains(value, "抽取"), strings.Contains(value, "识别"), strings.Contains(value, "比对"):
|
||||
return "analysis"
|
||||
case strings.Contains(value, "催办"), strings.Contains(value, "跟进"), strings.Contains(value, "升级"):
|
||||
return "coordination"
|
||||
default:
|
||||
return "execution"
|
||||
}
|
||||
}
|
||||
|
||||
func triggerMode(workerType string) string {
|
||||
if workerType == "adw" {
|
||||
return "auto_assisted"
|
||||
}
|
||||
return "manual_assisted"
|
||||
}
|
||||
|
||||
func inferRiskLevel(value string) string {
|
||||
switch {
|
||||
case strings.Contains(value, "升级"), strings.Contains(value, "写回"), strings.Contains(value, "审批"):
|
||||
return "medium"
|
||||
case strings.Contains(value, "删除"), strings.Contains(value, "发布"):
|
||||
return "high"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultActionStatus(index int) string {
|
||||
if index == 0 {
|
||||
return "ready"
|
||||
}
|
||||
return "pending"
|
||||
}
|
||||
|
||||
func approvalState(workerType, value string) string {
|
||||
if workerType == "adw" && (strings.Contains(value, "升级") || strings.Contains(value, "审批") || strings.Contains(value, "写回")) {
|
||||
return "approval_required"
|
||||
}
|
||||
return "not_required"
|
||||
}
|
||||
|
||||
func statusByMarketTag(tag string) string {
|
||||
switch tag {
|
||||
case "可升级":
|
||||
return "ready_for_upgrade"
|
||||
case "试用":
|
||||
return "trial"
|
||||
default:
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user