Initial commit
This commit is contained in:
169
internal/classifier/classifier.go
Normal file
169
internal/classifier/classifier.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package classifier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
|
||||
"pete/internal/ingestion"
|
||||
"pete/internal/matrix"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
const recentHeadlineLimit = 50
|
||||
|
||||
// ClassifyResult is the unified output of the classification pipeline.
|
||||
type ClassifyResult struct {
|
||||
Channel string
|
||||
Platforms []string
|
||||
DuplicateOf *string
|
||||
ShouldPost bool
|
||||
Rationale string
|
||||
}
|
||||
|
||||
// Classifier runs the classification pipeline for incoming stories.
|
||||
type Classifier struct {
|
||||
ollama *OllamaClient
|
||||
matrixClient *matrix.Client
|
||||
consecutiveFailures atomic.Int32
|
||||
}
|
||||
|
||||
// NewClassifier creates a new classifier.
|
||||
func NewClassifier(ollama *OllamaClient, matrixClient *matrix.Client) *Classifier {
|
||||
return &Classifier{
|
||||
ollama: ollama,
|
||||
matrixClient: matrixClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Classify runs the appropriate classification pipeline for a feed item.
|
||||
func (c *Classifier) Classify(item *ingestion.FeedItem) (*ClassifyResult, error) {
|
||||
// Direct routing — no LLM call needed
|
||||
if item.DirectRoute != nil {
|
||||
slog.Debug("direct route", "guid", item.GUID, "channel", *item.DirectRoute)
|
||||
|
||||
// Still check GUID dedup via recent headlines for semantic dedup
|
||||
// but direct-routed stories always post
|
||||
return &ClassifyResult{
|
||||
Channel: *item.DirectRoute,
|
||||
ShouldPost: true,
|
||||
Rationale: "direct route from source config",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get recent headlines for dedup context
|
||||
recent, err := storage.GetRecentHeadlines(recentHeadlineLimit)
|
||||
if err != nil {
|
||||
slog.Error("failed to get recent headlines", "err", err)
|
||||
recent = nil // continue without dedup context
|
||||
}
|
||||
|
||||
if item.Tier == 2 {
|
||||
return c.classifyTier2(item, recent)
|
||||
}
|
||||
|
||||
return c.classifyTier1(item, recent)
|
||||
}
|
||||
|
||||
func (c *Classifier) classifyTier1(item *ingestion.FeedItem, recent []storage.RecentHeadline) (*ClassifyResult, error) {
|
||||
prompt := BuildTier1Prompt(item, recent)
|
||||
|
||||
raw, err := c.ollama.Generate(prompt)
|
||||
if err != nil {
|
||||
c.handleOllamaFailure(err)
|
||||
return nil, fmt.Errorf("ollama generate: %w", err)
|
||||
}
|
||||
c.handleOllamaSuccess()
|
||||
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
slog.Error("malformed LLM response", "guid", item.GUID, "raw", raw, "err", err)
|
||||
return nil, fmt.Errorf("parse tier1: %w", err)
|
||||
}
|
||||
|
||||
// Log classification
|
||||
logResult(item.GUID, raw)
|
||||
|
||||
return &ClassifyResult{
|
||||
Channel: result.Channel,
|
||||
Platforms: result.Platforms,
|
||||
DuplicateOf: result.DuplicateOf,
|
||||
ShouldPost: true, // Tier 1 always posts
|
||||
Rationale: result.Rationale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Classifier) classifyTier2(item *ingestion.FeedItem, recent []storage.RecentHeadline) (*ClassifyResult, error) {
|
||||
// Run keyword gating first
|
||||
gatingResult, reason := RunGating(item.Headline, item.Lede)
|
||||
|
||||
switch gatingResult {
|
||||
case GatingDiscard:
|
||||
slog.Info("tier2 gating: discard", "guid", item.GUID, "reason", reason)
|
||||
logResult(item.GUID, fmt.Sprintf(`{"gating":"discard","reason":%q}`, reason))
|
||||
return &ClassifyResult{
|
||||
ShouldPost: false,
|
||||
Rationale: reason,
|
||||
}, nil
|
||||
|
||||
case GatingPost:
|
||||
slog.Info("tier2 gating: definite post, routing via LLM", "guid", item.GUID, "reason", reason)
|
||||
// Fall through to LLM for routing (but post=true is guaranteed)
|
||||
}
|
||||
|
||||
// Stage 3 (ambiguous) or Stage 1 (definite post needing routing) — send to LLM
|
||||
prompt := BuildTier2Prompt(item, recent)
|
||||
|
||||
raw, err := c.ollama.Generate(prompt)
|
||||
if err != nil {
|
||||
c.handleOllamaFailure(err)
|
||||
return nil, fmt.Errorf("ollama generate: %w", err)
|
||||
}
|
||||
c.handleOllamaSuccess()
|
||||
|
||||
result, err := ParseTier2Response(raw)
|
||||
if err != nil {
|
||||
slog.Error("malformed LLM response", "guid", item.GUID, "raw", raw, "err", err)
|
||||
return nil, fmt.Errorf("parse tier2: %w", err)
|
||||
}
|
||||
|
||||
logResult(item.GUID, raw)
|
||||
|
||||
// If gating said definite post, override LLM's post decision
|
||||
shouldPost := result.Post
|
||||
if gatingResult == GatingPost {
|
||||
shouldPost = true
|
||||
}
|
||||
|
||||
return &ClassifyResult{
|
||||
Channel: result.Channel,
|
||||
Platforms: result.Platforms,
|
||||
DuplicateOf: result.DuplicateOf,
|
||||
ShouldPost: shouldPost,
|
||||
Rationale: result.Rationale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Classifier) handleOllamaFailure(err error) {
|
||||
n := c.consecutiveFailures.Add(1)
|
||||
slog.Error("ollama failure", "err", err, "consecutive", n)
|
||||
if n == 3 && c.matrixClient != nil {
|
||||
c.matrixClient.SendAdminWarning(
|
||||
fmt.Sprintf("Ollama unavailable for 3 consecutive attempts: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Classifier) handleOllamaSuccess() {
|
||||
c.consecutiveFailures.Store(0)
|
||||
}
|
||||
|
||||
func logResult(guid, raw string) {
|
||||
// Try to compact the JSON for cleaner logging
|
||||
var compact json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &compact); err == nil {
|
||||
storage.InsertClassificationLog(guid, string(compact))
|
||||
} else {
|
||||
storage.InsertClassificationLog(guid, raw)
|
||||
}
|
||||
}
|
||||
77
internal/classifier/gating.go
Normal file
77
internal/classifier/gating.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package classifier
|
||||
|
||||
import "regexp"
|
||||
|
||||
// GatingResult indicates the outcome of keyword-based gating for Tier 2 stories.
|
||||
type GatingResult int
|
||||
|
||||
const (
|
||||
GatingPost GatingResult = iota // Stage 1: definite post
|
||||
GatingDiscard // Stage 2: definite discard
|
||||
GatingAmbiguous // Stage 3: escalate to LLM
|
||||
)
|
||||
|
||||
// Stage 1: Definite post patterns — stories with clear public significance.
|
||||
// Patterns use multi-word context to reduce false positives (e.g. "Star Wars" won't match).
|
||||
var postPatterns = []*regexp.Regexp{
|
||||
// Named heads of state / major international bodies
|
||||
regexp.MustCompile(`(?i)\b(president|prime minister|chancellor|pope)\s+\w+`),
|
||||
regexp.MustCompile(`(?i)\b(UN|United Nations|WHO|NATO|EU|European Union|IMF|World Bank)\b`),
|
||||
|
||||
// Casualty language with numbers
|
||||
regexp.MustCompile(`(?i)\b\d+\s*(dead|killed|injured|casualties|wounded)\b`),
|
||||
regexp.MustCompile(`(?i)\b(dead|killed|injured|casualties|wounded)\s*\d+\b`),
|
||||
|
||||
// Disaster language — require a location-like context word nearby
|
||||
regexp.MustCompile(`(?i)\b(earthquake|hurricane|typhoon|tsunami|wildfire|tornado)\b.*\b(hit|struck|destroyed|devastated|kills|deaths)\b`),
|
||||
|
||||
// Conflict language — require multi-word phrases to avoid "Star Wars", "culture war"
|
||||
regexp.MustCompile(`(?i)\b(military invasion|armed coup|airstrike\s+\w+|assassination\s+\w+|car bombing|suicide bombing)\b`),
|
||||
regexp.MustCompile(`(?i)\bwar\s+(in|on|with|against|between)\b`),
|
||||
|
||||
// Crisis language — require context word nearby
|
||||
regexp.MustCompile(`(?i)\b(state of emergency|economic crisis|economic collapse|disease outbreak|pandemic declared|pandemic phase|famine)\b`),
|
||||
|
||||
// Major financial scale
|
||||
regexp.MustCompile(`(?i)\$\d+\s*(bn|tn|billion|trillion)\b`),
|
||||
regexp.MustCompile(`(?i)\b(trillion|billion)\s*(dollar|euro|pound)\b`),
|
||||
}
|
||||
|
||||
// Stage 2: Definite discard patterns — stories unlikely to warrant posting.
|
||||
var discardPatterns = []*regexp.Regexp{
|
||||
// Local/municipal scope
|
||||
regexp.MustCompile(`(?i)\b(city council|municipal|town hall|zoning|local police)\b`),
|
||||
|
||||
// Sports scores and routine results
|
||||
regexp.MustCompile(`(?i)\b(final score|match result|league standings|defeated|innings)\b`),
|
||||
regexp.MustCompile(`(?i)\b\d+-\d+\s*(win|loss|defeat|victory)\b`),
|
||||
|
||||
// Entertainment/celebrity lifestyle
|
||||
regexp.MustCompile(`(?i)\b(red carpet|award show|celebrity gossip|dating rumor|relationship drama|album release)\b`),
|
||||
|
||||
// Routine corporate earnings
|
||||
regexp.MustCompile(`(?i)\b(quarterly earnings|Q[1-4] results|earnings call|fiscal quarter)\b`),
|
||||
}
|
||||
|
||||
// RunGating evaluates Tier 2 gating stages on headline + lede.
|
||||
// Returns the gating result and a reason string for logging.
|
||||
func RunGating(headline, lede string) (GatingResult, string) {
|
||||
combined := headline + " " + lede
|
||||
|
||||
// Stage 1: Definite post
|
||||
for _, re := range postPatterns {
|
||||
if re.MatchString(combined) {
|
||||
return GatingPost, "stage1: " + re.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: Definite discard
|
||||
for _, re := range discardPatterns {
|
||||
if re.MatchString(combined) {
|
||||
return GatingDiscard, "stage2: " + re.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: Ambiguous — escalate to LLM
|
||||
return GatingAmbiguous, "stage3: no keyword match"
|
||||
}
|
||||
74
internal/classifier/gating_test.go
Normal file
74
internal/classifier/gating_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package classifier
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGating_Stage1_DefinitePost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headline string
|
||||
lede string
|
||||
}{
|
||||
{"head of state", "President signs new climate bill", "The president signed legislation today."},
|
||||
{"international body", "WHO declares new pandemic phase", "The World Health Organization announced a new phase."},
|
||||
{"casualties", "12 killed in factory explosion", "Authorities report 12 dead after an explosion."},
|
||||
{"disaster", "Earthquake strikes northern Japan", "A major earthquake hit the region early Tuesday."},
|
||||
{"conflict", "Airstrike hits hospital in conflict zone", "An airstrike damaged a hospital."},
|
||||
{"crisis", "Economic crisis deepens in Argentina", "The ongoing crisis has intensified."},
|
||||
{"financial scale", "EU approves $50bn climate fund", "The European Union approved funding."},
|
||||
{"assassination", "Assassination attempt on opposition leader", "An armed assailant attacked the leader."},
|
||||
{"NATO", "NATO expands eastern flank deployment", "Alliance members agreed to new deployments."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, reason := RunGating(tt.headline, tt.lede)
|
||||
if result != GatingPost {
|
||||
t.Errorf("expected GatingPost, got %d (reason: %s)", result, reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGating_Stage2_DefiniteDiscard(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headline string
|
||||
lede string
|
||||
}{
|
||||
{"local government", "City council approves new parking meters", "The council voted 5-3 in favor."},
|
||||
{"sports scores", "Lakers defeat Celtics 112-98 in regular season", "Final score 112-98."},
|
||||
{"celebrity gossip", "Celebrity couple spotted on red carpet at Cannes", "The pair arrived together."},
|
||||
{"routine earnings", "Regional bank reports Q2 results slightly above estimates", "Quarterly earnings were released."},
|
||||
{"entertainment", "Pop star announces new album release date", "Fans have been waiting since 2023."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, reason := RunGating(tt.headline, tt.lede)
|
||||
if result != GatingDiscard {
|
||||
t.Errorf("expected GatingDiscard, got %d (reason: %s)", result, reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGating_Stage3_Ambiguous(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headline string
|
||||
lede string
|
||||
}{
|
||||
{"tech product", "Apple unveils new MacBook Pro with M5 chip", "The new laptops feature significant performance gains."},
|
||||
{"policy nuanced", "Government considers new data privacy framework", "Ministers met to discuss potential changes."},
|
||||
{"science", "Researchers discover high-temperature superconductor", "A team at MIT published findings in Nature."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, reason := RunGating(tt.headline, tt.lede)
|
||||
if result != GatingAmbiguous {
|
||||
t.Errorf("expected GatingAmbiguous, got %d (reason: %s)", result, reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
104
internal/classifier/ollama.go
Normal file
104
internal/classifier/ollama.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package classifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
)
|
||||
|
||||
// OllamaClient handles communication with the Ollama API.
|
||||
type OllamaClient struct {
|
||||
baseURL string
|
||||
model string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
// NewOllamaClient creates an Ollama API client from config.
|
||||
func NewOllamaClient(cfg config.OllamaConfig) *OllamaClient {
|
||||
return &OllamaClient{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
model: cfg.Model,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Generate sends a prompt to Ollama via the chat API and returns the raw response text.
|
||||
func (o *OllamaClient) Generate(prompt string) (string, error) {
|
||||
reqBody := chatRequest{
|
||||
Model: o.model,
|
||||
Messages: []chatMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "You are a JSON-only news classification API. Respond with valid JSON only. No preamble, no explanation, no markdown.",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: prompt,
|
||||
},
|
||||
},
|
||||
Stream: false,
|
||||
Format: "json",
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := o.baseURL + "/api/chat"
|
||||
slog.Debug("ollama: calling", "url", url, "model", o.model)
|
||||
|
||||
resp, err := o.httpClient.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ollama request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024)) // 1MB cap
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read ollama response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("ollama status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp chatResponse
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return "", fmt.Errorf("decode ollama response: %w (body: %s)", err, truncate(string(respBody), 200))
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(chatResp.Message.Content)
|
||||
if result == "" {
|
||||
return "", fmt.Errorf("ollama returned empty response (body: %s)", truncate(string(respBody), 200))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
123
internal/classifier/parse.go
Normal file
123
internal/classifier/parse.go
Normal file
@@ -0,0 +1,123 @@
|
||||
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]) + "..."
|
||||
}
|
||||
156
internal/classifier/parse_test.go
Normal file
156
internal/classifier/parse_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package classifier
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTier1Response_Clean(t *testing.T) {
|
||||
raw := `{"channel": "tech", "platforms": [], "duplicate_of": null, "rationale": "GPU launch story"}`
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Channel != "tech" {
|
||||
t.Errorf("channel = %q, want tech", result.Channel)
|
||||
}
|
||||
if len(result.Platforms) != 0 {
|
||||
t.Errorf("platforms = %v, want empty", result.Platforms)
|
||||
}
|
||||
if result.DuplicateOf != nil {
|
||||
t.Errorf("duplicate_of = %v, want nil", result.DuplicateOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTier1Response_WithPlatforms(t *testing.T) {
|
||||
raw := `{"channel":"gaming","platforms":["nintendo-switch","pc"],"duplicate_of":null,"rationale":"Switch 2 launch"}`
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Channel != "gaming" {
|
||||
t.Errorf("channel = %q", result.Channel)
|
||||
}
|
||||
if len(result.Platforms) != 2 {
|
||||
t.Fatalf("platforms = %v, want 2", result.Platforms)
|
||||
}
|
||||
if result.Platforms[0] != "nintendo-switch" || result.Platforms[1] != "pc" {
|
||||
t.Errorf("platforms = %v", result.Platforms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTier1Response_WithDuplicate(t *testing.T) {
|
||||
raw := `{"channel":"politics","platforms":[],"duplicate_of":"guid-abc-123","rationale":"same event"}`
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.DuplicateOf == nil || *result.DuplicateOf != "guid-abc-123" {
|
||||
t.Errorf("duplicate_of = %v", result.DuplicateOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTier1Response_InvalidChannel(t *testing.T) {
|
||||
raw := `{"channel":"sports","platforms":[],"duplicate_of":null,"rationale":"test"}`
|
||||
_, err := ParseTier1Response(raw)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTier1Response_FiltersInvalidPlatforms(t *testing.T) {
|
||||
raw := `{"channel":"gaming","platforms":["nintendo-switch","fake-platform","pc","also-fake"],"duplicate_of":null,"rationale":"test"}`
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Platforms) != 2 {
|
||||
t.Fatalf("platforms = %v, want [nintendo-switch pc]", result.Platforms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTier2Response_PostTrue(t *testing.T) {
|
||||
raw := `{"post":true,"channel":"politics","platforms":[],"duplicate_of":null,"rationale":"major world event"}`
|
||||
result, err := ParseTier2Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Post {
|
||||
t.Error("post = false, want true")
|
||||
}
|
||||
if result.Channel != "politics" {
|
||||
t.Errorf("channel = %q", result.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTier2Response_PostFalse(t *testing.T) {
|
||||
raw := `{"post":false,"platforms":[],"duplicate_of":null,"rationale":"local news"}`
|
||||
result, err := ParseTier2Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Post {
|
||||
t.Error("post = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairJSON_MarkdownFences(t *testing.T) {
|
||||
raw := "```json\n{\"channel\": \"tech\", \"platforms\": [], \"duplicate_of\": null, \"rationale\": \"test\"}\n```"
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Channel != "tech" {
|
||||
t.Errorf("channel = %q", result.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairJSON_ThinkBlocks(t *testing.T) {
|
||||
raw := "<think>I need to classify this as tech because it's about GPUs</think>\n{\"channel\": \"tech\", \"platforms\": [], \"duplicate_of\": null, \"rationale\": \"GPU story\"}"
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Channel != "tech" {
|
||||
t.Errorf("channel = %q", result.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairJSON_TrailingCommas(t *testing.T) {
|
||||
raw := `{"channel": "politics", "platforms": [], "duplicate_of": null, "rationale": "test",}`
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Channel != "politics" {
|
||||
t.Errorf("channel = %q", result.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairJSON_TrailingCommaInArray(t *testing.T) {
|
||||
raw := `{"channel": "gaming", "platforms": ["pc", "xbox",], "duplicate_of": null, "rationale": "test"}`
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Platforms) != 2 {
|
||||
t.Errorf("platforms = %v", result.Platforms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairJSON_CombinedIssues(t *testing.T) {
|
||||
raw := "<think>reasoning here</think>\n```json\n{\"channel\": \"tech\", \"platforms\": [],\"duplicate_of\": null, \"rationale\": \"test\",}\n```"
|
||||
result, err := ParseTier1Response(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Channel != "tech" {
|
||||
t.Errorf("channel = %q", result.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairJSON_GarbageInput(t *testing.T) {
|
||||
_, err := ParseTier1Response("I think this should go to tech because reasons")
|
||||
if err == nil {
|
||||
t.Error("expected error for garbage input")
|
||||
}
|
||||
}
|
||||
110
internal/classifier/prompts.go
Normal file
110
internal/classifier/prompts.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package classifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"pete/internal/ingestion"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// BuildTier1Prompt builds the routing-only prompt for Tier 1 stories.
|
||||
func BuildTier1Prompt(item *ingestion.FeedItem, recent []storage.RecentHeadline) string {
|
||||
return fmt.Sprintf(`You are a news router for a community discussion platform with three channels: tech, politics, and gaming.
|
||||
|
||||
This story comes from a curated Tier 1 source. It will be posted. Your only job is to decide which channel it belongs in.
|
||||
|
||||
Choose exactly one channel:
|
||||
- tech: technology products, software, hardware, industry — where technology itself is the primary subject
|
||||
- gaming: games, gaming platforms, gaming industry, esports
|
||||
- politics: everything else — political, policy, current events, public interest, significant world events
|
||||
|
||||
Politics is the default. When in doubt, choose politics.
|
||||
|
||||
Routing guidance:
|
||||
- GPU launch, software release, hardware review → tech
|
||||
- Game release, platform announcement, studio news → gaming
|
||||
- Election, legislation, court ruling → politics
|
||||
- AI regulation, antitrust action, surveillance policy → politics (policy is the story, not the technology)
|
||||
- Death of a public figure → politics
|
||||
- Violent crime, assassination attempt, arrest of public figure → politics
|
||||
- Natural disaster, humanitarian crisis → politics
|
||||
- Celebrity gossip, lifestyle with no broader significance → politics (still gets posted, still needs a home)
|
||||
|
||||
For gaming stories only: identify which platforms are relevant from this list:
|
||||
nintendo-switch, playstation, xbox, pc, retro, mobile, multi
|
||||
Return an empty array if no specific platform applies.
|
||||
|
||||
Also check: does this story cover the same underlying event as any story in the recent window? Different outlets covering the same event counts as a duplicate. If yes, set duplicate_of to that story's GUID. If no match, set duplicate_of to null.
|
||||
|
||||
Respond ONLY with valid JSON. No preamble, no explanation outside the JSON:
|
||||
{
|
||||
"channel": "tech|politics|gaming",
|
||||
"platforms": [string],
|
||||
"duplicate_of": string | null,
|
||||
"rationale": "one sentence max, for logging only"
|
||||
}
|
||||
|
||||
INCOMING STORY:
|
||||
Source: %s
|
||||
Tier: %d
|
||||
Feed hint: %s
|
||||
Headline: %s
|
||||
Lede: %s
|
||||
|
||||
RECENT STORIES (last 24hr):
|
||||
%s`, item.Source, item.Tier, item.FeedHint, item.Headline, item.Lede, formatRecentStories(recent))
|
||||
}
|
||||
|
||||
// BuildTier2Prompt builds the gating + routing prompt for Tier 2 stories.
|
||||
func BuildTier2Prompt(item *ingestion.FeedItem, recent []storage.RecentHeadline) string {
|
||||
return fmt.Sprintf(`You are a news classifier for a community discussion platform with three channels: tech, politics, and gaming.
|
||||
|
||||
This story comes from a wire service. Unlike curated sources, wire feeds publish everything — not all stories warrant posting. First decide if this story is worth posting, then decide where.
|
||||
|
||||
Step 1 — Should this be posted?
|
||||
Post if the story has clear public significance: major world events, policy decisions, significant deaths, natural disasters, major technology developments. Do not post: local news with no broader relevance, routine financial filings, minor sports results, entertainment fluff.
|
||||
|
||||
Step 2 — If posting, choose exactly one channel:
|
||||
- tech: technology products, software, hardware, industry
|
||||
- gaming: games, gaming platforms, gaming industry
|
||||
- politics: everything else — the default
|
||||
|
||||
Politics is the default when in doubt.
|
||||
|
||||
For gaming stories only: identify relevant platforms from: nintendo-switch, playstation, xbox, pc, retro, mobile, multi
|
||||
|
||||
Also check for duplicates against the recent window. If this story covers the same underlying event as a recent story, set duplicate_of to that story's GUID.
|
||||
|
||||
Respond ONLY with valid JSON:
|
||||
{
|
||||
"post": true|false,
|
||||
"channel": "tech|politics|gaming",
|
||||
"platforms": [string],
|
||||
"duplicate_of": string | null,
|
||||
"rationale": "one sentence max, for logging only"
|
||||
}
|
||||
|
||||
If post is false, channel may be omitted.
|
||||
|
||||
INCOMING STORY:
|
||||
Source: %s
|
||||
Tier: %d
|
||||
Feed hint: %s
|
||||
Headline: %s
|
||||
Lede: %s
|
||||
|
||||
RECENT STORIES (last 24hr):
|
||||
%s`, item.Source, item.Tier, item.FeedHint, item.Headline, item.Lede, formatRecentStories(recent))
|
||||
}
|
||||
|
||||
func formatRecentStories(recent []storage.RecentHeadline) string {
|
||||
if len(recent) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, h := range recent {
|
||||
fmt.Fprintf(&sb, "[%s] %s — %s\n", h.GUID, h.Headline, h.Source)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
Reference in New Issue
Block a user