build: /implement-feature persona-model --requirements 'DB migration in pers...
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
parent
f5f3229364
commit
9c009926d1
@ -102,6 +102,7 @@ func main() {
|
||||
// Without DATABASE_URL: in-memory repos + in-process AI (development)
|
||||
exampleRepo := memory.NewExampleRepository()
|
||||
albumRepo := memory.NewAlbumRepository()
|
||||
var personaRepo port.PersonaRepository
|
||||
var userRepo port.UserRepository
|
||||
var sessionRepo port.SessionRepository
|
||||
var authCodeRepo port.AuthCodeRepository
|
||||
@ -141,6 +142,7 @@ func main() {
|
||||
sessionRepo = postgres.NewSessionRepository(dbPool.DB)
|
||||
authCodeRepo = postgres.NewAuthCodeRepository(dbPool.DB)
|
||||
mediaRepo = postgres.NewMediaObjectRepository(dbPool.DB)
|
||||
personaRepo = postgres.NewPersonaRepository(dbPool.DB)
|
||||
|
||||
// DB-backed queue.
|
||||
jobQueue, jobReader = setupDBQueue(ctx, cfg, dbPool, sseHub, logger)
|
||||
@ -150,6 +152,7 @@ func main() {
|
||||
sessionRepo = memory.NewSessionRepository()
|
||||
authCodeRepo = memory.NewAuthCodeRepository()
|
||||
mediaRepo = memory.NewMediaRepository()
|
||||
personaRepo = memory.NewPersonaRepository()
|
||||
jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, albumRepo, sseHub, logger)
|
||||
}
|
||||
|
||||
@ -201,6 +204,7 @@ func main() {
|
||||
// Create services (business logic)
|
||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
||||
albumService := service.NewAlbumService(albumRepo, jobQueue, logger)
|
||||
personaService := service.NewPersonaService(personaRepo, jobQueue, sseHub, logger)
|
||||
authService := service.NewAuthService(
|
||||
userRepo, sessionRepo, authCodeRepo, emailSender,
|
||||
cfg.JWTSecret, cfg.RegistrationEnabled, logger,
|
||||
@ -219,6 +223,7 @@ func main() {
|
||||
ExampleService: exampleService,
|
||||
AuthService: authService,
|
||||
AlbumService: albumService,
|
||||
PersonaService: personaService,
|
||||
Queue: jobQueue,
|
||||
JobReader: jobReader,
|
||||
SSEHub: sseHub,
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
-- 004_create_personas.sql
|
||||
-- Persona table for AI-generated personas with multi-stage generation pipeline.
|
||||
-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
handle TEXT UNIQUE NOT NULL,
|
||||
gender TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
spec_json JSONB,
|
||||
anchor_url TEXT,
|
||||
avatar_url TEXT,
|
||||
banner_url TEXT,
|
||||
image_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
video_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_personas_status ON personas (status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_personas_handle ON personas (handle);
|
||||
128
services/persona-api/internal/adapter/memory/persona.go
Normal file
128
services/persona-api/internal/adapter/memory/persona.go
Normal file
@ -0,0 +1,128 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time verification that PersonaRepository implements port.PersonaRepository.
|
||||
var _ port.PersonaRepository = (*PersonaRepository)(nil)
|
||||
|
||||
// PersonaRepository is a thread-safe in-memory implementation of port.PersonaRepository.
|
||||
type PersonaRepository struct {
|
||||
mu sync.RWMutex
|
||||
personas map[domain.PersonaID]*domain.Persona
|
||||
handles map[string]domain.PersonaID
|
||||
}
|
||||
|
||||
// NewPersonaRepository creates a new in-memory persona repository.
|
||||
func NewPersonaRepository() *PersonaRepository {
|
||||
return &PersonaRepository{
|
||||
personas: make(map[domain.PersonaID]*domain.Persona),
|
||||
handles: make(map[string]domain.PersonaID),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PersonaRepository) Create(_ context.Context, persona *domain.Persona) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.handles[persona.Handle]; exists {
|
||||
return domain.ErrDuplicateHandle
|
||||
}
|
||||
|
||||
if persona.ID.IsZero() {
|
||||
persona.ID = domain.PersonaID(uuid.New().String())
|
||||
}
|
||||
|
||||
r.personas[persona.ID] = copyPersona(persona)
|
||||
r.handles[persona.Handle] = persona.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PersonaRepository) GetByID(_ context.Context, id domain.PersonaID) (*domain.Persona, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
p, ok := r.personas[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrPersonaNotFound
|
||||
}
|
||||
return copyPersona(p), nil
|
||||
}
|
||||
|
||||
func (r *PersonaRepository) List(_ context.Context, limit, offset int) ([]*domain.Persona, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
all := make([]*domain.Persona, 0, len(r.personas))
|
||||
for _, p := range r.personas {
|
||||
all = append(all, p)
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].CreatedAt.After(all[j].CreatedAt)
|
||||
})
|
||||
|
||||
if offset >= len(all) {
|
||||
return []*domain.Persona{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
|
||||
result := make([]*domain.Persona, 0, end-offset)
|
||||
for _, p := range all[offset:end] {
|
||||
result = append(result, copyPersona(p))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *PersonaRepository) Update(_ context.Context, persona *domain.Persona) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
existing, ok := r.personas[persona.ID]
|
||||
if !ok {
|
||||
return domain.ErrPersonaNotFound
|
||||
}
|
||||
|
||||
if existing.Handle != persona.Handle {
|
||||
if _, exists := r.handles[persona.Handle]; exists {
|
||||
return domain.ErrDuplicateHandle
|
||||
}
|
||||
delete(r.handles, existing.Handle)
|
||||
r.handles[persona.Handle] = persona.ID
|
||||
}
|
||||
|
||||
r.personas[persona.ID] = copyPersona(persona)
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyPersona(p *domain.Persona) *domain.Persona {
|
||||
cp := *p
|
||||
if p.Tags != nil {
|
||||
cp.Tags = make([]string, len(p.Tags))
|
||||
copy(cp.Tags, p.Tags)
|
||||
}
|
||||
if p.ImageURLs != nil {
|
||||
cp.ImageURLs = make([]string, len(p.ImageURLs))
|
||||
copy(cp.ImageURLs, p.ImageURLs)
|
||||
}
|
||||
if p.VideoURLs != nil {
|
||||
cp.VideoURLs = make([]string, len(p.VideoURLs))
|
||||
copy(cp.VideoURLs, p.VideoURLs)
|
||||
}
|
||||
if p.SpecJSON != nil {
|
||||
cp.SpecJSON = make(json.RawMessage, len(p.SpecJSON))
|
||||
copy(cp.SpecJSON, p.SpecJSON)
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
218
services/persona-api/internal/adapter/postgres/persona.go
Normal file
218
services/persona-api/internal/adapter/postgres/persona.go
Normal file
@ -0,0 +1,218 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.PersonaRepository = (*PersonaRepository)(nil)
|
||||
|
||||
// personaRow maps to the personas table.
|
||||
type personaRow struct {
|
||||
ID string `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Handle string `db:"handle"`
|
||||
Gender string `db:"gender"`
|
||||
Description string `db:"description"`
|
||||
Tags pq.StringArray `db:"tags"`
|
||||
SpecJSON []byte `db:"spec_json"`
|
||||
AnchorURL *string `db:"anchor_url"`
|
||||
AvatarURL *string `db:"avatar_url"`
|
||||
BannerURL *string `db:"banner_url"`
|
||||
ImageURLs pq.StringArray `db:"image_urls"`
|
||||
VideoURLs pq.StringArray `db:"video_urls"`
|
||||
Status string `db:"status"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
func (r *personaRow) toDomain() *domain.Persona {
|
||||
p := &domain.Persona{
|
||||
ID: domain.PersonaID(r.ID),
|
||||
Name: r.Name,
|
||||
Handle: r.Handle,
|
||||
Gender: r.Gender,
|
||||
Description: r.Description,
|
||||
Tags: []string(r.Tags),
|
||||
Status: domain.PersonaStatus(r.Status),
|
||||
CreatedAt: r.CreatedAt,
|
||||
ImageURLs: []string(r.ImageURLs),
|
||||
VideoURLs: []string(r.VideoURLs),
|
||||
}
|
||||
if p.Tags == nil {
|
||||
p.Tags = []string{}
|
||||
}
|
||||
if p.ImageURLs == nil {
|
||||
p.ImageURLs = []string{}
|
||||
}
|
||||
if p.VideoURLs == nil {
|
||||
p.VideoURLs = []string{}
|
||||
}
|
||||
if len(r.SpecJSON) > 0 {
|
||||
p.SpecJSON = json.RawMessage(r.SpecJSON)
|
||||
}
|
||||
if r.AnchorURL != nil {
|
||||
p.AnchorURL = *r.AnchorURL
|
||||
}
|
||||
if r.AvatarURL != nil {
|
||||
p.AvatarURL = *r.AvatarURL
|
||||
}
|
||||
if r.BannerURL != nil {
|
||||
p.BannerURL = *r.BannerURL
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// PersonaRepository implements port.PersonaRepository with PostgreSQL/CockroachDB.
|
||||
type PersonaRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewPersonaRepository creates a new Postgres-backed persona repository.
|
||||
func NewPersonaRepository(db *sqlx.DB) *PersonaRepository {
|
||||
return &PersonaRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *PersonaRepository) Create(ctx context.Context, persona *domain.Persona) error {
|
||||
var specJSON []byte
|
||||
if persona.SpecJSON != nil {
|
||||
specJSON = []byte(persona.SpecJSON)
|
||||
}
|
||||
|
||||
var anchorURL, avatarURL, bannerURL *string
|
||||
if persona.AnchorURL != "" {
|
||||
anchorURL = &persona.AnchorURL
|
||||
}
|
||||
if persona.AvatarURL != "" {
|
||||
avatarURL = &persona.AvatarURL
|
||||
}
|
||||
if persona.BannerURL != "" {
|
||||
bannerURL = &persona.BannerURL
|
||||
}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO personas (name, handle, gender, description, tags, spec_json, anchor_url, avatar_url, banner_url, image_urls, video_urls, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id
|
||||
`,
|
||||
persona.Name,
|
||||
persona.Handle,
|
||||
persona.Gender,
|
||||
persona.Description,
|
||||
pq.StringArray(persona.Tags),
|
||||
specJSON,
|
||||
anchorURL,
|
||||
avatarURL,
|
||||
bannerURL,
|
||||
pq.StringArray(persona.ImageURLs),
|
||||
pq.StringArray(persona.VideoURLs),
|
||||
string(persona.Status),
|
||||
persona.CreatedAt,
|
||||
).Scan(&persona.ID)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrDuplicateHandle
|
||||
}
|
||||
return fmt.Errorf("insert persona: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PersonaRepository) GetByID(ctx context.Context, id domain.PersonaID) (*domain.Persona, error) {
|
||||
var row personaRow
|
||||
err := r.db.GetContext(ctx, &row, `
|
||||
SELECT id, name, handle, gender, description, tags, spec_json, anchor_url, avatar_url, banner_url, image_urls, video_urls, status, created_at
|
||||
FROM personas WHERE id = $1
|
||||
`, string(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrPersonaNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get persona: %w", err)
|
||||
}
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
|
||||
func (r *PersonaRepository) List(ctx context.Context, limit, offset int) ([]*domain.Persona, error) {
|
||||
var rows []personaRow
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, name, handle, gender, description, tags, spec_json, anchor_url, avatar_url, banner_url, image_urls, video_urls, status, created_at
|
||||
FROM personas
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list personas: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*domain.Persona, len(rows))
|
||||
for i := range rows {
|
||||
result[i] = rows[i].toDomain()
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *PersonaRepository) Update(ctx context.Context, persona *domain.Persona) error {
|
||||
var specJSON []byte
|
||||
if persona.SpecJSON != nil {
|
||||
specJSON = []byte(persona.SpecJSON)
|
||||
}
|
||||
|
||||
var anchorURL, avatarURL, bannerURL *string
|
||||
if persona.AnchorURL != "" {
|
||||
anchorURL = &persona.AnchorURL
|
||||
}
|
||||
if persona.AvatarURL != "" {
|
||||
avatarURL = &persona.AvatarURL
|
||||
}
|
||||
if persona.BannerURL != "" {
|
||||
bannerURL = &persona.BannerURL
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE personas
|
||||
SET name = $2, handle = $3, gender = $4, description = $5, tags = $6,
|
||||
spec_json = $7, anchor_url = $8, avatar_url = $9, banner_url = $10,
|
||||
image_urls = $11, video_urls = $12, status = $13
|
||||
WHERE id = $1
|
||||
`,
|
||||
string(persona.ID),
|
||||
persona.Name,
|
||||
persona.Handle,
|
||||
persona.Gender,
|
||||
persona.Description,
|
||||
pq.StringArray(persona.Tags),
|
||||
specJSON,
|
||||
anchorURL,
|
||||
avatarURL,
|
||||
bannerURL,
|
||||
pq.StringArray(persona.ImageURLs),
|
||||
pq.StringArray(persona.VideoURLs),
|
||||
string(persona.Status),
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrDuplicateHandle
|
||||
}
|
||||
return fmt.Errorf("update persona: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update persona rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrPersonaNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -1,85 +1,151 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-5/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-5/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-5/pkg/httperror"
|
||||
"git.threesix.ai/jordan/persona-community-5/pkg/httpresponse"
|
||||
"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/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/service"
|
||||
)
|
||||
|
||||
// Persona handles HTTP requests for persona generation.
|
||||
// All generation is async: validate request, enqueue job, return 202 with job ID.
|
||||
// Results are delivered via SSE events to the user's `user:<userId>` channel:
|
||||
//
|
||||
// - persona_spec_started: LLM pipeline started
|
||||
// - persona_spec_complete: Persona profile generated
|
||||
// - persona_image_started: Starting a specific image position
|
||||
// - persona_image_progress: Image position complete with URL
|
||||
// - persona_image_complete: All 20 images generated
|
||||
// - persona_video_started: Starting a video motion type
|
||||
// - persona_video_complete: Video complete with URL
|
||||
// - persona_failed: Generation failed (check error field)
|
||||
// Persona handles HTTP requests for persona CRUD operations.
|
||||
type Persona struct {
|
||||
queue queue.Producer
|
||||
jobReader queue.JobReader
|
||||
logger *logging.Logger
|
||||
svc *service.PersonaService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewPersona creates a new Persona handler with injected dependencies.
|
||||
func NewPersona(q queue.Producer, jr queue.JobReader, logger *logging.Logger) *Persona {
|
||||
func NewPersona(svc *service.PersonaService, logger *logging.Logger) *Persona {
|
||||
return &Persona{
|
||||
queue: q,
|
||||
jobReader: jr,
|
||||
logger: logger.WithComponent("PersonaHandler"),
|
||||
svc: svc,
|
||||
logger: logger.WithComponent("PersonaHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePersonaRequest is the request body for persona generation.
|
||||
type GeneratePersonaRequest struct {
|
||||
// Description is a natural-language persona concept (required).
|
||||
// Example: "mysterious woman with dark hair who loves poetry"
|
||||
// CreatePersonaRequest is the request body for creating a persona.
|
||||
type CreatePersonaRequest struct {
|
||||
Description string `json:"description" validate:"required,min=3,max=1000"`
|
||||
|
||||
// Gender is the gender identity: "woman", "man", or "non_binary" (required).
|
||||
Gender string `json:"gender" validate:"required,oneof=woman man non_binary"`
|
||||
|
||||
// Name is an optional name override for the generated persona.
|
||||
Name string `json:"name"`
|
||||
Gender string `json:"gender" validate:"required,oneof=woman man non_binary"`
|
||||
CustomName string `json:"custom_name"`
|
||||
}
|
||||
|
||||
// GeneratePersona queues a persona generation job.
|
||||
// Returns immediately with job ID. Full lifecycle results come via SSE.
|
||||
//
|
||||
// Subscribe to SSE channel `user:<userId>` at /api/persona-api/events before calling.
|
||||
// Poll job status at GET /generate/jobs/{id} as a fallback to SSE.
|
||||
func (h *Persona) GeneratePersona(w http.ResponseWriter, r *http.Request) error {
|
||||
var req GeneratePersonaRequest
|
||||
// PersonaResponse is the API representation of a persona.
|
||||
type PersonaResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Handle string `json:"handle"`
|
||||
Gender string `json:"gender"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
SpecJSON any `json:"spec_json,omitempty"`
|
||||
AnchorURL string `json:"anchor_url,omitempty"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
BannerURL string `json:"banner_url,omitempty"`
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
VideoURLs []string `json:"video_urls"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toPersonaResponse(p *domain.Persona) PersonaResponse {
|
||||
resp := PersonaResponse{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Handle: p.Handle,
|
||||
Gender: p.Gender,
|
||||
Description: p.Description,
|
||||
Tags: p.Tags,
|
||||
AnchorURL: p.AnchorURL,
|
||||
AvatarURL: p.AvatarURL,
|
||||
BannerURL: p.BannerURL,
|
||||
ImageURLs: p.ImageURLs,
|
||||
VideoURLs: p.VideoURLs,
|
||||
Status: string(p.Status),
|
||||
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
if p.SpecJSON != nil {
|
||||
resp.SpecJSON = p.SpecJSON
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Create creates a new persona and enqueues a generate_spec job.
|
||||
// Returns 202 Accepted with the created persona.
|
||||
func (h *Persona) Create(w http.ResponseWriter, r *http.Request) error {
|
||||
var req CreatePersonaRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
jobID, err := h.queue.Enqueue(r.Context(), "persona_generate", map[string]any{
|
||||
"description": req.Description,
|
||||
"gender": req.Gender,
|
||||
"name": req.Name,
|
||||
"userID": user.ID,
|
||||
})
|
||||
persona, err := h.svc.Create(r.Context(), req.Description, req.Gender, req.CustomName)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to enqueue persona job", "error", err)
|
||||
return httperror.Internal("failed to queue persona generation")
|
||||
return mapPersonaDomainError(err)
|
||||
}
|
||||
|
||||
h.logger.Info("persona generation queued", "jobId", jobID, "userID", user.ID)
|
||||
|
||||
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||
httpresponse.Accepted(w, r, toPersonaResponse(persona))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID returns a single persona by ID.
|
||||
func (h *Persona) GetByID(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
return httperror.BadRequest("persona ID is required")
|
||||
}
|
||||
|
||||
persona, err := h.svc.GetByID(r.Context(), domain.PersonaID(id))
|
||||
if err != nil {
|
||||
return mapPersonaDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toPersonaResponse(persona))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns a paginated list of personas.
|
||||
func (h *Persona) List(w http.ResponseWriter, r *http.Request) error {
|
||||
limit := 20
|
||||
offset := 0
|
||||
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
personas, err := h.svc.List(r.Context(), limit, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]PersonaResponse, len(personas))
|
||||
for i, p := range personas {
|
||||
results[i] = toPersonaResponse(p)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, results)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapPersonaDomainError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrPersonaNotFound):
|
||||
return httperror.NotFound("persona not found")
|
||||
case errors.Is(err, domain.ErrDuplicateHandle):
|
||||
return httperror.Conflict("persona with this handle already exists")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
322
services/persona-api/internal/api/handlers/persona_test.go
Normal file
322
services/persona-api/internal/api/handlers/persona_test.go
Normal file
@ -0,0 +1,322 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-5/pkg/app"
|
||||
"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/services/persona-api/internal/adapter/memory"
|
||||
"git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/service"
|
||||
)
|
||||
|
||||
// mockProducer implements queue.Producer for testing.
|
||||
type mockProducer struct {
|
||||
jobs []mockJob
|
||||
}
|
||||
|
||||
type mockJob struct {
|
||||
jobType string
|
||||
payload map[string]any
|
||||
}
|
||||
|
||||
var _ queue.Producer = (*mockProducer)(nil)
|
||||
|
||||
func (m *mockProducer) Enqueue(_ context.Context, jobType string, payload map[string]any) (string, error) {
|
||||
m.jobs = append(m.jobs, mockJob{jobType: jobType, payload: payload})
|
||||
return "test-job-id", nil
|
||||
}
|
||||
|
||||
func (m *mockProducer) EnqueueWithOptions(_ context.Context, job queue.Job) (string, error) {
|
||||
m.jobs = append(m.jobs, mockJob{jobType: job.Type, payload: job.Payload})
|
||||
return "test-job-id", nil
|
||||
}
|
||||
|
||||
func newTestPersonaHandler() (*Persona, *memory.PersonaRepository, *mockProducer) {
|
||||
repo := memory.NewPersonaRepository()
|
||||
q := &mockProducer{}
|
||||
svc := service.NewPersonaService(repo, q, nil, logging.Nop())
|
||||
handler := NewPersona(svc, logging.Nop())
|
||||
return handler, repo, q
|
||||
}
|
||||
|
||||
func TestPersona_Create(t *testing.T) {
|
||||
handler, _, q := newTestPersonaHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body any
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
body: CreatePersonaRequest{
|
||||
Description: "mysterious woman with dark hair",
|
||||
Gender: "woman",
|
||||
},
|
||||
wantStatus: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "valid request with custom name",
|
||||
body: CreatePersonaRequest{
|
||||
Description: "energetic man who loves music",
|
||||
Gender: "man",
|
||||
CustomName: "DJ Beats",
|
||||
},
|
||||
wantStatus: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: nil,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing description",
|
||||
body: map[string]string{
|
||||
"gender": "woman",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid gender",
|
||||
body: map[string]string{
|
||||
"description": "a persona",
|
||||
"gender": "invalid",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/personas", app.Wrap(handler.Create))
|
||||
|
||||
var body []byte
|
||||
if tt.body != nil {
|
||||
var err error
|
||||
body, err = json.Marshal(tt.body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/personas", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d; body: %s", tt.wantStatus, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Verify a generate_spec job was enqueued
|
||||
found := false
|
||||
for _, j := range q.jobs {
|
||||
if j.jobType == "generate_spec" {
|
||||
found = true
|
||||
if j.payload["stage"] != "spec" {
|
||||
t.Errorf("expected stage 'spec', got %v", j.payload["stage"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected generate_spec job to be enqueued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersona_GetByID(t *testing.T) {
|
||||
handler, repo, _ := newTestPersonaHandler()
|
||||
|
||||
// Seed data
|
||||
persona := &domain.Persona{
|
||||
Name: "Test Persona",
|
||||
Handle: "test_persona",
|
||||
Gender: "woman",
|
||||
Description: "A test persona",
|
||||
Tags: []string{},
|
||||
ImageURLs: []string{},
|
||||
VideoURLs: []string{},
|
||||
Status: domain.PersonaStatusPending,
|
||||
}
|
||||
_ = repo.Create(context.Background(), persona)
|
||||
seededID := persona.ID.String()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "existing persona",
|
||||
id: seededID,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440099",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/personas/{id}", app.Wrap(handler.GetByID))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/personas/"+tt.id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d; body: %s", tt.wantStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if tt.wantStatus == http.StatusOK {
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
if data["id"] != seededID {
|
||||
t.Errorf("expected id %s, got %v", seededID, data["id"])
|
||||
}
|
||||
if data["name"] != "Test Persona" {
|
||||
t.Errorf("expected name 'Test Persona', got %v", data["name"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersona_List(t *testing.T) {
|
||||
handler, repo, _ := newTestPersonaHandler()
|
||||
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/personas", app.Wrap(handler.List))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/personas", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' to be an array")
|
||||
}
|
||||
if len(data) != 0 {
|
||||
t.Errorf("expected 0 items, got %d", len(data))
|
||||
}
|
||||
})
|
||||
|
||||
// Seed data
|
||||
for i, name := range []string{"Persona A", "Persona B", "Persona C"} {
|
||||
p := &domain.Persona{
|
||||
Name: name,
|
||||
Handle: name + "_handle",
|
||||
Gender: "woman",
|
||||
Description: "description " + string(rune('A'+i)),
|
||||
Tags: []string{},
|
||||
ImageURLs: []string{},
|
||||
VideoURLs: []string{},
|
||||
Status: domain.PersonaStatusPending,
|
||||
}
|
||||
_ = repo.Create(context.Background(), p)
|
||||
}
|
||||
|
||||
t.Run("returns all personas", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/personas", app.Wrap(handler.List))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/personas", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' to be an array")
|
||||
}
|
||||
if len(data) != 3 {
|
||||
t.Errorf("expected 3 items, got %d", len(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respects limit parameter", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/personas", app.Wrap(handler.List))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/personas?limit=2", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' to be an array")
|
||||
}
|
||||
if len(data) != 2 {
|
||||
t.Errorf("expected 2 items, got %d", len(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respects offset parameter", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/personas", app.Wrap(handler.List))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/personas?offset=2", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' to be an array")
|
||||
}
|
||||
if len(data) != 1 {
|
||||
t.Errorf("expected 1 item, got %d", len(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -35,7 +35,7 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
|
||||
chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
|
||||
mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, logger)
|
||||
albumHandler := handlers.NewAlbum(deps.AlbumService, logger)
|
||||
personaHandler := handlers.NewPersona(deps.Queue, deps.JobReader, logger)
|
||||
personaHandler := handlers.NewPersona(deps.PersonaService, logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
@ -164,8 +164,10 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
|
||||
r.Post("/albums/{id}/shots/{index}", app.Wrap(albumHandler.GenerateShot))
|
||||
r.Delete("/albums/{id}/shots/{index}", app.Wrap(albumHandler.ResetShot))
|
||||
|
||||
// Persona generation (5-stage LLM + 20 images + 4 videos, all async)
|
||||
r.Post("/persona/generate", app.Wrap(personaHandler.GeneratePersona))
|
||||
// Persona CRUD (Create enqueues generate_spec, returns 202)
|
||||
r.Post("/personas", app.Wrap(personaHandler.Create))
|
||||
r.Get("/personas", app.Wrap(personaHandler.List))
|
||||
r.Get("/personas/{id}", app.Wrap(personaHandler.GetByID))
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -175,6 +177,7 @@ type Dependencies struct {
|
||||
ExampleService *service.ExampleService
|
||||
AuthService *service.AuthService
|
||||
AlbumService *service.AlbumService
|
||||
PersonaService *service.PersonaService
|
||||
Queue queue.Producer
|
||||
JobReader queue.JobReader
|
||||
SSEHub *realtime.SSEHub
|
||||
|
||||
@ -8,7 +8,8 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
WithDescription("REST API for the persona-api service").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
WithTag("Examples", "Example CRUD endpoints").
|
||||
WithTag("Personas", "AI persona generation and management")
|
||||
|
||||
// Define reusable schemas
|
||||
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
|
||||
@ -108,5 +109,73 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
},
|
||||
})
|
||||
|
||||
// ----- Persona schemas -----
|
||||
|
||||
spec.WithSchema("Persona", openapi.Object(map[string]openapi.Schema{
|
||||
"id": openapi.UUID().WithDescription("Unique identifier"),
|
||||
"name": openapi.String().WithDescription("Persona display name"),
|
||||
"handle": openapi.String().WithDescription("Unique URL-safe handle"),
|
||||
"gender": openapi.String().WithDescription("Gender identity"),
|
||||
"description": openapi.String().WithDescription("Natural-language persona concept"),
|
||||
"tags": openapi.Array(openapi.String()).WithDescription("Tags"),
|
||||
"spec_json": openapi.Object(map[string]openapi.Schema{}, "").WithDescription("Generated persona specification"),
|
||||
"anchor_url": openapi.String().WithDescription("Anchor image URL"),
|
||||
"avatar_url": openapi.String().WithDescription("Avatar image URL"),
|
||||
"banner_url": openapi.String().WithDescription("Banner image URL"),
|
||||
"image_urls": openapi.Array(openapi.String()).WithDescription("Gallery image URLs"),
|
||||
"video_urls": openapi.Array(openapi.String()).WithDescription("Video URLs"),
|
||||
"status": openapi.String().WithDescription("Generation status: pending, generating, complete, failed"),
|
||||
"created_at": openapi.DateTime().WithDescription("Creation timestamp"),
|
||||
}, "id", "name", "handle", "gender", "description", "status"))
|
||||
|
||||
spec.WithSchema("CreatePersonaRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"description": openapi.StringWithMinMax(3, 1000).WithDescription("Natural-language persona concept"),
|
||||
"gender": openapi.String().WithDescription("Gender identity: woman, man, or non_binary"),
|
||||
"custom_name": openapi.String().WithDescription("Optional custom name override"),
|
||||
}, "description", "gender"))
|
||||
|
||||
// ----- Persona endpoints -----
|
||||
|
||||
spec.AddPath("/api/persona-api/personas", "post", map[string]any{
|
||||
"summary": "Create persona",
|
||||
"description": "Creates a new persona and enqueues a generate_spec job. Returns 202 Accepted.",
|
||||
"tags": []string{"Personas"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("CreatePersonaRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"202": openapi.OpResponse("Accepted", openapi.ResponseSchema(openapi.Ref("Persona"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
spec.AddPath("/api/persona-api/personas", "get", map[string]any{
|
||||
"summary": "List personas",
|
||||
"description": "Returns a paginated list of personas ordered by creation date (newest first).",
|
||||
"tags": []string{"Personas"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{
|
||||
openapi.QueryParam("limit", "Maximum number of results (default 20, max 100)", false),
|
||||
openapi.QueryParam("offset", "Number of results to skip (default 0)", false),
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Persona"))),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
spec.AddPath("/api/persona-api/personas/{id}", "get", map[string]any{
|
||||
"summary": "Get persona by ID",
|
||||
"tags": []string{"Personas"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Persona"))),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
@ -33,4 +33,8 @@ var (
|
||||
ErrNameTooLong = errors.New("name exceeds maximum length")
|
||||
ErrEmailTooLong = errors.New("email exceeds maximum length")
|
||||
ErrInvalidAvatarURL = errors.New("avatar URL must use http or https")
|
||||
|
||||
// Persona errors
|
||||
ErrPersonaNotFound = errors.New("persona not found")
|
||||
ErrDuplicateHandle = errors.New("persona with this handle already exists")
|
||||
)
|
||||
|
||||
65
services/persona-api/internal/domain/persona.go
Normal file
65
services/persona-api/internal/domain/persona.go
Normal file
@ -0,0 +1,65 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PersonaID is a strongly-typed identifier for personas.
|
||||
type PersonaID string
|
||||
|
||||
// String returns the string representation of the ID.
|
||||
func (id PersonaID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero returns true if the ID is empty.
|
||||
func (id PersonaID) IsZero() bool {
|
||||
return id == ""
|
||||
}
|
||||
|
||||
// PersonaStatus represents the current generation state of a persona.
|
||||
type PersonaStatus string
|
||||
|
||||
const (
|
||||
PersonaStatusPending PersonaStatus = "pending"
|
||||
PersonaStatusGenerating PersonaStatus = "generating"
|
||||
PersonaStatusComplete PersonaStatus = "complete"
|
||||
PersonaStatusFailed PersonaStatus = "failed"
|
||||
)
|
||||
|
||||
// PersonaStage represents a generation pipeline stage.
|
||||
type PersonaStage string
|
||||
|
||||
const (
|
||||
StageSpec PersonaStage = "spec"
|
||||
StageAnchor PersonaStage = "anchor"
|
||||
StageAvatar PersonaStage = "avatar"
|
||||
StageBanner PersonaStage = "banner"
|
||||
StageGalleryBatch PersonaStage = "gallery_batch"
|
||||
StageVideo PersonaStage = "video"
|
||||
)
|
||||
|
||||
// Persona represents an AI-generated persona.
|
||||
type Persona struct {
|
||||
ID PersonaID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Handle string `json:"handle"`
|
||||
Gender string `json:"gender"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
SpecJSON json.RawMessage `json:"spec_json,omitempty"`
|
||||
AnchorURL string `json:"anchor_url,omitempty"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
BannerURL string `json:"banner_url,omitempty"`
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
VideoURLs []string `json:"video_urls"`
|
||||
Status PersonaStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PersonaGenerateJob represents a queued persona generation job payload.
|
||||
type PersonaGenerateJob struct {
|
||||
PersonaID string `json:"persona_id"`
|
||||
Stage string `json:"stage"`
|
||||
}
|
||||
24
services/persona-api/internal/port/persona.go
Normal file
24
services/persona-api/internal/port/persona.go
Normal file
@ -0,0 +1,24 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain"
|
||||
)
|
||||
|
||||
// PersonaRepository defines the interface for persona persistence operations.
|
||||
type PersonaRepository interface {
|
||||
// Create stores a new persona.
|
||||
Create(ctx context.Context, persona *domain.Persona) error
|
||||
|
||||
// GetByID returns a persona by ID.
|
||||
// Returns domain.ErrPersonaNotFound if not found.
|
||||
GetByID(ctx context.Context, id domain.PersonaID) (*domain.Persona, error)
|
||||
|
||||
// List returns personas with pagination.
|
||||
List(ctx context.Context, limit, offset int) ([]*domain.Persona, error)
|
||||
|
||||
// Update persists changes to an existing persona.
|
||||
// Returns domain.ErrPersonaNotFound if not found.
|
||||
Update(ctx context.Context, persona *domain.Persona) error
|
||||
}
|
||||
141
services/persona-api/internal/service/persona.go
Normal file
141
services/persona-api/internal/service/persona.go
Normal file
@ -0,0 +1,141 @@
|
||||
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
|
||||
}
|
||||
260
services/persona-api/internal/service/persona_test.go
Normal file
260
services/persona-api/internal/service/persona_test.go
Normal file
@ -0,0 +1,260 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"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/services/persona-api/internal/adapter/memory"
|
||||
"git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain"
|
||||
)
|
||||
|
||||
// mockProducer implements queue.Producer for testing.
|
||||
type mockProducer struct {
|
||||
jobs []struct {
|
||||
jobType string
|
||||
payload map[string]any
|
||||
}
|
||||
}
|
||||
|
||||
var _ queue.Producer = (*mockProducer)(nil)
|
||||
|
||||
func (m *mockProducer) Enqueue(_ context.Context, jobType string, payload map[string]any) (string, error) {
|
||||
m.jobs = append(m.jobs, struct {
|
||||
jobType string
|
||||
payload map[string]any
|
||||
}{jobType: jobType, payload: payload})
|
||||
return "test-job-id", nil
|
||||
}
|
||||
|
||||
func (m *mockProducer) EnqueueWithOptions(_ context.Context, job queue.Job) (string, error) {
|
||||
m.jobs = append(m.jobs, struct {
|
||||
jobType string
|
||||
payload map[string]any
|
||||
}{jobType: job.Type, payload: job.Payload})
|
||||
return "test-job-id", nil
|
||||
}
|
||||
|
||||
func TestPersonaService_Create(t *testing.T) {
|
||||
repo := memory.NewPersonaRepository()
|
||||
q := &mockProducer{}
|
||||
svc := NewPersonaService(repo, q, nil, logging.Nop())
|
||||
|
||||
t.Run("creates persona with custom name", func(t *testing.T) {
|
||||
persona, err := svc.Create(context.Background(), "mysterious woman with dark hair", "woman", "Luna Shadow")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if persona.Name != "Luna Shadow" {
|
||||
t.Errorf("expected name 'Luna Shadow', got '%s'", persona.Name)
|
||||
}
|
||||
if persona.ID.IsZero() {
|
||||
t.Error("expected non-empty ID")
|
||||
}
|
||||
if persona.Gender != "woman" {
|
||||
t.Errorf("expected gender 'woman', got '%s'", persona.Gender)
|
||||
}
|
||||
if persona.Status != domain.PersonaStatusPending {
|
||||
t.Errorf("expected status 'pending', got '%s'", persona.Status)
|
||||
}
|
||||
if persona.Handle == "" {
|
||||
t.Error("expected non-empty handle")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("creates persona with generated name", func(t *testing.T) {
|
||||
persona, err := svc.Create(context.Background(), "energetic man who loves music", "man", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if persona.Name == "" {
|
||||
t.Error("expected auto-generated name")
|
||||
}
|
||||
if persona.ID.IsZero() {
|
||||
t.Error("expected non-empty ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enqueues generate_spec job", func(t *testing.T) {
|
||||
found := false
|
||||
for _, j := range q.jobs {
|
||||
if j.jobType == "generate_spec" {
|
||||
found = true
|
||||
if j.payload["stage"] != "spec" {
|
||||
t.Errorf("expected stage 'spec', got %v", j.payload["stage"])
|
||||
}
|
||||
if _, ok := j.payload["persona_id"]; !ok {
|
||||
t.Error("expected persona_id in job payload")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected generate_spec job to be enqueued")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initializes empty slices", func(t *testing.T) {
|
||||
persona, err := svc.Create(context.Background(), "test persona for slices", "non_binary", "SliceTest")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if persona.Tags == nil {
|
||||
t.Error("expected Tags to be initialized (not nil)")
|
||||
}
|
||||
if persona.ImageURLs == nil {
|
||||
t.Error("expected ImageURLs to be initialized (not nil)")
|
||||
}
|
||||
if persona.VideoURLs == nil {
|
||||
t.Error("expected VideoURLs to be initialized (not nil)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPersonaService_GetByID(t *testing.T) {
|
||||
repo := memory.NewPersonaRepository()
|
||||
q := &mockProducer{}
|
||||
svc := NewPersonaService(repo, q, nil, logging.Nop())
|
||||
|
||||
created, _ := svc.Create(context.Background(), "test persona", "woman", "GetTest")
|
||||
|
||||
t.Run("returns existing persona", func(t *testing.T) {
|
||||
persona, err := svc.GetByID(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if persona.Name != "GetTest" {
|
||||
t.Errorf("expected name 'GetTest', got '%s'", persona.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing persona", func(t *testing.T) {
|
||||
_, err := svc.GetByID(context.Background(), "nonexistent-id")
|
||||
if err != domain.ErrPersonaNotFound {
|
||||
t.Errorf("expected ErrPersonaNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPersonaService_List(t *testing.T) {
|
||||
repo := memory.NewPersonaRepository()
|
||||
q := &mockProducer{}
|
||||
svc := NewPersonaService(repo, q, nil, logging.Nop())
|
||||
|
||||
t.Run("returns empty list initially", func(t *testing.T) {
|
||||
personas, err := svc.List(context.Background(), 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(personas) != 0 {
|
||||
t.Errorf("expected 0 personas, got %d", len(personas))
|
||||
}
|
||||
})
|
||||
|
||||
// Create some personas
|
||||
_, _ = svc.Create(context.Background(), "persona one", "woman", "ListOne")
|
||||
_, _ = svc.Create(context.Background(), "persona two", "man", "ListTwo")
|
||||
_, _ = svc.Create(context.Background(), "persona three", "non_binary", "ListThree")
|
||||
|
||||
t.Run("returns all personas", func(t *testing.T) {
|
||||
personas, err := svc.List(context.Background(), 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(personas) != 3 {
|
||||
t.Errorf("expected 3 personas, got %d", len(personas))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respects limit", func(t *testing.T) {
|
||||
personas, err := svc.List(context.Background(), 2, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(personas) != 2 {
|
||||
t.Errorf("expected 2 personas, got %d", len(personas))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clamps negative limit to default", func(t *testing.T) {
|
||||
personas, err := svc.List(context.Background(), -1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(personas) != 3 {
|
||||
t.Errorf("expected 3 personas (default limit), got %d", len(personas))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clamps limit above 100", func(t *testing.T) {
|
||||
personas, err := svc.List(context.Background(), 200, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(personas) != 3 {
|
||||
t.Errorf("expected 3 personas (all within max limit), got %d", len(personas))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respects offset", func(t *testing.T) {
|
||||
personas, err := svc.List(context.Background(), 20, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(personas) != 1 {
|
||||
t.Errorf("expected 1 persona, got %d", len(personas))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateHandle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{name: "simple name", input: "Luna Shadow"},
|
||||
{name: "special chars", input: "DJ Beats!@#"},
|
||||
{name: "unicode", input: "Café Noir"},
|
||||
{name: "long name", input: "This Is A Very Long Name That Exceeds The Maximum Handle Length Allowed"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handle := generateHandle(tt.input)
|
||||
if handle == "" {
|
||||
t.Error("expected non-empty handle")
|
||||
}
|
||||
if len(handle) > 50 {
|
||||
t.Errorf("handle too long: %d chars", len(handle))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePersonaName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
desc string
|
||||
check func(string) bool
|
||||
}{
|
||||
{
|
||||
name: "short description",
|
||||
desc: "mysterious woman",
|
||||
check: func(s string) bool { return s != "" },
|
||||
},
|
||||
{
|
||||
name: "long description uses first 3 words",
|
||||
desc: "mysterious woman with dark hair who loves poetry",
|
||||
check: func(s string) bool { return s != "" && len(s) <= 50 },
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
name := generatePersonaName(tt.desc)
|
||||
if !tt.check(name) {
|
||||
t.Errorf("unexpected name for desc %q: %q", tt.desc, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
BIN
services/persona-api/server
Executable file
BIN
services/persona-api/server
Executable file
Binary file not shown.
Loading…
Reference in New Issue
Block a user