package classifier import ( "encoding/json" "fmt" "regexp" "strings" ) // Tier1Result is the LLM response for Tier 1 (routing only) stories. type Tier1Result struct { Channel string `json:"channel"` Platforms []string `json:"platforms"` DuplicateOf *string `json:"duplicate_of"` Rationale string `json:"rationale"` } // Tier2Result is the LLM response for Tier 2 (gating + routing) stories. type Tier2Result struct { Post bool `json:"post"` Channel string `json:"channel"` Platforms []string `json:"platforms"` DuplicateOf *string `json:"duplicate_of"` Rationale string `json:"rationale"` } var validChannels = map[string]bool{ "tech": true, "politics": true, "gaming": true, } var validPlatforms = map[string]bool{ "nintendo-switch": true, "playstation": true, "xbox": true, "pc": true, "retro": true, "mobile": true, "multi": true, } var thinkRe = regexp.MustCompile(`(?s).*?`) var fenceRe = regexp.MustCompile("(?s)```(?:json)?\\s*(.+?)\\s*```") var trailingCommaRe = regexp.MustCompile(`,\s*([}\]])`) // ParseTier1Response parses an LLM response into a Tier1Result. func ParseTier1Response(raw string) (*Tier1Result, error) { cleaned := repairJSON(raw) var result Tier1Result if err := json.Unmarshal([]byte(cleaned), &result); err != nil { return nil, fmt.Errorf("parse tier1 response: %w (raw: %s)", err, truncate(raw, 200)) } if !validChannels[result.Channel] { return nil, fmt.Errorf("invalid channel: %q", result.Channel) } result.Platforms = filterPlatforms(result.Platforms) return &result, nil } // ParseTier2Response parses an LLM response into a Tier2Result. func ParseTier2Response(raw string) (*Tier2Result, error) { cleaned := repairJSON(raw) var result Tier2Result if err := json.Unmarshal([]byte(cleaned), &result); err != nil { return nil, fmt.Errorf("parse tier2 response: %w (raw: %s)", err, truncate(raw, 200)) } // Always validate channel — gating may override post=false to true later if !validChannels[result.Channel] { if result.Post { return nil, fmt.Errorf("invalid channel: %q", result.Channel) } // Default to "politics" for discarded stories with garbage channels result.Channel = "politics" } result.Platforms = filterPlatforms(result.Platforms) return &result, nil } // repairJSON attempts to fix common LLM output issues. func repairJSON(raw string) string { s := strings.TrimSpace(raw) // Strip ... blocks (some models include reasoning) s = thinkRe.ReplaceAllString(s, "") // Strip markdown code fences if matches := fenceRe.FindStringSubmatch(s); len(matches) > 1 { s = matches[1] } s = strings.TrimSpace(s) // Remove trailing commas before closing braces/brackets (outside quoted strings) s = trailingCommaRe.ReplaceAllString(s, "$1") return s } func filterPlatforms(platforms []string) []string { var valid []string for _, p := range platforms { p = strings.TrimSpace(strings.ToLower(p)) if validPlatforms[p] { valid = append(valid, p) } } return valid } func truncate(s string, n int) string { runes := []rune(s) if len(runes) <= n { return s } return string(runes[:n]) + "..." }