build: /implement-feature persona-model --requirements 'DB migration in pers...
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
rdev-worker 2026-02-24 07:58:27 +00:00
parent f5f3229364
commit 9c009926d1
14 changed files with 1386 additions and 58 deletions

View File

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

View File

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

View 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
}

View 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
}

View File

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

View 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))
}
})
}

View File

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

View File

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

View File

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

View 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"`
}

View 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
}

View 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
}

View 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

Binary file not shown.