feat: model expert catalog categories

This commit is contained in:
2026-07-23 14:07:13 +08:00
parent 51ee510112
commit 6844af321e
6 changed files with 126 additions and 26 deletions

View File

@@ -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 {

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -32,6 +32,7 @@ func AutoMigrate(database *gorm.DB) error {
&SenlinAgentTask{},
&SenlinAgentNote{},
&SenlinAgentSource{},
&SenlinAgentAIExpertCategory{},
&SenlinAgentAIExpert{},
&SenlinAgentAISession{},
&SenlinAgentTag{},