package service import ( "context" "fmt" "regexp" "strings" "time" "git.threesix.ai/jordan/persona-community-5/pkg/logging" "git.threesix.ai/jordan/persona-community-5/pkg/queue" "git.threesix.ai/jordan/persona-community-5/pkg/realtime" "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain" "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/port" ) // PersonaService handles persona-related business logic. type PersonaService struct { repo port.PersonaRepository queue queue.Producer sseHub *realtime.SSEHub logger *logging.Logger } // NewPersonaService creates a new persona service. func NewPersonaService(repo port.PersonaRepository, q queue.Producer, hub *realtime.SSEHub, logger *logging.Logger) *PersonaService { return &PersonaService{ repo: repo, queue: q, sseHub: hub, logger: logger.WithService("PersonaService"), } } // Create creates a new persona and enqueues a generate_spec job. func (s *PersonaService) Create(ctx context.Context, description, gender, customName string) (*domain.Persona, error) { name := customName if name == "" { name = generatePersonaName(description) } handle := generateHandle(name) now := time.Now().UTC() persona := &domain.Persona{ Name: name, Handle: handle, Gender: gender, Description: description, Tags: []string{}, ImageURLs: []string{}, VideoURLs: []string{}, Status: domain.PersonaStatusPending, CreatedAt: now, } if err := s.repo.Create(ctx, persona); err != nil { return nil, fmt.Errorf("create persona: %w", err) } // Enqueue generate_spec job to kick off the pipeline. _, err := s.queue.Enqueue(ctx, "generate_spec", map[string]any{ "persona_id": persona.ID.String(), "stage": string(domain.StageSpec), }) if err != nil { s.logger.Error("failed to enqueue generate_spec job", "error", err, "persona_id", persona.ID) // Persona is created but job failed to enqueue — not fatal. // Caller can retry or a reconciler can pick it up. } s.publishUpdate(persona) s.logger.Info("persona created", "id", persona.ID, "name", name, "handle", handle) return persona, nil } // GetByID returns a persona by ID. func (s *PersonaService) GetByID(ctx context.Context, id domain.PersonaID) (*domain.Persona, error) { return s.repo.GetByID(ctx, id) } // List returns personas with pagination. func (s *PersonaService) List(ctx context.Context, limit, offset int) ([]*domain.Persona, error) { if limit <= 0 { limit = 20 } if limit > 100 { limit = 100 } if offset < 0 { offset = 0 } return s.repo.List(ctx, limit, offset) } // publishUpdate sends a persona_updated SSE event to channel:personas. func (s *PersonaService) publishUpdate(persona *domain.Persona) { if s.sseHub == nil { return } s.sseHub.SendToChannel("channel:personas", &realtime.SSEEvent{ Type: "persona_updated", Result: persona, }) } // handleRegex strips non-alphanumeric characters for handle generation. var handleRegex = regexp.MustCompile(`[^a-z0-9]+`) // generateHandle creates a URL-safe handle from a name. func generateHandle(name string) string { h := strings.ToLower(strings.TrimSpace(name)) h = handleRegex.ReplaceAllString(h, "_") h = strings.Trim(h, "_") if len(h) > 40 { h = h[:40] h = strings.TrimRight(h, "_") } // Append timestamp suffix to reduce collisions suffix := fmt.Sprintf("_%d", time.Now().UnixMilli()%100000) return h + suffix } // generatePersonaName derives a placeholder name from the description. func generatePersonaName(description string) string { words := strings.Fields(description) if len(words) > 3 { words = words[:3] } // Capitalize first letter of each word for i, w := range words { if len(w) > 0 { words[i] = strings.ToUpper(w[:1]) + w[1:] } } name := strings.Join(words, " ") if len(name) > 50 { name = name[:50] } return name }