Architecture
MilesAhead is one Go binary on a small OCI ARM VM. Every three hours it scans miles-and-points blogs, groups posts about the same story, skips stories it has already written, and has a model on the NVIDIA API write the article and a second model check it. An admin approves or rejects each article in the dashboard, while a background pass adds Scout’s Take, key details, tags and a photo. The same binary serves the public site and the admin behind Caddy, sends the weekly newsletter through OCI Email Delivery, and exposes metrics that Grafana Alloy ships to Grafana Cloud. Everything lives in one SQLite file, backed up daily and copied off the server.
System overview
Select a component to see what it does, where its code lives and how it is configured. With a keyboard, Tab moves between components, Enter opens one and Escape closes it.
The diagram is wider than this screen: swipe sideways to see all of it, or use the list below it.
- Runs on the VM
- Third-party service
- Not running in production
- Job or process
- Service
- Database
- External API
- Feed source
- Scheduler
- DNS
- Proxy
- People
- Monitoring
- CI and deploys
- Docs hosting
Sources
News sources RSS feeds · blog pages Feed source
The miles-and-points blogs and news sites in the sources table, added and
edited on the admin Sources page. Each has an RSS URL or a CSS selector for
scraping, and can override the scan interval. Articles are written from them
but never name them.
- Code
internal/scanner- Reads / writes
- Read by the scanner; rows managed in the admin (
sources). - Config
scanner.default_interval,scanner.user_agent
Read more: Adding sources · Source references and outlet mentions
Image sources 4 free image APIs External API
The image search queries four APIs in parallel for every search term: Unsplash and Pexels (API keys), Openverse (no key; aggregates Flickr, Wikimedia and other open collections) and the Wikimedia Commons MediaWiki API. At most 3 results per API per query, 3 per source after de-duplication and 15 in total.
- Code
internal/images/search.go,internal/images/sizing.go,internal/images/identity.go- Reads / writes
- No database access; results go to the enrichment reconciler.
- Config
ai.unsplash_key,ai.pexels_key- Metrics
milesahead_image_search_total{source,outcome},milesahead_image_search_candidates
Every request carries a MilesAhead/1.0 User-Agent (Wikimedia rejects
generic clients), and Wikimedia thumbnails are warmed at most two at a time,
as its robot policy asks.
Read more: Image pipeline
Ingestion
Scanner internal/scanner Job or process
Fetches each active source’s RSS feed, or scrapes its page, and stores new
posts. A SHA-256 hash of title and link rejects duplicates at insert. Runs at
startup and then every scanner.default_interval minutes (180 in
production); a scan still running when the next is due makes that one skip.
- Code
internal/scanner,cmd/milesahead/scan.go- Reads / writes
- Reads
sources; writesraw_articles. - Config
scanner.default_interval,scanner.user_agent- Metrics
milesahead_scans_total
Clustering internal/cluster Job or process
Groups the last 48 hours of scanned posts by title similarity (TF-IDF cosine). Only a cluster with posts from at least two different sites goes on to generation, so every article has more than one source to be checked against.
- Code
internal/cluster- Reads / writes
- Reads
raw_articles. - Config
scanner.cluster_threshold
Story dedup covered raws · 7-day check Job or process
Two checks before anything is written. Raw-ID check: a cluster that
reuses a post some article was written from (article_sources) or already
covers (covered_raws) is skipped, and its other posts are recorded in
covered_raws, so late coverage cannot come back as a second article once
the originals leave the 48-hour window. Story check: the cluster is
compared with every article from the last 7 days, in any status: a TF-IDF
prefilter keeps up to 5 scoring at least 0.25, then one short AI call on the
enrichment chain decides “same story, and if so materially new?”. If that
call fails, a lexical score of at least 0.55 decides.
- Code
internal/storydedup,internal/db/coverage.go,cmd/milesahead/scan.go- Reads / writes
- Reads
generated_articles,article_sources,covered_raws; writescovered_raws. - Config
scanner.story_dedup (shadow in production; block or off)- Metrics
milesahead_story_dedup_decisions_total{decision,mode}
In shadow mode the story check logs story dedup decision and counts, but
still generates. block skips the duplicate and records its posts with
reason story_match.
Read more: Deduplication
AI pipeline
AI pipeline write · check · revise Job or process
Turns a cluster into a checked draft. The generator writes (with a fallback chain); editor notes are split out; citation markers are removed and any numbered source reference or news-site mention is flagged; the critic chain scores it with thinking off; remarks that only confirm a passed check are dropped; if any issue is left, the generator revises once and the higher-scoring version is kept. Key details and links come from the raw reply, with a separate extraction call only as a fallback.
- Code
internal/ai,internal/editornote- Reads / writes
- None directly; returns a result to the article manager.
- Config
ai.generator_model,ai.fallback_generator_model,ai.critic_model,ai.critic_disable_thinking,ai.critic_threshold,ai.request_timeout- Metrics
milesahead_critic_score,milesahead_articles_generated_total,milesahead_ai_api_duration_seconds,milesahead_ai_api_errors_total
Read more: AI pipeline
NVIDIA API integrate.api.nvidia.com External API
The OpenAI-compatible endpoint (https://integrate.api.nvidia.com/v1) every
model is called through. A startup preflight probes each configured model,
since the provider’s catalogue lists models that do not answer.
- Config
ai.hf_base_url,ai.hf_api_token- Metrics
milesahead_ai_preflight_models_reachable{role},milesahead_ai_api_errors_total
| Role | Models (tried in order) |
|---|---|
| Generator | nvidia/nemotron-3-ultra-550b-a55b, then openai/gpt-oss-20b, nvidia/nemotron-3-super-120b-a12b |
| Critic (thinking off) | nvidia/nemotron-3-super-120b-a12b, then moonshotai/kimi-k3 |
| Short calls | openai/gpt-oss-20b, then nvidia/nemotron-3-super-120b-a12b |
| Vision | meta/llama-3.2-11b-vision-instruct |
The API key is the NVIDIA_API_KEY GitHub secret.
Read more: Models
Enrichment internal/enrich Job or process
A background pass that fills what generation leaves empty: Scout’s Take, key details, tags and the image. A miss is simply retried next pass. Each pass first resizes stored oversized images and serves any Find a new image request; the new picture replaces the old one only once it is found.
- Code
internal/enrich,internal/images/scorer.go- Reads / writes
- Reads and writes
generated_articles;article_image_requests,article_rejected_images. - Config
ai.enrichment_model,ai.enrichment_interval,ai.enrichment_max_attempts,ai.enrichment_max_duration,ai.vision_model- Metrics
milesahead_articles_degraded,milesahead_enrichment_passes_total,milesahead_enrichment_last_success_timestamp_seconds,milesahead_image_resize_total,milesahead_article_images_unsized
Short calls are capped at 30 s. A Scout’s Take that names a news site or cites “Source 1” is refused, and such key details are dropped. Grafana alerts when no pass has finished for an hour.
Read more: Enrichment · Find a new image
Content & data
Article manager status · revisions · undo Job or process
Owns an article’s status. A new article is pending at or above
critic_threshold (75) and draft below. Approve and reject post the
revision the admin saw and are refused if a rewrite landed since; an approval
can be undone for 30 s. Rewrites start at once and a revision reconciler
retries them every 5 min with backoff (5, 10, 20, 40 min), giving up after 5
failures.
- Code
internal/article,internal/cardupdate,internal/linker- Reads / writes
generated_articles,article_revisions,admin_feedback,verification_results,approval_undo,article_sources.- Metrics
milesahead_articles_approved_total,milesahead_revisions_total,milesahead_revision_reconcile_total{outcome},milesahead_revisions_waiting{state}
Read more: Article workflow
SQLite WAL · FTS5 · backups Database
One SQLite file in WAL mode (modernc.org/sqlite, pure Go) at
/opt/milesahead/data/milesahead.db. Migrations run at every start. Search
uses two FTS5 indexes (articles_fts, cards_fts; porter stemming, accents
folded), rebuilt once when their version in search_index_versions changes.
- Code
internal/db,deploy/backup.sh,.github/workflows/backup.yml- Config
db.path
Backups. milesahead-backup.timer runs backup.sh daily at 03:30:
VACUUM INTO (a plain copy would miss pages still in the WAL),
PRAGMA integrity_check, gzip, 30 days kept on the server. The Database
Backup workflow then pulls the newest archive off the box daily, checks that
it restores, and keeps the 14 most recent in the db-backups release.
Read more: Database tables · Backups
Review replies IMAP · not running Mail
Code for reviewing by email: review messages with a review+TOKEN@
Reply-To, and an IMAP poller that reads replies (APPROVE, REJECT, UNPUBLISH,
or anything else as rewrite feedback). Off: nothing sends review emails
and nothing matches a reply to its article, so the poller never starts.
Startup logs review by email is off (mail.imap not configured) and the
settings page says so. Reviews happen in the admin dashboard; ROADMAP.md
lists what review by email would take.
- Code
internal/mail/inbound.go,internal/mail/reviewbyemail.go- Config
mail.imap.host,mail.imap.user,mail.imap.password,mail.imap.poll_interval
Read more: Email
Delivery & mail
Web server site · admin · API Service
The Go net/http server on port 9090: the public site (home, articles,
cards, compare, search, feed, sign-up), the admin dashboard (session login,
CSRF, noindex), POST /api/vote, /healthz and /metrics. HTML is
no-cache; content-versioned static files are cached for a year, others for
an hour. It also sends sign-up confirmation emails. Admin decisions
(approve, reject, rewrite, undo) go through the article manager.
- Code
internal/web,templates/,static/- Reads / writes
- Reads nearly every table; writes admin decisions, votes, subscribers, sessions and image requests.
- Config
server.port,server.domain,admin.password_hash,newsletter.signup_enabled- Metrics
milesahead_http_requests_total,milesahead_http_duration_seconds,milesahead_http_response_bytes,milesahead_votes_total
Read more: Reader site · Article workflow
Caddy TLS · zstd/gzip Proxy
The VM’s shared reverse proxy, in Docker, reading one file per site from
/opt/fynans/caddy-sites/. milesahead.caddy serves
milesahead.kumaranik.com over HTTPS, compresses responses with
encode zstd gzip, sets security headers and proxies to the app on port
9090. The deploy validates the file inside the container before installing
it, and fails if the reload fails.
- Code
deploy/milesahead.caddy
Read more: Deployment
Cloudflare DNS site · docs · mail records DNS
DNS for the site, which resolves to the VM, the docs subdomain (Cloudflare
Pages), and the mail-authentication records that let inboxes trust mail from
milesahead.kumaranik.com.
| Record | Name | Value |
|---|---|---|
| CNAME (DKIM) | milesahead-202610._domainkey.milesahead.kumaranik.com | milesahead-202610.milesahead.kumaranik.com.dkim.sjc1.oracleemaildelivery.com |
| TXT (SPF) | milesahead.kumaranik.com | v=spf1 include:rp.oracleemaildelivery.com ~all |
| TXT (DMARC) | _dmarc.milesahead.kumaranik.com | v=DMARC1; p=none; |
Read more: Email
Newsletter weekly · Saturdays Job or process
Builds the weekly issue the evening before the send (AI-ranked candidates,
Scout’s Pick, Card of the Week) and sends it on newsletter.send_day at
send_time (Saturday 08:00 New York time) to confirmed subscribers. The issue is re-rendered at send time and each copy
gets its reader’s own unsubscribe link, in the body and in
List-Unsubscribe with one-click List-Unsubscribe-Post.
- Code
internal/newsletter,internal/mail/outbound.go,internal/web/subscribe.go- Reads / writes
- Reads
generated_articles,reference_pages,subscribers; writesnewsletters,newsletter_candidates. - Config
newsletter.send_day,newsletter.send_time,newsletter.timezone,newsletter.signup_enabled,newsletter.unsubscribe_mailbox- Metrics
milesahead_emails_sent_total{kind,outcome},milesahead_subscribers_total
Read more: Newsletter
OCI Email Delivery SMTP 587 · STARTTLS Mail
The outbound mail relay for confirmation emails and newsletters (and vote
alerts, when votes.alert_email is set): Oracle Cloud’s Email Delivery
service, reached over SMTP with STARTTLS.
- Config
mail.smtp.host,mail.smtp.port,mail.smtp.user,mail.smtp.password,mail.smtp.from- Metrics
milesahead_emails_sent_total{kind,outcome}
| Setting | Value |
|---|---|
| Region | us-sanjose-1, Always Free tier (3,000 emails a month) |
| Email domain | milesahead.kumaranik.com, DKIM selector milesahead-202610 |
| Approved sender | [email protected] |
| SMTP endpoint | smtp.email.us-sanjose-1.oci.oraclecloud.com:587, STARTTLS |
| Sending identity | IAM user milesahead-smtp in group milesahead-smtp-senders, policy milesahead-smtp-send: “Allow group milesahead-smtp-senders to use email-family in tenancy” |
| Credentials | GitHub secrets SMTP_HOST, SMTP_USER, SMTP_PASSWORD (optional SMTP_PORT, SMTP_FROM), written into mail.smtp by the deploy’s config job, JSON-quoted with jq, and checked with -check-config |
A test message passed SPF, DKIM and DMARC at Gmail.
Suppression list. OCI adds its own List-Unsubscribe to any message
that has no https one, and an unsubscribe through it puts the address on
OCI’s suppression list, not in the app. Every reader email therefore carries
the app’s own per-reader link, which OCI leaves alone. Admin emails still get
OCI’s header, so do not use a mail client’s Unsubscribe button on them.
Read more: Email
Readers
Inboxes readers' mail Mail
Readers’ mail providers. They check SPF, DKIM and DMARC against the records
in Cloudflare, and Gmail and Yahoo require a one-click unsubscribe from bulk
senders, which the List-Unsubscribe and List-Unsubscribe-Post headers
provide.
Read more: Email troubleshooting
Readers browsers · the admin People
People reading the site, and the admin, in a browser at
milesahead.kumaranik.com. Cloudflare DNS sends them to the VM, Caddy
answers over HTTPS and passes each request to the web server. Readers
browse, search, vote and sign up for the newsletter; the admin reviews
articles in the dashboard under /admin.
Read more: Reader site · Article workflow
Operations
Scheduler cron jobs Scheduler
robfig/cron with panic recovery, wired in cmd/milesahead/main.go. It
starts every background job in the process.
- Code
internal/scheduler,cmd/milesahead/main.go- Config
scanner.default_interval,ai.enrichment_interval,mail.imap.poll_interval
| Job | When |
|---|---|
| Scan, cluster, generate | at startup, then every 3 h (overlapping runs skipped) |
| Enrichment pass | ~90 s after startup, then every 15 min |
| Revision reconciler | ~45 s after startup, then every 5 min |
| Newsletter build | 20:00 the day before the send (Fridays, New York time) |
| Newsletter send | newsletter.send_day at send_time (Saturdays 08:00 New York time) |
| IMAP poll | never: review by email is not built |
Shutdown is bounded to 85 s, inside systemd’s 90 s stop timeout: 60 s for running jobs, 10 s for HTTP, 15 s for request-started rewrites.
GitHub Actions tests gate deploys CI and deploys
deploy.yml runs on every push to main: test (go vet, go test -race,
the node tests) → build (linux/arm64) → config (writes config.yaml
from secrets, runs -check-config) → deploy (copy files, validate and
reload Caddy, refresh Alloy and the backup timer, swap the binary, restart,
check it is active) → seed and grafana. Deploys queue rather than
overlap. ci.yml checks pull requests; backup.yml takes the daily off-box
backup.
- Code
.github/workflows/deploy.yml,.github/workflows/ci.yml,.github/workflows/backup.yml
Read more: Deployment
Docs site Cloudflare Pages Docs hosting
This site: Hugo with the Hextra theme, in docs/, published by Cloudflare
Pages at docs.milesahead.kumaranik.com. This diagram is the architecture
shortcode, drawn from docs/assets/architecture/architecture.yaml.
- Code
docs/
Read more: Development
Grafana Alloy metrics · logs Monitoring
Runs on the VM under systemd. Scrapes localhost:9090/metrics every 30 s
and remote-writes to Grafana Cloud’s Prometheus, and ships the
milesahead.service journal to Grafana Cloud’s Loki.
- Code
deploy/alloy-config.alloy,deploy/alloy.service
Read more: Observability
Grafana Cloud dashboards · alerts Monitoring
Holds the metrics and logs, two dashboards and five alert rules, all kept in
deploy/ and pushed by the deploy’s grafana job when its secrets are set.
- Code
deploy/grafana-alerts.json,deploy/grafana-dashboard-business.json,deploy/grafana-dashboard-service.json
| Alert | Fires when |
|---|---|
| No articles generated in 24 h | fewer than 1 generated in 24 h, for 1 h |
| AI API failures sustained | more than 30 final AI API failures in 6 h |
| Enrichment reconciler has stalled | no completed pass for over an hour |
| Confirmation emails failing | 2 or more failed in 6 h |
| Newsletter send failed | any failed send in 6 h |
Read more: Observability
Key components
| Package | Purpose |
|---|---|
internal/scanner | Fetches RSS feeds and scrapes blog pages into raw_articles; SHA-256 of title and link rejects duplicates |
internal/cluster | Groups the last 48 hours of posts about the same story by title similarity (scanner.cluster_threshold) |
internal/storydedup | Story-level duplicate check against every article from the last 7 days: TF-IDF prefilter, then one short AI call (scanner.story_dedup) |
internal/ai | Generator with fallbacks, critic chain, auto-revision, the passed-check filter, source-reference and news-site guards, key details, model preflight, prompts |
internal/editornote | Splits admin-only editor notes (<div class="editor-note">) out of article content |
internal/enrich | Enrichment reconciler: Scout’s Take, key details, tags and the image every 15 minutes; resizes stored images; serves “Find a new image” requests |
internal/images | Image search across four APIs, text and vision scoring, URL sizing and byte caps, and picture identity across sizes (identity.go) |
internal/article | Article manager: status changes with the revision guard, undo, rewrites and the revision reconciler |
internal/cardupdate | After an approval, drafts updates to credit-card pages the article affects, for the admin to review |
internal/linker | Finds card mentions in an article and links them to the card pages |
internal/db | SQLite (WAL) access, migrations, the FTS5 search indexes, the admin queue, image requests, approval undo, story coverage |
internal/web | Public site, admin dashboard, sign-up and unsubscribe, JSON API, error pages, caching headers |
internal/scheduler | Cron jobs: scan, enrichment, revision reconciler, newsletter build and send, mail polling |
internal/newsletter | Weekly issue: build, AI pick, Card of the Week, per-reader send; the admin’s manual send |
internal/mail | Outbound SMTP (outbound.go: confirmations, newsletters with per-reader List-Unsubscribe, vote alerts to votes.alert_email) and the IMAP review-reply poller (inbound.go, not running in production) |
internal/offer | Reads an offer’s end date from its key details (expires_on) and decides, in Hawaii time, whether it has ended |
internal/metrics | Prometheus metrics served at /metrics |
internal/vote | Reader votes with downvote alerts: counted over votes.alert_window, recorded in vote_alerts for the admin Published page |
internal/config | YAML config; ${NAME} expands only when it is a whole value |
internal/seeddata | Real card data plus made-up articles for tests and local previews, never production data |
The commands in cmd/ are the server (milesahead, with the scan loop in
scan.go) and its tools: seed-cards and seed-structured (card pages and
comparison data), ab-models (measure candidate models), flag-outlet-mentions
(read-only report of articles naming a news site), repair-editor-notes (move old
inline notes out) and backfill-scouts-take. See
Development for how to run them.
Database tables
One SQLite file in WAL mode. Migrations in internal/db/migrations.go run at every
start and are idempotent.
| Table | Purpose |
|---|---|
sources | Blogs and news sites to scan, with RSS URL, scrape selector and interval |
raw_articles | Scanned posts, unique by a hash of title and link |
generated_articles | Articles with status, revision number, structured fields and enrichment state (columns below) |
article_sources | Which posts an article was written from |
covered_raws | Posts an article covers without citing them: late coverage skipped as a duplicate, with the reason. Read only by the duplicate check, so an article’s sources stay as written |
verification_results | Critic score, issues and the four checks for each revision |
admin_feedback | Rewrite requests, from the dashboard or (in code) email |
article_revisions | Each rewrite’s before and after content, linked to the feedback it answered |
approval_undo | The last approval of each article and the state before it, for the dashboard’s 30-second Undo |
article_image_requests | Pending or failed “Find a new image” requests, with the admin’s search words and a sequence number |
article_rejected_images | Pictures an admin turned down for an article, never picked for it again |
article_votes | Reader up and down votes, one per hashed IP |
vote_alerts | Articles whose recent votes crossed the alert line, with the counts, until the admin acknowledges them (at most one open per article) |
newsletters | Issues with their HTML, status and send time |
newsletter_candidates | The articles ranked for a weekly issue, and which is featured |
subscribers | Newsletter addresses, confirmation and unsubscribe state |
admin_sessions | Admin session tokens and expiry |
reference_pages | Credit-card pages: sections, verdict, keywords, structured comparison data, pending updates, flags |
articles_fts, cards_fts | FTS5 search indexes (porter stemming, accents folded) over articles and cards |
search_index_versions | The version each search index was built with; a change rebuilds it once at startup |
Columns added to generated_articles over time: tldr, key_details, links,
image_url, image_thumbnail, image_attribution, scouts_take, tags_json,
related_cards_json, linked_cards_heading, enrichment_attempts,
enrichment_last_at, expires_on (the offer’s last day), revision_failures,
revision_retry_after, revision_last_error (the revision reconciler’s retry
state) and editor_notes (admin-only). subscribers gained unsubscribed_at (an
unsubscribe keeps the row) and confirmation_sent_unix (the 10-minute resend
cooldown).
Tech stack
| Layer | Technology |
|---|---|
| Language | Go 1.26, standard library net/http and html/template |
| Database | SQLite in WAL mode via modernc.org/sqlite (pure Go), FTS5 for search |
| AI models | NVIDIA API: nvidia/nemotron-3-ultra-550b-a55b writes (fallbacks openai/gpt-oss-20b, nvidia/nemotron-3-super-120b-a12b); critic chain nvidia/nemotron-3-super-120b-a12b then moonshotai/kimi-k3, thinking off; short calls on openai/gpt-oss-20b then nvidia/nemotron-3-super-120b-a12b; vision meta/llama-3.2-11b-vision-instruct |
| Images | Unsplash, Pexels, Openverse and Wikimedia Commons APIs |
| Feeds and scraping | gofeed, goquery |
| Scheduling | robfig/cron v3 |
gomail over SMTP to OCI Email Delivery; go-imap v2 for the (unused) reply poller | |
| Front end | Server-rendered HTML, plain CSS and vanilla JavaScript, tested with node --test |
| Hosting | OCI ARM VM (VM.Standard.A1.Flex, us-sanjose-1, Always Free), Ubuntu, systemd; Caddy in Docker, shared with other sites |
| DNS | Cloudflare |
| Observability | Prometheus metrics, Grafana Alloy, Grafana Cloud (metrics, logs, dashboards, alerts) |
| CI/CD | GitHub Actions: tests gate every deploy |
| Docs | Hugo with the Hextra theme, on Cloudflare Pages |