636 lines
24 KiB
Go
636 lines
24 KiB
Go
package personagen
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.threesix.ai/jordan/persona-community-2/pkg/persona"
|
|
"git.threesix.ai/jordan/persona-community-2/pkg/textgen"
|
|
)
|
|
|
|
// generatePersonaSpec runs the 5-stage LLM pipeline to produce a complete PersonaSpec.
|
|
// Each stage builds on the output of the previous one.
|
|
func generatePersonaSpec(ctx context.Context, tg *textgen.Manager, seed SeedParams, logger *slog.Logger) (*persona.PersonaSpec, error) {
|
|
logger = logger.With("op", "generatePersonaSpec")
|
|
|
|
// Stage 1: Core identity (name, demographics, occupation, locations)
|
|
logger.Info("specgen stage 1: generating core identity")
|
|
identity, err := genIdentity(ctx, tg, seed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stage 1 identity: %w", err)
|
|
}
|
|
|
|
// Stage 2: Psychology (HEXACO, attachment, values)
|
|
logger.Info("specgen stage 2: generating psychology")
|
|
psych, err := genPsychology(ctx, tg, identity)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stage 2 psychology: %w", err)
|
|
}
|
|
|
|
// Stage 3: Lifestyle (interests, fashion context, vacation style)
|
|
logger.Info("specgen stage 3: generating lifestyle")
|
|
lifestyle, err := genLifestyle(ctx, tg, identity, psych)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stage 3 lifestyle: %w", err)
|
|
}
|
|
|
|
// Stage 4: Visual DNA (face, body, voice characteristics)
|
|
logger.Info("specgen stage 4: generating visual DNA")
|
|
dna, err := genDNA(ctx, tg, identity, lifestyle)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stage 4 visual DNA: %w", err)
|
|
}
|
|
|
|
// Stage 5: Populate image matrix with lifestyle-derived outfits and scenes.
|
|
logger.Info("specgen stage 5: populating image matrix")
|
|
imageMatrix := populateImageMatrix(persona.DefaultImageMatrix(), lifestyle)
|
|
|
|
tier := inferGenerationTier(seed.Description)
|
|
spec := &persona.PersonaSpec{
|
|
ID: generateID(),
|
|
CreatedAt: now(),
|
|
DNA: dna,
|
|
Name: identity.Name,
|
|
Psychology: *psych,
|
|
Lifestyle: *lifestyle,
|
|
ImageMatrix: imageMatrix,
|
|
Videos: persona.DefaultVideoMatrix(),
|
|
GenerationTier: tier,
|
|
Attractiveness: inferAttractiveness(tier),
|
|
}
|
|
|
|
logger.Info("specgen complete", "persona_id", spec.ID)
|
|
return spec, nil
|
|
}
|
|
|
|
// ── Internal LLM response structs ──────────────────────────────────────────
|
|
|
|
type identityLLMResponse struct {
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Nickname string `json:"nickname,omitempty"`
|
|
Age int `json:"age"`
|
|
Gender string `json:"gender"`
|
|
Ethnicity string `json:"ethnicity"`
|
|
Nationality string `json:"nationality"`
|
|
Sexuality string `json:"sexuality,omitempty"`
|
|
Occupation string `json:"occupation,omitempty"`
|
|
BirthCity string `json:"birth_city,omitempty"`
|
|
CurrentCity string `json:"current_city,omitempty"`
|
|
}
|
|
|
|
type hexacoScoreLLM struct {
|
|
Score int `json:"score"`
|
|
BehavioralImplications string `json:"behavioral_implications,omitempty"`
|
|
}
|
|
|
|
type psychLLMResponse struct {
|
|
HonestyHumility hexacoScoreLLM `json:"honesty_humility"`
|
|
Emotionality hexacoScoreLLM `json:"emotionality"`
|
|
Extraversion hexacoScoreLLM `json:"extraversion"`
|
|
Agreeableness hexacoScoreLLM `json:"agreeableness"`
|
|
Conscientiousness hexacoScoreLLM `json:"conscientiousness"`
|
|
Openness hexacoScoreLLM `json:"openness"`
|
|
AttachmentPrimary string `json:"attachment_primary"`
|
|
AttachmentPattern string `json:"attachment_pattern,omitempty"`
|
|
CoreValues []string `json:"core_values,omitempty"`
|
|
LifePhilosophy string `json:"life_philosophy,omitempty"`
|
|
}
|
|
|
|
type lifestyleLLMResponse struct {
|
|
CreativeInterests []string `json:"creative_interests"`
|
|
ActiveInterests []string `json:"active_interests"`
|
|
SocialInterests []string `json:"social_interests"`
|
|
IntellectualInterests []string `json:"intellectual_interests"`
|
|
LifestyleInterests []string `json:"lifestyle_interests"`
|
|
FashionPrimary string `json:"fashion_primary"`
|
|
FashionSecondary string `json:"fashion_secondary,omitempty"`
|
|
FashionSignature []string `json:"fashion_signature_details,omitempty"`
|
|
VacationPrimary string `json:"vacation_primary"`
|
|
VacationActivities []string `json:"vacation_activities,omitempty"`
|
|
DreamDestinations []string `json:"dream_destinations,omitempty"`
|
|
}
|
|
|
|
type dnaLLMResponse struct {
|
|
// Face
|
|
FaceShape string `json:"face_shape"`
|
|
BoneStructure string `json:"bone_structure"`
|
|
Jawline string `json:"jawline"`
|
|
Cheekbones string `json:"cheekbones"`
|
|
EyeShape string `json:"eye_shape"`
|
|
EyeColor string `json:"eye_color"`
|
|
EyeSpacing string `json:"eye_spacing"`
|
|
EyeSize string `json:"eye_size"`
|
|
NoseShape string `json:"nose_shape"`
|
|
NoseBridge string `json:"nose_bridge"`
|
|
NoseTip string `json:"nose_tip"`
|
|
LipShape string `json:"lip_shape"`
|
|
LipFullness string `json:"lip_fullness"`
|
|
SmileType string `json:"smile_type"`
|
|
BrowShape string `json:"brow_shape"`
|
|
BrowThickness string `json:"brow_thickness"`
|
|
SkinTone string `json:"skin_tone"`
|
|
SkinUndertone string `json:"skin_undertone"`
|
|
SkinTexture string `json:"skin_texture"`
|
|
HairColor string `json:"hair_color"`
|
|
HairTexture string `json:"hair_texture"`
|
|
HairLength string `json:"hair_length"`
|
|
HairThickness string `json:"hair_thickness"`
|
|
// Body
|
|
HeightCM int `json:"height_cm"`
|
|
Build string `json:"build"`
|
|
BodyFatPercent int `json:"body_fat_percent"`
|
|
MuscleDefinition string `json:"muscle_definition"`
|
|
ShoulderWidth string `json:"shoulder_width"`
|
|
HipWidth string `json:"hip_width"`
|
|
WHRatio float64 `json:"wh_ratio"`
|
|
LegLength string `json:"leg_length"`
|
|
TorsoLength string `json:"torso_length"`
|
|
BustSize string `json:"bust_size,omitempty"`
|
|
Posture string `json:"posture"`
|
|
// Heritage breakdown (only populated for mixed-ethnicity personas)
|
|
PrimaryHeritage string `json:"primary_heritage,omitempty"`
|
|
SecondaryHeritage string `json:"secondary_heritage,omitempty"`
|
|
MixPercentage int `json:"mix_percentage,omitempty"`
|
|
// Voice
|
|
Pitch string `json:"pitch"`
|
|
PitchRange string `json:"pitch_range"`
|
|
Tone string `json:"tone"`
|
|
Timbre string `json:"timbre"`
|
|
Accent string `json:"accent"`
|
|
AccentStrength string `json:"accent_strength"`
|
|
Cadence string `json:"cadence"`
|
|
RhythmPattern string `json:"rhythm_pattern"`
|
|
Volume string `json:"volume"`
|
|
Clarity string `json:"clarity"`
|
|
Expressiveness string `json:"expressiveness"`
|
|
}
|
|
|
|
// ── Stage implementations ──────────────────────────────────────────────────
|
|
|
|
func genIdentity(ctx context.Context, tg *textgen.Manager, seed SeedParams) (*persona.CoreIdentity, error) {
|
|
validGenders := `"woman", "man", "non_binary"`
|
|
validEthnicities := `"east_asian", "south_asian", "southeast_asian", "african", "hispanic", "middle_eastern", "caucasian", "mixed"`
|
|
|
|
prompt := fmt.Sprintf(`You are generating a synthetic persona profile. Given this seed:
|
|
Description: %q
|
|
Gender: %q
|
|
Name override (empty = generate one): %q
|
|
|
|
Return ONLY a JSON object with these exact fields:
|
|
{
|
|
"first_name": "string (culturally appropriate given ethnicity)",
|
|
"last_name": "string",
|
|
"nickname": "string or empty",
|
|
"age": number (18-35),
|
|
"gender": one of [%s],
|
|
"ethnicity": one of [%s],
|
|
"nationality": "string (country name)",
|
|
"sexuality": "string (e.g. heterosexual, bisexual)",
|
|
"occupation": "string (job title or role)",
|
|
"birth_city": "string",
|
|
"current_city": "string (where they live now)"
|
|
}
|
|
|
|
Ensure name fits the ethnicity and nationality. Return ONLY valid JSON.`,
|
|
seed.Description, seed.Gender, seed.Name, validGenders, validEthnicities)
|
|
|
|
resp, err := tg.GenerateText(ctx, textgen.TextRequest{
|
|
Prompt: prompt,
|
|
MaxTokens: 400,
|
|
Temperature: 0.8,
|
|
Timeout: 30 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LLM call: %w", err)
|
|
}
|
|
|
|
var r identityLLMResponse
|
|
if err := parseLLMJSON(resp.Text, &r); err != nil {
|
|
return nil, fmt.Errorf("parsing identity response: %w", err)
|
|
}
|
|
|
|
// Apply name override from seed if provided.
|
|
firstName, lastName := r.FirstName, r.LastName
|
|
if seed.Name != "" {
|
|
parts := strings.Fields(seed.Name)
|
|
if len(parts) > 0 {
|
|
firstName = parts[0]
|
|
}
|
|
if len(parts) > 1 {
|
|
lastName = strings.Join(parts[1:], " ")
|
|
}
|
|
}
|
|
|
|
return &persona.CoreIdentity{
|
|
Name: persona.NameSpec{
|
|
First: firstName,
|
|
Last: lastName,
|
|
Nickname: r.Nickname,
|
|
DisplayName: firstName,
|
|
},
|
|
Age: r.Age,
|
|
Gender: persona.GenderIdentity(r.Gender),
|
|
Ethnicity: persona.EthnicityCode(r.Ethnicity),
|
|
Nationality: r.Nationality,
|
|
Sexuality: r.Sexuality,
|
|
Occupation: r.Occupation,
|
|
BirthCity: r.BirthCity,
|
|
CurrentCity: r.CurrentCity,
|
|
}, nil
|
|
}
|
|
|
|
func genPsychology(ctx context.Context, tg *textgen.Manager, identity *persona.CoreIdentity) (*persona.Psychology, error) {
|
|
prompt := fmt.Sprintf(`Given this persona:
|
|
Name: %s %s, Age: %d, Gender: %s, Occupation: %s
|
|
|
|
Generate a HEXACO personality profile. Score each dimension 1-10 (1=very low, 10=very high).
|
|
Valid attachment styles: "secure", "anxious", "avoidant", "disorganized"
|
|
|
|
Return ONLY a JSON object:
|
|
{
|
|
"honesty_humility": {"score": number, "behavioral_implications": "string"},
|
|
"emotionality": {"score": number, "behavioral_implications": "string"},
|
|
"extraversion": {"score": number, "behavioral_implications": "string"},
|
|
"agreeableness": {"score": number, "behavioral_implications": "string"},
|
|
"conscientiousness": {"score": number, "behavioral_implications": "string"},
|
|
"openness": {"score": number, "behavioral_implications": "string"},
|
|
"attachment_primary": "string",
|
|
"attachment_pattern": "string (1-2 sentences)",
|
|
"core_values": ["value1", "value2", "value3", "value4", "value5"],
|
|
"life_philosophy": "string (one guiding principle)"
|
|
}`,
|
|
identity.Name.First, identity.Name.Last, identity.Age, identity.Gender, identity.Occupation)
|
|
|
|
resp, err := tg.GenerateText(ctx, textgen.TextRequest{
|
|
Prompt: prompt,
|
|
MaxTokens: 600,
|
|
Temperature: 0.7,
|
|
Timeout: 30 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LLM call: %w", err)
|
|
}
|
|
|
|
var r psychLLMResponse
|
|
if err := parseLLMJSON(resp.Text, &r); err != nil {
|
|
return nil, fmt.Errorf("parsing psychology response: %w", err)
|
|
}
|
|
|
|
toTraitScore := func(s hexacoScoreLLM) persona.TraitScore {
|
|
return persona.TraitScore{Score: s.Score, BehavioralImplications: s.BehavioralImplications}
|
|
}
|
|
|
|
return &persona.Psychology{
|
|
HEXACO: persona.HEXACOProfile{
|
|
HonestyHumility: toTraitScore(r.HonestyHumility),
|
|
Emotionality: toTraitScore(r.Emotionality),
|
|
Extraversion: toTraitScore(r.Extraversion),
|
|
Agreeableness: toTraitScore(r.Agreeableness),
|
|
Conscientiousness: toTraitScore(r.Conscientiousness),
|
|
Openness: toTraitScore(r.Openness),
|
|
},
|
|
Attachment: persona.AttachmentStyle{
|
|
Primary: r.AttachmentPrimary,
|
|
Pattern: r.AttachmentPattern,
|
|
},
|
|
Values: persona.Values{
|
|
Core: r.CoreValues,
|
|
LifePhilosophy: r.LifePhilosophy,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func genLifestyle(ctx context.Context, tg *textgen.Manager, identity *persona.CoreIdentity, psych *persona.Psychology) (*persona.Lifestyle, error) {
|
|
fashionOptions := strings.Join(func() []string {
|
|
names := persona.AllFashionContextNames()
|
|
out := make([]string, len(names))
|
|
for i, n := range names {
|
|
out[i] = fmt.Sprintf("%q", n)
|
|
}
|
|
return out
|
|
}(), ", ")
|
|
|
|
prompt := fmt.Sprintf(`Given this persona:
|
|
Name: %s, Age: %d, Occupation: %s, Nationality: %s
|
|
Extraversion: %d/10, Openness: %d/10
|
|
|
|
Generate lifestyle profile. Valid fashion contexts: [%s]
|
|
|
|
Return ONLY a JSON object:
|
|
{
|
|
"creative_interests": ["interest1", "interest2"] (2-4 items),
|
|
"active_interests": ["interest1"] (1-3 items),
|
|
"social_interests": ["interest1", "interest2"] (2-4 items),
|
|
"intellectual_interests": ["interest1"] (1-3 items),
|
|
"lifestyle_interests": ["interest1", "interest2"] (2-3 items),
|
|
"fashion_primary": "one of the valid fashion context names",
|
|
"fashion_secondary": "one of the valid fashion context names (different from primary)",
|
|
"fashion_signature_details": ["detail1", "detail2"] (1-3 personal styling touches),
|
|
"vacation_primary": "beach|city|adventure|luxury|cultural",
|
|
"vacation_activities": ["activity1", "activity2"] (2-3 items),
|
|
"dream_destinations": ["destination1", "destination2", "destination3"]
|
|
}`,
|
|
identity.Name.First, identity.Age, identity.Occupation, identity.Nationality,
|
|
psych.HEXACO.Extraversion.Score, psych.HEXACO.Openness.Score,
|
|
fashionOptions)
|
|
|
|
resp, err := tg.GenerateText(ctx, textgen.TextRequest{
|
|
Prompt: prompt,
|
|
MaxTokens: 500,
|
|
Temperature: 0.8,
|
|
Timeout: 30 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LLM call: %w", err)
|
|
}
|
|
|
|
var r lifestyleLLMResponse
|
|
if err := parseLLMJSON(resp.Text, &r); err != nil {
|
|
return nil, fmt.Errorf("parsing lifestyle response: %w", err)
|
|
}
|
|
|
|
return &persona.Lifestyle{
|
|
Interests: persona.Interests{
|
|
Creative: r.CreativeInterests,
|
|
Active: r.ActiveInterests,
|
|
Social: r.SocialInterests,
|
|
Intellectual: r.IntellectualInterests,
|
|
Lifestyle: r.LifestyleInterests,
|
|
},
|
|
FashionSense: persona.FashionSense{
|
|
Primary: persona.FashionContextName(r.FashionPrimary),
|
|
Secondary: persona.FashionContextName(r.FashionSecondary),
|
|
SignatureDetails: r.FashionSignature,
|
|
},
|
|
VacationStyle: persona.VacationStyle{
|
|
Primary: r.VacationPrimary,
|
|
Activities: r.VacationActivities,
|
|
DreamDestinations: r.DreamDestinations,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func genDNA(ctx context.Context, tg *textgen.Manager, identity *persona.CoreIdentity, lifestyle *persona.Lifestyle) (*persona.DNA, error) {
|
|
fashionCtx := persona.FashionContextFor(lifestyle.FashionSense.Primary)
|
|
prompt := fmt.Sprintf(`Generate precise visual characteristics for:
|
|
Name: %s, Age: %d, Gender: %s, Ethnicity: %s, Nationality: %s
|
|
Fashion style: %s (%s)
|
|
|
|
All values MUST use exactly the allowed enum values listed below.
|
|
|
|
Return ONLY a JSON object (all fields required):
|
|
{
|
|
"face_shape": "oval|heart|square|round|diamond|oblong",
|
|
"bone_structure": "delicate|moderate|strong",
|
|
"jawline": "soft|rounded|defined|angular|square",
|
|
"cheekbones": "subtle|moderate|prominent|high",
|
|
"eye_shape": "almond|round|hooded|monolid|upturned|downturned|deep_set",
|
|
"eye_color": "dark_brown|brown|hazel|amber|green|blue|gray",
|
|
"eye_spacing": "close_set|average|wide_set",
|
|
"eye_size": "small|average|large",
|
|
"nose_shape": "button|straight|wide|roman|aquiline",
|
|
"nose_bridge": "low|moderate|high|bumped",
|
|
"nose_tip": "rounded|pointed|upturned|bulbous",
|
|
"lip_shape": "full|thin|bow|rounded|wide",
|
|
"lip_fullness": "subtle|moderate|plump|voluptuous",
|
|
"smile_type": "subtle|broad|asymmetric|gummy|closed",
|
|
"brow_shape": "natural|arched|straight|rounded|angled",
|
|
"brow_thickness": "thin|natural|thick|fluffy|bleached",
|
|
"skin_tone": "fair|light|medium|olive|tan|brown|dark_brown|deep",
|
|
"skin_undertone": "warm|cool|neutral",
|
|
"skin_texture": "smooth|normal|textured|mature",
|
|
"hair_color": "black|dark_brown|brown|light_brown|blonde|red|auburn|gray",
|
|
"hair_texture": "straight|wavy|curly|coily|kinky",
|
|
"hair_length": "pixie|short|chin|shoulder|mid_back|long|very_long",
|
|
"hair_thickness": "fine|medium|thick",
|
|
"height_cm": number (150-185),
|
|
"build": "slender|athletic|curvy|muscular|average|plus_curvy|petite",
|
|
"body_fat_percent": number (15-35),
|
|
"muscle_definition": "none|subtle|moderate|defined|ripped",
|
|
"shoulder_width": "narrow|average|broad",
|
|
"hip_width": "narrow|average|wide",
|
|
"wh_ratio": number (0.65-0.90),
|
|
"leg_length": "short|proportional|long",
|
|
"torso_length": "short|proportional|long",
|
|
"bust_size": "small|medium|large|very_large",
|
|
"posture": "upright|relaxed|confident|athletic",
|
|
"pitch": "very_low|low|medium|high|very_high",
|
|
"pitch_range": "narrow|moderate|wide",
|
|
"tone": "warm|cool|neutral|rich|bright",
|
|
"timbre": "clear|smooth|husky|breathy|rich|crisp",
|
|
"accent": "north_american|british|australian|global_english|non_native",
|
|
"accent_strength": "subtle|moderate|strong",
|
|
"cadence": "very_slow|slow|medium|fast|very_fast",
|
|
"rhythm_pattern": "steady|variable|dynamic",
|
|
"volume": "soft|moderate|loud",
|
|
"clarity": "crisp|natural|relaxed",
|
|
"expressiveness": "monotone|moderate|expressive|animated"
|
|
}`,
|
|
identity.Name.First, identity.Age, identity.Gender, identity.Ethnicity, identity.Nationality,
|
|
fashionCtx.Name, fashionCtx.Description)
|
|
|
|
if identity.Ethnicity == persona.EthnicityMixed {
|
|
prompt += `
|
|
|
|
IMPORTANT — this persona is mixed-race. Append these 3 fields to the JSON response:
|
|
"primary_heritage": one of ["east_asian","south_asian","southeast_asian","african","hispanic","middle_eastern","caucasian"],
|
|
"secondary_heritage": one of the same list (different from primary_heritage),
|
|
"mix_percentage": number (50-80, percentage that is primary heritage)`
|
|
}
|
|
|
|
resp, err := tg.GenerateText(ctx, textgen.TextRequest{
|
|
Prompt: prompt,
|
|
MaxTokens: 900,
|
|
Temperature: 0.6,
|
|
Timeout: 45 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LLM call: %w", err)
|
|
}
|
|
|
|
var r dnaLLMResponse
|
|
if err := parseLLMJSON(resp.Text, &r); err != nil {
|
|
return nil, fmt.Errorf("parsing DNA response: %w", err)
|
|
}
|
|
|
|
dna := &persona.DNA{
|
|
Identity: persona.IdentityDNA{
|
|
Ethnicity: identity.Ethnicity,
|
|
Age: identity.Age,
|
|
Gender: identity.Gender,
|
|
Nationality: identity.Nationality,
|
|
PrimaryHeritage: identity.Ethnicity, // matches Ethnicity for non-mixed personas
|
|
},
|
|
Face: persona.FaceDNA{
|
|
FaceShape: persona.FaceShapeCategory(r.FaceShape),
|
|
BoneStructure: persona.BoneStructureCategory(r.BoneStructure),
|
|
Jawline: persona.JawlineCategory(r.Jawline),
|
|
Cheekbones: persona.CheekbonesCategory(r.Cheekbones),
|
|
EyeShape: persona.EyeShapeCategory(r.EyeShape),
|
|
EyeColor: persona.EyeColorCategory(r.EyeColor),
|
|
EyeSpacing: persona.EyeSpacingCategory(r.EyeSpacing),
|
|
EyeSize: persona.EyeSizeCategory(r.EyeSize),
|
|
NoseShape: persona.NoseShapeCategory(r.NoseShape),
|
|
NoseBridge: persona.NoseBridgeCategory(r.NoseBridge),
|
|
NoseTip: persona.NoseTipCategory(r.NoseTip),
|
|
LipShape: persona.LipShapeCategory(r.LipShape),
|
|
LipFullness: persona.LipFullnessCategory(r.LipFullness),
|
|
SmileType: persona.SmileTypeCategory(r.SmileType),
|
|
BrowShape: persona.BrowShapeCategory(r.BrowShape),
|
|
BrowThickness: persona.BrowThicknessCategory(r.BrowThickness),
|
|
SkinTone: persona.SkinToneCategory(r.SkinTone),
|
|
SkinUndertone: persona.SkinUndertoneCategory(r.SkinUndertone),
|
|
SkinTexture: persona.SkinTextureCategory(r.SkinTexture),
|
|
HairColor: persona.HairColorCategory(r.HairColor),
|
|
HairTexture: persona.HairTextureCategory(r.HairTexture),
|
|
HairLength: persona.HairLengthCategory(r.HairLength),
|
|
HairThickness: persona.HairThicknessCategory(r.HairThickness),
|
|
},
|
|
Body: persona.BodyDNA{
|
|
Height: persona.HeightCategoryFromCM(r.HeightCM),
|
|
HeightCM: r.HeightCM,
|
|
Build: persona.BodyBuildCategory(r.Build),
|
|
BodyFatPercent: r.BodyFatPercent,
|
|
MuscleDefinition: persona.MuscleDefinitionCategory(r.MuscleDefinition),
|
|
ShoulderWidth: persona.ShoulderWidthCategory(r.ShoulderWidth),
|
|
HipWidth: persona.HipWidthCategory(r.HipWidth),
|
|
WHRatio: r.WHRatio,
|
|
LegLength: persona.LegLengthCategory(r.LegLength),
|
|
TorsoLength: persona.TorsoLengthCategory(r.TorsoLength),
|
|
BustSize: persona.BustSizeCategory(r.BustSize),
|
|
PostureType: persona.PostureCategory(r.Posture),
|
|
},
|
|
Voice: persona.VoiceDNA{
|
|
Pitch: persona.PitchCategory(r.Pitch),
|
|
PitchRange: persona.PitchRangeCategory(r.PitchRange),
|
|
Tone: persona.ToneCategory(r.Tone),
|
|
Timbre: persona.TimbreCategory(r.Timbre),
|
|
Accent: persona.AccentCategory(r.Accent),
|
|
AccentStrength: persona.AccentStrengthCategory(r.AccentStrength),
|
|
Cadence: persona.CadenceCategory(r.Cadence),
|
|
RhythmPattern: persona.RhythmPatternCategory(r.RhythmPattern),
|
|
Volume: persona.VolumeCategory(r.Volume),
|
|
Clarity: persona.ClarityCategory(r.Clarity),
|
|
Expressiveness: persona.ExpressivenessCategory(r.Expressiveness),
|
|
},
|
|
}
|
|
|
|
// Populate mixed-heritage breakdown when the LLM returned heritage fields.
|
|
if r.PrimaryHeritage != "" {
|
|
dna.Identity.PrimaryHeritage = persona.EthnicityCode(r.PrimaryHeritage)
|
|
}
|
|
if r.SecondaryHeritage != "" {
|
|
sec := persona.EthnicityCode(r.SecondaryHeritage)
|
|
dna.Identity.SecondaryHeritage = &sec
|
|
dna.Identity.MixPercentage = r.MixPercentage
|
|
}
|
|
|
|
return dna, nil
|
|
}
|
|
|
|
// populateImageMatrix assigns outfit and fashion context details to each image spec
|
|
// based on the persona's lifestyle. This is Stage 5 of the pipeline.
|
|
func populateImageMatrix(matrix []persona.ImageSpec, lifestyle *persona.Lifestyle) []persona.ImageSpec {
|
|
if lifestyle == nil {
|
|
return matrix
|
|
}
|
|
|
|
primaryCtx := persona.FashionContextFor(lifestyle.FashionSense.Primary)
|
|
secondaryCtx := persona.FashionContextFor(lifestyle.FashionSense.Secondary)
|
|
|
|
for i := range matrix {
|
|
spec := &matrix[i]
|
|
|
|
switch spec.ClothingState {
|
|
case "fashion outfit", "complete fashion outfit", "full fashion outfit":
|
|
spec.FashionContext = string(primaryCtx.Name)
|
|
keyCount := min(3, len(primaryCtx.KeyPieces))
|
|
if keyCount > 0 {
|
|
spec.Outfit = strings.Join(primaryCtx.KeyPieces[:keyCount], ", ")
|
|
}
|
|
spec.Outfit += " — " + primaryCtx.Silhouette
|
|
|
|
case "casual", "casual homewear", "casual streetwear":
|
|
if secondaryCtx.Name != "" {
|
|
spec.FashionContext = string(secondaryCtx.Name)
|
|
if len(secondaryCtx.KeyPieces) > 0 {
|
|
spec.Outfit = secondaryCtx.KeyPieces[0] + " casual look"
|
|
}
|
|
} else {
|
|
spec.FashionContext = string(primaryCtx.Name)
|
|
spec.Outfit = "casual everyday look"
|
|
}
|
|
|
|
case "athletic wear":
|
|
spec.FashionContext = string(persona.FashionAthleisurePro)
|
|
spec.Outfit = "fitted athletic set, performance fabric"
|
|
|
|
case "cozy layered", "comfortable/cozy", "minimal/cozy":
|
|
spec.FashionContext = string(persona.FashionLuxeLoungewear)
|
|
spec.Outfit = "soft layered loungewear, neutral tones"
|
|
|
|
case "off-shoulder":
|
|
spec.FashionContext = string(primaryCtx.Name)
|
|
spec.Outfit = "off-shoulder " + firstKeyPiece(primaryCtx)
|
|
|
|
default:
|
|
spec.FashionContext = string(primaryCtx.Name)
|
|
spec.Outfit = firstKeyPiece(primaryCtx)
|
|
}
|
|
|
|
// Position 1 (anchor) gets the persona's signature styling detail.
|
|
if spec.Position == 1 && len(lifestyle.FashionSense.SignatureDetails) > 0 {
|
|
spec.Outfit += ", " + lifestyle.FashionSense.SignatureDetails[0]
|
|
}
|
|
}
|
|
|
|
return matrix
|
|
}
|
|
|
|
// firstKeyPiece returns the first key piece from a fashion context, or empty string.
|
|
func firstKeyPiece(fc persona.FashionContext) string {
|
|
if len(fc.KeyPieces) > 0 {
|
|
return fc.KeyPieces[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// parseLLMJSON extracts and unmarshals JSON from an LLM text response.
|
|
// Handles both raw JSON and JSON wrapped in markdown code blocks.
|
|
func parseLLMJSON(text string, dst any) error {
|
|
text = strings.TrimSpace(text)
|
|
|
|
// Strip markdown code fences if present.
|
|
if strings.HasPrefix(text, "```") {
|
|
lines := strings.Split(text, "\n")
|
|
if len(lines) >= 2 {
|
|
inner := lines[1:]
|
|
if len(inner) > 0 && strings.TrimSpace(inner[len(inner)-1]) == "```" {
|
|
inner = inner[:len(inner)-1]
|
|
}
|
|
text = strings.Join(inner, "\n")
|
|
}
|
|
}
|
|
|
|
// Extract outermost JSON object from potentially noisy LLM output.
|
|
start := strings.Index(text, "{")
|
|
end := strings.LastIndex(text, "}")
|
|
if start >= 0 && end > start {
|
|
text = text[start : end+1]
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(text), dst); err != nil {
|
|
preview := text
|
|
if len(preview) > 120 {
|
|
preview = preview[:120] + "..."
|
|
}
|
|
return fmt.Errorf("JSON unmarshal: %w (preview: %s)", err, preview)
|
|
}
|
|
return nil
|
|
}
|