Field notes · what I learned building it
The engineering ideas I ran into while building a multi-source, agentic AI news-briefing pipeline — each with the plain lesson and the line to say out loud in an interview.
I didn't build a "deterministic app" and an "agentic app." I built one pipeline where every stage is shared except the curation step, which sits behind a Curator abstract base class. A deterministic ranker and an ADK agent both implement the same curate() method, so a config flag swaps them without touching anything else.
I used the Strategy pattern so the deterministic and agentic curators were interchangeable behind one interface — comparing them was a config flag, not a rewrite.
An ABC does two things at once: it lets me add new strategies freely (extensibility), and @abstractmethod makes the program refuse to run a subclass that forgot to implement the method (an enforced contract). The same idea gave me a Source ABC so Gmail, RSS, Hacker News, and a stock API all return the same normalized Story object — the adapter pattern.
I normalized every source into one schema behind a Source interface, so the pipeline downstream was completely source-agnostic — adding a feed was a new adapter, not a pipeline change.
New topics get added in a feeds.yaml file, not in code — the engine is open to new behavior but closed to modification. The feed catalog (sources, word budgets, prompts) is data; the engine that runs it is code.
For a combined digest, the wrong move is dumping every source into one giant prompt — each topic needs its own voice, and you blow the token budget. Instead each topic is rewritten with its own persona and word budget, then a composer stitches the segments with spoken transitions. Adding a topic doesn't change the composer.
The delivery path (fetch → rewrite → TTS → email) stays deterministic because it's an unattended cron job that must be reliable. The agent lives only in curation, where dynamic judgment actually helps — and if it errors, it falls back to the deterministic curator so the digest always ships.
I used an agent only where dynamic judgment added value and kept the delivery path deterministic for reliability and cost — with the deterministic version as an automatic fallback.
To judge a segment, I put both the source stories and the produced script into a single prompt and ask the model to score faithfulness, coverage, and relevance. It needs both halves: you can't catch a hallucination without the source to check against, and you can't catch a missed story without seeing what could have been included.
Reference-free: the judge reasons from the source material alone. Reference-based: the judge grades against a human-written answer key (expectations.md) — my must-includes, noise to drop, duplicates to merge, and red lines. I wired the answer key into the judge so my editorial judgment became part of the automated score.
I built both reference-free and reference-based evals — the reference-based one grades output against a golden answer key I wrote, so the score reflects real editorial intent, not just internal consistency.
A golden set is captured source stories (sources.json, frozen so it never changes) plus my expectations. Because the input is fixed, when I tweak a prompt any change in the score is caused by my change, not by the news being different that day. That's what makes an eval a fair, repeatable test.
A plain generate_content() call has no internet access — it only sees the text in the prompt. A URL in the data is just characters; the model can't click it. Web access comes only from a tool (my agentic curator has a fetch_article() tool; the judge deliberately doesn't).
Coverage and relevance hold up with headline-only sources — a headline is enough to tell whether a story was included and on-topic. Faithfulness does not — verifying invented details needs the full article text.
A stateless daily job can't say "Y beats last week's X" because it never saw X. I persist a compact record after each run and give the agent a recent_digest_history() tool to read it. That cross-run memory — distinct from the agent's within-run context — is what enables competitive comparisons.
The same Gemini SDK can talk to two backends: Vertex AI (uses your cloud project + ADC credentials) or AI Studio (needs an API key). ADK defaulted to the API-key path and failed with "No API key provided" until I set GOOGLE_GENAI_USE_VERTEXAI=True plus project and location.
One eval run is N generation calls + N judge calls (plus curation if agentic) — roughly 8–12 model calls for four segments, doubled when comparing two curators. And using the same model to generate and to judge invites self-bias; a common practice is a stronger or different judge model.
Every TTS engine caps input per request, because one call is meant for a chunk of speech, not a whole document. A ~1,000-word briefing can't go in one call — so the code splits the script on sentence boundaries, synthesizes each chunk, and concatenates the audio bytes into one file.
| Path | Per-request limit |
|---|---|
| Gemini TTS | 4,000 bytes |
Standard synthesize_speech (Chirp3-HD, Neural2) | 5,000 bytes |
| Long Audio API (async, writes to GCS) | ~1,000,000 bytes |
This one bit me. A — em-dash is 3 bytes; a curly quote is 3 bytes. A 4,500-character script can be well over 5,000 bytes, so my chunker had to measure UTF-8 byte length, not len().
The TTS limit is measured in bytes, not characters, so multi-byte punctuation like em-dashes can push you over even when the character count looks safe — I made the chunker byte-aware.
In Cloud TTS, VoiceSelectionParams takes a voice name and language_code. To use Gemini TTS you also pass model_name (only in library ≥ 2.29.0) and can add a style prompt on SynthesisInput. Passing a model into the wrong field — or as the old model= — just errors.
I A/B'd a premium Gemini TTS voice against Chirp3-HD and honestly couldn't tell the difference — so I dropped the preview model (it's unstable and forced that 4,000-byte limit) for the stable voice. "It feels like it's just reading the screen" is a script and format problem — the fix is conversational writing or a two-host dialogue, not a fancier voice.
I tested a premium TTS model, measured no perceptible gain over the stable voice, and cut it — naturalness comes from the script and format, not the voice tier.
Pushing to GitHub stores and versions code; it runs nothing. Deploying to GCP (via deploy.sh → Cloud Build → Cloud Run) is what actually runs it — and it deploys the code in my working folder, not what's on GitHub. Safe order: merge the PR → pull main → deploy.
A push uploads a branch to GitHub; a pull request is a separate proposal to merge that branch into main, reviewed and accepted with a Merge button. And all branches share one working folder — uncommitted edits float and aren't tied to a branch, which is why Git blocks a switch that would overwrite them (commit or stash first).
The per-segment word budget does four jobs at once: it controls total listen time (~150 words/minute), allocates attention by topic (AI gets more than markets), forces concision so the model prioritizes instead of dumping, and gives the eval a concrete target to score.
My A/B result was honest: for a typical day, the agentic curator improved relevance and competitive framing but didn't justify ~2× the latency and cost, and both curators shared a coverage gap I traced to ranking. Reporting that plainly — instead of declaring the fancy thing a win — is the mature move.
I A/B'd an agentic curator against a deterministic baseline with an eval harness. The agent improved relevance and framing but didn't justify 2× latency on typical input, and both shared a coverage gap I traced to ranking — so I kept the baseline as default and the agent behind a flag.