Somewhere in the middle of those code sessions, I had a simpler thought: if I, as a software developer, could make sense of that same stream — typed events, timestamped, player names, statistics attached — why couldn’t an LLM?

It was from that premise that I started this project.


Two systems, one shared database

The narration pipeline and the editorial pipeline don’t call each other. The connection between them is a Firestore collection: one system writes to it, the other reads from it. Neither was designed with the other in mind — the integration works because the narration data happened to be structured well enough to serve as source material for journalism.

That shared database is the contract between two systems that don’t know each other exists. Which is elegant, until something changes on one side without the other knowing. Two systems sharing a collection with no explicit schema contract means any change to how narration events are structured can silently break the editorial pipeline. We didn’t hit that wall during the Copa — but the wall is there.


What the AI actually receives

A goal event as it comes from the data API is a flat object: player ID, team ID, minute, period, timestamp. That’s enough to send a push notification. It’s nowhere near enough to write a paragraph.

Before anything goes to the AI, the editorial job assembles context. When a match finishes, it reads all narration events for that match from Firestore — in chronological order, goals first, then cards, substitutions, and period changes — and pairs them with aggregate match statistics fetched from the data API: possession, shots on goal, fouls, corners.

The result isn’t a raw data dump. It’s closer to a structured match report: what happened, in order, with the numbers that give those events meaning. A goal in the 78th minute by the trailing team reads very differently when you also know the trailing team had 35% possession and three shots on target in 90 minutes. That context is what we built the prompt around — and structuring the input like a journalist would structure their notes turned out to matter more than the prompt itself. The first version sent too much noise and too little signal.

We used LangChain to orchestrate the chain — assembling the input, calling the model, and parsing the response. The model returns four fields: headline, support line, deck, and body. The first three are structural; the body is where the configurability lives. Unlike Jinja2 templates, which always produce the same structure, the body shifts with the prompt — tone, emphasis, voice — without touching the pipeline. Orchestrating those steps as a chain kept the logic readable and the pieces independently testable. The data is always the same. What changes is how you tell the story — and that’s the part we managed to put in the journalist’s hands.

Here’s what came out the other side. For a match that ended 2–1, decided by a goal in the 89th minute after a red card in the 64th:

Headline: Late drama seals narrow win as ten-man side holds on

Deck: A second-half red card turned a comfortable lead into a tense finale — and the statistics backed every anxious minute of it.

Body: For sixty-three minutes, the match looked settled. Then the red card changed everything. What followed was twenty-six minutes of sustained pressure — 71% possession for the trailing team in the second half, eight shots on target — that only converted once, too late to level the tie. The goal that settled it came in the 89th minute. By then, it felt inevitable.

The model didn’t have access to words like “tense” or “inevitable” in the input. It inferred them from the shape of the numbers.


What the AI did with it

This is the part that took some adjustment to trust.

Language models have surprisingly good priors about football. They understand that a red card in the first half changes the shape of a match. They know that a goal against the run of play is worth naming as such. They can read “67% possession, 0 goals” and understand it as a narrative of domination without reward. None of that is in the data explicitly — it’s inferred from what the data describes.

What the model can’t do is recover from gaps in the event log. If the narration pipeline missed a substitution — because of a polling gap or a late API response — the AI writes about the match as if that substitution never happened. It doesn’t know to flag the absence. It just tells the story it was given.

That dependency is worth sitting with: the quality of the AI output is bounded by the completeness of the event log. Prompt engineering helps, but only up to the point where the data runs out.

There’s a second, quieter limitation: the model has no memory across matches. It can’t tell you that a player has scored in three of the last five games, or that this team has never won a knockout round against this opponent. That kind of inference requires historical data — and right now, we don’t feed it any. Every article is written as if the tournament started five minutes ago.

The fix isn’t complicated in concept: after each match, ingest the structured event and statistics data into a persistent store. For aggregate queries — win rates, goal tallies, head-to-head records — a relational database works fine. For something closer to semantic retrieval, where the model could pull relevant historical narratives rather than raw numbers, a vector database would be a better fit. Either way, the pipeline doesn’t change much; what changes is how rich the context assembled before the model call can be.


The editorial interface that doesn’t exist

When the job finishes, the article goes directly to the CMS. No queue, no review screen, no approval step. The editorial team gets an email — headline and tags — after the piece is already live.

This is the most counterintuitive part of the product, and worth naming plainly. We’re not building tools for editors. We’re running a publishing pipeline that notifies editors when it’s done. The distinction matters more than it sounds — it changes what failure looks like, who’s responsible for quality, and what monitoring actually needs to catch.

The editorial team’s visibility ends at the email. The engineering team’s visibility is a different story. With Prometheus instrumented across the pipeline, we have dashboards that track what the editorial team never sees: how many articles were generated per match, how long each stage took, how often jobs failed silently. When there’s no human in the approval loop, metrics become the closest thing to oversight you have.


Latency, and why it matters less than I thought

A reader coming to a post-game chronicle wants something to read, not a score they already know — so being first by thirty seconds is irrelevant. The pressure on latency belongs to the narration product: goal notifications, card alerts, live updates. For editorial, correctness and completeness matter more than speed. A few minutes after the final whistle is fine.

The latency here is compounded: up to 60 seconds for the match-end event to be detected by the narration pipeline, then however long until the editorial job’s next cycle. The practical window is usually two to five minutes after the final whistle. That’s fast enough — but “usually” is doing a lot of work in that sentence.

Knowing what’s actually happening in production requires measurement. We track end-to-end latency as a histogram in Prometheus: from the moment the match-finished event is detected to the moment the article reaches the CMS. Histograms let you look past averages — the p90 tells you what the experience looks like on a bad day, not just a typical one. During the Copa, the p90 stayed under four minutes. That number only has meaning because we measured it; without the histogram, “usually two to five minutes” is just a guess.

The rate() and increase() functions in Prometheus were useful in a different way: tracking how often jobs were firing, how many articles were generated per competition window, and whether the polling pipeline was calling the data API at the expected frequency. That last metric mattered more than it seemed — and became central to what broke in the next phase of the project.

The riskier edge case is the editorial job firing before all events have been written to Firestore. A late API response, a polling cycle that caught the final whistle but not the goal scored just before it. The job takes whatever is there and publishes. There’s no reconciliation, no second pass — and no alert telling you the chronicle is missing an event.


What we learned

Structure the input like a journalist would structure their notes. The model performs better when the context mirrors how a human would prepare to write. Chronological events plus supporting statistics, not a flat list of fields. That framing took iteration to land on — the first version sent too much noise and too little signal.

LangChain earned its place in the stack. Orchestrating the assembly, the model call, and the response parsing as a chain made the logic readable and the pieces independently testable. For a one-pass pipeline with a structured output schema, it was the right tool.

The shared database is convenient until it isn’t. Two systems sharing a Firestore collection with no explicit schema contract means any change to how narration events are structured can silently break the editorial pipeline. We didn’t hit that wall during the Copa, but the wall is there.

“The AI will figure it out” only works when the data is good. The model is confident. It will write a coherent chronicle even on incomplete data, and it won’t tell you what it didn’t know. The editorial risk doesn’t come from the AI writing something wrong — it comes from the AI writing something plausible that’s missing a goal.


Everything described in this post assumes the data is flowing. The polling pipeline is running, the API is responding, events are arriving on schedule. That assumption held — until, in the middle of a live match, the data just stopped coming. The pipeline was fine. The problem wasn’t ours.