diff --git a/README.md b/README.md
index 1bb2923..b480655 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,15 @@
# Pete
-A Matrix news bot that ingests RSS feeds from curated sources, classifies stories using a local LLM, deduplicates semantically, and routes posts to the appropriate channel.
+A Matrix news bot that ingests RSS feeds from curated sources and routes each story to a configured channel.
## Features
-- **RSS ingestion** from configurable sources (Guardian, Ars Technica) with per-source polling intervals
-- **Two-tier classification** — Tier 1 sources use direct routing or lightweight LLM routing; Tier 2 (wire services) use a keyword gating pipeline before LLM
-- **Semantic deduplication** — GUID exact match + LLM-based cross-source duplicate detection
+- **RSS ingestion** from configurable sources (Guardian, Ars Technica, Time Extension, …) with per-source polling intervals
+- **Direct routing** — every source declares a `direct_route` channel; Pete posts there. No LLM, no classification step
+- **Deduplication** — GUID exact match, canonical-URL match, normalized-headline match, and per-channel canonical-URL cooldown
- **Metered release** — per-channel post queues with minimum interval (5min) and burst cap (3/30min)
- **Round-robin mode** — opt-in pacing: one story per N hours (default 4), cycling through channels in sorted order, skip-and-advance over empty channels, state persists across restarts
-- **Reaction tracking** — records emoji reactions on posts for classifier tuning
-- **`!explain` via ❓ reaction** — react with `❓` (or `❔ ⁉ 🤔 ?`) on any post; Pete fetches the article body and replies in-thread with a 3-bullet Ollama-generated TL;DR
+- **Reaction tracking** — records emoji reactions on posts
- **`!post` on demand** — type `!post` in any configured channel room and Pete force-publishes the next queued story for that channel, bypassing min-interval, burst cap, and daily cap (canonical-URL dedup still applies); replies in-thread if the queue is empty
- **Paywall detection** — if an article's visible body text is below threshold, Pete swaps in a Wayback Machine snapshot URL for both the lead image and the posted link
- **FTS5 search** — full-text search across headlines and ledes
@@ -22,13 +21,12 @@ A Matrix news bot that ingests RSS feeds from curated sources, classifies storie
| Channel | Purpose |
|---|---|
| `tech` | Technology news, product/industry stories |
-| `politics` | Political, policy, current events (default/catch-all) |
-| `gaming` | Gaming news, releases, platform announcements (no active sources yet) |
+| `politics` | Political, policy, current events |
+| `gaming` | Gaming news, releases, platform announcements |
## Requirements
- Go 1.25+
-- [Ollama](https://ollama.ai) running locally or on a reachable host
- A Matrix account for Pete with access to target rooms
- SQLite (bundled via pure Go driver, no CGo)
- No CGo dependencies — E2EE uses pure Go crypto (goolm) via mautrix v0.28
@@ -42,7 +40,7 @@ go build -tags goolm .
# Create config from example
cp config.example.yaml config.yaml
-# Edit config.yaml — fill in Matrix credentials, room IDs, Ollama endpoint
+# Edit config.yaml — fill in Matrix credentials, room IDs, and a direct_route for every source
# First run: seed current feed items as seen (prevents flood)
./pete -config config.yaml -seed
@@ -59,10 +57,9 @@ cp config.example.yaml config.yaml
See `config.example.yaml` for the full structure. Key sections:
- `matrix` — homeserver, credentials, channel room IDs, optional admin room
-- `ollama` — base URL, model name, timeout
- `posting` — rate limiting (min interval, burst cap, daily cap), optional `round_robin` block
- `storage` — database path, retention windows
-- `sources` — RSS feeds with tier, polling interval, feed hint, optional direct routing
+- `sources` — RSS feeds with tier, polling interval, and **required** `direct_route` (must match a key in `matrix.channels`)
- `web` — read-only HTTP UI (enabled toggle, listen address, site title, public base URL)
Environment variables can be referenced with `${VAR}` syntax in the YAML.
@@ -86,7 +83,7 @@ Front the server with a reverse proxy (Caddy, nginx, Traefik) terminating TLS fo
### Round-robin mode
-Set `posting.round_robin.enabled: true` to switch Pete from "post on classify" to a paced rotation. On each tick (`interval_hours`, default 4) Pete picks the newest unposted classified story routed to the next channel in sorted order, posting through the existing queue. Empty channels are skipped; the rotation pointer advances to whichever channel actually posted, and `last_channel` / `last_tick_at` are persisted so restarts don't reset the cycle. Rotating by channel (not by source) guarantees variety even when one channel has many more feeds than the others.
+Set `posting.round_robin.enabled: true` to switch Pete from "post immediately" to a paced rotation. On each tick (`interval_hours`, default 4) Pete picks the newest unposted story routed to the next channel in sorted order, posting through the existing queue. Empty channels are skipped; the rotation pointer advances to whichever channel actually posted, and `last_channel` / `last_tick_at` are persisted so restarts don't reset the cycle. Rotating by channel (not by source) guarantees variety even when one channel has many more feeds than the others.
With a 4-hour cadence Pete will post at most 6 stories/day, so set `posting.daily_cap_total` to 0 (or ≥6) when enabling this — otherwise the daily cap can silently swallow a tick.
@@ -109,15 +106,9 @@ Set `PETE_PASSWORD` in your environment or a `.env` file for the Matrix password
## Architecture
```
-RSS Feed → Poller → GUID Dedup → Canonical/Headline Dedup → Article Fetch → Store → Classifier → Semantic Dedup → Image Validate → Queue → Matrix
- ↓ ↓
- Paywall? → Wayback Direct Route (no LLM)
- or
- Ollama /api/chat (Tier 1 routing)
- or
- Keyword Gating → Ollama (Tier 2)
-
-Posted message ← ❓ reaction → Explainer → Article Fetch → Ollama summary → Threaded reply
+RSS Feed → Poller → GUID/Canonical/Headline Dedup → Article Fetch → Store → Route by direct_route → Image Validate → Queue → Matrix
+ ↓
+ Paywall? → Wayback snapshot
```
### Packages
@@ -126,11 +117,10 @@ Posted message ← ❓ reaction → Explainer → Article Fetch → Ollama summa
|---|---|
| `internal/config` | YAML config loading with `${ENV}` expansion |
| `internal/storage` | SQLite with WAL, FTS5, all queries |
-| `internal/ingestion` | Per-source RSS polling, feed parsing, image validation |
-| `internal/classifier` | Ollama client, Tier 1/2 prompts, JSON repair, keyword gating |
+| `internal/ingestion` | Per-source RSS polling, feed parsing, image validation, paywall detection |
+| `internal/dedup` | Canonical URL + headline normalization helpers |
| `internal/matrix` | Password auth with device persistence, posting, threaded replies, reaction + message listener |
-| `internal/poster` | Per-channel metered release queue, reaction tracking, callback hook |
-| `internal/explainer` | ❓-reaction → article fetch → Ollama summary → threaded reply |
+| `internal/poster` | Per-channel metered release queue, reaction tracking |
| `internal/scheduler` | Round-robin posting scheduler: paced rotation across channels when enabled |
| `internal/web` | Read-only HTTP UI: Tailwind templates, day/night palette, per-channel feed pages |
@@ -143,8 +133,6 @@ Lede text from feed
`source name`
```
-Gaming stories append platform tags: `` `source` · `nintendo-switch` · `pc` ``
-
## Testing
```bash
diff --git a/config.example.yaml b/config.example.yaml
index 42402a5..b707d0f 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -11,11 +11,6 @@ matrix:
politics: "!politicsroomid:matrix.example.org"
gaming: "!gamingroomid:matrix.example.org"
-ollama:
- base_url: "http://localhost:11434"
- model: "gemma4:27b"
- timeout_seconds: 30
-
posting:
min_interval_seconds: 300
burst_cap_count: 3
@@ -23,12 +18,11 @@ posting:
daily_cap_total: 5 # hard global cap across ALL channels (rolling 24h); 0 disables
round_robin:
enabled: false # when true, replaces immediate posting with paced rotation
- interval_hours: 4 # one story per N hours, cycling through sources in config order
+ interval_hours: 4 # one story per N hours, cycling through channels in sorted order
storage:
db_path: "./data/pete.db"
recent_window_hours: 24
- classification_log_days: 7
web:
enabled: true
@@ -36,28 +30,20 @@ web:
site_title: "Pete"
base_url: "https://news.parodia.dev"
+# Every enabled source MUST set direct_route to a key from matrix.channels above.
+# There is no automatic classification — Pete posts each story to its configured channel.
sources:
- name: "The Guardian — World"
feed_url: "https://www.theguardian.com/world/rss"
tier: 1
poll_interval_minutes: 20
- feed_hint: "politics"
direct_route: "politics"
enabled: true
- - name: "The Guardian — Technology"
- feed_url: "https://www.theguardian.com/technology/rss"
- tier: 1
- poll_interval_minutes: 20
- feed_hint: "tech"
- direct_route: null
- enabled: true
-
- name: "The Guardian — Politics"
feed_url: "https://www.theguardian.com/politics/rss"
tier: 1
poll_interval_minutes: 20
- feed_hint: "politics"
direct_route: "politics"
enabled: true
@@ -65,7 +51,6 @@ sources:
feed_url: "https://www.theguardian.com/us-news/rss"
tier: 1
poll_interval_minutes: 20
- feed_hint: "politics"
direct_route: "politics"
enabled: true
@@ -73,22 +58,12 @@ sources:
feed_url: "https://feeds.arstechnica.com/arstechnica/index"
tier: 1
poll_interval_minutes: 20
- feed_hint: "tech"
- direct_route: null
- enabled: true
-
- - name: "Ars Technica — Policy"
- feed_url: "https://feeds.arstechnica.com/arstechnica/tech-policy"
- tier: 1
- poll_interval_minutes: 20
- feed_hint: "tech+politics"
- direct_route: null
+ direct_route: "tech"
enabled: true
- name: "Time Extension"
feed_url: "https://www.timeextension.com/feeds/news"
tier: 1
poll_interval_minutes: 20
- feed_hint: "gaming"
direct_route: "gaming"
enabled: true
diff --git a/internal/classifier/classifier.go b/internal/classifier/classifier.go
deleted file mode 100644
index 04f9dcb..0000000
--- a/internal/classifier/classifier.go
+++ /dev/null
@@ -1,171 +0,0 @@
-package classifier
-
-import (
- "context"
- "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.
-// ctx is checked before any LLM call so shutdown aborts in-flight work.
-func (c *Classifier) Classify(ctx context.Context, 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(ctx, item, recent)
- }
-
- return c.classifyTier1(ctx, item, recent)
-}
-
-func (c *Classifier) classifyTier1(ctx context.Context, item *ingestion.FeedItem, recent []storage.RecentHeadline) (*ClassifyResult, error) {
- prompt := BuildTier1Prompt(item, recent)
-
- raw, err := c.ollama.Generate(ctx, 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(ctx context.Context, 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(ctx, 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)
- }
-}
diff --git a/internal/classifier/gating.go b/internal/classifier/gating.go
deleted file mode 100644
index 5dea7e2..0000000
--- a/internal/classifier/gating.go
+++ /dev/null
@@ -1,77 +0,0 @@
-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"
-}
diff --git a/internal/classifier/gating_test.go b/internal/classifier/gating_test.go
deleted file mode 100644
index 3be33b1..0000000
--- a/internal/classifier/gating_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-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)
- }
- })
- }
-}
diff --git a/internal/classifier/ollama.go b/internal/classifier/ollama.go
deleted file mode 100644
index 16de5c8..0000000
--- a/internal/classifier/ollama.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package classifier
-
-import (
- "bytes"
- "context"
- "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. Honors ctx cancellation — callers can abort an in-flight
-// call instead of waiting for the configured timeout.
-func (o *OllamaClient) Generate(ctx context.Context, prompt string) (string, error) {
- return o.call(ctx,
- "You are a JSON-only news classification API. Respond with valid JSON only. No preamble, no explanation, no markdown.",
- prompt,
- "json",
- )
-}
-
-// GenerateText sends a system + user prompt to Ollama and returns the raw
-// text response. Unlike Generate, no JSON mode is forced.
-func (o *OllamaClient) GenerateText(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
- return o.call(ctx, systemPrompt, userPrompt, "")
-}
-
-func (o *OllamaClient) call(ctx context.Context, systemPrompt, userPrompt, format string) (string, error) {
- reqBody := chatRequest{
- Model: o.model,
- Messages: []chatMessage{
- {Role: "system", Content: systemPrompt},
- {Role: "user", Content: userPrompt},
- },
- Stream: false,
- Format: format,
- }
-
- 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)
-
- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
- if err != nil {
- return "", fmt.Errorf("ollama request build: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
- resp, err := o.httpClient.Do(req)
- 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
-}
diff --git a/internal/classifier/parse.go b/internal/classifier/parse.go
deleted file mode 100644
index 66e6802..0000000
--- a/internal/classifier/parse.go
+++ /dev/null
@@ -1,123 +0,0 @@
-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)
" + html.EscapeString(raw) + "" - } - - var pb, hb strings.Builder - hb.WriteString("
") || !strings.HasSuffix(htmlBody, "") { - t.Errorf("html should wrap raw in
, got %q", htmlBody)
- }
- if !strings.Contains(htmlBody, "single paragraph") {
- t.Errorf("html should contain the body text, got %q", htmlBody)
- }
-}
-
-func TestIsQuestionReaction(t *testing.T) {
- want := []string{"❓", "❓️", "❔", "❔️", "⁉", "⁉️", "🤔", "?", "?"}
- for _, k := range want {
- if !IsQuestionReaction(k) {
- t.Errorf("expected %q to trigger explain", k)
- }
- }
- notQuestion := []string{"👍", "👎", "🔥", "", "??", "❤️"}
- for _, k := range notQuestion {
- if IsQuestionReaction(k) {
- t.Errorf("expected %q to NOT trigger explain", k)
- }
- }
-}
diff --git a/internal/ingestion/parser.go b/internal/ingestion/parser.go
index 9e1cada..20c2043 100644
--- a/internal/ingestion/parser.go
+++ b/internal/ingestion/parser.go
@@ -16,7 +16,7 @@ const userAgent = "Pete/1.0 (newsbot; +https://github.com/reala-misaki/pete)"
var feedClient = &http.Client{Timeout: 30 * time.Second}
-// FeedItem represents a parsed RSS item ready for classification.
+// FeedItem represents a parsed RSS item ready for routing.
type FeedItem struct {
GUID string
Headline string
@@ -24,9 +24,7 @@ type FeedItem struct {
ImageURL string
ArticleURL string
Source string
- FeedHint string
- Tier int
- DirectRoute *string
+ DirectRoute string
}
var (
diff --git a/internal/ingestion/poller.go b/internal/ingestion/poller.go
index cc79e83..a6bc13e 100644
--- a/internal/ingestion/poller.go
+++ b/internal/ingestion/poller.go
@@ -11,9 +11,7 @@ import (
"pete/internal/storage"
)
-// ProcessFunc is called for each new feed item that needs classification.
-// ctx is the poller's parent context; callbacks should pass it into any
-// downstream LLM/HTTP work so shutdown aborts in-flight calls.
+// ProcessFunc is called for each new feed item that needs routing.
type ProcessFunc func(ctx context.Context, item *FeedItem)
// Poller manages per-source RSS polling goroutines.
@@ -81,7 +79,6 @@ func (p *Poller) pollSource(ctx context.Context, src config.SourceConfig) {
"source", src.Name,
"failures", consecutiveFailures,
)
- // TODO: send admin room warning via matrix client
}
} else {
consecutiveFailures = 0
@@ -163,11 +160,9 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
// Stamp source metadata onto the item
items[i].Source = src.Name
- items[i].FeedHint = src.FeedHint
- items[i].Tier = src.Tier
items[i].DirectRoute = src.DirectRoute
- // Store immediately (before classification) to prevent re-ingestion
+ // Store immediately (before routing) to prevent re-ingestion
if err := storage.InsertStory(&storage.Story{
GUID: items[i].GUID,
Headline: items[i].Headline,
@@ -177,7 +172,6 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
URLCanonical: canonical,
HeadlineNorm: headlineNorm,
Source: items[i].Source,
- FeedHint: items[i].FeedHint,
SeenAt: time.Now().Unix(),
}); err != nil {
slog.Error("failed to insert story", "guid", items[i].GUID, "err", err)
@@ -192,40 +186,5 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
slog.Info("ingested new stories", "source", src.Name, "count", newCount)
}
- // Retry unclassified stories from this source only (cap at 20 to avoid runaway retries)
- unclassified, err := storage.GetUnclassifiedStories(src.Name)
- if err != nil {
- slog.Error("failed to get unclassified stories", "err", err)
- return nil
- }
- for _, s := range unclassified {
- if ctx.Err() != nil {
- return nil
- }
- // Skip stories we just ingested (they're already being processed above)
- alreadyProcessed := false
- for _, item := range items {
- if item.GUID == s.GUID {
- alreadyProcessed = true
- break
- }
- }
- if alreadyProcessed {
- continue
- }
-
- p.process(ctx, &FeedItem{
- GUID: s.GUID,
- Headline: s.Headline,
- Lede: s.Lede,
- ImageURL: s.ImageURL,
- ArticleURL: s.ArticleURL,
- Source: s.Source,
- FeedHint: s.FeedHint,
- Tier: src.Tier,
- DirectRoute: src.DirectRoute,
- })
- }
-
return nil
}
diff --git a/internal/scheduler/roundrobin_test.go b/internal/scheduler/roundrobin_test.go
index cc293d7..4f4e3e5 100644
--- a/internal/scheduler/roundrobin_test.go
+++ b/internal/scheduler/roundrobin_test.go
@@ -33,7 +33,6 @@ func insertPostable(t *testing.T, guid, source, channel string, seenAt int64) {
Lede: "Lede for " + guid,
ArticleURL: "https://example.com/" + guid,
Source: source,
- FeedHint: "tech",
SeenAt: seenAt,
}); err != nil {
t.Fatalf("InsertStory(%s): %v", guid, err)
diff --git a/internal/storage/db.go b/internal/storage/db.go
index 67fef8b..f7d49a3 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -101,15 +101,7 @@ func runMigrations(d *sql.DB) error {
}
// RunMaintenance prunes stale data. Called periodically.
-func RunMaintenance(recentWindowHours, classificationLogDays int) {
- recentCutoff := nowUnix() - int64(recentWindowHours*3600)
- exec("prune recent_headlines",
- `DELETE FROM recent_headlines WHERE seen_at < ?`, recentCutoff)
-
- logCutoff := nowUnix() - int64(classificationLogDays*86400)
- exec("prune classification_log",
- `DELETE FROM classification_log WHERE logged_at < ?`, logCutoff)
-
+func RunMaintenance() {
// Prune old stories (30 days) and their post logs / reactions
storyCutoff := nowUnix() - int64(30*86400)
exec("prune old stories",
diff --git a/internal/storage/queries.go b/internal/storage/queries.go
index d358c60..8c7254e 100644
--- a/internal/storage/queries.go
+++ b/internal/storage/queries.go
@@ -29,9 +29,9 @@ func InsertStory(s *Story) error {
classified = 1
}
_, err := Get().Exec(
- `INSERT INTO stories (guid, headline, lede, image_url, article_url, url_canonical, headline_norm, source, feed_hint, platforms, channel, classified, seen_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- s.GUID, s.Headline, s.Lede, s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.FeedHint, s.Platforms, s.Channel, classified, s.SeenAt,
+ `INSERT INTO stories (guid, headline, lede, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, seen_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ s.GUID, s.Headline, s.Lede, s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, s.SeenAt,
)
return err
}
@@ -99,73 +99,18 @@ func MarkClassified(guid, channel, platforms string) {
channel, platforms, guid)
}
-// GetUnclassifiedStories returns stories from a specific source that were ingested but not yet classified.
-func GetUnclassifiedStories(source string) ([]Story, error) {
- rows, err := Get().Query(
- `SELECT guid, headline, lede, image_url, article_url, source, feed_hint, platforms, seen_at
- FROM stories WHERE classified = 0 AND source = ? ORDER BY seen_at ASC LIMIT 20`, source)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var stories []Story
- for rows.Next() {
- var s Story
- if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.FeedHint, &s.Platforms, &s.SeenAt); err != nil {
- return nil, err
- }
- stories = append(stories, s)
- }
- return stories, rows.Err()
-}
-
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
func GetStoryByGUID(guid string) (*Story, error) {
row := Get().QueryRow(
- `SELECT guid, headline, lede, image_url, article_url, source, feed_hint, platforms, channel, seen_at
+ `SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
FROM stories WHERE guid = ?`, guid)
var s Story
- if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.FeedHint, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
+ if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
return nil, err
}
return &s, nil
}
-// InsertRecentHeadline adds a headline to the dedup context window.
-func InsertRecentHeadline(guid, headline, source string) {
- exec("insert recent_headline",
- `INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`,
- guid, headline, source, nowUnix())
-}
-
-// GetRecentHeadlines returns the most recent headlines for LLM dedup context, capped at limit.
-func GetRecentHeadlines(limit int) ([]RecentHeadline, error) {
- rows, err := Get().Query(
- `SELECT guid, headline, source, seen_at FROM recent_headlines ORDER BY seen_at DESC LIMIT ?`, limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var headlines []RecentHeadline
- for rows.Next() {
- var h RecentHeadline
- if err := rows.Scan(&h.GUID, &h.Headline, &h.Source, &h.SeenAt); err != nil {
- return nil, err
- }
- headlines = append(headlines, h)
- }
- return headlines, rows.Err()
-}
-
-// InsertClassificationLog stores a classification result for tuning review.
-func InsertClassificationLog(guid, resultJSON string) {
- exec("insert classification_log",
- `INSERT OR REPLACE INTO classification_log (guid, result, logged_at) VALUES (?, ?, ?)`,
- guid, resultJSON, nowUnix())
-}
-
// InsertPostLog records that a story was posted to a channel.
// Uses OR IGNORE to prevent duplicate posts for the same story+channel.
func InsertPostLog(guid, channel, eventID, urlCanonical string) {
@@ -228,7 +173,7 @@ func InsertReaction(postGUID, channel, eventID, emoji, userID string, reactedAt
// row in post_log). Returns (nil, nil) when nothing qualifies.
func GetNewestPostableStory(source string) (*Story, error) {
row := Get().QueryRow(
- `SELECT guid, headline, lede, image_url, article_url, source, feed_hint, platforms, channel, seen_at
+ `SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
FROM stories
WHERE classified = 1
AND source = ?
@@ -238,7 +183,7 @@ func GetNewestPostableStory(source string) (*Story, error) {
ORDER BY seen_at DESC
LIMIT 1`, source)
var s Story
- if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.FeedHint, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
+ if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -253,7 +198,7 @@ func GetNewestPostableStory(source string) (*Story, error) {
// between ticks). Returns (nil, nil) when nothing qualifies.
func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
row := Get().QueryRow(
- `SELECT guid, headline, lede, image_url, article_url, source, feed_hint, platforms, channel, seen_at
+ `SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
FROM stories
WHERE classified = 1
AND channel = ?
@@ -261,7 +206,7 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
ORDER BY seen_at DESC
LIMIT 1`, channel)
var s Story
- if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.FeedHint, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
+ if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -275,7 +220,7 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
// channels (_discarded, _duplicate) are excluded.
func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) {
rows, err := Get().Query(
- `SELECT guid, headline, lede, image_url, article_url, source, feed_hint, platforms, channel, seen_at
+ `SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
FROM stories
WHERE classified = 1
AND channel = ?
@@ -288,7 +233,7 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error)
var out []Story
for rows.Next() {
var s Story
- if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.FeedHint, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
+ if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
return nil, err
}
out = append(out, s)
diff --git a/internal/storage/schema.go b/internal/storage/schema.go
index e7ca475..cf1b513 100644
--- a/internal/storage/schema.go
+++ b/internal/storage/schema.go
@@ -11,26 +11,12 @@ CREATE TABLE IF NOT EXISTS stories (
url_canonical TEXT,
headline_norm TEXT,
source TEXT NOT NULL,
- feed_hint TEXT,
platforms TEXT,
channel TEXT,
classified INTEGER NOT NULL DEFAULT 0,
seen_at INTEGER NOT NULL
);
-CREATE TABLE IF NOT EXISTS recent_headlines (
- guid TEXT PRIMARY KEY,
- headline TEXT NOT NULL,
- source TEXT NOT NULL,
- seen_at INTEGER NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS classification_log (
- guid TEXT PRIMARY KEY,
- result TEXT NOT NULL,
- logged_at INTEGER NOT NULL
-);
-
CREATE TABLE IF NOT EXISTS post_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL,
diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go
index 496becb..6b39448 100644
--- a/internal/storage/storage_test.go
+++ b/internal/storage/storage_test.go
@@ -1,7 +1,6 @@
package storage
import (
- "fmt"
"path/filepath"
"testing"
)
@@ -48,7 +47,6 @@ func TestInsertStoryAndGUIDSeen(t *testing.T) {
Lede: "Test lede sentence.",
ArticleURL: "https://example.com/article",
Source: "Test Source",
- FeedHint: "tech",
SeenAt: 1700000000,
}
if err := InsertStory(s); err != nil {
@@ -79,63 +77,21 @@ func TestInsertStoryDuplicateGUID(t *testing.T) {
}
}
-func TestMarkClassifiedAndGetUnclassified(t *testing.T) {
+func TestMarkClassified(t *testing.T) {
setupTestDB(t)
InsertStory(&Story{
GUID: "g1", Headline: "H1", ArticleURL: "https://a.com", Source: "S", SeenAt: 1,
})
- InsertStory(&Story{
- GUID: "g2", Headline: "H2", ArticleURL: "https://b.com", Source: "S", SeenAt: 2,
- })
-
- unclassified, err := GetUnclassifiedStories("S")
- if err != nil {
- t.Fatal(err)
- }
- if len(unclassified) != 2 {
- t.Fatalf("unclassified = %d, want 2", len(unclassified))
- }
MarkClassified("g1", "tech", "[]")
- unclassified, err = GetUnclassifiedStories("S")
- if err != nil {
- t.Fatal(err)
- }
- if len(unclassified) != 1 {
- t.Fatalf("unclassified = %d, want 1", len(unclassified))
- }
- if unclassified[0].GUID != "g2" {
- t.Errorf("remaining = %q, want g2", unclassified[0].GUID)
- }
-}
-
-func TestRecentHeadlines(t *testing.T) {
- setupTestDB(t)
-
- // Insert with explicit timestamps to ensure ordering
- db := Get()
- db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`,
- "g1", "Headline One", "Source A", 1000)
- db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`,
- "g2", "Headline Two", "Source B", 2000)
- db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`,
- "g3", "Headline Three", "Source A", 3000)
-
- headlines, err := GetRecentHeadlines(2)
- if err != nil {
- t.Fatal(err)
- }
- if len(headlines) != 2 {
- t.Fatalf("headlines = %d, want 2", len(headlines))
- }
- // Should be most recent first
- if headlines[0].GUID != "g3" {
- t.Errorf("first = %q, want g3", headlines[0].GUID)
- }
- if headlines[1].GUID != "g2" {
- t.Errorf("second = %q, want g2", headlines[1].GUID)
+ var channel string
+ var classified int
+ Get().QueryRow(`SELECT channel, classified FROM stories WHERE guid = ?`, "g1").
+ Scan(&channel, &classified)
+ if channel != "tech" || classified != 1 {
+ t.Errorf("after MarkClassified: channel=%q classified=%d, want tech/1", channel, classified)
}
}
@@ -269,57 +225,6 @@ func TestInsertReaction_DuplicateIgnored(t *testing.T) {
}
}
-func TestGetUnclassifiedStories_ScopedBySource(t *testing.T) {
- setupTestDB(t)
-
- InsertStory(&Story{GUID: "g1", Headline: "H1", ArticleURL: "https://a.com", Source: "SourceA", SeenAt: 1})
- InsertStory(&Story{GUID: "g2", Headline: "H2", ArticleURL: "https://b.com", Source: "SourceB", SeenAt: 2})
- InsertStory(&Story{GUID: "g3", Headline: "H3", ArticleURL: "https://c.com", Source: "SourceA", SeenAt: 3})
-
- got, err := GetUnclassifiedStories("SourceA")
- if err != nil {
- t.Fatal(err)
- }
- if len(got) != 2 {
- t.Fatalf("expected 2 stories for SourceA, got %d", len(got))
- }
-
- got, err = GetUnclassifiedStories("SourceB")
- if err != nil {
- t.Fatal(err)
- }
- if len(got) != 1 {
- t.Fatalf("expected 1 story for SourceB, got %d", len(got))
- }
-
- got, err = GetUnclassifiedStories("SourceC")
- if err != nil {
- t.Fatal(err)
- }
- if len(got) != 0 {
- t.Fatalf("expected 0 stories for SourceC, got %d", len(got))
- }
-}
-
-func TestGetUnclassifiedStories_Limit20(t *testing.T) {
- setupTestDB(t)
-
- for i := 0; i < 30; i++ {
- InsertStory(&Story{
- GUID: fmt.Sprintf("g%d", i), Headline: fmt.Sprintf("H%d", i),
- ArticleURL: "https://a.com", Source: "S", SeenAt: int64(i),
- })
- }
-
- got, err := GetUnclassifiedStories("S")
- if err != nil {
- t.Fatal(err)
- }
- if len(got) != 20 {
- t.Fatalf("expected 20 (capped), got %d", len(got))
- }
-}
-
func TestRunMaintenance(t *testing.T) {
setupTestDB(t)
db := Get()
@@ -341,11 +246,7 @@ func TestRunMaintenance(t *testing.T) {
InsertReaction("old", "tech", "$r1", "👍", "@u:x", old)
InsertReaction("new", "tech", "$r2", "👍", "@u:x", now)
- // Insert old + recent headlines
- db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`, "old", "Old", "S", old)
- db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`, "new", "New", "S", now)
-
- RunMaintenance(24, 7)
+ RunMaintenance()
// Old stories should be pruned
var count int
@@ -363,11 +264,6 @@ func TestRunMaintenance(t *testing.T) {
if count != 1 {
t.Errorf("reactions: expected 1, got %d", count)
}
-
- db.QueryRow(`SELECT COUNT(*) FROM recent_headlines`).Scan(&count)
- if count != 1 {
- t.Errorf("recent_headlines: expected 1, got %d", count)
- }
}
func TestFTS5Search(t *testing.T) {
diff --git a/internal/storage/types.go b/internal/storage/types.go
index 05e97f3..88f1a22 100644
--- a/internal/storage/types.go
+++ b/internal/storage/types.go
@@ -2,26 +2,17 @@ package storage
// Story is the core record for an ingested news item.
type Story struct {
- ID int64
- GUID string
- Headline string
- Lede string
+ ID int64
+ GUID string
+ Headline string
+ Lede string
ImageURL string
ArticleURL string
URLCanonical string
HeadlineNorm string
Source string
- FeedHint string
- Platforms string // JSON array e.g. '["nintendo-switch","multi"]'
- Channel string
- Classified bool
- SeenAt int64
-}
-
-// RecentHeadline is a lightweight record for the LLM dedup context window.
-type RecentHeadline struct {
- GUID string
- Headline string
- Source string
- SeenAt int64
+ Platforms string // JSON array e.g. '["nintendo-switch","multi"]'
+ Channel string
+ Classified bool
+ SeenAt int64
}
diff --git a/main.go b/main.go
index 5837d28..35ae4e5 100644
--- a/main.go
+++ b/main.go
@@ -10,9 +10,7 @@ import (
"syscall"
"time"
- "pete/internal/classifier"
"pete/internal/config"
- "pete/internal/explainer"
"pete/internal/ingestion"
"pete/internal/matrix"
"pete/internal/poster"
@@ -43,7 +41,6 @@ func main() {
slog.Info("config loaded",
"sources", len(cfg.Sources),
"channels", len(cfg.Matrix.Channels),
- "ollama_model", cfg.Ollama.Model,
)
// Initialize database
@@ -72,17 +69,11 @@ func main() {
os.Exit(1)
}
- // Create Ollama client and classifier
- ollamaClient := classifier.NewOllamaClient(cfg.Ollama)
- cls := classifier.NewClassifier(ollamaClient, mx)
-
// Create post queue
queue := poster.NewQueue(cfg.Posting, mx)
- // Wire reaction handler + ❓ → summary hook
+ // Wire reaction handler
mx.SetReactionHandler(poster.HandleReaction)
- exp := explainer.New(mx, ollamaClient)
- poster.SetReactionCallback(exp.Handle)
// Wire !post command: force-publish next queued story for the room's channel.
mx.SetMessageHandler(func(channel string, roomID id.RoomID, eventID id.EventID, sender id.UserID, body string) {
@@ -142,70 +133,37 @@ func main() {
roundRobinMode := cfg.Posting.RoundRobin.Enabled
- // Build the pipeline callback: classify → enqueue (or just classify+store
- // when round-robin mode is enabled; the scheduler does the enqueueing).
- processItem := func(ctx context.Context, item *ingestion.FeedItem) {
- result, err := cls.Classify(ctx, item)
- if err != nil {
- slog.Error("classification failed, will retry next cycle",
- "guid", item.GUID,
- "err", err,
- )
- return // story stays unclassified for retry
- }
-
- // Add to recent headlines for future dedup
- storage.InsertRecentHeadline(item.GUID, item.Headline, item.Source)
-
- if !result.ShouldPost {
- // Mark classified with sentinel channel so it's not retried
+ // Pipeline callback: route the story to its direct_route channel, then
+ // either enqueue immediately or leave it for the round-robin scheduler.
+ processItem := func(_ context.Context, item *ingestion.FeedItem) {
+ if item.DirectRoute == "" {
+ slog.Error("item has no direct_route, skipping", "guid", item.GUID, "source", item.Source)
storage.MarkClassified(item.GUID, "_discarded", "[]")
- slog.Info("story gated out",
- "guid", item.GUID,
- "rationale", result.Rationale,
- )
return
}
- // Mark classified with actual channel
- platforms := storage.MarshalPlatforms(result.Platforms)
- storage.MarkClassified(item.GUID, result.Channel, platforms)
-
- if result.DuplicateOf != nil {
- // Overwrite with sentinel so the round-robin scheduler doesn't pick
- // this up later. Immediate-mode posting already returned above.
- storage.MarkClassified(item.GUID, "_duplicate", "[]")
- slog.Info("story deduplicated",
- "guid", item.GUID,
- "duplicate_of", *result.DuplicateOf,
- )
- return
- }
+ channel := item.DirectRoute
+ storage.MarkClassified(item.GUID, channel, "[]")
if roundRobinMode {
- // Story stays in DB classified+postable; the scheduler will pick
- // it up on the next rotation tick.
- slog.Info("story classified, awaiting round-robin tick",
- "guid", item.GUID, "source", item.Source, "channel", result.Channel)
+ slog.Info("story routed, awaiting round-robin tick",
+ "guid", item.GUID, "source", item.Source, "channel", channel)
return
}
- // Validate image
imageURL := ""
if item.ImageURL != "" && ingestion.ValidateImageURL(item.ImageURL) {
imageURL = item.ImageURL
}
- // Enqueue for posting
queue.Enqueue(poster.QueueItem{
GUID: item.GUID,
Headline: item.Headline,
Lede: item.Lede,
ImageURL: imageURL,
ArticleURL: item.ArticleURL,
- Source: item.Source,
- Channel: result.Channel,
- Platforms: result.Platforms,
+ Source: item.Source,
+ Channel: channel,
})
}
@@ -238,7 +196,7 @@ func main() {
}
// Run maintenance on startup
- storage.RunMaintenance(cfg.Storage.RecentWindowHours, cfg.Storage.ClassificationLogDays)
+ storage.RunMaintenance()
// Wait for shutdown signal
sig := <-sigCh
@@ -272,15 +230,13 @@ func runSeed(cfg *config.Config) {
Lede: item.Lede,
ImageURL: item.ImageURL,
ArticleURL: item.ArticleURL,
- Source: src.Name,
- FeedHint: src.FeedHint,
+ Source: src.Name,
Classified: true,
SeenAt: timeNow(),
}); err != nil {
slog.Error("seed: insert failed", "guid", item.GUID, "err", err)
continue
}
- storage.InsertRecentHeadline(item.GUID, item.Headline, src.Name)
total++
}
slog.Info("seed: source done", "source", src.Name, "items", len(items))
@@ -307,8 +263,6 @@ func runTest(cfg *config.Config, sourceName string) {
if len(items) > 0 {
item := items[0]
item.Source = s.Name
- item.FeedHint = s.FeedHint
- item.Tier = s.Tier
item.DirectRoute = s.DirectRoute
testItem = &item
src = s
@@ -326,10 +280,10 @@ func runTest(cfg *config.Config, sourceName string) {
"guid", testItem.GUID,
)
- // Determine channel
- channel := "politics" // default
- if testItem.DirectRoute != nil {
- channel = *testItem.DirectRoute
+ channel := testItem.DirectRoute
+ if channel == "" {
+ slog.Error("test: source has no direct_route", "source", src.Name)
+ os.Exit(1)
}
// Validate image
@@ -351,7 +305,7 @@ func runTest(cfg *config.Config, sourceName string) {
Headline: testItem.Headline,
ArticleURL: testItem.ArticleURL,
Lede: testItem.Lede,
- Source: testItem.Source,
+ Source: testItem.Source,
Channel: channel,
}
diff --git a/main_test.go b/main_test.go
index 7dbd418..7928575 100644
--- a/main_test.go
+++ b/main_test.go
@@ -58,7 +58,7 @@ func TestRunSeed_IngestsStories(t *testing.T) {
FeedURL: ts.URL,
Tier: 1,
PollIntervalMinutes: 20,
- FeedHint: "tech",
+ DirectRoute: "tech",
Enabled: true,
},
},
@@ -74,13 +74,11 @@ func TestRunSeed_IngestsStories(t *testing.T) {
}
}
- // Should have no unclassified stories (seed marks classified=true)
- unclassified, err := storage.GetUnclassifiedStories("Test Feed")
- if err != nil {
- t.Fatal(err)
- }
- if len(unclassified) != 0 {
- t.Errorf("expected 0 unclassified after seed, got %d", len(unclassified))
+ // Seed marks rows classified=1; confirm none remain unclassified.
+ var unclassified int
+ storage.Get().QueryRow(`SELECT COUNT(*) FROM stories WHERE classified = 0`).Scan(&unclassified)
+ if unclassified != 0 {
+ t.Errorf("expected 0 unclassified after seed, got %d", unclassified)
}
}
@@ -96,7 +94,7 @@ func TestRunSeed_Idempotent(t *testing.T) {
FeedURL: ts.URL,
Tier: 1,
PollIntervalMinutes: 20,
- FeedHint: "tech",
+ DirectRoute: "tech",
Enabled: true,
},
},
@@ -139,35 +137,6 @@ func TestRunSeed_SkipsDisabledSources(t *testing.T) {
}
}
-func TestRunSeed_RecentHeadlinesPopulated(t *testing.T) {
- setupTestDB(t)
- ts := serveFeed(t, 2)
- defer ts.Close()
-
- cfg := &config.Config{
- Sources: []config.SourceConfig{
- {
- Name: "Test Feed",
- FeedURL: ts.URL,
- Tier: 1,
- PollIntervalMinutes: 20,
- FeedHint: "tech",
- Enabled: true,
- },
- },
- }
-
- runSeed(cfg)
-
- headlines, err := storage.GetRecentHeadlines(10)
- if err != nil {
- t.Fatal(err)
- }
- if len(headlines) != 2 {
- t.Errorf("expected 2 recent headlines after seed, got %d", len(headlines))
- }
-}
-
func TestRunSeed_BadFeedURL(t *testing.T) {
setupTestDB(t)