diff --git a/backend/internal/models/ai_expert.go b/backend/internal/models/ai_expert.go index e7126f3..2184a44 100644 --- a/backend/internal/models/ai_expert.go +++ b/backend/internal/models/ai_expert.go @@ -4,21 +4,25 @@ import "time" // SenlinAgentAIExpert 是本地专家库中的可选 AI 角色。 type SenlinAgentAIExpert struct { - ID uint `gorm:"primaryKey"` - Identity string `gorm:"type:char(36);uniqueIndex"` - Slug string `gorm:"size:160;uniqueIndex;not null"` - Category string `gorm:"size:80;index;not null"` - CategoryName string `gorm:"size:120;not null"` - Name string `gorm:"size:160;index;not null"` - Description string `gorm:"type:text;not null"` - Emoji string `gorm:"size:32"` - Color string `gorm:"size:32"` - SystemPrompt string `gorm:"type:text;not null"` - Source string `gorm:"size:255;not null"` - SourceLicense string `gorm:"size:32;not null"` - Enabled bool `gorm:"not null;default:true;index"` - CreatedAt time.Time - UpdatedAt time.Time + ID uint `gorm:"primaryKey"` + Identity string `gorm:"type:char(36);uniqueIndex"` + Slug string `gorm:"size:160;uniqueIndex;not null"` + CategoryID uint `gorm:"index"` + CategoryIdentity string `gorm:"type:char(36);index"` + ExpertCategory SenlinAgentAIExpertCategory `gorm:"foreignKey:CategoryID"` + Category string `gorm:"size:80;index;not null"` + CategoryName string `gorm:"size:120;not null"` + Name string `gorm:"size:160;index;not null"` + Description string `gorm:"type:text;not null"` + Emoji string `gorm:"size:32"` + Color string `gorm:"size:32"` + SystemPrompt string `gorm:"type:text;not null"` + Source string `gorm:"size:255;not null"` + Reference string `gorm:"size:255"` + SourceLicense string `gorm:"size:32;not null"` + Enabled bool `gorm:"not null;default:true;index"` + CreatedAt time.Time + UpdatedAt time.Time } func (SenlinAgentAIExpert) TableName() string { diff --git a/backend/internal/models/ai_expert_category.go b/backend/internal/models/ai_expert_category.go new file mode 100644 index 0000000..718a0c2 --- /dev/null +++ b/backend/internal/models/ai_expert_category.go @@ -0,0 +1,17 @@ +package models + +import "time" + +// SenlinAgentAIExpertCategory groups experts from the local expert catalog. +type SenlinAgentAIExpertCategory struct { + ID uint `gorm:"primaryKey"` + Identity string `gorm:"type:char(36);uniqueIndex"` + Slug string `gorm:"size:80;uniqueIndex;not null"` + Name string `gorm:"size:120;not null"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentAIExpertCategory) TableName() string { + return "senlin_agent_ai_expert_categories" +} diff --git a/backend/internal/models/identity.go b/backend/internal/models/identity.go index d80823d..e4f3e46 100644 --- a/backend/internal/models/identity.go +++ b/backend/internal/models/identity.go @@ -96,10 +96,17 @@ func (m *SenlinAgentAISession) BeforeCreate(tx *gorm.DB) error { return resolveOptionalIdentity(tx, &SenlinAgentAIExpert{}, m.ExpertID, &m.ExpertIdentity) } -func (m *SenlinAgentAIExpert) BeforeCreate(_ *gorm.DB) error { +func (m *SenlinAgentAIExpertCategory) BeforeCreate(_ *gorm.DB) error { return ensureIdentity(&m.Identity) } +func (m *SenlinAgentAIExpert) BeforeCreate(tx *gorm.DB) error { + if err := ensureIdentity(&m.Identity); err != nil { + return err + } + return resolveIdentity(tx, &SenlinAgentAIExpertCategory{}, m.CategoryID, &m.CategoryIdentity) +} + func (m *SenlinAgentTag) BeforeCreate(tx *gorm.DB) error { if err := ensureIdentity(&m.Identity); err != nil { return err diff --git a/backend/internal/models/migrations.go b/backend/internal/models/migrations.go index 94dff36..fdccbbb 100644 --- a/backend/internal/models/migrations.go +++ b/backend/internal/models/migrations.go @@ -33,6 +33,7 @@ func runVersionedMigrations(database *gorm.DB) error { {version: 1, name: "normalize_project_identifiers", run: normalizeLegacyProjectIdentifiers}, {version: 2, name: "deduplicate_project_tags", run: deduplicateLegacyProjectTags}, {version: 3, name: "seed_ai_experts", run: seedAIExperts}, + {version: 4, name: "normalize_ai_expert_categories", run: normalizeAIExpertCategories}, } for _, migration := range migrations { // Connection callbacks can provide an initialized Gorm session. Start each @@ -69,9 +70,10 @@ func runVersionedMigrations(database *gorm.DB) error { var expertCatalogJSON []byte type embeddedExpertCatalog struct { - Source string `json:"source"` - License string `json:"license"` - Experts []embeddedExpertCatalogItem `json:"experts"` + Source string `json:"source"` + Reference string `json:"reference"` + License string `json:"license"` + Experts []embeddedExpertCatalogItem `json:"experts"` } type embeddedExpertCatalogItem struct { @@ -86,17 +88,19 @@ type embeddedExpertCatalogItem struct { } func seedAIExperts(tx *gorm.DB) (map[string]int, error) { - var catalog embeddedExpertCatalog - if err := json.Unmarshal(expertCatalogJSON, &catalog); err != nil { - return nil, fmt.Errorf("decode embedded AI expert catalog: %w", err) + catalog, categories, err := loadAIExpertCatalog(tx) + if err != nil { + return nil, err } records := make([]SenlinAgentAIExpert, 0, len(catalog.Experts)) for _, expert := range catalog.Experts { + category := categories[expert.Category] records = append(records, SenlinAgentAIExpert{ - Slug: expert.Slug, Category: expert.Category, CategoryName: expert.CategoryName, + Slug: expert.Slug, CategoryID: category.ID, CategoryIdentity: category.Identity, + Category: expert.Category, CategoryName: expert.CategoryName, Name: expert.Name, Description: expert.Description, Emoji: expert.Emoji, Color: expert.Color, SystemPrompt: expert.SystemPrompt, Source: catalog.Source, - SourceLicense: catalog.License, Enabled: true, + Reference: catalog.Reference, SourceLicense: catalog.License, Enabled: true, }) } if len(records) > 0 { @@ -107,6 +111,63 @@ func seedAIExperts(tx *gorm.DB) (map[string]int, error) { return map[string]int{"inserted": len(records)}, nil } +func normalizeAIExpertCategories(tx *gorm.DB) (map[string]int, error) { + catalog, categories, err := loadAIExpertCatalog(tx) + if err != nil { + return nil, err + } + + linked := 0 + for _, expert := range catalog.Experts { + category := categories[expert.Category] + result := tx.Model(&SenlinAgentAIExpert{}).Where("slug = ?", expert.Slug).Updates(map[string]any{ + "category_id": category.ID, + "category_identity": category.Identity, + "reference": catalog.Reference, + }) + if result.Error != nil { + return nil, result.Error + } + linked += int(result.RowsAffected) + } + return map[string]int{"categories": len(categories), "linked": linked}, nil +} + +func loadAIExpertCatalog(tx *gorm.DB) (embeddedExpertCatalog, map[string]SenlinAgentAIExpertCategory, error) { + var catalog embeddedExpertCatalog + if err := json.Unmarshal(expertCatalogJSON, &catalog); err != nil { + return catalog, nil, fmt.Errorf("decode embedded AI expert catalog: %w", err) + } + + categoryNames := make(map[string]string) + categoryOrder := make([]string, 0) + for _, expert := range catalog.Experts { + if _, exists := categoryNames[expert.Category]; exists { + continue + } + categoryNames[expert.Category] = expert.CategoryName + categoryOrder = append(categoryOrder, expert.Category) + } + + categories := make(map[string]SenlinAgentAIExpertCategory, len(categoryOrder)) + for _, slug := range categoryOrder { + category := SenlinAgentAIExpertCategory{Slug: slug} + if err := tx.Where("slug = ?", slug). + Attrs(SenlinAgentAIExpertCategory{Name: categoryNames[slug]}). + FirstOrCreate(&category).Error; err != nil { + return catalog, nil, err + } + if category.Name != categoryNames[slug] { + category.Name = categoryNames[slug] + if err := tx.Model(&category).Update("name", category.Name).Error; err != nil { + return catalog, nil, err + } + } + categories[slug] = category + } + return catalog, categories, nil +} + func normalizeLegacyProjectIdentifiers(tx *gorm.DB) (map[string]int, error) { var projects []SenlinAgentProject if err := tx.Model(&SenlinAgentProject{}).Select("id", "owner_id", "identifier").Order("owner_id asc, id asc").Find(&projects).Error; err != nil { diff --git a/backend/internal/models/migrations_test.go b/backend/internal/models/migrations_test.go index 844fbc6..4c00d33 100644 --- a/backend/internal/models/migrations_test.go +++ b/backend/internal/models/migrations_test.go @@ -57,18 +57,28 @@ func TestAutoMigrateUpgradesLegacyProjectsAndTagsWithoutLosingAssociations(t *te var migrationCount int64 require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error) - require.Equal(t, int64(3), migrationCount) + require.Equal(t, int64(4), migrationCount) var auditDetails []string require.NoError(t, database.Table("senlin_agent_schema_migrations").Order("version asc").Pluck("details", &auditDetails).Error) require.Contains(t, auditDetails[0], `"updated":3`) require.Contains(t, auditDetails[1], `"deduplicated":1`) require.Contains(t, auditDetails[2], `"inserted":267`) + require.Contains(t, auditDetails[3], `"categories":19`) + require.Contains(t, auditDetails[3], `"linked":267`) + var categoryCount int64 + require.NoError(t, database.Model(&SenlinAgentAIExpertCategory{}).Count(&categoryCount).Error) + require.Equal(t, int64(19), categoryCount) var expertCount int64 require.NoError(t, database.Model(&SenlinAgentAIExpert{}).Count(&expertCount).Error) require.Equal(t, int64(267), expertCount) + var expert SenlinAgentAIExpert + require.NoError(t, database.Preload("ExpertCategory").Where("slug = ?", "academic-geographer").First(&expert).Error) + require.Equal(t, "academic", expert.ExpertCategory.Slug) + require.Equal(t, "学术研究", expert.ExpertCategory.Name) + require.Equal(t, "https://ao.aiolaola.com/experts", expert.Reference) require.NoError(t, AutoMigrate(database), "versioned migrations must be safe to run again") require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error) - require.Equal(t, int64(3), migrationCount) + require.Equal(t, int64(4), migrationCount) require.Error(t, database.Exec(`INSERT INTO senlin_agent_projects (owner_id, name, identifier) VALUES (7, 'still duplicate', 'DUP')`).Error) require.Error(t, database.Exec(`INSERT INTO senlin_agent_tags (project_id, name) VALUES (1, 'UI')`).Error) diff --git a/backend/internal/models/new.go b/backend/internal/models/new.go index 8308990..be5f89c 100644 --- a/backend/internal/models/new.go +++ b/backend/internal/models/new.go @@ -32,6 +32,7 @@ func AutoMigrate(database *gorm.DB) error { &SenlinAgentTask{}, &SenlinAgentNote{}, &SenlinAgentSource{}, + &SenlinAgentAIExpertCategory{}, &SenlinAgentAIExpert{}, &SenlinAgentAISession{}, &SenlinAgentTag{},