Brief

your feed, learned one read at a time

Your Feed · PersonalizedFresh stories fetched by scheduled workflowSUNDAY 27 SEPTEMBER 2026
← Back to your feed

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. 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 own try/catch, so one broken feed is recorded and skipped while the rest continue. Adapters all return { source, title, canonicalUrl, summary, publishedAt }.

  2. 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 normalizeArticle strips HTML, decodes entities, and computes a contentHash. The canonical_url column is UNIQUE and inserts use ON 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. 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 ≥ 2 meaningful tokens
    • published within 3 days of 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.

  4. 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 keeps art / culture from splintering.

    Visa story → immigration: 1.0, australia: 0.75.

  5. 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 as delta = 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. 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. 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.

Back to your feed →