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 }