Compare commits

..

5 Commits

Author SHA1 Message Date
prosolis
5ea64c1d32 Paywall signal detection, sqlite driver registration, /img route fix
- Detect explicit paywall markers (article:content_tier meta, JSON-LD
  isAccessibleForFree) so metered articles fall back to Wayback even
  when body length is above threshold
- Blank-import go.mau.fi/util/dbutil/litestream so the sqlite3-fk-wal
  driver mautrix's cryptohelper depends on is registered
- Fix /img route: Go ServeMux requires {wildcard} to be a whole segment,
  so capture {name} and strip the .avif suffix in the handler
2026-05-25 07:50:36 -07:00
prosolis
bddd15f7d1 Web UI: music channel, posted-to-Matrix glow, redesigned index, AVIF thumbnails
- Add music as a fourth channel (nav + theme color)
- Glowing themed border on cards that have been posted to Matrix
- Replace per-channel index sections with: "Pete just posted" strip,
  channel dashboard (last post, 24h count, totals), unified latest feed
- /img proxy: SSRF-guarded thumbnail re-encoder that resizes to 800px
  and runs avifenc -q 45, cached under data/img-cache
2026-05-24 23:07:17 -07:00
prosolis
0111a1b06d Local mode, Makefile, image fixes, Pete avatar
- Add -local flag: web/RSS-only mode that skips Matrix login and posting,
  so the web UI can be exercised against live feeds without credentials
- Add Makefile (build/local/seed/test/clean) that handles Tailwind +
  go build in one shot
- Fix Guardian thumbnails: NormalizeImageURL was rewriting width=1200
  onto signed i.guim.co.uk URLs, invalidating the s= signature and
  returning 401. Leave signed URLs alone and pick the widest
  media:content variant up front instead
- Use pete.avif as the header logo, favicon, and footer mark; drop the
  unused leaf.svg
2026-05-24 22:43:03 -07:00
prosolis
9d5db63c56 Switch config from YAML to TOML 2026-05-24 22:11:36 -07:00
prosolis
e88483526d Rip out Ollama: direct_route only, no LLM layer
Pete moves to a remote host without Ollama access. Every source must
declare a direct_route channel; the classifier, explainer, semantic
dedup, !explain summaries, feed_hint, and the recent_headlines /
classification_log tables are gone. Deterministic dedup (canonical URL,
headline_norm, per-channel cooldown) remains.
2026-05-24 22:07:13 -07:00
44 changed files with 1006 additions and 1939 deletions

1
.gitignore vendored
View File

@@ -3,4 +3,5 @@ data/
.env .env
pete pete
config.yaml config.yaml
config.toml
node_modules/ node_modules/

View File

@@ -20,4 +20,4 @@ WORKDIR /app
COPY --from=builder /app/pete . COPY --from=builder /app/pete .
VOLUME /app/data VOLUME /app/data
EXPOSE 8080 EXPOSE 8080
CMD ["./pete", "-config", "/app/config.yaml"] CMD ["./pete", "-config", "/app/config.toml"]

25
Makefile Normal file
View File

@@ -0,0 +1,25 @@
.PHONY: build css go-build local seed test clean
build: css go-build
css: node_modules
npm run build:css
node_modules: package.json package-lock.json
npm install
@touch node_modules
go-build:
go build -tags goolm -o pete .
local: build
./pete -local
seed: build
./pete -seed
test:
go test ./...
clean:
rm -f pete internal/web/static/css/output.css

View File

@@ -1,16 +1,15 @@
# Pete # 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 ## Features
- **RSS ingestion** from configurable sources (Guardian, Ars Technica) with per-source polling intervals - **RSS ingestion** from configurable sources (Guardian, Ars Technica, Time Extension, …) 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 - **Direct routing** — every source declares a `direct_route` channel; Pete posts there. No LLM, no classification step
- **Semantic deduplication** — GUID exact match + LLM-based cross-source duplicate detection - **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) - **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 - **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 - **Reaction tracking** — records emoji reactions on posts
- **`!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
- **`!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 - **`!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 - **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 - **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 | | Channel | Purpose |
|---|---| |---|---|
| `tech` | Technology news, product/industry stories | | `tech` | Technology news, product/industry stories |
| `politics` | Political, policy, current events (default/catch-all) | | `politics` | Political, policy, current events |
| `gaming` | Gaming news, releases, platform announcements (no active sources yet) | | `gaming` | Gaming news, releases, platform announcements |
## Requirements ## Requirements
- Go 1.25+ - Go 1.25+
- [Ollama](https://ollama.ai) running locally or on a reachable host
- A Matrix account for Pete with access to target rooms - A Matrix account for Pete with access to target rooms
- SQLite (bundled via pure Go driver, no CGo) - SQLite (bundled via pure Go driver, no CGo)
- No CGo dependencies — E2EE uses pure Go crypto (goolm) via mautrix v0.28 - No CGo dependencies — E2EE uses pure Go crypto (goolm) via mautrix v0.28
@@ -41,17 +39,17 @@ git clone <repo-url> && cd pete
go build -tags goolm . go build -tags goolm .
# Create config from example # Create config from example
cp config.example.yaml config.yaml cp config.example.yaml config.toml
# Edit config.yaml — fill in Matrix credentials, room IDs, Ollama endpoint # Edit config.toml — fill in Matrix credentials, room IDs, and a direct_route for every source
# First run: seed current feed items as seen (prevents flood) # First run: seed current feed items as seen (prevents flood)
./pete -config config.yaml -seed ./pete -config config.toml -seed
# Post one story to verify the pipeline # Post one story to verify the pipeline
./pete -config config.yaml -test ./pete -config config.toml -test
# Run # Run
./pete -config config.yaml ./pete -config config.toml
``` ```
## Configuration ## Configuration
@@ -59,13 +57,12 @@ cp config.example.yaml config.yaml
See `config.example.yaml` for the full structure. Key sections: See `config.example.yaml` for the full structure. Key sections:
- `matrix` — homeserver, credentials, channel room IDs, optional admin room - `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 - `posting` — rate limiting (min interval, burst cap, daily cap), optional `round_robin` block
- `storage` — database path, retention windows - `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) - `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. Environment variables can be referenced with `${VAR}` syntax in the TOML.
### Web UI ### Web UI
@@ -86,7 +83,7 @@ Front the server with a reverse proxy (Caddy, nginx, Traefik) terminating TLS fo
### Round-robin mode ### 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. 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.
@@ -94,9 +91,10 @@ With a 4-hour cadence Pete will post at most 6 stories/day, so set `posting.dail
| Flag | Description | | Flag | Description |
|---|---| |---|---|
| `-config <path>` | Path to config file (default: `config.yaml`) | | `-config <path>` | Path to config file (default: `config.toml`) |
| `-seed` | Ingest all current feed items as seen without posting, then exit | | `-seed` | Ingest all current feed items as seen without posting, then exit |
| `-test` | Post one story to verify the full pipeline, then exit | | `-test` | Post one story to verify the full pipeline, then exit |
| `-local` | Web/RSS-only mode: poll feeds and serve the web UI on `web.listen_addr`. Skips Matrix login and posting — useful for local testing |
## Docker ## Docker
@@ -109,15 +107,9 @@ Set `PETE_PASSWORD` in your environment or a `.env` file for the Matrix password
## Architecture ## Architecture
``` ```
RSS Feed → Poller → GUID Dedup → Canonical/Headline Dedup → Article Fetch → Store → Classifier → Semantic Dedup → Image Validate → Queue → Matrix RSS Feed → Poller → GUID/Canonical/Headline Dedup → Article Fetch → Store → Route by direct_route → Image Validate → Queue → Matrix
Paywall? → Wayback Direct Route (no LLM) Paywall? → Wayback snapshot
or
Ollama /api/chat (Tier 1 routing)
or
Keyword Gating → Ollama (Tier 2)
Posted message ← ❓ reaction → Explainer → Article Fetch → Ollama summary → Threaded reply
``` ```
### Packages ### Packages
@@ -126,11 +118,10 @@ Posted message ← ❓ reaction → Explainer → Article Fetch → Ollama summa
|---|---| |---|---|
| `internal/config` | YAML config loading with `${ENV}` expansion | | `internal/config` | YAML config loading with `${ENV}` expansion |
| `internal/storage` | SQLite with WAL, FTS5, all queries | | `internal/storage` | SQLite with WAL, FTS5, all queries |
| `internal/ingestion` | Per-source RSS polling, feed parsing, image validation | | `internal/ingestion` | Per-source RSS polling, feed parsing, image validation, paywall detection |
| `internal/classifier` | Ollama client, Tier 1/2 prompts, JSON repair, keyword gating | | `internal/dedup` | Canonical URL + headline normalization helpers |
| `internal/matrix` | Password auth with device persistence, posting, threaded replies, reaction + message listener | | `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/poster` | Per-channel metered release queue, reaction tracking |
| `internal/explainer` | ❓-reaction → article fetch → Ollama summary → threaded reply |
| `internal/scheduler` | Round-robin posting scheduler: paced rotation across channels when enabled | | `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 | | `internal/web` | Read-only HTTP UI: Tailwind templates, day/night palette, per-channel feed pages |
@@ -143,8 +134,6 @@ Lede text from feed
`source name` `source name`
``` ```
Gaming stories append platform tags: `` `source` · `nintendo-switch` · `pc` ``
## Testing ## Testing
```bash ```bash

75
config.example.toml Normal file
View File

@@ -0,0 +1,75 @@
[matrix]
homeserver = "https://matrix.example.org"
user_id = "@pete:matrix.example.org"
password = "${PETE_PASSWORD}"
pickle_key = "${PETE_PICKLE_KEY}"
display_name = "Pete"
data_dir = "./data"
admin_room = "!adminroomid:matrix.example.org"
[matrix.channels]
tech = "!techroomid:matrix.example.org"
politics = "!politicsroomid:matrix.example.org"
gaming = "!gamingroomid:matrix.example.org"
[posting]
min_interval_seconds = 300
burst_cap_count = 3
burst_cap_window_seconds = 1800
daily_cap_total = 5 # hard global cap across ALL channels (rolling 24h); 0 disables
[posting.round_robin]
enabled = false # when true, replaces immediate posting with paced rotation
interval_hours = 4 # one story per N hours, cycling through channels in sorted order
[storage]
db_path = "./data/pete.db"
recent_window_hours = 24
[web]
enabled = true
listen_addr = ":8080"
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
direct_route = "politics"
enabled = true
[[sources]]
name = "The Guardian — Politics"
feed_url = "https://www.theguardian.com/politics/rss"
tier = 1
poll_interval_minutes = 20
direct_route = "politics"
enabled = true
[[sources]]
name = "The Guardian — US News"
feed_url = "https://www.theguardian.com/us-news/rss"
tier = 1
poll_interval_minutes = 20
direct_route = "politics"
enabled = true
[[sources]]
name = "Ars Technica"
feed_url = "https://feeds.arstechnica.com/arstechnica/index"
tier = 1
poll_interval_minutes = 20
direct_route = "tech"
enabled = true
[[sources]]
name = "Time Extension"
feed_url = "https://www.timeextension.com/feeds/news"
tier = 1
poll_interval_minutes = 20
direct_route = "gaming"
enabled = true

View File

