persona-community-5/services/persona-api/internal/service/persona.go
rdev-worker 9c009926d1
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /implement-feature persona-model --requirements 'DB migration in pers...
2026-02-24 07:58:27 +00:00

142 lines
3.8 KiB
Go

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
}