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.
The orchestrator asks: a topic, who the readers are, and whether it wants a question.
{text, audienceContext?, includeQuestion?} — extra fields 422, lengths capped.
A Google News search for the topic; zero hits → the top-headlines feed instead.
SerpApi google_news, free-text q; 1 retry, Retry-After honored; empty result → one fallback search without q (top headlines).
≤sA 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 capWhile the model decides, the likely sources are already being downloaded.
Top clusters × URLs, cancelled if unused.
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 ≤ sourcesThe 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 leftOptionally, 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-inThe 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.
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.
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).
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.
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.
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.
news source:provider: …
Fail-fast: missing/partial keys abort startup unless NEWS_ALLOW_FAKE_PROVIDER=true (…).
AI access:Gemini key: …
Content logging: …
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.
The knobs, from constants.py — each has a rationale comment there.