digitalme-news architecture

Ask for a story.
Get a briefing written for your audience.

A stateless one-shot pipeline:
search → select → scrape → write, under a deadline budget.

One request comes in with a topic and a description of the readers. The service finds the most relevant fresh story on Google News, reads its sources, and writes a short briefing tuned to that audience — optionally with one follow-up question. Nothing is stored; the response is the product.

POST /internal/news/articles/generate runs the whole pipeline inline (ADR 0009). No database, no scheduler, no chains — three LLM calls max, structured concurrency, every stage bounded by a slice of a s deadline. SKIP is a typed decision (200 + reasonCode); failure is 502; deadline is 504.

selector … writer … question … provider … LLM key …

→ Request

The orchestrator asks: a topic, who the readers are, and whether it wants a question.

{text, audienceContext?, includeQuestion?} — extra fields 422, lengths capped.

2 Select

A fast model picks the one story this audience would care about — from headlines only. Nothing fits? The top story is served instead — never an empty answer.

Clusters preserved; candidate id validated against the input set; null/failure → top cluster fallback.

≤s cap

∥ Speculative scrape

While the model decides, the likely sources are already being downloaded.

Top clusters × URLs, cancelled if unused.

3 Scrape

Full text of the chosen story's sources is fetched — fastest first; an unreadable story hands off to another one.

Serper.dev, as_completed race: proceed at bodies; semaphore ; zero bodies → rescue on another cluster.

≤s ≤ sources

4 Write

The briefing is written for this exact audience — short, factual, ≤100 words. If the writer declines the story, the next one gets one retry.

Frozen articles_writer.md; CURRENT_PROFILE = audienceContext; bodies truncated to chars. SKIP → one retry on the next cluster (budget permitting); a second decline stays a 200-null.

min s left

? Question

Optionally, one plain follow-up question — written at the same time as the article.

Runs ∥ writer from topic+snippets; discarded if the writer skips/fails.

opt-in

← Response

The article, its sources, the question, and timings go back. Nothing is saved. An empty answer is possible only when the writer declines twice (or a retry is impossible) — rare.

200 Written / 200 null+WRITER_SKIPPED (double decline / no viable retry, reason says which) / 502 Failed / 504 deadline.

1 Search

We run a Google News search for the requested topic and group the results into story clusters — each cluster is one event covered by several outlets. If the query finds nothing at all, we search the current top headlines instead, so there is always something to pick from.

SerpApi engine=google_news free-text query; normalize() preserves cluster grouping (highlight + stories), dedups URLs in-request, caps at candidates. One retry with jitter; 429 honors Retry-After within the stage budget. Zero candidates → one fallback search without q (the top-headlines feed); both empty → 502.

budget s cap

2 Select the quality gate

A fast model reads only the headlines and snippets and picks the one story this audience would find most interesting. If nothing truly fits, the reader still gets the top story of the moment — an empty answer survives only when the writer itself declines twice. While the model thinks, we already start downloading the most likely sources in the background.

Selector LLM (, thinking minimal) returns selectedCandidateId — validated against the input set (hallucination guard) — plus the writer's TOPIC slot. Never-empty policy: null/failure degrades to the top cluster (warning in meta); zero search candidates fall back to the top-headlines feed first. Speculative scrape: top clusters × sources, cancelled when unused (structured concurrency — no task outlives the request).

budget s retries

3 Scrape

We download the full text of the chosen story's sources — whichever answers first wins; one slow site never stalls the request. If that story turns out to be unreadable (paywall, cookie wall), we move to another story instead of failing.

Serper.dev scrape of up to selected sources; as_completed race proceeds at bodies (≥1 required); remaining fetches cancelled. App-wide semaphore bounds burst fan-out. Zero bodies → rescue: an already-speculatively-scraped cluster (free), else one extra scrape round on the next cluster while s + s remain (rescueScrapeMs). 502 only when NO cluster is scrapable.

budget s

4 Write (with the optional question in parallel)

The article is written for this exact audience: their interests shape the tone and focus, never the facts. If a question was requested, it is written at the same time, so it costs no extra wait. The story's image is checked in parallel too.

Writer LLM (, thinking low) on the frozen articles_writer.md: TOPIC from the selector, bodies truncated to chars, CURRENT_PROFILE = audienceContext. Question LLM () runs ∥ from topic+snippets, discarded unless the writer returns Written. Image HEAD-validated ∥. Writer never starts with less than s remaining → 504 instead of a doomed call.

question needs s+

Deadline budget

The whole request must finish within seconds. Every stage gets its own slice, so one slow step can't eat everyone else's time.

Retries only run while their stage budget allows; attempt counts and per-stage ms are returned in meta.timings and logged.

select s
scrape s
write ≥s

Integrations

news source:provider: …

Fail-fast: missing/partial keys abort startup unless NEWS_ALLOW_FAKE_PROVIDER=true (…).

AI access:Gemini key: …

Content logging: …

What this service deliberately does NOT do

It keeps no history, sends nothing anywhere on its own, and forgets each request the moment it's answered.

No Postgres, no scheduler, no chains/actualization, no push (ADR 0004/0009). The response is the only output; observability is the structured per-request log line.

Tunables

The knobs, from constants.py — each has a rationale comment there.

request deadlines
search budgets
selector budgets
scrape budgets
writer min budgets
question min remainings
candidate cap
speculative clusters
spec. sources / cluster
max selected sources
min bodies (early)
body max chars
max concurrent scrapes
LLM retries
text max chars
audience max chars