@@ -1,94 +0,0 @@
matrix:
homeserver: "https://matrix.example.org"
user_id: "@pete:matrix.example.org"
password: "${PETE_PASSWORD}"
pickle_key: "${PETE_PICKLE_KEY}"
display_name: "Pete"
data_dir: "./data"
admin_room: "!adminroomid:matrix.example.org"
channels:
tech: "!techroomid:matrix.example.org"
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
burst_cap_window_seconds: 1800
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
storage:
db_path: "./data/pete.db"
recent_window_hours: 24
classification_log_days: 7
web:
enabled: true
listen_addr: ":8080"
site_title: "Pete"
base_url: "https://news.parodia.dev"
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
- name: "The Guardian — US News"
feed_url: "https://www.theguardian.com/us-news/rss"
tier: 1
poll_interval_minutes: 20
feed_hint: "politics"
direct_route: "politics"
enabled: true
- name: "Ars Technica"
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
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

View File

@@ -8,4 +8,4 @@ services:
- "${PETE_WEB_PORT:-8080}:8080" - "${PETE_WEB_PORT:-8080}:8080"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./config.yaml:/app/config.yaml:ro - ./config.toml:/app/config.toml:ro

3
go.mod
View File

@@ -3,9 +3,9 @@ module pete
go 1.25.0 go 1.25.0
require ( require (
github.com/BurntSushi/toml v1.6.0
github.com/PuerkitoBio/goquery v1.12.0 github.com/PuerkitoBio/goquery v1.12.0
github.com/mmcdole/gofeed v1.3.0 github.com/mmcdole/gofeed v1.3.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.28.0 maunium.net/go/mautrix v0.28.0
modernc.org/sqlite v1.50.1 modernc.org/sqlite v1.50.1
) )
@@ -33,6 +33,7 @@ require (
go.mau.fi/util v0.9.9 // indirect go.mau.fi/util v0.9.9 // indirect
golang.org/x/crypto v0.52.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect

6
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
@@ -74,6 +76,8 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -143,8 +147,6 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ= maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)<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

@@ -1,156 +0,0 @@
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

@@ -1,110 +0,0 @@
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()
}

View File

