refactor: 后端仓库层收口(A1:课程/产品/素材)+ 收进工作区既有对象化重构
本提交含两部分。第一部分是本轮工作;第二部分是此前一直留在工作区、
从未提交的对象化重构,与第一部分在文件上互相咬合(internal/repository
整个包都是未跟踪状态,且 api 层已有文件引用它),无法拆成两个可编译的提交。
一、仓库层收口 A1 批(本轮工作)
把 api 层手写的 store.DB 查询收进具名仓库方法,只给真正获益的对象做方法,
不机械包裹全量。本批迁移 22 处裸查询(courses.go 9 / media.go 12 / products.go 1),
新增方法:
- MediaFileRepo.ListByBind / ListForAudit / MarkExtracted
- KnowledgeChunkRepo.CountByMediaFile
- ProductRepo.GetVisibleByID
两条业务口径改由仓库单点持有,避免各处手写漂移:
「只有 approved 素材出现在课程详情」与「已停用产品不在课程详情露出」。
修掉两个真实缺陷:
- ProductRepo.GetByID 缺 Where 条件。此前 GET /api/products/{id} 对任意 id 都返回
第一条产品、对不存在的 id 返回 200,且 PUT /api/products/{id} 会覆盖第一条产品
—— 数据损坏级。全仓扫描确认这是唯一一处同型写法。
- ProductRepo.Delete 写 status="deleted",而 DELETE 处理器文档与回包都声称
"inactive",接口在说谎;管理员用 status=all 拉列表会看到前端不认识的状态。
已对齐为 inactive(与 CourseRepo.Delete 一致)。
删除 8 个零调用且列名不存在的死方法(一调即 SQL 报错):
- media_file 上的 file_path / file_type / approval_status 三列并不存在,
GetByPath / ListByType / UpdateStatus 全废
- knowledge_chunk 上的 space_id 列不存在(模型早已改为 knowledge_space_key),
List / Total / ListBySpaceIDs / DeleteBySpace / SearchByVector 全废
取舍边界:能对当前 schema 跑通的死方法保留,跑不通的删或修。
CourseRepo.List 补齐 status=all 档(此前传给它会当作 status='all' 过滤出空列表)。
该方法此前零调用,现与产品列表语义对齐。
验证:go build ./... 与 go test ./... 全绿;另用真实 HTTP 请求验证 34 项
(课程 17 / 产品 3 / 素材 14),跑在数据库副本与独立 KB_DATA_DIR 上,
含 multipart 真上传 → 审批 → pdftotext 提取 → 分片入库的完整链路。
二、此前未提交的对象化重构(非本轮工作)
- 新增 internal/repository 仓库层、connectors、skills、specialists、xapps、jsonutil,
model/task_record|task_run|task_artifact、api/task_runtime|action_definition|chat_message
- 删除 api/app_definition、connectors、my_app_center、notification、office_skill、
export_docx|pptx|xlsx、official_account_* 等,随 XApp/Skill/Specialist/Connector
可插拔打包方向(AR10/AR11)调整
- 资产目录归位:backend-go/knowledge_source → assets/knowledge/source、
training_materials → assets/training/materials;README 内相对路径同步加深两级;
deploy env 补 ASSET_ROOT_DIR 并改 KNOWLEDGE_SOURCE_DIR / TRAINING_MATERIALS_DIR
- 前端新增 skills/ specialists/ connectors/ xapps/ 目录与对应页面
验证:前端 npm run build 通过(7.26s)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -27,10 +27,10 @@ func newBackupFixture(t *testing.T) (*gorm.DB, string, string) {
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试库失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.WorkerTask{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.TaskRecord{}); err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.WorkerTask{
|
||||
if err := db.Create(&model.TaskRecord{
|
||||
SpecialistKey: "general-assistant",
|
||||
Title: "备份验证",
|
||||
}).Error; err != nil {
|
||||
@@ -79,7 +79,7 @@ func TestBackupProducesReadableCopy(t *testing.T) {
|
||||
t.Fatalf("打开备份库失败: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := copied.Model(&model.WorkerTask{}).Count(&count).Error; err != nil {
|
||||
if err := copied.Model(&model.TaskRecord{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("查备份库失败: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
skillmodel "eai_agentplatform/backend/internal/skills/model"
|
||||
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
|
||||
)
|
||||
|
||||
// DB 全局数据库句柄
|
||||
@@ -30,6 +33,19 @@ func Init(dbPath string) (*gorm.DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := migrateLegacyTaskRuntimeSchema(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := migrateLegacyXAppSchema(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := migrateAIUsageKindColumn(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := migrateObjectModeColumns(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.Product{},
|
||||
@@ -52,19 +68,18 @@ func Init(dbPath string) (*gorm.DB, error) {
|
||||
&model.PointEvent{},
|
||||
&model.Certificate{},
|
||||
&model.Department{},
|
||||
&model.Notification{},
|
||||
&model.StudyNote{},
|
||||
&model.Specialist{},
|
||||
&model.SkillDefinition{},
|
||||
&model.ActionDefinition{},
|
||||
&specialistmodel.Specialist{},
|
||||
&skillmodel.SkillDefinition{},
|
||||
&model.ActionDefinition{},
|
||||
&model.Project{},
|
||||
&model.WorkerTask{},
|
||||
&model.WorkerArtifact{},
|
||||
&model.WorkerRun{},
|
||||
&model.UserAppCenter{},
|
||||
&model.AppDefinition{},
|
||||
&model.OfficialAccountArticle{},
|
||||
&model.OfficialAccountHotspot{},
|
||||
&model.TaskRecord{},
|
||||
&model.TaskArtifact{},
|
||||
&model.TaskRun{},
|
||||
&model.UserXAppCenter{},
|
||||
&model.XAppDefinition{},
|
||||
&officialaccountmodel.OfficialAccountArticle{},
|
||||
&officialaccountmodel.OfficialAccountHotspot{},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,18 +93,96 @@ func Init(dbPath string) (*gorm.DB, error) {
|
||||
if err := migrateSkillObjectKindColumn(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeSkillObjectKinds(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
DB = db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func migrateAIUsageKindColumn(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("ai_call_log") {
|
||||
return nil
|
||||
}
|
||||
return migrateModeColumn(db, "ai_call_log", "usage_kind", "capability")
|
||||
}
|
||||
|
||||
func migrateLegacyXAppSchema(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable("app_definition") && !db.Migrator().HasTable("xapp_definition") {
|
||||
if err := db.Migrator().RenameTable("app_definition", "xapp_definition"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("user_app_center") && !db.Migrator().HasTable("user_xapp_center") {
|
||||
if err := db.Migrator().RenameTable("user_app_center", "user_xapp_center"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("user_xapp_center") &&
|
||||
db.Migrator().HasColumn("user_xapp_center", "custom_apps") &&
|
||||
!db.Migrator().HasColumn("user_xapp_center", "custom_xapps") {
|
||||
if err := db.Migrator().RenameColumn("user_xapp_center", "custom_apps", "custom_xapps"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyTaskRuntimeSchema(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable("worker_task") && !db.Migrator().HasTable("task_record") {
|
||||
if err := db.Migrator().RenameTable("worker_task", "task_record"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("worker_run") && !db.Migrator().HasTable("task_run") {
|
||||
if err := db.Migrator().RenameTable("worker_run", "task_run"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("worker_artifact") && !db.Migrator().HasTable("task_artifact") {
|
||||
if err := db.Migrator().RenameTable("worker_artifact", "task_artifact"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateObjectModeColumns(db *gorm.DB) error {
|
||||
if err := migrateModeColumn(db, "specialist", "specialist_mode", "worker_type"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateModeColumn(db, "xapp_definition", "xapp_mode", "worker_type"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateModeColumn(db *gorm.DB, tableName, targetColumn, legacyColumn string) error {
|
||||
if !db.Migrator().HasTable(tableName) || !db.Migrator().HasColumn(tableName, legacyColumn) {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(tableName, targetColumn) {
|
||||
return db.Migrator().RenameColumn(tableName, legacyColumn, targetColumn)
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`
|
||||
UPDATE %s
|
||||
SET %s = %s
|
||||
WHERE COALESCE(%s, '') = ''
|
||||
AND COALESCE(%s, '') <> ''
|
||||
`, tableName, targetColumn, legacyColumn, targetColumn, legacyColumn)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return dropColumn(db, tableName, legacyColumn)
|
||||
}
|
||||
|
||||
func migrateObjectEntryRouteColumns(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable(&model.Specialist{}) {
|
||||
if db.Migrator().HasColumn(&model.Specialist{}, "object_entry_route") {
|
||||
if db.Migrator().HasTable(&specialistmodel.Specialist{}) {
|
||||
if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "object_entry_route") {
|
||||
specialistLegacyExpr := coalesceRouteExpr(
|
||||
db.Migrator().HasColumn(&model.Specialist{}, "entry_route"),
|
||||
db.Migrator().HasColumn(&specialistmodel.Specialist{}, "entry_route"),
|
||||
"entry_route",
|
||||
db.Migrator().HasColumn(&model.Specialist{}, "route"),
|
||||
db.Migrator().HasColumn(&specialistmodel.Specialist{}, "route"),
|
||||
"route",
|
||||
)
|
||||
if specialistLegacyExpr != "" {
|
||||
@@ -103,24 +196,24 @@ func migrateObjectEntryRouteColumns(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.Specialist{}, "entry_route") {
|
||||
if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "entry_route") {
|
||||
if err := dropColumn(db, "specialist", "entry_route"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.Specialist{}, "route") {
|
||||
if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "route") {
|
||||
if err := dropColumn(db, "specialist", "route"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable(&model.SkillDefinition{}) {
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "object_entry_route") {
|
||||
if db.Migrator().HasTable(&skillmodel.SkillDefinition{}) {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "object_entry_route") {
|
||||
skillLegacyExpr := coalesceRouteExpr(
|
||||
db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route"),
|
||||
db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "entry_route"),
|
||||
"entry_route",
|
||||
db.Migrator().HasColumn(&model.SkillDefinition{}, "route"),
|
||||
db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "route"),
|
||||
"route",
|
||||
)
|
||||
if skillLegacyExpr != "" {
|
||||
@@ -134,27 +227,27 @@ func migrateObjectEntryRouteColumns(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "entry_route") {
|
||||
if err := dropColumn(db, "skill_definition", "entry_route"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "route") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "route") {
|
||||
if err := dropColumn(db, "skill_definition", "route"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "legacy_entry_route") {
|
||||
if err := dropColumn(db, "skill_definition", "legacy_entry_route"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_route") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "legacy_route") {
|
||||
if err := dropColumn(db, "skill_definition", "legacy_route"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_object_entry_route") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "legacy_object_entry_route") {
|
||||
if err := dropColumn(db, "skill_definition", "legacy_object_entry_route"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -165,7 +258,7 @@ func migrateObjectEntryRouteColumns(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
func migrateSpecialistStructuredColumns(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&model.Specialist{}) {
|
||||
if !db.Migrator().HasTable(&specialistmodel.Specialist{}) {
|
||||
return nil
|
||||
}
|
||||
if err := migrateColumnData(db, "specialist", "interaction_card_json", "role_card_json"); err != nil {
|
||||
@@ -178,12 +271,23 @@ func migrateSpecialistStructuredColumns(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
func migrateSkillObjectKindColumn(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&model.SkillDefinition{}) {
|
||||
if !db.Migrator().HasTable(&skillmodel.SkillDefinition{}) {
|
||||
return nil
|
||||
}
|
||||
return migrateColumnData(db, "skill_definition", "object_kind", "role_kind")
|
||||
}
|
||||
|
||||
func normalizeSkillObjectKinds(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&skillmodel.SkillDefinition{}) || !db.Migrator().HasColumn("skill_definition", "object_kind") {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(`
|
||||
UPDATE skill_definition
|
||||
SET object_kind = 'skill'
|
||||
WHERE object_kind = 'assistant'
|
||||
`).Error
|
||||
}
|
||||
|
||||
func migrateColumnData(db *gorm.DB, tableName, targetColumn, legacyColumn string) error {
|
||||
if !db.Migrator().HasColumn(tableName, legacyColumn) {
|
||||
return nil
|
||||
@@ -230,5 +334,43 @@ func joinCSV(parts []string) string {
|
||||
}
|
||||
|
||||
func dropColumn(db *gorm.DB, tableName, columnName string) error {
|
||||
// SQLite 拒绝删除仍被索引引用的列,而 GORM 的 AutoMigrate 只会新建索引、
|
||||
// 不会清理旧模型遗留的索引(例如 role_kind 改名 object_kind 后,
|
||||
// idx_skill_definition_role_kind 会一直留在库里)。
|
||||
// 不先摘掉这些索引,DROP COLUMN 会以
|
||||
// "error in index ... after drop column" 失败,导致 store.Init 直接崩在启动。
|
||||
if err := dropIndexesOnColumn(db, tableName, columnName); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec(fmt.Sprintf(`ALTER TABLE "%s" DROP COLUMN "%s"`, tableName, columnName)).Error
|
||||
}
|
||||
|
||||
// dropIndexesOnColumn 删除表上所有引用指定列的索引(含旧模型遗留的)。
|
||||
func dropIndexesOnColumn(db *gorm.DB, tableName, columnName string) error {
|
||||
var names []string
|
||||
// sql IS NOT NULL 过滤掉 SQLite 为 UNIQUE/PK 约束自动建的内部索引,那些删不掉。
|
||||
if err := db.Raw(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL`,
|
||||
tableName,
|
||||
).Scan(&names).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range names {
|
||||
var cols []struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
if err := db.Raw(fmt.Sprintf(`PRAGMA index_info("%s")`, name)).Scan(&cols).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cols {
|
||||
if c.Name != columnName {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, name)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
skillmodel "eai_agentplatform/backend/internal/skills/model"
|
||||
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
|
||||
)
|
||||
|
||||
func openMigrationTestDB(t *testing.T) *gorm.DB {
|
||||
@@ -22,7 +24,7 @@ func openMigrationTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) {
|
||||
db := openMigrationTestDB(t)
|
||||
if err := db.AutoMigrate(&model.Specialist{}, &model.SkillDefinition{}); err != nil {
|
||||
if err := db.AutoMigrate(&specialistmodel.Specialist{}, &skillmodel.SkillDefinition{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
@@ -48,11 +50,11 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) {
|
||||
t.Fatalf("add skill_definition.legacy_object_entry_route: %v", err)
|
||||
}
|
||||
|
||||
specialist := model.Specialist{
|
||||
specialist := specialistmodel.Specialist{
|
||||
Key: "spec-demo",
|
||||
Label: "专员演示",
|
||||
Tier: "generic",
|
||||
WorkerType: "dw",
|
||||
SpecialistMode: "dw",
|
||||
ObjectEntryRoute: "",
|
||||
State: "active",
|
||||
MarketTag: "installed",
|
||||
@@ -64,7 +66,7 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) {
|
||||
t.Fatalf("seed specialist legacy routes: %v", err)
|
||||
}
|
||||
|
||||
skill := model.SkillDefinition{
|
||||
skill := skillmodel.SkillDefinition{
|
||||
Key: "skill-demo",
|
||||
Label: "技能演示",
|
||||
ObjectKind: "skill",
|
||||
@@ -85,7 +87,7 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) {
|
||||
t.Fatalf("migrate object entry route columns: %v", err)
|
||||
}
|
||||
|
||||
var migratedSpecialist model.Specialist
|
||||
var migratedSpecialist specialistmodel.Specialist
|
||||
if err := db.First(&migratedSpecialist, specialist.ID).Error; err != nil {
|
||||
t.Fatalf("reload specialist: %v", err)
|
||||
}
|
||||
@@ -93,7 +95,7 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) {
|
||||
t.Fatalf("specialist object_entry_route = %q, want %q", migratedSpecialist.ObjectEntryRoute, "/legacy-specialist")
|
||||
}
|
||||
|
||||
var migratedSkill model.SkillDefinition
|
||||
var migratedSkill skillmodel.SkillDefinition
|
||||
if err := db.First(&migratedSkill, skill.ID).Error; err != nil {
|
||||
t.Fatalf("reload skill: %v", err)
|
||||
}
|
||||
@@ -101,22 +103,22 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) {
|
||||
t.Fatalf("skill object_entry_route = %q, want %q", migratedSkill.ObjectEntryRoute, "/legacy-skill")
|
||||
}
|
||||
|
||||
if db.Migrator().HasColumn(&model.Specialist{}, "entry_route") {
|
||||
if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "entry_route") {
|
||||
t.Fatalf("specialist.entry_route should be dropped")
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.Specialist{}, "route") {
|
||||
if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "route") {
|
||||
t.Fatalf("specialist.route should be dropped")
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "entry_route") {
|
||||
t.Fatalf("skill_definition.entry_route should be dropped")
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "route") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "route") {
|
||||
t.Fatalf("skill_definition.route should be dropped")
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "legacy_entry_route") {
|
||||
t.Fatalf("skill_definition.legacy_entry_route should be dropped")
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_route") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "legacy_route") {
|
||||
t.Fatalf("skill_definition.legacy_route should be dropped")
|
||||
}
|
||||
if db.Migrator().HasColumn("skill_definition", "legacy_object_entry_route") {
|
||||
@@ -126,7 +128,7 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) {
|
||||
|
||||
func TestMigrateSpecialistStructuredColumnsDropsLegacyColumns(t *testing.T) {
|
||||
db := openMigrationTestDB(t)
|
||||
if err := db.AutoMigrate(&model.Specialist{}); err != nil {
|
||||
if err := db.AutoMigrate(&specialistmodel.Specialist{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
@@ -137,11 +139,11 @@ func TestMigrateSpecialistStructuredColumnsDropsLegacyColumns(t *testing.T) {
|
||||
t.Fatalf("add specialist.source_records_json: %v", err)
|
||||
}
|
||||
|
||||
item := model.Specialist{
|
||||
item := specialistmodel.Specialist{
|
||||
Key: "spec-structured",
|
||||
Label: "结构化专员",
|
||||
Tier: "generic",
|
||||
WorkerType: "dw",
|
||||
SpecialistMode: "dw",
|
||||
ObjectEntryRoute: "/home",
|
||||
State: "active",
|
||||
MarketTag: "installed",
|
||||
@@ -159,7 +161,7 @@ func TestMigrateSpecialistStructuredColumnsDropsLegacyColumns(t *testing.T) {
|
||||
t.Fatalf("migrate specialist structured columns: %v", err)
|
||||
}
|
||||
|
||||
var migrated model.Specialist
|
||||
var migrated specialistmodel.Specialist
|
||||
if err := db.First(&migrated, item.ID).Error; err != nil {
|
||||
t.Fatalf("reload specialist: %v", err)
|
||||
}
|
||||
@@ -169,17 +171,17 @@ func TestMigrateSpecialistStructuredColumnsDropsLegacyColumns(t *testing.T) {
|
||||
if migrated.InputsRecordsJSON != `[{"input_name":"crm"}]` {
|
||||
t.Fatalf("inputs_records_json = %q", migrated.InputsRecordsJSON)
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.Specialist{}, "role_card_json") {
|
||||
if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "role_card_json") {
|
||||
t.Fatalf("specialist.role_card_json should be dropped")
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.Specialist{}, "source_records_json") {
|
||||
if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "source_records_json") {
|
||||
t.Fatalf("specialist.source_records_json should be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSkillObjectKindColumnDropsLegacyColumn(t *testing.T) {
|
||||
db := openMigrationTestDB(t)
|
||||
if err := db.AutoMigrate(&model.SkillDefinition{}); err != nil {
|
||||
if err := db.AutoMigrate(&skillmodel.SkillDefinition{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
@@ -187,7 +189,7 @@ func TestMigrateSkillObjectKindColumnDropsLegacyColumn(t *testing.T) {
|
||||
t.Fatalf("add skill_definition.role_kind: %v", err)
|
||||
}
|
||||
|
||||
item := model.SkillDefinition{
|
||||
item := skillmodel.SkillDefinition{
|
||||
Key: "skill-kind-demo",
|
||||
Label: "技能类型演示",
|
||||
ObjectKind: "",
|
||||
@@ -206,14 +208,146 @@ func TestMigrateSkillObjectKindColumnDropsLegacyColumn(t *testing.T) {
|
||||
t.Fatalf("migrate skill object kind column: %v", err)
|
||||
}
|
||||
|
||||
var migrated model.SkillDefinition
|
||||
var migrated skillmodel.SkillDefinition
|
||||
if err := db.First(&migrated, item.ID).Error; err != nil {
|
||||
t.Fatalf("reload skill: %v", err)
|
||||
}
|
||||
if migrated.ObjectKind != "skill" {
|
||||
t.Fatalf("object_kind = %q", migrated.ObjectKind)
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.SkillDefinition{}, "role_kind") {
|
||||
if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "role_kind") {
|
||||
t.Fatalf("skill_definition.role_kind should be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAIUsageKindColumnDropsLegacyColumn(t *testing.T) {
|
||||
db := openMigrationTestDB(t)
|
||||
if err := db.AutoMigrate(&model.AiCallLog{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`ALTER TABLE ai_call_log ADD COLUMN capability TEXT DEFAULT ''`).Error; err != nil {
|
||||
t.Fatalf("add ai_call_log.capability: %v", err)
|
||||
}
|
||||
|
||||
item := model.AiCallLog{
|
||||
UserID: 1,
|
||||
UsageKind: "",
|
||||
Status: "success",
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create ai_call_log: %v", err)
|
||||
}
|
||||
if err := db.Exec(`UPDATE ai_call_log SET usage_kind = '', capability = ? WHERE id = ?`, "text_gen", item.ID).Error; err != nil {
|
||||
t.Fatalf("seed ai_call_log legacy capability: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateAIUsageKindColumn(db); err != nil {
|
||||
t.Fatalf("migrate ai usage kind column: %v", err)
|
||||
}
|
||||
|
||||
var migrated model.AiCallLog
|
||||
if err := db.First(&migrated, item.ID).Error; err != nil {
|
||||
t.Fatalf("reload ai_call_log: %v", err)
|
||||
}
|
||||
if migrated.UsageKind != "text_gen" {
|
||||
t.Fatalf("usage_kind = %q", migrated.UsageKind)
|
||||
}
|
||||
if db.Migrator().HasColumn("ai_call_log", "capability") {
|
||||
t.Fatalf("ai_call_log.capability should be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSkillObjectKindsMapsAssistantToSkill(t *testing.T) {
|
||||
db := openMigrationTestDB(t)
|
||||
if err := db.AutoMigrate(&skillmodel.SkillDefinition{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
item := skillmodel.SkillDefinition{
|
||||
Key: "smart-assistant",
|
||||
Label: "通用助手",
|
||||
ObjectKind: "assistant",
|
||||
Source: "eai",
|
||||
ObjectEntryRoute: "/home",
|
||||
State: "active",
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create skill: %v", err)
|
||||
}
|
||||
|
||||
if err := normalizeSkillObjectKinds(db); err != nil {
|
||||
t.Fatalf("normalize skill object kinds: %v", err)
|
||||
}
|
||||
|
||||
var migrated skillmodel.SkillDefinition
|
||||
if err := db.First(&migrated, item.ID).Error; err != nil {
|
||||
t.Fatalf("reload skill: %v", err)
|
||||
}
|
||||
if migrated.ObjectKind != "skill" {
|
||||
t.Fatalf("object_kind = %q", migrated.ObjectKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyTaskRuntimeSchemaRenamesTables(t *testing.T) {
|
||||
db := openMigrationTestDB(t)
|
||||
if err := db.Exec(`CREATE TABLE worker_task (id integer primary key, title text)`).Error; err != nil {
|
||||
t.Fatalf("create worker_task: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE worker_run (id integer primary key, task_id integer)`).Error; err != nil {
|
||||
t.Fatalf("create worker_run: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE worker_artifact (id integer primary key, task_id integer)`).Error; err != nil {
|
||||
t.Fatalf("create worker_artifact: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateLegacyTaskRuntimeSchema(db); err != nil {
|
||||
t.Fatalf("migrate legacy task runtime schema: %v", err)
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("worker_task") {
|
||||
t.Fatalf("worker_task should be renamed")
|
||||
}
|
||||
if db.Migrator().HasTable("worker_run") {
|
||||
t.Fatalf("worker_run should be renamed")
|
||||
}
|
||||
if db.Migrator().HasTable("worker_artifact") {
|
||||
t.Fatalf("worker_artifact should be renamed")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.TaskRecord{}) {
|
||||
t.Fatalf("task_record should exist")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.TaskRun{}) {
|
||||
t.Fatalf("task_run should exist")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.TaskArtifact{}) {
|
||||
t.Fatalf("task_artifact should exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateObjectModeColumnsRenamesLegacyWorkerTypeColumns(t *testing.T) {
|
||||
db := openMigrationTestDB(t)
|
||||
if err := db.Exec(`CREATE TABLE specialist (id integer primary key, key text, label text, tier text, worker_type text, object_entry_route text, state text, market_tag text)`).Error; err != nil {
|
||||
t.Fatalf("create specialist table: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE xapp_definition (id integer primary key, key text, label text, worker_type text)`).Error; err != nil {
|
||||
t.Fatalf("create xapp_definition table: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateObjectModeColumns(db); err != nil {
|
||||
t.Fatalf("migrate object mode columns: %v", err)
|
||||
}
|
||||
|
||||
if db.Migrator().HasColumn("specialist", "worker_type") {
|
||||
t.Fatalf("specialist.worker_type should be renamed")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&specialistmodel.Specialist{}, "specialist_mode") {
|
||||
t.Fatalf("specialist.specialist_mode should exist")
|
||||
}
|
||||
if db.Migrator().HasColumn("xapp_definition", "worker_type") {
|
||||
t.Fatalf("xapp_definition.worker_type should be renamed")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&model.XAppDefinition{}, "xapp_mode") {
|
||||
t.Fatalf("xapp_definition.xapp_mode should exist")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package store
|
||||
|
||||
import "fmt"
|
||||
|
||||
type objectCodeScheme struct {
|
||||
DisplayPrefix string
|
||||
LogicPrefix string
|
||||
}
|
||||
|
||||
var objectCodeSchemes = map[string]objectCodeScheme{
|
||||
"specialist": {DisplayPrefix: "专", LogicPrefix: "S"},
|
||||
"skill": {DisplayPrefix: "能", LogicPrefix: "K"},
|
||||
"xapp": {DisplayPrefix: "应", LogicPrefix: "X"},
|
||||
"connector": {DisplayPrefix: "连", LogicPrefix: "C"},
|
||||
}
|
||||
|
||||
func formatObjectSequence(index int) string {
|
||||
return fmt.Sprintf("%02d", index)
|
||||
}
|
||||
|
||||
// FormatDisplayCode / FormatLogicCode 是各对象目录共用的展示码/逻辑码生成器。
|
||||
// 专员目录的种子编排在 specialists 域(specialists/seeding)里,需要复用这套规则,
|
||||
// 因此对外导出(store 是最底层数据层,specialists 允许向下引用 store)。
|
||||
func FormatDisplayCode(kind string, index int) string {
|
||||
scheme := objectCodeSchemes[kind]
|
||||
return scheme.DisplayPrefix + formatObjectSequence(index)
|
||||
}
|
||||
|
||||
func FormatLogicCode(kind string, index int) string {
|
||||
scheme := objectCodeSchemes[kind]
|
||||
return "EAI-" + scheme.LogicPrefix + "-" + formatObjectSequence(index)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
@@ -12,9 +11,16 @@ import (
|
||||
|
||||
"eai_agentplatform/backend/internal/auth"
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/jsonutil"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
skillmodel "eai_agentplatform/backend/internal/skills/model"
|
||||
)
|
||||
|
||||
// mustJSON 是 seed 内历史遗留的便捷包装,实现收敛到中立的 jsonutil 包(避免重复)。
|
||||
func mustJSON(v any) string {
|
||||
return jsonutil.MustJSON(v)
|
||||
}
|
||||
|
||||
// SeedDefaults 插入默认管理员 + system_config 默认项(幂等,可重复执行)
|
||||
func SeedDefaults() error {
|
||||
// 默认管理员 admin / admin123(交付前 Clonezilla 清理时改密)
|
||||
@@ -88,16 +94,13 @@ func SeedDefaults() error {
|
||||
if err := seedTrainingMedia(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedSpecialists(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedSkillDefinitions(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedActionDefinitions(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedAppDefinitions(); err != nil {
|
||||
if err := seedXAppDefinitions(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -152,12 +155,9 @@ func seedCoursesFromJSON() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := firstExistingPath(
|
||||
"training_materials/work/courses.json",
|
||||
"../training_materials/work/courses.json",
|
||||
"eai_agentplatform/training_materials/work/courses.json",
|
||||
)
|
||||
if path == "" {
|
||||
cfg := config.Load()
|
||||
path := filepath.Join(cfg.TrainingMaterialsDir, "work", "courses.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@ type mediaSeed struct {
|
||||
func seedTrainingMedia() error {
|
||||
cfg := config.Load()
|
||||
approvedDir := filepath.Join(cfg.KBDataDir, "approved")
|
||||
materialsDir := cfg.TrainingMaterialsDir
|
||||
if err := os.MkdirAll(approvedDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -227,47 +228,47 @@ func seedTrainingMedia() error {
|
||||
seeds := []mediaSeed{
|
||||
{
|
||||
filename: "01_公司介绍.mp4",
|
||||
sourcePath: firstExistingPath("training_materials/mp4/01_公司介绍.mp4", "../training_materials/mp4/01_公司介绍.mp4", "eai_agentplatform/training_materials/mp4/01_公司介绍.mp4"),
|
||||
sourcePath: filepath.Join(materialsDir, "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/training_materials/pdf/01_公司介绍.pdf"),
|
||||
sourcePath: filepath.Join(materialsDir, "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/training_materials/mp4/05_资本咨询销售要点.mp4"),
|
||||
sourcePath: filepath.Join(materialsDir, "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/training_materials/mp4/03_高企认定八大条件.mp4"),
|
||||
sourcePath: filepath.Join(materialsDir, "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/training_materials/mp4/06_企业AI落地方法论.mp4"),
|
||||
sourcePath: filepath.Join(materialsDir, "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/training_materials/pdf/03_资本咨询类产品手册.pdf"),
|
||||
sourcePath: filepath.Join(materialsDir, "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/training_materials/pdf/05_AI咨询类产品手册.pdf"),
|
||||
sourcePath: filepath.Join(materialsDir, "pdf", "05_AI咨询类产品手册.pdf"),
|
||||
storedName: "seed_course_co003_manual.pdf",
|
||||
bindType: "course",
|
||||
bindCode: "CO-003",
|
||||
@@ -293,420 +294,37 @@ func seedTrainingMedia() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func applySpecialistCodes(items []model.Specialist) []model.Specialist {
|
||||
visibleIndex := 1
|
||||
func applySkillCodes(items []skillmodel.SkillDefinition) []skillmodel.SkillDefinition {
|
||||
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
|
||||
items[i].DisplayCode = FormatDisplayCode("skill", i+1)
|
||||
}
|
||||
if strings.TrimSpace(items[i].EAILogicCode) == "" {
|
||||
items[i].EAILogicCode = "EAI-S-" + suffix
|
||||
items[i].EAILogicCode = FormatLogicCode("skill", i+1)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func applySkillCodes(items []model.SkillDefinition) []model.SkillDefinition {
|
||||
func applyXAppCodes(items []model.XAppDefinition) []model.XAppDefinition {
|
||||
for i := range items {
|
||||
suffix := fmt.Sprintf("%02d", i+1)
|
||||
if strings.TrimSpace(items[i].DisplayCode) == "" {
|
||||
items[i].DisplayCode = "能" + suffix
|
||||
items[i].DisplayCode = FormatDisplayCode("xapp", i+1)
|
||||
}
|
||||
if strings.TrimSpace(items[i].EAILogicCode) == "" {
|
||||
items[i].EAILogicCode = "EAI-K-" + suffix
|
||||
items[i].EAILogicCode = FormatLogicCode("xapp", i+1)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func applyAppCodes(items []model.AppDefinition) []model.AppDefinition {
|
||||
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-A-" + suffix
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func seedSpecialists() error {
|
||||
items := applySpecialistCodes([]model.Specialist{
|
||||
{
|
||||
// 「新建任务」在还没选专员/工具之前,任务先落在这里。
|
||||
// state = system:它不是一个能装到工作区的专员,只是任务的默认归属,
|
||||
// 所以不出现在专员市场、我的专员和 + 菜单里(ListSpecialists 会挡掉)。
|
||||
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",
|
||||
ObjectEntryRoute: "/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",
|
||||
ObjectEntryRoute: "/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",
|
||||
ObjectEntryRoute: "/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",
|
||||
ObjectEntryRoute: "/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",
|
||||
ObjectEntryRoute: "/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",
|
||||
ObjectEntryRoute: "/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",
|
||||
ObjectEntryRoute: "/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",
|
||||
ObjectEntryRoute: "/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",
|
||||
ObjectEntryRoute: "/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",
|
||||
ObjectEntryRoute: "/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,
|
||||
},
|
||||
{
|
||||
Key: "presentation-briefing",
|
||||
Label: "汇报专员",
|
||||
Tier: "generic",
|
||||
WorkerType: "dw",
|
||||
ObjectEntryRoute: "/apps/presentation-briefing",
|
||||
Summary: "经营汇报、方案汇报与培训课件统一出稿",
|
||||
WorkStatus: "2 个待成稿",
|
||||
RiskLabel: "1 个页数待压缩",
|
||||
Color: "#4f46e5",
|
||||
Stage: "演示编排",
|
||||
Progress: 68,
|
||||
MarketTag: "已安装",
|
||||
Version: "v1.0",
|
||||
ConnectorScope: "连接任务结果 / 知识库 / 模板中心",
|
||||
PermissionScope: "读取材料、生成演示稿、导出讲稿备注",
|
||||
ResourceBindings: "演示模板、知识库、任务产物、汇报素材",
|
||||
InfoSources: "主题目标、受众、汇报材料、页数限制",
|
||||
BaseSkills: "演示结构规划、页面要点提炼、讲稿备注生成、口径统一",
|
||||
AIAssistance: "自动压缩材料并给出适合管理层、客户或培训场景的页面结构",
|
||||
GeneratedSkills: "把高质量汇报结构沉淀为可复用的演示模板",
|
||||
State: "active",
|
||||
SortOrder: 100,
|
||||
},
|
||||
})
|
||||
// 岗位说明书与技能绑定按 key 挂载(见 seed_specialist_rules.go)
|
||||
if err := applySpecialistRuleFiles(items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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.DisplayCode == "" {
|
||||
updates["display_code"] = item.DisplayCode
|
||||
}
|
||||
if existing.EAILogicCode == "" {
|
||||
updates["eailogic_code"] = item.EAILogicCode
|
||||
}
|
||||
if existing.WorkerType == "" {
|
||||
updates["worker_type"] = item.WorkerType
|
||||
}
|
||||
if existing.InteractionCardJSON == "" {
|
||||
updates["interaction_card_json"] = item.InteractionCardJSON
|
||||
}
|
||||
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.RuleFileMarkdown == "" {
|
||||
updates["rule_file_markdown"] = item.RuleFileMarkdown
|
||||
}
|
||||
if existing.AllowedSkills == "" {
|
||||
updates["allowed_skills"] = item.AllowedSkills
|
||||
}
|
||||
if existing.InputsRecordsJSON == "" {
|
||||
updates["inputs_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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logSpecialistRuleFileCoverage()
|
||||
log.Println("[OK] 专员目录种子已导入")
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedSkillDefinitions() error {
|
||||
items := applySkillCodes([]model.SkillDefinition{
|
||||
items := applySkillCodes([]skillmodel.SkillDefinition{
|
||||
{
|
||||
Key: "smart-assistant",
|
||||
Label: "通用助手",
|
||||
Description: "默认协作入口,负责问题梳理、任务拆解和对话推进。",
|
||||
ObjectKind: "assistant",
|
||||
ObjectKind: "skill",
|
||||
Source: "eai",
|
||||
ObjectEntryRoute: "/home",
|
||||
ExposedToUser: true,
|
||||
@@ -831,7 +449,7 @@ func seedSkillDefinitions() error {
|
||||
})
|
||||
|
||||
for _, item := range items {
|
||||
var existing model.SkillDefinition
|
||||
var existing skillmodel.SkillDefinition
|
||||
if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil {
|
||||
if err := DB.Create(&item).Error; err != nil {
|
||||
return err
|
||||
@@ -1021,15 +639,19 @@ func seedActionDefinitions() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedAppDefinitions() error {
|
||||
items := applyAppCodes([]model.AppDefinition{
|
||||
func seedXAppDefinitions() error {
|
||||
builtinOpenRouteOverrides := map[string]string{
|
||||
"internal-exam": "/xapps/internal-exam",
|
||||
"internal-training": "/xapps/internal-training",
|
||||
}
|
||||
items := applyXAppCodes([]model.XAppDefinition{
|
||||
{
|
||||
Key: "knowledge-hub",
|
||||
Label: "知识库",
|
||||
Badge: "内建 APP",
|
||||
Kind: "组织级 APP",
|
||||
MarketTag: "内建 APP",
|
||||
WorkerType: "adw",
|
||||
XAppMode: "orchestrated",
|
||||
Tier: "platform",
|
||||
Source: "eai",
|
||||
Color: "#5b7cff",
|
||||
@@ -1052,7 +674,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "长程 APP",
|
||||
Kind: "内部考试",
|
||||
MarketTag: "长程 APP",
|
||||
WorkerType: "adw",
|
||||
XAppMode: "orchestrated",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#29b36a",
|
||||
@@ -1060,7 +682,7 @@ func seedAppDefinitions() error {
|
||||
CoverTone: "linear-gradient(135deg, #1c9f5a 0%, #6ee7b7 100%)",
|
||||
Summary: "围绕练习、自测、正式考试、记录、证书和岗位应学组织的内部考试 APP。",
|
||||
Description: "内部考试 APP 用来承接训练、练习、考试、成绩记录和证书结果,适合需要完整过程管理的学习场景。",
|
||||
OpenRoute: "/exam/self-test",
|
||||
OpenRoute: "/xapps/internal-exam",
|
||||
DefaultPrompt: "我想组织一轮内部考试",
|
||||
PromptsJSON: mustJSON([]string{"我想组织一轮内部考试", "帮我看看这个考试流程怎么搭", "这个场景适合放到内部考试 APP 吗"}),
|
||||
TagsJSON: mustJSON([]string{"训练流程", "考试记录", "结果沉淀"}),
|
||||
@@ -1075,7 +697,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "长程 APP",
|
||||
Kind: "内部培训",
|
||||
MarketTag: "长程 APP",
|
||||
WorkerType: "worker",
|
||||
XAppMode: "direct",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#9b6bff",
|
||||
@@ -1083,7 +705,7 @@ func seedAppDefinitions() error {
|
||||
CoverTone: "linear-gradient(135deg, #8b5cf6 0%, #c4b5fd 100%)",
|
||||
Summary: "统一承接销售培训、产品知识和公司介绍三个培训模块的内部培训 APP。",
|
||||
Description: "内部培训 APP 用来把销售培训、产品知识和公司介绍收在同一个对象下面,避免把同一条培训链拆成多个 APP。",
|
||||
OpenRoute: "/courses",
|
||||
OpenRoute: "/xapps/internal-training",
|
||||
DefaultPrompt: "我想搭一个内部培训 APP",
|
||||
PromptsJSON: mustJSON([]string{"我想搭一个内部培训 APP", "帮我梳理销售培训、产品知识和公司介绍的关系", "这个培训主题应该挂到内部培训 APP 吗"}),
|
||||
TagsJSON: mustJSON([]string{"课程组织", "产品知识", "组织导览"}),
|
||||
@@ -1098,7 +720,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "办公中枢",
|
||||
Kind: "结果沉淀",
|
||||
MarketTag: "办公中枢",
|
||||
WorkerType: "adw",
|
||||
XAppMode: "orchestrated",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#2563eb",
|
||||
@@ -1121,7 +743,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "成品应用",
|
||||
Kind: "办公速用",
|
||||
MarketTag: "成品应用",
|
||||
WorkerType: "worker",
|
||||
XAppMode: "direct",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#0f766e",
|
||||
@@ -1144,7 +766,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "成品应用",
|
||||
Kind: "法务办公",
|
||||
MarketTag: "成品应用",
|
||||
WorkerType: "worker",
|
||||
XAppMode: "direct",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#b91c1c",
|
||||
@@ -1167,7 +789,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "成品应用",
|
||||
Kind: "汇报加速",
|
||||
MarketTag: "成品应用",
|
||||
WorkerType: "worker",
|
||||
XAppMode: "direct",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#7c3aed",
|
||||
@@ -1190,7 +812,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "成品应用",
|
||||
Kind: "进展同步",
|
||||
MarketTag: "成品应用",
|
||||
WorkerType: "worker",
|
||||
XAppMode: "direct",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#1d4ed8",
|
||||
@@ -1213,7 +835,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "成品应用",
|
||||
Kind: "汇报交付",
|
||||
MarketTag: "成品应用",
|
||||
WorkerType: "worker",
|
||||
XAppMode: "direct",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#4f46e5",
|
||||
@@ -1237,7 +859,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "成品应用",
|
||||
Kind: "结构整理",
|
||||
MarketTag: "成品应用",
|
||||
WorkerType: "worker",
|
||||
XAppMode: "direct",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#ea580c",
|
||||
@@ -1260,7 +882,7 @@ func seedAppDefinitions() error {
|
||||
Badge: "成品应用",
|
||||
Kind: "图文理解",
|
||||
MarketTag: "成品应用",
|
||||
WorkerType: "worker",
|
||||
XAppMode: "direct",
|
||||
Tier: "business",
|
||||
Source: "eai",
|
||||
Color: "#0ea5e9",
|
||||
@@ -1280,7 +902,7 @@ func seedAppDefinitions() error {
|
||||
})
|
||||
|
||||
for _, item := range items {
|
||||
var existing model.AppDefinition
|
||||
var existing model.XAppDefinition
|
||||
if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil {
|
||||
if err := DB.Create(&item).Error; err != nil {
|
||||
return err
|
||||
@@ -1303,8 +925,8 @@ func seedAppDefinitions() error {
|
||||
if existing.MarketTag == "" {
|
||||
updates["market_tag"] = item.MarketTag
|
||||
}
|
||||
if existing.WorkerType == "" {
|
||||
updates["worker_type"] = item.WorkerType
|
||||
if existing.XAppMode == "" {
|
||||
updates["xapp_mode"] = item.XAppMode
|
||||
}
|
||||
if existing.Tier == "" {
|
||||
updates["tier"] = item.Tier
|
||||
@@ -1324,7 +946,9 @@ func seedAppDefinitions() error {
|
||||
if existing.Description == "" {
|
||||
updates["description"] = item.Description
|
||||
}
|
||||
if existing.OpenRoute == "" {
|
||||
if expectedRoute, ok := builtinOpenRouteOverrides[item.Key]; ok && existing.OpenRoute != expectedRoute {
|
||||
updates["open_route"] = expectedRoute
|
||||
} else if existing.OpenRoute == "" {
|
||||
updates["open_route"] = item.OpenRoute
|
||||
}
|
||||
if existing.SpecialistKey == "" {
|
||||
@@ -1464,15 +1088,3 @@ func copySeedMedia(src, dst string) error {
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
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. 风险边界:哪些是本次不做的,讲清楚,避免交付时扯皮。
|
||||
|
||||
## 输出要求
|
||||
- 区分「已确认需求」和「我方假设」,假设要显式标出来让销售去核。
|
||||
- 涉及能力边界的表述,只说现有产品能做的。
|
||||
|
||||
## 边界
|
||||
- 不承诺工期、不承诺价格——这两项由销售与交付确认。
|
||||
- 不替研发承诺功能,不贬低竞品。`,
|
||||
|
||||
"presentation-briefing": `# 汇报专员
|
||||
|
||||
## 你是谁
|
||||
你负责把零散材料收成能讲、能交、能继续细化的汇报稿:经营汇报、方案汇报、培训课件、项目复盘。
|
||||
你面对的是马上要上台讲的人,他要的是一版结构清楚、页数合适、重点能讲明白的演示稿。
|
||||
|
||||
## 你怎么推进
|
||||
1. 先确认汇报对象、场景和页数限制:给管理层、给客户、给培训学员,写法完全不同。
|
||||
2. 先出结构,不急着写满内容:按「开场结论 → 现状 / 问题 → 方案 / 进展 → 风险 / 下一步」组织。
|
||||
3. 每页只保留一层主结论,宁可留白,不要把 Word 直接搬进 PPT。
|
||||
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"},
|
||||
"presentation-briefing": {"ppt-generation", "proposal-summary", "mind-map", "progress-report"},
|
||||
"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)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// seedSpecialistKeys 从 seedSpecialists 的字面量里抽专员 key。
|
||||
// 直接反射那份清单不方便(它建在函数体里),所以从源码文本抓——
|
||||
// 与 skill_keys_test.go 抓前端 staticSkillCatalog 是同一套路子。
|
||||
//
|
||||
// 必须限定在 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
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// EnsureSpecialistStructuredRecords 补齐四类结构化记录,供工作区 UI 直接消费。
|
||||
func EnsureSpecialistStructuredRecords(item *model.Specialist) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
EnsureSpecialistInteractionCard(item)
|
||||
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 EnsureSpecialistInteractionCard(item *model.Specialist) {
|
||||
if item == nil || strings.TrimSpace(item.InteractionCardJSON) != "" {
|
||||
return
|
||||
}
|
||||
item.InteractionCardJSON = mustJSON(map[string]any{
|
||||
"name": item.Label,
|
||||
"tagline": firstNonEmpty(item.Summary, item.WorkStatus, item.Label),
|
||||
"greeting": "我是" + item.Label + ",已经准备好接手这条任务并继续推进。",
|
||||
"relationship_to_user": "你的协作搭档",
|
||||
"tone": "专业、直接、可执行",
|
||||
"opening_prompt": "告诉我当前事项、目标和约束,我先帮你起步。",
|
||||
"starter_prompts": defaultStarterPrompts(item),
|
||||
"boundaries": []string{
|
||||
"高风险动作会明确标记待确认",
|
||||
"需要人工审批的步骤不会默认自动执行",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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 ValidateJSONObjectJSON(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
var obj map[string]any
|
||||
return json.Unmarshal([]byte(value), &obj) == nil
|
||||
}
|
||||
|
||||
func ValidateJSONStringArray(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
var arr []string
|
||||
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 defaultStarterPrompts(item *model.Specialist) []string {
|
||||
prompts := make([]string, 0, 3)
|
||||
for _, skill := range splitText(item.BaseSkills) {
|
||||
prompts = append(prompts, "帮我处理:"+skill)
|
||||
if len(prompts) == 3 {
|
||||
return prompts
|
||||
}
|
||||
}
|
||||
if item.Summary != "" {
|
||||
prompts = append(prompts, "继续推进这条事项")
|
||||
}
|
||||
if len(prompts) == 0 {
|
||||
prompts = []string{"先帮我梳理当前事项", "告诉我下一步怎么做", "先看有哪些待确认点"}
|
||||
}
|
||||
return prompts
|
||||
}
|
||||
|
||||
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