124 lines
3.2 KiB
Go
124 lines
3.2 KiB
Go
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)<think>.*?</think>`)
|
|
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 <think>...</think> 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]) + "..."
|
|
}
|