@@ -6,79 +6,70 @@ import (
"os" "os"
"regexp" "regexp"
"gopkg.in/yaml.v3" "github.com/BurntSushi/toml"
) )
// envBracketRe matches only ${VAR} style env references, not bare $VAR. // envBracketRe matches only ${VAR} style env references, not bare $VAR.
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`) var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
type Config struct { type Config struct {
Matrix MatrixConfig `yaml:"matrix"` Matrix MatrixConfig `toml:"matrix"`
Ollama OllamaConfig `yaml:"ollama"` Posting PostingConfig `toml:"posting"`
Posting PostingConfig `yaml:"posting"` Storage StorageConfig `toml:"storage"`
Storage StorageConfig `yaml:"storage"` Web WebConfig `toml:"web"`
Web WebConfig `yaml:"web"` Sources []SourceConfig `toml:"sources"`
Sources []SourceConfig `yaml:"sources"`
} }
// WebConfig controls the read-only HTTP interface (news.parodia.dev style). // WebConfig controls the read-only HTTP interface (news.parodia.dev style).
type WebConfig struct { type WebConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `toml:"enabled"`
ListenAddr string `yaml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080" ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
SiteTitle string `yaml:"site_title"` // display name in the header SiteTitle string `toml:"site_title"` // display name in the header
BaseURL string `yaml:"base_url"` // public URL (used in metadata only) BaseURL string `toml:"base_url"` // public URL (used in metadata only)
} }
type MatrixConfig struct { type MatrixConfig struct {
Homeserver string `yaml:"homeserver"` Homeserver string `toml:"homeserver"`
UserID string `yaml:"user_id"` UserID string `toml:"user_id"`
Password string `yaml:"password"` Password string `toml:"password"`
PickleKey string `yaml:"pickle_key"` PickleKey string `toml:"pickle_key"`
DisplayName string `yaml:"display_name"` DisplayName string `toml:"display_name"`
DataDir string `yaml:"data_dir"` DataDir string `toml:"data_dir"`
AdminRoom string `yaml:"admin_room"` AdminRoom string `toml:"admin_room"`
Channels map[string]string `yaml:"channels"` Channels map[string]string `toml:"channels"`
}
type OllamaConfig struct {
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
TimeoutSeconds int `yaml:"timeout_seconds"`
} }
type PostingConfig struct { type PostingConfig struct {
MinIntervalSeconds int `yaml:"min_interval_seconds"` MinIntervalSeconds int `toml:"min_interval_seconds"`
BurstCapCount int `yaml:"burst_cap_count"` BurstCapCount int `toml:"burst_cap_count"`
BurstCapWindowSeconds int `yaml:"burst_cap_window_seconds"` BurstCapWindowSeconds int `toml:"burst_cap_window_seconds"`
DedupCooldownHours int `yaml:"dedup_cooldown_hours"` DedupCooldownHours int `toml:"dedup_cooldown_hours"`
// DailyCapTotal is the hard global cap on posts across ALL channels in a // DailyCapTotal is the hard global cap on posts across ALL channels in a
// rolling 24-hour window. 0 disables the cap. // rolling 24-hour window. 0 disables the cap.
DailyCapTotal int `yaml:"daily_cap_total"` DailyCapTotal int `toml:"daily_cap_total"`
RoundRobin RoundRobinConfig `yaml:"round_robin"` RoundRobin RoundRobinConfig `toml:"round_robin"`
} }
// RoundRobinConfig switches Pete from immediate-on-classify posting to a // RoundRobinConfig switches Pete from immediate-on-classify posting to a
// paced rotation: one story per IntervalHours, picking the next source in // paced rotation: one story per IntervalHours, picking the next channel in
// config order that has a postable story (skip-and-advance, newest-first). // rotation order that has a postable story (skip-and-advance, newest-first).
type RoundRobinConfig struct { type RoundRobinConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `toml:"enabled"`
IntervalHours int `yaml:"interval_hours"` IntervalHours int `toml:"interval_hours"`
} }
type StorageConfig struct { type StorageConfig struct {
DBPath string `yaml:"db_path"` DBPath string `toml:"db_path"`
RecentWindowHours int `yaml:"recent_window_hours"` RecentWindowHours int `toml:"recent_window_hours"`
ClassificationLogDays int `yaml:"classification_log_days"`
} }
type SourceConfig struct { type SourceConfig struct {
Name string `yaml:"name"` Name string `toml:"name"`
FeedURL string `yaml:"feed_url"` FeedURL string `toml:"feed_url"`
Tier int `yaml:"tier"` Tier int `toml:"tier"`
PollIntervalMinutes int `yaml:"poll_interval_minutes"` PollIntervalMinutes int `toml:"poll_interval_minutes"`
FeedHint string `yaml:"feed_hint"` DirectRoute string `toml:"direct_route"`
DirectRoute *string `yaml:"direct_route"` Enabled bool `toml:"enabled"`
Enabled bool `yaml:"enabled"`
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
@@ -99,7 +90,7 @@ func Load(path string) (*Config, error) {
}) })
var cfg Config var cfg Config
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { if _, err := toml.Decode(expanded, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err) return nil, fmt.Errorf("parse config: %w", err)
} }
@@ -125,12 +116,6 @@ func (c *Config) validate() error {
if len(c.Matrix.Channels) == 0 { if len(c.Matrix.Channels) == 0 {
return fmt.Errorf("matrix.channels must have at least one entry") return fmt.Errorf("matrix.channels must have at least one entry")
} }
if c.Ollama.BaseURL == "" {
return fmt.Errorf("ollama.base_url is required")
}
if c.Ollama.Model == "" {
return fmt.Errorf("ollama.model is required")
}
if c.Storage.DBPath == "" { if c.Storage.DBPath == "" {
return fmt.Errorf("storage.db_path is required") return fmt.Errorf("storage.db_path is required")
} }
@@ -148,6 +133,15 @@ func (c *Config) validate() error {
if s.PollIntervalMinutes <= 0 { if s.PollIntervalMinutes <= 0 {
return fmt.Errorf("sources[%d].poll_interval_minutes must be > 0", i) return fmt.Errorf("sources[%d].poll_interval_minutes must be > 0", i)
} }
if !s.Enabled {
continue
}
if s.DirectRoute == "" {
return fmt.Errorf("sources[%d] (%s): direct_route is required for enabled sources", i, s.Name)
}
if _, ok := c.Matrix.Channels[s.DirectRoute]; !ok {
return fmt.Errorf("sources[%d] (%s): direct_route %q is not a configured matrix.channels key", i, s.Name, s.DirectRoute)
}
} }
return nil return nil
@@ -163,9 +157,6 @@ func (c *Config) applyDefaults() {
if c.Matrix.PickleKey == "" { if c.Matrix.PickleKey == "" {
c.Matrix.PickleKey = "pete_pickle_key" c.Matrix.PickleKey = "pete_pickle_key"
} }
if c.Ollama.TimeoutSeconds == 0 {
c.Ollama.TimeoutSeconds = 30
}
if c.Posting.MinIntervalSeconds == 0 { if c.Posting.MinIntervalSeconds == 0 {
c.Posting.MinIntervalSeconds = 300 c.Posting.MinIntervalSeconds = 300
} }
@@ -184,9 +175,6 @@ func (c *Config) applyDefaults() {
if c.Storage.RecentWindowHours == 0 { if c.Storage.RecentWindowHours == 0 {
c.Storage.RecentWindowHours = 24 c.Storage.RecentWindowHours = 24
} }
if c.Storage.ClassificationLogDays == 0 {
c.Storage.ClassificationLogDays = 7
}
if c.Web.ListenAddr == "" { if c.Web.ListenAddr == "" {
c.Web.ListenAddr = ":8080" c.Web.ListenAddr = ":8080"
} }

View File

@@ -8,30 +8,29 @@ import (
// validMatrix is a reusable matrix config block for tests. // validMatrix is a reusable matrix config block for tests.
const validMatrix = ` const validMatrix = `
matrix: [matrix]
homeserver: "https://matrix.example.org" homeserver = "https://matrix.example.org"
user_id: "@pete:example.org" user_id = "@pete:example.org"
password: "testpass" password = "testpass"
channels: [matrix.channels]
tech: "!tech:example.org" tech = "!tech:example.org"
politics: "!politics:example.org" politics = "!politics:example.org"
` `
func TestLoadValid(t *testing.T) { func TestLoadValid(t *testing.T) {
yaml := validMatrix + ` toml := validMatrix + `
ollama: [storage]
base_url: "http://localhost:11434" db_path = "/tmp/test.db"
model: "gemma4:27b"
timeout_seconds: 60 [[sources]]
storage: name = "Test Source"
db_path: "/tmp/test.db" feed_url = "https://example.com/rss"
sources: tier = 1
- name: "Test Source" poll_interval_minutes = 20
feed_url: "https://example.com/rss" direct_route = "tech"
tier: 1 enabled = true
enabled: true
` `
cfg := loadFromString(t, yaml) cfg := loadFromString(t, toml)
if cfg.Matrix.Homeserver != "https://matrix.example.org" { if cfg.Matrix.Homeserver != "https://matrix.example.org" {
t.Errorf("homeserver = %q", cfg.Matrix.Homeserver) t.Errorf("homeserver = %q", cfg.Matrix.Homeserver)
@@ -39,35 +38,32 @@ sources:
if cfg.Matrix.UserID != "@pete:example.org" { if cfg.Matrix.UserID != "@pete:example.org" {
t.Errorf("user_id = %q", cfg.Matrix.UserID) t.Errorf("user_id = %q", cfg.Matrix.UserID)
} }
if cfg.Ollama.TimeoutSeconds != 60 {
t.Errorf("timeout = %d, want 60", cfg.Ollama.TimeoutSeconds)
}
if len(cfg.Sources) != 1 { if len(cfg.Sources) != 1 {
t.Fatalf("sources = %d, want 1", len(cfg.Sources)) t.Fatalf("sources = %d, want 1", len(cfg.Sources))
} }
if cfg.Sources[0].Name != "Test Source" { if cfg.Sources[0].Name != "Test Source" {
t.Errorf("source name = %q", cfg.Sources[0].Name) t.Errorf("source name = %q", cfg.Sources[0].Name)
} }
if cfg.Sources[0].DirectRoute != "tech" {
t.Errorf("direct_route = %q, want tech", cfg.Sources[0].DirectRoute)
}
} }
func TestEnvVarExpansion(t *testing.T) { func TestEnvVarExpansion(t *testing.T) {
t.Setenv("TEST_PETE_PASS", "secret_pass_123") t.Setenv("TEST_PETE_PASS", "secret_pass_123")
yaml := ` toml := `
matrix: [matrix]
homeserver: "https://matrix.example.org" homeserver = "https://matrix.example.org"
user_id: "@pete:example.org" user_id = "@pete:example.org"
password: "${TEST_PETE_PASS}" password = "${TEST_PETE_PASS}"
channels: [matrix.channels]
tech: "!tech:example.org" tech = "!tech:example.org"
ollama:
base_url: "http://localhost:11434" [storage]
model: "gemma4:27b" db_path = "/tmp/test.db"
storage:
db_path: "/tmp/test.db"
sources: []
` `
cfg := loadFromString(t, yaml) cfg := loadFromString(t, toml)
if cfg.Matrix.Password != "secret_pass_123" { if cfg.Matrix.Password != "secret_pass_123" {
t.Errorf("password = %q, want secret_pass_123", cfg.Matrix.Password) t.Errorf("password = %q, want secret_pass_123", cfg.Matrix.Password)
@@ -75,19 +71,19 @@ sources: []
} }
func TestDefaults(t *testing.T) { func TestDefaults(t *testing.T) {
yaml := validMatrix + ` toml := validMatrix + `
ollama: [storage]
base_url: "http://localhost:11434" db_path = "/tmp/test.db"
model: "gemma4:27b"
storage: [[sources]]
db_path: "/tmp/test.db" name = "S"
sources: feed_url = "https://example.com/rss"
- name: "S" tier = 1
feed_url: "https://example.com/rss" poll_interval_minutes = 20
tier: 1 direct_route = "tech"
enabled: true enabled = true
` `
cfg := loadFromString(t, yaml) cfg := loadFromString(t, toml)
if cfg.Matrix.DataDir != "./data" { if cfg.Matrix.DataDir != "./data" {
t.Errorf("data_dir default = %q, want ./data", cfg.Matrix.DataDir) t.Errorf("data_dir default = %q, want ./data", cfg.Matrix.DataDir)
@@ -95,9 +91,6 @@ sources:
if cfg.Matrix.DisplayName != "Pete" { if cfg.Matrix.DisplayName != "Pete" {
t.Errorf("display_name default = %q, want Pete", cfg.Matrix.DisplayName) t.Errorf("display_name default = %q, want Pete", cfg.Matrix.DisplayName)
} }
if cfg.Ollama.TimeoutSeconds != 30 {
t.Errorf("timeout default = %d, want 30", cfg.Ollama.TimeoutSeconds)
}
if cfg.Posting.MinIntervalSeconds != 300 { if cfg.Posting.MinIntervalSeconds != 300 {
t.Errorf("min_interval default = %d, want 300", cfg.Posting.MinIntervalSeconds) t.Errorf("min_interval default = %d, want 300", cfg.Posting.MinIntervalSeconds)
} }
@@ -110,125 +103,130 @@ sources:
if cfg.Storage.RecentWindowHours != 24 { if cfg.Storage.RecentWindowHours != 24 {
t.Errorf("recent_window default = %d, want 24", cfg.Storage.RecentWindowHours) t.Errorf("recent_window default = %d, want 24", cfg.Storage.RecentWindowHours)
} }
if cfg.Storage.ClassificationLogDays != 7 {
t.Errorf("classification_log default = %d, want 7", cfg.Storage.ClassificationLogDays)
}
if cfg.Sources[0].PollIntervalMinutes != 20 {
t.Errorf("poll_interval default = %d, want 20", cfg.Sources[0].PollIntervalMinutes)
}
}
// shortMatrix returns a minimal valid matrix block for validation error tests.
func shortMatrix(overrides string) string {
base := `
matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
`
if overrides != "" {
return overrides
}
return base
} }
func TestValidationErrors(t *testing.T) { func TestValidationErrors(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
yaml string toml string
}{ }{
{"missing homeserver", ` {"missing homeserver", `
matrix: [matrix]
user_id: "@p:h" user_id = "@p:h"
password: "pw" password = "pw"
channels: {tech: "!t:e"} [matrix.channels]
ollama: {base_url: "http://l", model: "m"} tech = "!t:e"
storage: {db_path: "/tmp/t.db"} [storage]
db_path = "/tmp/t.db"
`}, `},
{"missing user_id", ` {"missing user_id", `
matrix: [matrix]
homeserver: "https://h" homeserver = "https://h"
password: "pw" password = "pw"
channels: {tech: "!t:e"} [matrix.channels]
ollama: {base_url: "http://l", model: "m"} tech = "!t:e"
storage: {db_path: "/tmp/t.db"} [storage]
db_path = "/tmp/t.db"
`}, `},
{"missing password", ` {"missing password", `
matrix: [matrix]
homeserver: "https://h" homeserver = "https://h"
user_id: "@p:h" user_id = "@p:h"
channels: {tech: "!t:e"} [matrix.channels]
ollama: {base_url: "http://l", model: "m"} tech = "!t:e"
storage: {db_path: "/tmp/t.db"} [storage]
db_path = "/tmp/t.db"
`}, `},
{"no channels", ` {"no channels", `
matrix: [matrix]
homeserver: "https://h" homeserver = "https://h"
user_id: "@p:h" user_id = "@p:h"
password: "pw" password = "pw"
ollama: {base_url: "http://l", model: "m"} [storage]
storage: {db_path: "/tmp/t.db"} db_path = "/tmp/t.db"
`},
{"missing ollama base_url", `
matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {model: "m"}
storage: {db_path: "/tmp/t.db"}
`},
{"missing ollama model", `
matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {base_url: "http://l"}
storage: {db_path: "/tmp/t.db"}
`}, `},
{"missing db_path", ` {"missing db_path", `
matrix: [matrix]
homeserver: "https://h" homeserver = "https://h"
user_id: "@p:h" user_id = "@p:h"
password: "pw" password = "pw"
channels: {tech: "!t:e"} [matrix.channels]
ollama: {base_url: "http://l", model: "m"} tech = "!t:e"
storage: {} [storage]
`}, `},
{"invalid tier", ` {"invalid tier", `
matrix: [matrix]
homeserver: "https://h" homeserver = "https://h"
user_id: "@p:h" user_id = "@p:h"
password: "pw" password = "pw"
channels: {tech: "!t:e"} [matrix.channels]
ollama: {base_url: "http://l", model: "m"} tech = "!t:e"
storage: {db_path: "/tmp/t.db"} [storage]
sources: db_path = "/tmp/t.db"
- name: "S" [[sources]]
feed_url: "https://e.com/rss" name = "S"
tier: 5 feed_url = "https://e.com/rss"
tier = 5
poll_interval_minutes = 20
direct_route = "tech"
enabled = true
`}, `},
{"missing source name", ` {"missing source name", `
matrix: [matrix]
homeserver: "https://h" homeserver = "https://h"
user_id: "@p:h" user_id = "@p:h"
password: "pw" password = "pw"
channels: {tech: "!t:e"} [matrix.channels]
ollama: {base_url: "http://l", model: "m"} tech = "!t:e"
storage: {db_path: "/tmp/t.db"} [storage]
sources: db_path = "/tmp/t.db"
- feed_url: "https://e.com/rss" [[sources]]
tier: 1 feed_url = "https://e.com/rss"
tier = 1
poll_interval_minutes = 20
direct_route = "tech"
enabled = true
`},
{"enabled source missing direct_route", `
[matrix]
homeserver = "https://h"
user_id = "@p:h"
password = "pw"
[matrix.channels]
tech = "!t:e"
[storage]
db_path = "/tmp/t.db"
[[sources]]
name = "S"
feed_url = "https://e.com/rss"
tier = 1
poll_interval_minutes = 20
enabled = true
`},
{"direct_route not in channels", `
[matrix]
homeserver = "https://h"
user_id = "@p:h"
password = "pw"
[matrix.channels]
tech = "!t:e"
[storage]
db_path = "/tmp/t.db"
[[sources]]
name = "S"
feed_url = "https://e.com/rss"
tier = 1
poll_interval_minutes = 20
direct_route = "gaming"
enabled = true
`}, `},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.yaml") path := filepath.Join(dir, "config.toml")
os.WriteFile(path, []byte(tt.yaml), 0o644) os.WriteFile(path, []byte(tt.toml), 0o644)
_, err := Load(path) _, err := Load(path)
if err == nil { if err == nil {
t.Error("expected validation error, got nil") t.Error("expected validation error, got nil")
@@ -237,45 +235,35 @@ sources:
} }
} }
func TestDirectRouteNullable(t *testing.T) { func TestDisabledSourceSkipsDirectRouteCheck(t *testing.T) {
yaml := ` // A disabled source without direct_route must not fail validation.
matrix: toml := `
homeserver: "https://h" [matrix]
user_id: "@p:h" homeserver = "https://h"
password: "pw" user_id = "@p:h"
channels: {tech: "!t:e"} password = "pw"
ollama: {base_url: "http://l", model: "m"} [matrix.channels]
storage: {db_path: "/tmp/t.db"} tech = "!t:e"
sources: [storage]
- name: "Direct" db_path = "/tmp/t.db"
feed_url: "https://e.com/rss" [[sources]]
tier: 1 name = "Off"
direct_route: "politics" feed_url = "https://e.com/rss"
enabled: true tier = 1
- name: "LLM" poll_interval_minutes = 20
feed_url: "https://e.com/rss2" enabled = false
tier: 1
direct_route: null
enabled: true
` `
cfg := loadFromString(t, yaml) cfg := loadFromString(t, toml)
if cfg.Sources[0].DirectRoute != "" {
if cfg.Sources[0].DirectRoute == nil { t.Errorf("expected empty direct_route, got %q", cfg.Sources[0].DirectRoute)
t.Fatal("direct source should have non-nil DirectRoute")
}
if *cfg.Sources[0].DirectRoute != "politics" {
t.Errorf("direct_route = %q, want politics", *cfg.Sources[0].DirectRoute)
}
if cfg.Sources[1].DirectRoute != nil {
t.Errorf("null source should have nil DirectRoute, got %v", cfg.Sources[1].DirectRoute)
} }
} }
func loadFromString(t *testing.T, yamlContent string) *Config { func loadFromString(t *testing.T, tomlContent string) *Config {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.yaml") path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte(yamlContent), 0o644); err != nil { if err := os.WriteFile(path, []byte(tomlContent), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
cfg, err := Load(path) cfg, err := Load(path)

View File

@@ -1,196 +0,0 @@
// Package explainer summarizes a posted story on demand: when a user reacts ❓
// on one of Pete's posts, Pete fetches the article body, asks Ollama for a
// 3-bullet TL;DR, and replies in a thread rooted at the original post.
package explainer
import (
"context"
"fmt"
"html"
"log/slog"
"strings"
"sync"
"time"
"pete/internal/classifier"
"pete/internal/ingestion"
"pete/internal/matrix"
"pete/internal/storage"
"maunium.net/go/mautrix/id"
)
// questionReactions is the set of reaction keys that trigger a summary.
// Matrix delivers the key verbatim; we accept several question-y glyphs plus
// plain "?" so users don't have to hunt for the exact red question mark.
var questionReactions = map[string]bool{
"❓": true, // U+2753 red question mark
"❓️": true, // U+2753 + VS16 (emoji presentation)
"❔": true, // U+2754 white question mark
"❔️": true, // U+2754 + VS16
"⁉": true, // U+2049 exclamation question mark
"⁉️": true, // U+2049 + VS16 (emoji presentation)
"🤔": true, // U+1F914 thinking face
"?": true, // plain ascii
"": true, // U+FF1F fullwidth question mark
}
// IsQuestionReaction reports whether a reaction key should trigger a summary.
func IsQuestionReaction(key string) bool {
return questionReactions[key]
}
// cooldown for repeat-explain on the same story (per process).
const explainCooldown = 5 * time.Minute
// Explainer holds the dependencies needed to produce a story summary.
type Explainer struct {
mx *matrix.Client
ollama *classifier.OllamaClient
mu sync.Mutex
lastSeen map[string]time.Time // guid -> last explanation time
}
// New builds an Explainer wired to the Matrix client and Ollama backend.
func New(mx *matrix.Client, ollama *classifier.OllamaClient) *Explainer {
return &Explainer{
mx: mx,
ollama: ollama,
lastSeen: make(map[string]time.Time),
}
}
// Handle is the entry point used as a callback from poster.HandleReaction.
// It runs synchronously — callers should invoke it in a goroutine.
func (e *Explainer) Handle(roomID id.RoomID, rootEventID id.EventID, guid, channel, emoji string) {
if !IsQuestionReaction(emoji) {
return
}
if !e.acquire(guid) {
slog.Debug("explain: skipping recent duplicate", "guid", guid)
return
}
story, err := storage.GetStoryByGUID(guid)
if err != nil || story == nil {
slog.Warn("explain: story lookup failed", "guid", guid, "err", err)
return
}
body := ingestion.FetchArticleBody(story.ArticleURL)
if body == "" {
// Couldn't get the body — fall back to lede so the user at least gets something.
body = story.Lede
}
if body == "" {
slog.Warn("explain: no body or lede available", "guid", guid, "url", story.ArticleURL)
_ = e.mx.PostThreadedReply(channel, rootEventID,
"Couldn't fetch the article body to summarize.",
"<em>Couldn't fetch the article body to summarize.</em>")
return
}
summary, err := e.summarize(story.Headline, body)
if err != nil {
slog.Error("explain: ollama failed", "guid", guid, "err", err)
_ = e.mx.PostThreadedReply(channel, rootEventID,
"Sorry, I couldn't generate a summary right now.",
"<em>Sorry, I couldn't generate a summary right now.</em>")
return
}
plain, htmlBody := formatSummary(summary)
if err := e.mx.PostThreadedReply(channel, rootEventID, plain, htmlBody); err != nil {
slog.Error("explain: send reply failed", "guid", guid, "err", err)
return
}
slog.Info("explain: summary posted", "guid", guid, "channel", channel)
}
// acquire returns true if this guid hasn't been explained within the cooldown.
// Updates the timestamp on success.
func (e *Explainer) acquire(guid string) bool {
e.mu.Lock()
defer e.mu.Unlock()
if t, ok := e.lastSeen[guid]; ok && time.Since(t) < explainCooldown {
return false
}
e.lastSeen[guid] = time.Now()
return true
}
const summarySystem = `You summarize news articles in exactly 3 short bullet points for a chat reader.
Rules:
- Output ONLY the 3 bullets, each on its own line, prefixed with "- ".
- No preamble, no headings, no closing remarks.
- Each bullet is one concise sentence (under 25 words).
- Stick strictly to facts in the article. Do not speculate.`
func (e *Explainer) summarize(headline, body string) (string, error) {
// Reaction-driven flow has no parent ctx; bound the LLM call ourselves.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
prompt := fmt.Sprintf("Headline: %s\n\nArticle body:\n%s", headline, body)
out, err := e.ollama.GenerateText(ctx, summarySystem, prompt)
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
// formatSummary converts the LLM's "- bullet\n- bullet" output into a Matrix
// plain + HTML pair. Any stray prefixes ("Summary:", "•", "*") are normalized
// to "-" bullets.
func formatSummary(raw string) (plain, htmlBody string) {
lines := strings.Split(raw, "\n")
var bullets []string
sawMarker := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Skip header-ish lines the model sometimes adds
low := strings.ToLower(line)
if strings.HasPrefix(low, "summary:") || strings.HasPrefix(low, "tl;dr") {
continue
}
// Normalize common bullet markers; only count this line if it had one.
hadMarker := false
for _, prefix := range []string{"- ", "* ", "• ", "·"} {
if strings.HasPrefix(line, prefix) {
line = strings.TrimSpace(line[len(prefix):])
hadMarker = true
break
}
}
if !hadMarker {
continue
}
sawMarker = true
if line == "" {
continue
}
bullets = append(bullets, line)
}
if !sawMarker || len(bullets) == 0 {
// Model produced something but we couldn't parse bullets — pass through.
return raw, "<pre>" + html.EscapeString(raw) + "</pre>"
}
var pb, hb strings.Builder
hb.WriteString("<ul>")
for i, b := range bullets {
if i > 0 {
pb.WriteByte('\n')
}
pb.WriteString("• ")
pb.WriteString(b)
hb.WriteString("<li>")
hb.WriteString(html.EscapeString(b))
hb.WriteString("</li>")
}
hb.WriteString("</ul>")
return pb.String(), hb.String()
}

View File

@@ -1,110 +0,0 @@
package explainer
import (
"strings"
"testing"
)
func TestFormatSummary(t *testing.T) {
cases := []struct {
name string
in string
wantPlain string
wantHTML string
}{
{
name: "dash bullets",
in: "- First point.\n- Second point.\n- Third point.",
wantPlain: "• First point.\n• Second point.\n• Third point.",
wantHTML: "<ul><li>First point.</li><li>Second point.</li><li>Third point.</li></ul>",
},
{
name: "asterisk bullets",
in: "* alpha\n* beta",
wantPlain: "• alpha\n• beta",
wantHTML: "<ul><li>alpha</li><li>beta</li></ul>",
},
{
name: "unicode bullets",
in: "• one\n• two",
wantPlain: "• one\n• two",
wantHTML: "<ul><li>one</li><li>two</li></ul>",
},
{
name: "blank lines ignored",
in: "- a\n\n- b\n\n\n- c",
wantPlain: "• a\n• b\n• c",
wantHTML: "<ul><li>a</li><li>b</li><li>c</li></ul>",
},
{
name: "summary header stripped",
in: "Summary:\n- first\n- second",
wantPlain: "• first\n• second",
wantHTML: "<ul><li>first</li><li>second</li></ul>",
},
{
name: "tldr header stripped",
in: "TL;DR of the article\n- key point\n- other point",
wantPlain: "• key point\n• other point",
wantHTML: "<ul><li>key point</li><li>other point</li></ul>",
},
{
name: "single bullet",
in: "- just one",
wantPlain: "• just one",
wantHTML: "<ul><li>just one</li></ul>",
},
{
name: "html escaped in bullets",
in: `- A "quote" & <tag>`,
wantPlain: `• A "quote" & <tag>`,
wantHTML: "<ul><li>A &#34;quote&#34; &amp; &lt;tag&gt;</li></ul>",
},
{
name: "leading whitespace trimmed",
in: " - first\n - second ",
wantPlain: "• first\n• second",
wantHTML: "<ul><li>first</li><li>second</li></ul>",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotPlain, gotHTML := formatSummary(c.in)
if gotPlain != c.wantPlain {
t.Errorf("plain:\n got %q\n want %q", gotPlain, c.wantPlain)
}
if gotHTML != c.wantHTML {
t.Errorf("html:\n got %q\n want %q", gotHTML, c.wantHTML)
}
})
}
}
func TestFormatSummary_NoBulletsPassthrough(t *testing.T) {
in := "Just a single paragraph with no bullets at all."
plain, htmlBody := formatSummary(in)
if plain != in {
t.Errorf("plain should pass through raw, got %q", plain)
}
if !strings.HasPrefix(htmlBody, "<pre>") || !strings.HasSuffix(htmlBody, "</pre>") {
t.Errorf("html should wrap raw in <pre>, 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)
}
}
}

View File

@@ -2,6 +2,7 @@ package ingestion
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -52,6 +53,7 @@ type ArticleMeta struct {
ImageURL string // og:image or twitter:image, absolute URL ImageURL string // og:image or twitter:image, absolute URL
BodyChars int // length of extracted visible body text BodyChars int // length of extracted visible body text
Fetched bool // true if we got an HTTP 200 with HTML Fetched bool // true if we got an HTTP 200 with HTML
Paywalled bool // true if the page explicitly declares gated access
} }
var articleClient = &http.Client{Timeout: 12 * time.Second} var articleClient = &http.Client{Timeout: 12 * time.Second}
@@ -92,9 +94,79 @@ func FetchArticleMeta(articleURL string) ArticleMeta {
ImageURL: extractOGImage(doc, articleURL), ImageURL: extractOGImage(doc, articleURL),
BodyChars: extractBodyChars(doc), BodyChars: extractBodyChars(doc),
Fetched: true, Fetched: true,
Paywalled: detectPaywall(doc),
} }
} }
// detectPaywall checks the page for explicit gating signals that publishers
// expose for Google News and crawlers. We treat the article as paywalled if:
// - <meta name="article:content_tier" content="metered|locked"> (Conde Nast,
// Hearst, many WordPress VIP sites including Wired)
// - JSON-LD with "isAccessibleForFree": false (schema.org standard, used by
// NYT, WaPo, Bloomberg, FT, WSJ, and most metered publishers)
func detectPaywall(doc *goquery.Document) bool {
tier, _ := doc.Find(`meta[name="article:content_tier"]`).First().Attr("content")
switch strings.ToLower(strings.TrimSpace(tier)) {
case "metered", "locked":
return true
}
gated := false
doc.Find(`script[type="application/ld+json"]`).EachWithBreak(func(_ int, s *goquery.Selection) bool {
if jsonLDDeclaresGated(s.Text()) {
gated = true
return false
}
return true
})
return gated
}
// jsonLDDeclaresGated returns true if the JSON-LD payload contains
// "isAccessibleForFree": false anywhere in its (possibly nested or arrayed)
// structure. Publishers vary wildly in shape, so we walk generically.
func jsonLDDeclaresGated(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return false
}
return walkJSONLDForGated(v)
}
func walkJSONLDForGated(v any) bool {
switch x := v.(type) {
case map[string]any:
if raw, ok := x["isAccessibleForFree"]; ok {
switch r := raw.(type) {
case bool:
if !r {
return true
}
case string:
s := strings.ToLower(strings.TrimSpace(r))
if s == "false" || s == "no" {
return true
}
}
}
for _, vv := range x {
if walkJSONLDForGated(vv) {
return true
}
}
case []any:
for _, vv := range x {
if walkJSONLDForGated(vv) {
return true
}
}
}
return false
}
// FetchOGImage is a thin wrapper around FetchArticleMeta kept for callers // FetchOGImage is a thin wrapper around FetchArticleMeta kept for callers
// that only care about the image. Returns "" when not found. // that only care about the image. Returns "" when not found.
func FetchOGImage(articleURL string) string { func FetchOGImage(articleURL string) string {

View File

@@ -24,6 +24,13 @@ func NormalizeImageURL(raw string) string {
if q.Get("width") == "" { if q.Get("width") == "" {
return raw return raw
} }
// The Guardian signs each (width, quality, fit, …) combination with `s=`.
// Changing width without re-signing yields a 401 "invalid signature",
// so leave signed URLs alone — the parser is now responsible for picking
// the widest media:content variant up front.
if q.Get("s") != "" {
return raw
}
q.Set("width", "1200") q.Set("width", "1200")
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
return u.String() return u.String()

View File

@@ -6,17 +6,19 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
"github.com/mmcdole/gofeed" "github.com/mmcdole/gofeed"
ext "github.com/mmcdole/gofeed/extensions"
) )
const userAgent = "Pete/1.0 (newsbot; +https://github.com/reala-misaki/pete)" const userAgent = "Pete/1.0 (newsbot; +https://github.com/reala-misaki/pete)"
var feedClient = &http.Client{Timeout: 30 * time.Second} 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 { type FeedItem struct {
GUID string GUID string
Headline string Headline string
@@ -24,9 +26,7 @@ type FeedItem struct {
ImageURL string ImageURL string
ArticleURL string ArticleURL string
Source string Source string
FeedHint string DirectRoute string
Tier int
DirectRoute *string
} }
var ( var (
@@ -90,21 +90,19 @@ func extractLede(desc string) string {
// scraped from content:encoded / description (catches feeds like Ars that // scraped from content:encoded / description (catches feeds like Ars that
// embed the lead image in the article body but not as a media:* element). // embed the lead image in the article body but not as a media:* element).
func extractImageURL(item *gofeed.Item) string { func extractImageURL(item *gofeed.Item) string {
// Check media content (common in RSS 2.0 with media namespace) // Check media content (common in RSS 2.0 with media namespace).
// When multiple media:content variants are advertised (Guardian RSS lists
// 140/460/700/1200…) we want the widest signed URL we can find.
if item.Extensions != nil { if item.Extensions != nil {
if media, ok := item.Extensions["media"]; ok { if media, ok := item.Extensions["media"]; ok {
if contents, ok := media["content"]; ok { if contents, ok := media["content"]; ok {
for _, c := range contents { if url := widestURL(contents); url != "" {
if url := c.Attrs["url"]; url != "" { return url
return url
}
} }
} }
if thumbs, ok := media["thumbnail"]; ok { if thumbs, ok := media["thumbnail"]; ok {
for _, t := range thumbs { if url := widestURL(thumbs); url != "" {
if url := t.Attrs["url"]; url != "" { return url
return url
}
} }
} }
// media:group wraps nested media:content / media:thumbnail in some feeds // media:group wraps nested media:content / media:thumbnail in some feeds
@@ -145,6 +143,25 @@ func extractImageURL(item *gofeed.Item) string {
return "" return ""
} }
// widestURL picks the entry with the largest declared width attribute, falling
// back to the first URL if none have a parsable width.
func widestURL(entries []ext.Extension) string {
best := ""
bestW := -1
for _, e := range entries {
url := e.Attrs["url"]
if url == "" {
continue
}
w, _ := strconv.Atoi(e.Attrs["width"])
if w > bestW {
best = url
bestW = w
}
}
return best
}
func firstImgSrc(htmlBody string) string { func firstImgSrc(htmlBody string) string {
if htmlBody == "" { if htmlBody == "" {
return "" return ""

View File

@@ -11,9 +11,7 @@ import (
"pete/internal/storage" "pete/internal/storage"
) )
// ProcessFunc is called for each new feed item that needs classification. // ProcessFunc is called for each new feed item that needs routing.
// ctx is the poller's parent context; callbacks should pass it into any
// downstream LLM/HTTP work so shutdown aborts in-flight calls.
type ProcessFunc func(ctx context.Context, item *FeedItem) type ProcessFunc func(ctx context.Context, item *FeedItem)
// Poller manages per-source RSS polling goroutines. // Poller manages per-source RSS polling goroutines.
@@ -81,7 +79,6 @@ func (p *Poller) pollSource(ctx context.Context, src config.SourceConfig) {
"source", src.Name, "source", src.Name,
"failures", consecutiveFailures, "failures", consecutiveFailures,
) )
// TODO: send admin room warning via matrix client
} }
} else { } else {
consecutiveFailures = 0 consecutiveFailures = 0
@@ -135,7 +132,7 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
// the article is gated, so swap in a Wayback snapshot for both // the article is gated, so swap in a Wayback snapshot for both
// Pete (image) and the reader (posted link). // Pete (image) and the reader (posted link).
meta := FetchArticleMeta(originalURL) meta := FetchArticleMeta(originalURL)
paywalled := !meta.Fetched || meta.BodyChars < PaywallBodyThreshold paywalled := !meta.Fetched || meta.Paywalled || meta.BodyChars < PaywallBodyThreshold
if paywalled { if paywalled {
if snap := ResolveWayback(originalURL); snap != "" { if snap := ResolveWayback(originalURL); snap != "" {
snapMeta := FetchArticleMeta(snap) snapMeta := FetchArticleMeta(snap)
@@ -163,11 +160,9 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
// Stamp source metadata onto the item // Stamp source metadata onto the item
items[i].Source = src.Name items[i].Source = src.Name
items[i].FeedHint = src.FeedHint
items[i].Tier = src.Tier
items[i].DirectRoute = src.DirectRoute 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{ if err := storage.InsertStory(&storage.Story{
GUID: items[i].GUID, GUID: items[i].GUID,
Headline: items[i].Headline, Headline: items[i].Headline,
@@ -177,7 +172,6 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
URLCanonical: canonical, URLCanonical: canonical,
HeadlineNorm: headlineNorm, HeadlineNorm: headlineNorm,
Source: items[i].Source, Source: items[i].Source,
FeedHint: items[i].FeedHint,
SeenAt: time.Now().Unix(), SeenAt: time.Now().Unix(),
}); err != nil { }); err != nil {
slog.Error("failed to insert story", "guid", items[i].GUID, "err", err) 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) 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 return nil
} }

View File

@@ -20,6 +20,8 @@ import (
"pete/internal/config" "pete/internal/config"
_ "go.mau.fi/util/dbutil/litestream" // registers "sqlite3-fk-wal" driver used by cryptohelper
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"

View File

@@ -33,7 +33,6 @@ func insertPostable(t *testing.T, guid, source, channel string, seenAt int64) {
Lede: "Lede for " + guid, Lede: "Lede for " + guid,
ArticleURL: "https://example.com/" + guid, ArticleURL: "https://example.com/" + guid,
Source: source, Source: source,
FeedHint: "tech",
SeenAt: seenAt, SeenAt: seenAt,
}); err != nil { }); err != nil {
t.Fatalf("InsertStory(%s): %v", guid, err) t.Fatalf("InsertStory(%s): %v", guid, err)

View File

@@ -101,15 +101,7 @@ func runMigrations(d *sql.DB) error {
} }
// RunMaintenance prunes stale data. Called periodically. // RunMaintenance prunes stale data. Called periodically.
func RunMaintenance(recentWindowHours, classificationLogDays int) { func RunMaintenance() {
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)
// Prune old stories (30 days) and their post logs / reactions // Prune old stories (30 days) and their post logs / reactions
storyCutoff := nowUnix() - int64(30*86400) storyCutoff := nowUnix() - int64(30*86400)
exec("prune old stories", exec("prune old stories",

View File

@@ -29,9 +29,9 @@ func InsertStory(s *Story) error {
classified = 1 classified = 1
} }
_, err := Get().Exec( _, 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) `INSERT INTO stories (guid, headline, lede, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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, 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 return err
} }
@@ -99,73 +99,18 @@ func MarkClassified(guid, channel, platforms string) {
channel, platforms, guid) 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. // GetStoryByGUID returns the full story record for a GUID, or nil if not found.
func GetStoryByGUID(guid string) (*Story, error) { func GetStoryByGUID(guid string) (*Story, error) {
row := Get().QueryRow( 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) FROM stories WHERE guid = ?`, guid)
var s Story 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 nil, err
} }
return &s, nil 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. // InsertPostLog records that a story was posted to a channel.
// Uses OR IGNORE to prevent duplicate posts for the same story+channel. // Uses OR IGNORE to prevent duplicate posts for the same story+channel.
func InsertPostLog(guid, channel, eventID, urlCanonical string) { 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. // row in post_log). Returns (nil, nil) when nothing qualifies.
func GetNewestPostableStory(source string) (*Story, error) { func GetNewestPostableStory(source string) (*Story, error) {
row := Get().QueryRow( 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 FROM stories
WHERE classified = 1 WHERE classified = 1
AND source = ? AND source = ?
@@ -238,7 +183,7 @@ func GetNewestPostableStory(source string) (*Story, error) {
ORDER BY seen_at DESC ORDER BY seen_at DESC
LIMIT 1`, source) LIMIT 1`, source)
var s Story 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
@@ -253,7 +198,7 @@ func GetNewestPostableStory(source string) (*Story, error) {
// between ticks). Returns (nil, nil) when nothing qualifies. // between ticks). Returns (nil, nil) when nothing qualifies.
func GetNewestPostableStoryByChannel(channel string) (*Story, error) { func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
row := Get().QueryRow( 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 FROM stories
WHERE classified = 1 WHERE classified = 1
AND channel = ? AND channel = ?
@@ -261,7 +206,7 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
ORDER BY seen_at DESC ORDER BY seen_at DESC
LIMIT 1`, channel) LIMIT 1`, channel)
var s Story 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
@@ -275,11 +220,12 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
// channels (_discarded, _duplicate) are excluded. // channels (_discarded, _duplicate) are excluded.
func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) { func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) {
rows, err := Get().Query( rows, err := Get().Query(
`SELECT guid, headline, lede, image_url, article_url, source, feed_hint, platforms, channel, seen_at `SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.seen_at,
FROM stories EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
WHERE classified = 1 FROM stories s
AND channel = ? WHERE s.classified = 1
ORDER BY seen_at DESC AND s.channel = ?
ORDER BY s.seen_at DESC
LIMIT ? OFFSET ?`, channel, limit, offset) LIMIT ? OFFSET ?`, channel, limit, offset)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -288,7 +234,7 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error)
var out []Story var out []Story
for rows.Next() { for rows.Next() {
var s Story 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, &s.Posted); err != nil {
return nil, err return nil, err
} }
out = append(out, s) out = append(out, s)
@@ -296,6 +242,59 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error)
return out, rows.Err() return out, rows.Err()
} }
// ListAllClassified returns up to `limit` classified stories across all real
// channels, newest first. Sentinel channels are excluded.
func ListAllClassified(limit, offset int) ([]Story, error) {
rows, err := Get().Query(
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.seen_at,
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
FROM stories s
WHERE s.classified = 1
AND s.channel IS NOT NULL
AND s.channel NOT IN ('_discarded', '_duplicate')
ORDER BY s.seen_at DESC
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
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.Platforms, &s.Channel, &s.SeenAt, &s.Posted); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// ListRecentlyPosted returns up to `limit` stories that have been posted to
// Matrix, ordered by post time (newest first). Posted is always true.
func ListRecentlyPosted(limit int) ([]Story, error) {
rows, err := Get().Query(
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.seen_at
FROM stories s
JOIN post_log p ON p.guid = s.guid
GROUP BY s.guid
ORDER BY MAX(p.posted_at) DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
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.Platforms, &s.Channel, &s.SeenAt); err != nil {
return nil, err
}
s.Posted = true
out = append(out, s)
}
return out, rows.Err()
}
// CountClassifiedByChannel returns how many classified stories exist for a channel. // CountClassifiedByChannel returns how many classified stories exist for a channel.
func CountClassifiedByChannel(channel string) (int, error) { func CountClassifiedByChannel(channel string) (int, error) {
var n int var n int
@@ -305,6 +304,20 @@ func CountClassifiedByChannel(channel string) (int, error) {
return n, err return n, err
} }
// IsKnownImageURL reports whether any story has this exact image_url. Used
// by the thumbnail proxy to guard against arbitrary URL fetches.
func IsKnownImageURL(url string) bool {
if url == "" {
return false
}
var n int
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories WHERE image_url = ? LIMIT 1`, url).Scan(&n); err != nil {
slog.Error("IsKnownImageURL query failed", "err", err)
return false
}
return n > 0
}
// GetRoundRobinState returns the last channel posted by the round-robin // GetRoundRobinState returns the last channel posted by the round-robin
// scheduler and the timestamp of that tick. Empty string + 0 if no state yet. // scheduler and the timestamp of that tick. Empty string + 0 if no state yet.
func GetRoundRobinState() (lastChannel string, lastTickAt int64, err error) { func GetRoundRobinState() (lastChannel string, lastTickAt int64, err error) {

View File

@@ -11,26 +11,12 @@ CREATE TABLE IF NOT EXISTS stories (
url_canonical TEXT, url_canonical TEXT,
headline_norm TEXT, headline_norm TEXT,
source TEXT NOT NULL, source TEXT NOT NULL,
feed_hint TEXT,
platforms TEXT, platforms TEXT,
channel TEXT, channel TEXT,
classified INTEGER NOT NULL DEFAULT 0, classified INTEGER NOT NULL DEFAULT 0,
seen_at INTEGER NOT NULL 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 ( CREATE TABLE IF NOT EXISTS post_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL, guid TEXT NOT NULL,

View File

@@ -1,7 +1,6 @@
package storage package storage
import ( import (
"fmt"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@@ -48,7 +47,6 @@ func TestInsertStoryAndGUIDSeen(t *testing.T) {
Lede: "Test lede sentence.", Lede: "Test lede sentence.",
ArticleURL: "https://example.com/article", ArticleURL: "https://example.com/article",
Source: "Test Source", Source: "Test Source",
FeedHint: "tech",
SeenAt: 1700000000, SeenAt: 1700000000,
} }
if err := InsertStory(s); err != nil { 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) setupTestDB(t)
InsertStory(&Story{ InsertStory(&Story{
GUID: "g1", Headline: "H1", ArticleURL: "https://a.com", Source: "S", SeenAt: 1, 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", "[]") MarkClassified("g1", "tech", "[]")
unclassified, err = GetUnclassifiedStories("S") var channel string
if err != nil { var classified int
t.Fatal(err) Get().QueryRow(`SELECT channel, classified FROM stories WHERE guid = ?`, "g1").
} Scan(&channel, &classified)
if len(unclassified) != 1 { if channel != "tech" || classified != 1 {
t.Fatalf("unclassified = %d, want 1", len(unclassified)) t.Errorf("after MarkClassified: channel=%q classified=%d, want tech/1", channel, classified)
}
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)
} }
} }
@@ -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) { func TestRunMaintenance(t *testing.T) {
setupTestDB(t) setupTestDB(t)
db := Get() db := Get()
@@ -341,11 +246,7 @@ func TestRunMaintenance(t *testing.T) {
InsertReaction("old", "tech", "$r1", "👍", "@u:x", old) InsertReaction("old", "tech", "$r1", "👍", "@u:x", old)
InsertReaction("new", "tech", "$r2", "👍", "@u:x", now) InsertReaction("new", "tech", "$r2", "👍", "@u:x", now)
// Insert old + recent headlines RunMaintenance()
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)
// Old stories should be pruned // Old stories should be pruned
var count int var count int
@@ -363,11 +264,6 @@ func TestRunMaintenance(t *testing.T) {
if count != 1 { if count != 1 {
t.Errorf("reactions: expected 1, got %d", count) 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) { func TestFTS5Search(t *testing.T) {

View File

@@ -2,26 +2,18 @@ package storage
// Story is the core record for an ingested news item. // Story is the core record for an ingested news item.
type Story struct { type Story struct {
ID int64 ID int64
GUID string GUID string
Headline string Headline string
Lede string Lede string
ImageURL string ImageURL string
ArticleURL string ArticleURL string
URLCanonical string URLCanonical string
HeadlineNorm string HeadlineNorm string
Source string Source string
FeedHint string Platforms string // JSON array e.g. '["nintendo-switch","multi"]'
Platforms string // JSON array e.g. '["nintendo-switch","multi"]' Channel string
Channel string Classified bool
Classified bool SeenAt int64
SeenAt int64 Posted bool // true if this story has been posted to Matrix (joined from post_log)
}
// RecentHeadline is a lightweight record for the LLM dedup context window.
type RecentHeadline struct {
GUID string
Headline string
Source string
SeenAt int64
} }

View File

@@ -21,6 +21,8 @@ type StoryView struct {
ArticleURL string ArticleURL string
Source string Source string
SeenAt time.Time SeenAt time.Time
Posted bool
Channel string // channel slug; also the theme key
} }
func toView(s storage.Story) StoryView { func toView(s storage.Story) StoryView {
@@ -31,6 +33,8 @@ func toView(s storage.Story) StoryView {
ArticleURL: s.ArticleURL, ArticleURL: s.ArticleURL,
Source: s.Source, Source: s.Source,
SeenAt: time.Unix(s.SeenAt, 0), SeenAt: time.Unix(s.SeenAt, 0),
Posted: s.Posted,
Channel: s.Channel,
} }
} }
@@ -54,12 +58,17 @@ type channelPage struct {
type indexPage struct { type indexPage struct {
pageData pageData
Sections []indexSection JustPosted []StoryView
Stats []channelStat
Latest []StoryView
} }
type indexSection struct { type channelStat struct {
Channel Channel Channel Channel
Stories []StoryView // top 3 per channel LastPostedAt time.Time // zero if never posted
HasLastPosted bool
PostsToday int
Total int
} }
func (s *Server) base() pageData { func (s *Server) base() pageData {
@@ -70,21 +79,56 @@ func (s *Server) base() pageData {
} }
func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) { func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) {
sections := make([]indexSection, 0, len(channels)) const (
for _, ch := range channels { justPostedLimit = 6
rows, err := storage.ListClassifiedByChannel(ch.Slug, 4, 0) latestLimit = 16
if err != nil { )
slog.Error("web: list failed", "channel", ch.Slug, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError) postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
return if err != nil {
} slog.Error("web: list recently posted failed", "err", err)
views := make([]StoryView, 0, len(rows)) http.Error(w, "internal error", http.StatusInternalServerError)
for _, r := range rows { return
views = append(views, toView(r)) }
} justPosted := make([]StoryView, 0, len(postedRows))
sections = append(sections, indexSection{Channel: ch, Stories: views}) for _, r := range postedRows {
justPosted = append(justPosted, toView(r))
}
latestRows, err := storage.ListAllClassified(latestLimit, 0)
if err != nil {
slog.Error("web: list all classified failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
latest := make([]StoryView, 0, len(latestRows))
for _, r := range latestRows {
latest = append(latest, toView(r))
}
dayStart := time.Now().Add(-24 * time.Hour).Unix()
stats := make([]channelStat, 0, len(channels))
for _, ch := range channels {
total, _ := storage.CountClassifiedByChannel(ch.Slug)
lastTs := storage.GetLastPostTime(ch.Slug)
stat := channelStat{
Channel: ch,
PostsToday: storage.CountPostsInWindow(ch.Slug, dayStart),
Total: total,
}
if lastTs > 0 {
stat.LastPostedAt = time.Unix(lastTs, 0)
stat.HasLastPosted = true
}
stats = append(stats, stat)
}
data := indexPage{
pageData: s.base(),
JustPosted: justPosted,
Stats: stats,
Latest: latest,
} }
data := indexPage{pageData: s.base(), Sections: sections}
s.render(w, "index", data) s.render(w, "index", data)
} }
@@ -141,6 +185,15 @@ func (s *Server) render(w http.ResponseWriter, page string, data any) {
} }
var funcs = template.FuncMap{ var funcs = template.FuncMap{
"thumb": thumbURL,
"channel": func(slug string) Channel {
for _, ch := range channels {
if ch.Slug == slug {
return ch
}
}
return Channel{Slug: slug, Title: slug, Theme: slug}
},
"dict": func(pairs ...any) map[string]any { "dict": func(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2) m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 { for i := 0; i+1 < len(pairs); i += 2 {

View File

@@ -34,6 +34,7 @@ var channels = []Channel{
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."}, {Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."}, {Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
{Slug: "politics", Title: "Politics", Theme: "politics", Emoji: "🏛️", Blurb: "Policy, power, and the news of the day."}, {Slug: "politics", Title: "Politics", Theme: "politics", Emoji: "🏛️", Blurb: "Policy, power, and the news of the day."},
{Slug: "music", Title: "Music", Theme: "music", Emoji: "🎵", Blurb: "Records, scenes, and the artists shaping the sound."},
} }
// Server is the HTTP server for Pete's web UI. // Server is the HTTP server for Pete's web UI.
@@ -68,6 +69,7 @@ func New(cfg config.WebConfig) (*Server, error) {
return nil, err return nil, err
} }
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.HandleFunc("GET /img/{name}", s.handleImg)
mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /{$}", s.handleIndex)
for _, ch := range channels { for _, ch := range channels {

View File

@@ -58,18 +58,33 @@ html[data-phase="night"] {
.bg-theme-gaming { background-color: #4caf7d; } .bg-theme-gaming { background-color: #4caf7d; }
.bg-theme-tech { background-color: #5aa9e6; } .bg-theme-tech { background-color: #5aa9e6; }
.bg-theme-politics { background-color: #e07a5f; } .bg-theme-politics { background-color: #e07a5f; }
.bg-theme-music { background-color: #b079d6; }
.text-theme-gaming { color: #2d8a5a; } .text-theme-gaming { color: #2d8a5a; }
.text-theme-tech { color: #2f7fb8; } .text-theme-tech { color: #2f7fb8; }
.text-theme-politics { color: #b8523a; } .text-theme-politics { color: #b8523a; }
.text-theme-music { color: #8a4fb8; }
.decoration-theme-gaming { text-decoration-color: #4caf7d; } .decoration-theme-gaming { text-decoration-color: #4caf7d; }
.decoration-theme-tech { text-decoration-color: #5aa9e6; } .decoration-theme-tech { text-decoration-color: #5aa9e6; }
.decoration-theme-politics { text-decoration-color: #e07a5f; } .decoration-theme-politics { text-decoration-color: #e07a5f; }
.decoration-theme-music { text-decoration-color: #b079d6; }
.border-theme-gaming { border-color: #4caf7d; } .border-theme-gaming { border-color: #4caf7d; }
.border-theme-tech { border-color: #5aa9e6; } .border-theme-tech { border-color: #5aa9e6; }
.border-theme-politics { border-color: #e07a5f; } .border-theme-politics { border-color: #e07a5f; }
.border-theme-music { border-color: #b079d6; }
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
.glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-tech { box-shadow: 0 0 0 2px rgba(90,169,230,0.35), 0 0 24px 4px rgba(90,169,230,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-politics { box-shadow: 0 0 0 2px rgba(224,122,95,0.35), 0 0 24px 4px rgba(224,122,95,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-music { box-shadow: 0 0 0 2px rgba(176,121,214,0.35), 0 0 24px 4px rgba(176,121,214,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@keyframes pete-glow-pulse {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.08); }
}
.line-clamp-3 { .line-clamp-3 {
display: -webkit-box; display: -webkit-box;

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M48 8C28 8 12 22 12 42c0 6 2 12 6 16 4-12 14-22 26-26-10 8-16 18-18 30 16 0 30-14 30-34 0-8-4-16-8-20z" fill="#6db73c" stroke="#3f6e1f" stroke-width="3" stroke-linejoin="round"/>
<path d="M22 52c8-12 18-20 30-26" stroke="#3f6e1f" stroke-width="2" fill="none" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -1,17 +1,24 @@
{{define "card"}} {{define "card"}}
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer" <a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
class="group block rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition"> class="group block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
{{if .Story.ImageURL}} {{if .Story.ImageURL}}
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5"> <div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
<img src="{{.Story.ImageURL}}" alt="" loading="lazy" <img src="{{thumb .Story.ImageURL}}" alt="" loading="lazy" decoding="async"
class="h-full w-full object-cover group-hover:scale-105 transition duration-500"> class="h-full w-full object-cover group-hover:scale-105 transition duration-500">
</div> </div>
{{end}} {{end}}
<div class="p-4 space-y-2"> <div class="p-4 space-y-2">
<div class="flex items-center gap-2 text-xs"> <div class="flex items-center gap-2 text-xs flex-wrap">
{{if .ShowChannel}}{{$ch := channel .Story.Channel}}
<span class="inline-flex items-center rounded-full bg-theme-{{$ch.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
<span aria-hidden="true" class="mr-1">{{$ch.Emoji}}</span>{{$ch.Title}}
</span>
<span class="text-[color:var(--ink)]/60 font-semibold">{{.Story.Source}}</span>
{{else}}
<span class="inline-flex items-center rounded-full bg-theme-{{.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider"> <span class="inline-flex items-center rounded-full bg-theme-{{.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
{{.Story.Source}} {{.Story.Source}}
</span> </span>
{{end}}
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span> <span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
</div> </div>
<h3 class="font-display text-lg font-semibold leading-snug group-hover:underline underline-offset-2 decoration-theme-{{.Theme}}"> <h3 class="font-display text-lg font-semibold leading-snug group-hover:underline underline-offset-2 decoration-theme-{{.Theme}}">

View File

@@ -1,37 +1,81 @@
{{define "title"}}{{.SiteTitle}} — your news feed{{end}} {{define "title"}}{{.SiteTitle}} — your news feed{{end}}
{{define "main"}} {{define "main"}}
<section class="mt-2 mb-10 sm:mb-14 text-center"> <section class="mt-2 mb-8 text-center">
<h1 class="font-display text-4xl sm:text-6xl font-bold leading-tight"> <h1 class="font-display text-4xl sm:text-6xl font-bold leading-tight">
a friendlier news feed. a friendlier news feed.
</h1> </h1>
<p class="mx-auto mt-3 max-w-xl text-base sm:text-lg text-[color:var(--ink)]/70"> <p class="mx-auto mt-3 max-w-xl text-base sm:text-lg text-[color:var(--ink)]/70">
Pete reads RSS, sorts stories, and posts them to Matrix. Pete reads RSS, sorts stories, and posts them to Matrix.
Here's what he's been reading lately. Glowing cards are the ones he's posted.
</p> </p>
</section> </section>
{{range .Sections}} {{if .JustPosted}}
<section class="mb-10">
<div class="flex items-end justify-between gap-3 mb-4">
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">📡</span> Pete just posted
</h2>
<span class="text-xs text-[color:var(--ink)]/50">most recent posts to Matrix</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{{range .JustPosted}}
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
{{end}}
</div>
</section>
{{end}}
<section class="mb-10">
<div class="flex items-end justify-between gap-3 mb-4">
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">📊</span> Channels
</h2>
<span class="text-xs text-[color:var(--ink)]/50">last 24 hours</span>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{{range .Stats}}
<a href="/{{.Channel.Slug}}"
class="block rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete p-4 hover:-translate-y-1 hover:shadow-pete-lg transition">
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center h-9 w-9 rounded-2xl bg-theme-{{.Channel.Theme}} text-white text-lg">
{{.Channel.Emoji}}
</span>
<span class="font-display text-lg font-semibold">{{.Channel.Title}}</span>
</div>
<dl class="mt-3 space-y-1 text-xs text-[color:var(--ink)]/70">
<div class="flex justify-between gap-2">
<dt>last post</dt>
<dd class="font-semibold text-[color:var(--ink)]">
{{if .HasLastPosted}}{{timeAgo .LastPostedAt}}{{else}}—{{end}}
</dd>
</div>
<div class="flex justify-between gap-2">
<dt>posted 24h</dt>
<dd class="font-semibold text-[color:var(--ink)]">{{.PostsToday}}</dd>
</div>
<div class="flex justify-between gap-2">
<dt>stories</dt>
<dd class="font-semibold text-[color:var(--ink)]">{{.Total}}</dd>
</div>
</dl>
</a>
{{end}}
</div>
</section>
<section class="mb-12"> <section class="mb-12">
<div class="flex items-end justify-between gap-3 mb-4"> <div class="flex items-end justify-between gap-3 mb-4">
<div> <h2 class="font-display text-xl sm:text-2xl font-bold">
<h2 class="font-display text-2xl sm:text-3xl font-bold"> <span aria-hidden="true">🗞️</span> Latest across the feed
<span aria-hidden="true">{{.Channel.Emoji}}</span> </h2>
<a href="/{{.Channel.Slug}}" class="hover:underline underline-offset-4 decoration-theme-{{.Channel.Theme}} decoration-4"> <span class="text-xs text-[color:var(--ink)]/50">newest first, all channels</span>
{{.Channel.Title}}
</a>
</h2>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">{{.Channel.Blurb}}</p>
</div>
<a href="/{{.Channel.Slug}}" class="hidden sm:inline-flex items-center gap-1 text-sm font-semibold rounded-full bg-theme-{{.Channel.Theme}} text-white px-4 py-2 shadow-pete hover:-translate-y-0.5 transition">
more →
</a>
</div> </div>
{{if .Latest}}
{{if .Stories}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{range .Stories}} {{range .Latest}}
{{template "card" dict "Story" . "Theme" $.Channel.Theme}} {{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
@@ -41,4 +85,3 @@
{{end}} {{end}}
</section> </section>
{{end}} {{end}}
{{end}}

View File

@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/output.css"> <link rel="stylesheet" href="/static/css/output.css">
<link rel="icon" href="/static/img/leaf.svg" type="image/svg+xml"> <link rel="icon" href="/static/img/pete.avif" type="image/avif">
<script> <script>
// Pick a palette phase from the browser clock and update it each minute. // Pick a palette phase from the browser clock and update it each minute.
(function () { (function () {
@@ -32,7 +32,8 @@
<header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12"> <header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<a href="/" class="flex items-center gap-3 group"> <a href="/" class="flex items-center gap-3 group">
<span class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 text-2xl group-hover:-rotate-6 transition-transform">🌿</span> <img src="/static/img/pete.avif" alt="Pete" width="48" height="48"
class="h-12 w-12 rounded-2xl bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 object-cover group-hover:-rotate-6 transition-transform">
<span class="font-display text-3xl font-bold tracking-tight">{{.SiteTitle}}</span> <span class="font-display text-3xl font-bold tracking-tight">{{.SiteTitle}}</span>
<span class="hidden sm:inline rounded-full bg-[color:var(--accent)]/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/70" data-phase-label>day</span> <span class="hidden sm:inline rounded-full bg-[color:var(--accent)]/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/70" data-phase-label>day</span>
</a> </a>
@@ -54,6 +55,8 @@
</main> </main>
<footer class="mx-auto max-w-6xl px-4 pb-10 text-center text-sm text-[color:var(--ink)]/50"> <footer class="mx-auto max-w-6xl px-4 pb-10 text-center text-sm text-[color:var(--ink)]/50">
<img src="/static/img/pete.avif" alt="" width="20" height="20"
class="inline-block h-5 w-5 rounded-full object-cover align-[-4px] mr-1">
served by <span class="font-semibold">Pete</span> · also a Matrix bot · <span data-clock></span> served by <span class="font-semibold">Pete</span> · also a Matrix bot · <span data-clock></span>
</footer> </footer>

204
internal/web/thumbs.go Normal file
View File

@@ -0,0 +1,204 @@
package web
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"pete/internal/storage"
)
const (
thumbWidth = 800
thumbMaxBytes = 12 << 20 // 12 MiB cap on source download
thumbFetchTimeout = 12 * time.Second
thumbCacheDir = "./data/img-cache"
thumbQuality = "45"
thumbSpeed = "8"
)
var (
thumbClient = &http.Client{Timeout: thumbFetchTimeout}
thumbMu sync.Mutex
thumbInFlight = map[string]*sync.WaitGroup{}
)
func thumbKey(u string) string {
sum := sha256.Sum256([]byte(u))
return hex.EncodeToString(sum[:16])
}
// thumbURL maps a source image URL to the /img proxy path. Empty input → "".
func thumbURL(src string) string {
if src == "" {
return ""
}
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
}
func (s *Server) handleImg(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
hash, ok := strings.CutSuffix(name, ".avif")
if !ok {
http.NotFound(w, r)
return
}
src := r.URL.Query().Get("u")
if src == "" || thumbKey(src) != hash {
http.NotFound(w, r)
return
}
if !(strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://")) {
http.NotFound(w, r)
return
}
if !storage.IsKnownImageURL(src) {
http.NotFound(w, r)
return
}
if err := os.MkdirAll(thumbCacheDir, 0o755); err != nil {
http.Error(w, "cache dir", http.StatusInternalServerError)
return
}
cachePath := filepath.Join(thumbCacheDir, hash+".avif")
if fi, err := os.Stat(cachePath); err == nil && fi.Size() > 0 {
serveAvif(w, r, cachePath)
return
}
// Single-flight: collapse concurrent requests for the same image.
thumbMu.Lock()
wg, busy := thumbInFlight[hash]
if !busy {
wg = &sync.WaitGroup{}
wg.Add(1)
thumbInFlight[hash] = wg
}
thumbMu.Unlock()
if busy {
wg.Wait()
if fi, err := os.Stat(cachePath); err == nil && fi.Size() > 0 {
serveAvif(w, r, cachePath)
return
}
http.Redirect(w, r, src, http.StatusFound)
return
}
defer func() {
thumbMu.Lock()
delete(thumbInFlight, hash)
thumbMu.Unlock()
wg.Done()
}()
if err := buildAvifThumb(src, cachePath); err != nil {
slog.Warn("thumb build failed", "url", src, "err", err)
http.Redirect(w, r, src, http.StatusFound)
return
}
serveAvif(w, r, cachePath)
}
func serveAvif(w http.ResponseWriter, r *http.Request, path string) {
w.Header().Set("Content-Type", "image/avif")
w.Header().Set("Cache-Control", "public, max-age=2592000, immutable")
http.ServeFile(w, r, path)
}
// buildAvifThumb downloads src, decodes + resizes in Go, then shells out to
// avifenc to produce a compact AVIF at outPath.
func buildAvifThumb(src, outPath string) error {
req, err := http.NewRequest(http.MethodGet, src, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Pete-Thumb/1.0 (+https://pete.example)")
resp, err := thumbClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("upstream %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, thumbMaxBytes+1))
if err != nil {
return err
}
if len(body) > thumbMaxBytes {
return fmt.Errorf("image too big (>%d bytes)", thumbMaxBytes)
}
src2, _, err := image.Decode(bytes.NewReader(body))
if err != nil {
return fmt.Errorf("decode: %w", err)
}
b := src2.Bounds()
srcW, srcH := b.Dx(), b.Dy()
if srcW <= 0 || srcH <= 0 {
return fmt.Errorf("empty image")
}
dstW, dstH := srcW, srcH
if srcW > thumbWidth {
dstW = thumbWidth
dstH = int(float64(srcH) * float64(thumbWidth) / float64(srcW))
if dstH < 1 {
dstH = 1
}
}
var resized image.Image = src2
if dstW != srcW || dstH != srcH {
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.CatmullRom.Scale(dst, dst.Bounds(), src2, b, draw.Over, nil)
resized = dst
}
tmp, err := os.CreateTemp("", "pete-thumb-*.png")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := png.Encode(tmp, resized); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
// Encode to a sibling temp file then atomically rename so partial outputs
// never appear in the cache.
stagedOut := outPath + ".tmp"
cmd := exec.Command("avifenc",
"-q", thumbQuality,
"-s", thumbSpeed,
tmpPath, stagedOut,
)
if out, err := cmd.CombinedOutput(); err != nil {
os.Remove(stagedOut)
return fmt.Errorf("avifenc: %w: %s", err, strings.TrimSpace(string(out)))
}
return os.Rename(stagedOut, outPath)
}

134
main.go
View File

@@ -10,9 +10,7 @@ import (
"syscall" "syscall"
"time" "time"
"pete/internal/classifier"
"pete/internal/config" "pete/internal/config"
"pete/internal/explainer"
"pete/internal/ingestion" "pete/internal/ingestion"
"pete/internal/matrix" "pete/internal/matrix"
"pete/internal/poster" "pete/internal/poster"
@@ -24,10 +22,11 @@ import (
) )
func main() { func main() {
configPath := flag.String("config", "config.yaml", "path to config file") configPath := flag.String("config", "config.toml", "path to config file")
seed := flag.Bool("seed", false, "ingest current feed items as seen without posting, then exit") seed := flag.Bool("seed", false, "ingest current feed items as seen without posting, then exit")
test := flag.Bool("test", false, "post one story to verify the full pipeline, then exit") test := flag.Bool("test", false, "post one story to verify the full pipeline, then exit")
testSource := flag.String("test-source", "", "source name to use for -test (default: first enabled)") testSource := flag.String("test-source", "", "source name to use for -test (default: first enabled)")
local := flag.Bool("local", false, "web/RSS-only mode: poll feeds and serve the web UI, no Matrix login or posting")
flag.Parse() flag.Parse()
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
@@ -43,7 +42,6 @@ func main() {
slog.Info("config loaded", slog.Info("config loaded",
"sources", len(cfg.Sources), "sources", len(cfg.Sources),
"channels", len(cfg.Matrix.Channels), "channels", len(cfg.Matrix.Channels),
"ollama_model", cfg.Ollama.Model,
) )
// Initialize database // Initialize database
@@ -65,6 +63,12 @@ func main() {
return return
} }
// Local mode: poll feeds + serve web UI only. No Matrix, no posting queue.
if *local {
runLocal(cfg)
return
}
// Create Matrix client // Create Matrix client
mx, err := matrix.New(cfg.Matrix) mx, err := matrix.New(cfg.Matrix)
if err != nil { if err != nil {
@@ -72,17 +76,11 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Create Ollama client and classifier
ollamaClient := classifier.NewOllamaClient(cfg.Ollama)
cls := classifier.NewClassifier(ollamaClient, mx)
// Create post queue // Create post queue
queue := poster.NewQueue(cfg.Posting, mx) queue := poster.NewQueue(cfg.Posting, mx)
// Wire reaction handler + ❓ → summary hook // Wire reaction handler
mx.SetReactionHandler(poster.HandleReaction) 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. // 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) { mx.SetMessageHandler(func(channel string, roomID id.RoomID, eventID id.EventID, sender id.UserID, body string) {
@@ -142,70 +140,37 @@ func main() {
roundRobinMode := cfg.Posting.RoundRobin.Enabled roundRobinMode := cfg.Posting.RoundRobin.Enabled
// Build the pipeline callback: classify → enqueue (or just classify+store // Pipeline callback: route the story to its direct_route channel, then
// when round-robin mode is enabled; the scheduler does the enqueueing). // either enqueue immediately or leave it for the round-robin scheduler.
processItem := func(ctx context.Context, item *ingestion.FeedItem) { processItem := func(_ context.Context, item *ingestion.FeedItem) {
result, err := cls.Classify(ctx, item) if item.DirectRoute == "" {
if err != nil { slog.Error("item has no direct_route, skipping", "guid", item.GUID, "source", item.Source)
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
storage.MarkClassified(item.GUID, "_discarded", "[]") storage.MarkClassified(item.GUID, "_discarded", "[]")
slog.Info("story gated out",
"guid", item.GUID,
"rationale", result.Rationale,
)
return return
} }
// Mark classified with actual channel channel := item.DirectRoute
platforms := storage.MarshalPlatforms(result.Platforms) storage.MarkClassified(item.GUID, channel, "[]")
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
}
if roundRobinMode { if roundRobinMode {
// Story stays in DB classified+postable; the scheduler will pick slog.Info("story routed, awaiting round-robin tick",
// it up on the next rotation tick. "guid", item.GUID, "source", item.Source, "channel", channel)
slog.Info("story classified, awaiting round-robin tick",
"guid", item.GUID, "source", item.Source, "channel", result.Channel)
return return
} }
// Validate image
imageURL := "" imageURL := ""
if item.ImageURL != "" && ingestion.ValidateImageURL(item.ImageURL) { if item.ImageURL != "" && ingestion.ValidateImageURL(item.ImageURL) {
imageURL = item.ImageURL imageURL = item.ImageURL
} }
// Enqueue for posting
queue.Enqueue(poster.QueueItem{ queue.Enqueue(poster.QueueItem{
GUID: item.GUID, GUID: item.GUID,
Headline: item.Headline, Headline: item.Headline,
Lede: item.Lede, Lede: item.Lede,
ImageURL: imageURL, ImageURL: imageURL,
ArticleURL: item.ArticleURL, ArticleURL: item.ArticleURL,
Source: item.Source, Source: item.Source,
Channel: result.Channel, Channel: channel,
Platforms: result.Platforms,
}) })
} }
@@ -238,7 +203,7 @@ func main() {
} }
// Run maintenance on startup // Run maintenance on startup
storage.RunMaintenance(cfg.Storage.RecentWindowHours, cfg.Storage.ClassificationLogDays) storage.RunMaintenance()
// Wait for shutdown signal // Wait for shutdown signal
sig := <-sigCh sig := <-sigCh
@@ -251,6 +216,47 @@ func main() {
slog.Info("pete stopped") slog.Info("pete stopped")
} }
func runLocal(cfg *config.Config) {
cfg.Web.Enabled = true
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
processItem := func(_ context.Context, item *ingestion.FeedItem) {
if item.DirectRoute == "" {
slog.Info("local: item has no direct_route, discarding", "guid", item.GUID, "source", item.Source)
storage.MarkClassified(item.GUID, "_discarded", "[]")
return
}
storage.MarkClassified(item.GUID, item.DirectRoute, "[]")
slog.Info("local: story classified",
"guid", item.GUID, "source", item.Source, "channel", item.DirectRoute, "headline", item.Headline)
}
poller := ingestion.NewPoller(cfg.Sources, processItem)
poller.Start(ctx)
slog.Info("local: pollers started")
ws, err := web.New(cfg.Web)
if err != nil {
slog.Error("local: web server init failed", "err", err)
os.Exit(1)
}
go ws.Start(ctx)
slog.Info("local: web UI listening", "addr", cfg.Web.ListenAddr)
storage.RunMaintenance()
sig := <-sigCh
slog.Info("local: shutdown signal received", "signal", sig)
cancel()
poller.Wait()
slog.Info("pete stopped")
}
func runSeed(cfg *config.Config) { func runSeed(cfg *config.Config) {
total := 0 total := 0
for _, src := range cfg.Sources { for _, src := range cfg.Sources {
@@ -272,15 +278,13 @@ func runSeed(cfg *config.Config) {
Lede: item.Lede, Lede: item.Lede,
ImageURL: item.ImageURL, ImageURL: item.ImageURL,
ArticleURL: item.ArticleURL, ArticleURL: item.ArticleURL,
Source: src.Name, Source: src.Name,
FeedHint: src.FeedHint,
Classified: true, Classified: true,
SeenAt: timeNow(), SeenAt: timeNow(),
}); err != nil { }); err != nil {
slog.Error("seed: insert failed", "guid", item.GUID, "err", err) slog.Error("seed: insert failed", "guid", item.GUID, "err", err)
continue continue
} }
storage.InsertRecentHeadline(item.GUID, item.Headline, src.Name)
total++ total++
} }
slog.Info("seed: source done", "source", src.Name, "items", len(items)) slog.Info("seed: source done", "source", src.Name, "items", len(items))
@@ -307,8 +311,6 @@ func runTest(cfg *config.Config, sourceName string) {
if len(items) > 0 { if len(items) > 0 {
item := items[0] item := items[0]
item.Source = s.Name item.Source = s.Name
item.FeedHint = s.FeedHint
item.Tier = s.Tier
item.DirectRoute = s.DirectRoute item.DirectRoute = s.DirectRoute
testItem = &item testItem = &item
src = s src = s
@@ -326,10 +328,10 @@ func runTest(cfg *config.Config, sourceName string) {
"guid", testItem.GUID, "guid", testItem.GUID,
) )
// Determine channel channel := testItem.DirectRoute
channel := "politics" // default if channel == "" {
if testItem.DirectRoute != nil { slog.Error("test: source has no direct_route", "source", src.Name)
channel = *testItem.DirectRoute os.Exit(1)
} }
// Validate image // Validate image
@@ -351,7 +353,7 @@ func runTest(cfg *config.Config, sourceName string) {
Headline: testItem.Headline, Headline: testItem.Headline,
ArticleURL: testItem.ArticleURL, ArticleURL: testItem.ArticleURL,
Lede: testItem.Lede, Lede: testItem.Lede,
Source: testItem.Source, Source: testItem.Source,
Channel: channel, Channel: channel,
} }

View File

@@ -58,7 +58,7 @@ func TestRunSeed_IngestsStories(t *testing.T) {
FeedURL: ts.URL, FeedURL: ts.URL,
Tier: 1, Tier: 1,
PollIntervalMinutes: 20, PollIntervalMinutes: 20,
FeedHint: "tech", DirectRoute: "tech",
Enabled: true, Enabled: true,
}, },
}, },
@@ -74,13 +74,11 @@ func TestRunSeed_IngestsStories(t *testing.T) {
} }
} }
// Should have no unclassified stories (seed marks classified=true) // Seed marks rows classified=1; confirm none remain unclassified.
unclassified, err := storage.GetUnclassifiedStories("Test Feed") var unclassified int
if err != nil { storage.Get().QueryRow(`SELECT COUNT(*) FROM stories WHERE classified = 0`).Scan(&unclassified)
t.Fatal(err) if unclassified != 0 {
} t.Errorf("expected 0 unclassified after seed, got %d", unclassified)
if len(unclassified) != 0 {
t.Errorf("expected 0 unclassified after seed, got %d", len(unclassified))
} }
} }
@@ -96,7 +94,7 @@ func TestRunSeed_Idempotent(t *testing.T) {
FeedURL: ts.URL, FeedURL: ts.URL,
Tier: 1, Tier: 1,
PollIntervalMinutes: 20, PollIntervalMinutes: 20,
FeedHint: "tech", DirectRoute: "tech",
Enabled: true, 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) { func TestRunSeed_BadFeedURL(t *testing.T) {
setupTestDB(t) setupTestDB(t)

BIN
pete.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB