Initial commit

This commit is contained in:
prosolis
2026-05-22 17:25:27 -07:00
commit 652d6dfa38
40 changed files with 5855 additions and 0 deletions

View 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)
}
}

View 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"
}

View 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)
}
})
}
}

View 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
}

View 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]) + "..."
}

View 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")
}
}

View 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()
}