persona-community-2/pkg/personagen/specgen.go
jordan cb3d4d5786
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-23 10:53:55 +00:00

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
}