How it works
How Brief builds your feed
Every day dozens of sources put out hundreds of stories. Brief’s whole job is to turn that pile into a feed that’s actually yours: it clears out the duplicates, works out what each story is about, and learns a little more about your taste every time you vote. Here’s the full path one story takes to reach you, step by step. Want the real numbers behind a step? Open its Under the hood.
The 30-second version
Brief is two halves that never talk to each other directly — they only pass notes through the database. One half writes (goes out, gathers news, files it neatly). The other half reads (pulls that filed news back out and ranks it for you). Your 👍 / 👎 is the bridge: it teaches the reading half what to float up.
We’ll follow one real-ish story the whole way down: “Australia tightens student visa rules amid migration crackdown”, which three different outlets happen to run on the same morning.
- 1
Fetch the news
Several times a day, Brief visits every source it follows — Hacker News and a set of RSS feeds — and pulls down whatever’s new. Each source has its own little adapter, but they all hand back the same tidy shape, so nothing downstream has to care where a story came from.
Likea clipping service that checks every newspaper through the day and drops the cuttings in one basket — and if one paper doesn’t show up, the others still get read.
Under the hood
A scheduled job hits
POST /api/poll(guarded by a secret). Every source is wrapped in its owntry/catch, so one broken feed is recorded and skipped while the rest continue. Adapters all return{ source, title, canonicalUrl, summary, publishedAt }. - 2
Clean & fingerprint it
Raw feeds are messy — HTML tags,
'gremlins, inconsistent fields. One central step scrubs every article into plain text and stamps it with a unique fingerprint, so the exact same article is never stored twice.Like retyping every scrappy cutting onto an identical index card, and refusing to file a card you already have.
Under the hood
A single
normalizeArticlestrips HTML, decodes entities, and computes acontentHash. Thecanonical_urlcolumn isUNIQUEand inserts useON CONFLICT DO NOTHING, so re-polling inserts 0 duplicates— the whole poll is idempotent. Cleaning lives in one place on purpose: a new source can’t leak markup into the database even if it forgets to sanitise. - 3
Group the same story (de-dupe)
When five outlets cover the visa story, you don’t want it five times. Brief compares headlines and merges the matches into a single story — remembering how many outlets carried it, which is itself a useful “this is a big deal” signal.
Like a news editor pinning every cutting about the same event into one folder, instead of ten folders that all say the same thing.
Under the hood
Titles are tokenised (lowercase, drop stopwords and tokens under 3 chars). Similarity is the overlap coefficient:
similarity = shared tokens / size of the smaller title
Two articles merge when all of these hold:
- overlap ≥
0.5 - they share ≥
2meaningful tokens - published within
3 daysof each other - they’re from different outlets(this stops one outlet’s templated “Stocks to watch…” headlines false-merging)
Our visa story from three outlets → one cluster with
source_count = 3. - overlap ≥
- 4
Work out what it’s about (tag)
Each story gets one or more topic labels with a strength score. Most are caught instantly by keyword rules. Anything the rules miss is handed to an AI, which either reuses an existing label or coins a brand-new one — so the vocabulary can grow toward new interests instead of hitting a ceiling.
Like a librarian stamping subject labels on each folder — and when nothing on the shelf fits, inventing a sensible new label rather than leaving it blank.
Under the hood
Tier 1 — keywords (free, instant). For each topic, count keyword hits with the title weighted double:
raw = 2 × title-hits + 1 × summary-hits score = min(1, raw / 4) // keep only if raw ≥ 2
Tier 2 — AI fallback (open vocabulary). Only for stories that match no keyword topic: the model is sent the article plus the current tag list and asked to reuse or coin a kebab-case tag (fixed strength
0.6). Batched 12 at a time, capped at 48 per poll to stay on the free tier; a near-synonym alias map keepsart/culturefrom splintering.Visa story →
immigration: 1.0,australia: 0.75. - 5
You vote — and it learns
A 👍 or 👎 isn’t about that one article — it teaches Brief about its topics. Upvote the visa story and your interest in immigration and australia both nudge up, so future stories on those topics rise. Each account learns on its own.
Liketelling the librarian “more like this” — and having them remember it by subject, not just that single clipping.
Under the hood
Every topic’s interest starts at
1(neutral). One vote per (user, article) — voting the same way again clears it, the opposite flips it (computed asdelta = new − old, so nothing double-counts). For each of the story’s tags:interest += 0.2 × delta × tag.score interest = clamp(interest, 0.1, 5)
So upvoting the visa story (immigration 1.0, australia 0.75) →
immigration 1.20,australia 1.15; topics you never touch stay at 1 and don’t sway anything. - 6
Rank your feed
Every time the page loads, each story gets a score built from three things: how much you like its topics, how fresh it is, and a nudge that stops any single outlet from hogging the top. Brand-new here with no votes? You simply get newest-first with a good spread — nothing looks broken.
Likea personal front-page editor laying out the paper by what you care about and what’s newest — while making sure it isn’t all from one masthead.
Under the hood
interest = Σ(tag.score × your-interest) / Σ(tag.score) freshness = 0.5 ^ (ageHours / 48) // 1 now, ½ at 48h, ¼ at 96h base = interest × freshness
Then a diversity re-rank fills slots one at a time: a story’s effective score is discounted for each story already shown from its outlet.
effective = base × 0.7 ^ (stories already shown from this outlet)
So the 2nd story from an outlet is ×0.7, the 3rd ×0.49 — outlets interleave. We pick one at a time (not a plain sort) because each pick changes the next round’s penalties.
- 7
Summarise on demand
Brief doesn’t waste money summarising things you’ll never open. Hit Summarize on a story and it fetches the full article, has an AI write a short recap, and remembers it — so opening it again (for anyone) is instant and free.
Like asking a colleague for the gist only when you actually care — and having them jot it on the folder so no one has to read it twice.
Under the hood
POST /api/enrich: cached summary → returned free; otherwise a reader service pulls clean article text and a single AI call summarises it in 3–4 sentences, stored on the article and shared across users. Paywalled or unreadable pages degrade gracefully to just the link, with a diagnosable reason — never a broken card.
Every number here is a knob
The 0.5 match threshold, the 48-hour freshness half-life, the 0.7 diversity penalty, the 0.2 learning rate — none are magic. Each is a single named constant at the top of its file, tuned to taste. Turn one, redeploy, and the feed behaves differently.