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:
eaiadmin
2026-09-19 01:23:51 +08:00
co-authored by Claude Code
parent ddd2d2cbd8
commit 14f303459e
448 changed files with 18792 additions and 17027 deletions
@@ -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 {
+170 -28
View File
@@ -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"
}
}