Compare commits
5 Commits
fbd4b84eaf
...
5ea64c1d32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ea64c1d32 | ||
|
|
bddd15f7d1 | ||
|
|
0111a1b06d | ||
|
|
9d5db63c56 | ||
|
|
e88483526d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,4 +3,5 @@ data/
|
|||||||
.env
|
.env
|
||||||
pete
|
pete
|
||||||
config.yaml
|
config.yaml
|
||||||
|
config.toml
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -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
25
Makefile
Normal 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
|
||||||
57
README.md
57
README.md
@@ -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
75
config.example.toml
Normal 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
|
||||||
@@ -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
|
|
||||||
@@ -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
3
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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]) + "..."
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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 "quote" & <tag></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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,23 +90,21 @@ 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
|
||||||
if groups, ok := media["group"]; ok {
|
if groups, ok := media["group"]; ok {
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -11,17 +11,9 @@ type Story struct {
|
|||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("web: list failed", "channel", ch.Slug, "err", err)
|
slog.Error("web: list recently posted failed", "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
views := make([]StoryView, 0, len(rows))
|
justPosted := make([]StoryView, 0, len(postedRows))
|
||||||
for _, r := range rows {
|
for _, r := range postedRows {
|
||||||
views = append(views, toView(r))
|
justPosted = append(justPosted, toView(r))
|
||||||
}
|
}
|
||||||
sections = append(sections, indexSection{Channel: ch, Stories: views})
|
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 |
BIN
internal/web/static/img/pete.avif
Normal file
BIN
internal/web/static/img/pete.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
@@ -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}}">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
<a href="/{{.Channel.Slug}}" class="hover:underline underline-offset-4 decoration-theme-{{.Channel.Theme}} decoration-4">
|
|
||||||
{{.Channel.Title}}
|
|
||||||
</a>
|
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-[color:var(--ink)]/60 mt-1">{{.Channel.Blurb}}</p>
|
<span class="text-xs text-[color:var(--ink)]/50">newest first, all channels</span>
|
||||||
</div>
|
</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">
|
{{if .Latest}}
|
||||||
more →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{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}}
|
|
||||||
|
|||||||
@@ -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
204
internal/web/thumbs.go
Normal 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)
|
||||||
|
}
|
||||||
128
main.go
128
main.go
@@ -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,61 +140,29 @@ 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,
|
||||||
@@ -204,8 +170,7 @@ func main() {
|
|||||||
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 {
|
||||||
@@ -273,14 +279,12 @@ func runSeed(cfg *config.Config) {
|
|||||||
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
|
||||||
|
|||||||
45
main_test.go
45
main_test.go
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user