article_generation Lambda Overview
Triggered by messages on the SQS article-generation queue, this Lambda produces a
"10-second race result summary" (one headline plus three sentences) from the race result, the
race video and โ for horse racing โ the course image, then hands it to the article_builder Lambda
over the SQS article-builder queue.
This Lambda does not build the article HTML. Rendering and persistence (moving status from
generating to draft) are article_builder's job.
See I/O definition for the schemas.
Trigger
| Sender | When |
|---|---|
App Lambda (odds_poc_app) |
On an article generation request (POST /v1/articles/generation) |
App Lambda (odds_poc_app) |
On a regeneration request for a failed article (POST /v1/articles/{article_id}/regeneration) |
Two-stage generation
Generation is split into stage 1: overall race analysis and stage 2: article writing, because multimodal analysis and text writing call for different models.
| Stage | Package | Model | What it does |
|---|---|---|---|
| 1 | race_analysis_agent |
Amazon Nova Pro | Analyses the race video, course image and race result together into a single summary |
| 2 | article_generation_agent |
Claude Sonnet (falls back to Nova Pro) | Writes the 10-second summary, grounded in confirmed facts and the stage-1 summary |
Stage 2 uses Claude Sonnet when BEDROCK_CLAUDE_SONNET_PROFILE_ARN is set, and falls back to Nova Pro
otherwise. Stage 1 stays on Nova Pro, which supports video input.
Processing flow
- Receive the SQS event, parse and validate the payload
- Connect to DocumentDB (before generation, since failure recording also needs the DB)
- Check the target
articleis stillstatus=generating; otherwise exit without doing anything (guards against redelivery for a discarded article) - Check the race result has a finishing order (not void / cancelled); otherwise mark
status=failedand exit - Fetch media from S3
- Race video
{racing_type}/video/{race_id}.mp4โ passed to Bedrock as an S3 URI (too large for the Converse API's 25 MB inline limit) - Course image
horse_racing/course_image/{race_id}.gif(horse racing only) โ passed as inline bytes - Both are optional: missing media is treated as absent rather than failing generation
- Race video
- Load NG words from DocumentDB and build the Aho-Corasick matcher
- Stage 1: analyse the race with
race_analysis_agent - Stage 2: write the article with
article_generation_agent(subject to the guards below) - Assemble the accuracy-verification data (
accuracy_test_data) - Send the result to the SQS
article-builderqueue
flowchart TD
AppLambda([App Lambda]) -->|SQS| Start
Start[Receive SQS trigger] --> Parse[Parse payload]
Parse --> DB[Connect to DocumentDB]
DB --> Guard[Check article is generating]
Guard -->|not generating| Skip[Exit without work]
Guard -->|OK| Void[Check finishing order]
Void -->|none| Failed[Mark status=failed and exit]
Void -->|OK| Fetch[Fetch video / course image from S3<br/>continue if missing]
Fetch --> Analyze[Stage 1: race_analysis_agent]
Analyze --> Write[Stage 2: article_generation_agent]
Write -->|guards unresolved| Error[Raise]
Write -->|success| SendSQS[Send to article-builder queue]
SendSQS -.->|SQS| Builder([article_builder Lambda])
Error -->|final attempt| MarkFailed[Mark status=failed with the reason]
Factual guards
Asking an LLM to transcribe values or perform multi-step matching produces mix-ups, so the division of labour is: the machine interprets, matches and transcribes; the LLM only writes the Japanese.
| Guard | What it does | Owner |
|---|---|---|
| Eligibility | Races with no finishing order (void / cancelled) are never generated | race_result_facts.void_reason |
| Confirmed facts | Lap-by-lap and corner-by-corner leaders are resolved down to "number + name" before being handed over | race_result_facts.build_fact_block |
| Placeholder substitution | Entrant "number + name", payout amounts, combinations and popularity are written as placeholders and filled in from the official data by code | article_generation_agent.placeholders |
| Popularity correction | Popularity written directly (without a placeholder), and "the Nth favourite finished Mth" mix-ups, are corrected to the official values | race_result_facts.article_checks |
| Fact checking | Number-to-name mapping and per-bet-type payouts are machine-checked against the official data | race_result_facts.find_fact_errors |
| NG word screening | The generated text is scanned for prohibited words (matches inside entrant/jockey names are excluded) | ng_word_filter |
| Length | Each sentence must be 60 characters or fewer | article_generation_agent |
When a guard trips, the findings are fed back into the prompt and the article is rewritten (up to twice). If it still cannot be resolved, an exception is raised โ articles that contradict the facts or contain prohibited words are never published. Length is the exception: it is not a factual problem, so an over-length sentence is logged but does not block publication.
Error handling
| Situation | Behaviour |
|---|---|
| Failure on a non-final attempt | Re-raise, leaving recovery to SQS redelivery |
| Failure on the final attempt | Mark the article as status=failed with the reason in generation_error, swallow the exception and finish |
Whether a delivery is the final attempt is decided from the SQS record's ApproximateReceiveCount and
SQS_MAX_RECEIVE_COUNT (default 2, kept in sync with the queue's redrive_policy.maxReceiveCount).
Why the final attempt is not left in the DLQ
Reprocessing the same input fails for the same reason, so the message has no value in the DLQ.
Rebuilding is left to regeneration from the admin UI
(POST /v1/articles/{article_id}/regeneration, with extra instructions). Since the exception is
swallowed, the stack trace is logged for investigation.
Generation failures used to hard-delete the article, which made races with "generated but no
article" indistinguishable in the list. They are now kept as failed with a reason, and act as the
starting point for regeneration.
Bedrock retries
Transient Bedrock errors are retried in the application, since botocore does not retry them.
| Error code | Handling |
|---|---|
ModelErrorException (corrupted ToolUse token sequence) |
Retry after a short delay |
ModelTimeoutException / ServiceUnavailableException |
Retry after a short delay |
ThrottlingException (TPM quota exceeded) |
Retry after a longer delay, enough for the one-minute window to recover |
AWS services and libraries
| Service / library | Use |
|---|---|
| Amazon SQS | Trigger (article-generation) and result dispatch (article-builder) |
| Amazon S3 | Fetching the race video and course image |
| Amazon Bedrock | Stage-1 analysis (Nova Pro) and stage-2 writing (Claude Sonnet / Nova Pro) |
| Amazon DocumentDB | Loading NG words, checking article status, recording failures |
| LangGraph | Building and running the stage-1 analysis agent (tool-calling loop) |
DocumentDB access
This Lambda does not store the article body, but it does reach DocumentDB to load NG words and to record generation failures. Storing the body is article_builder's job.