AI-powered football takes, straight from the touchline. Target domain: touchlinetakes.com
Touchline Takes is a football news site run by an automated, multi-agent newsroom. Stories move through specialised agents: ingest, classify, research, analyse, write, fact-check, edit and publish. A Flask site shows what the pipeline publishes, and a human editor can approve or reject drafts before they go live.
Two rules hold throughout:
[S1] that points to a source story, a results-data record, a fixture or a model
output. A claim that can’t be traced is removed, sent back to the Writer, or blocked when
the Publisher runs its final check.Everything runs offline by default, with no API keys. It falls back to bundled sample feeds, synthetic results data, template writing, a lexicon classifier and TF-IDF embeddings. Configure the real providers when you have them (see Going online).
flowchart LR
subgraph Sources
RSS[RSS feeds<br/>BBC Sport etc.]
API[football-data.org<br/>fixtures]
CSV[Football-Data.co.uk<br/>results CSVs]
end
RSS --> ING[Ingest agent<br/>normalise + embedding dedupe]
ING --> DB[(SQLite)]
DB --> CLS[Classifier agent<br/>league / team / topic / sentiment / emotion]
CLS --> PHO[Photo tagger<br/>VGG16 transfer learning]
PHO --> IDX[Research index<br/>RAG: loader → splitter → embeddings → Chroma]
API --> ANA[Analyst agent<br/>logistic regression W/D/L]
CSV --> ANA
IDX --> WR[Writer agent<br/>facts-first drafting]
ANA --> WR
CSV --> WR
WR --> FC[Fact-check agent<br/>every claim → cited evidence]
FC -->|too many unsupported claims| WR
FC --> ED[Editor agent<br/>style · tone · length · originality]
ED -->|send back, max 2 revisions| WR
ED -->|approved| PUB[Publisher<br/>guardrail + RSS]
PUB -->|AUTO_PUBLISH=false| REV[Admin review desk]
REV --> SITE[Flask site · /ask · /feed.xml]
PUB -->|AUTO_PUBLISH=true| SITE
IDX --> ASK["/ask QA chain"]
The pipeline runs as a LangGraph StateGraph (touchline_takes/graph.py). This is the
actual compiled graph, printed by python -m touchline_takes.graph (dotted lines are
conditional edges):
graph TD;
__start__([start]) --> ingest;
ingest --> classifier;
classifier --> photo_tagger;
photo_tagger --> research;
research --> analyst;
analyst --> writer;
writer --> factcheck;
factcheck --> editor;
editor -.->|drafts need revision, max 2| writer;
editor -.-> publisher;
publisher --> finalize;
finalize -.->|drafts await approval| human_review;
finalize -.-> __end__([end]);
human_review -.->|still pending| human_review;
human_review -.-> __end__;
GraphState) carries the
NewsroomState object and a revision-round counter. Nodes are timed and isolated from
failures, so one agent erroring doesn’t sink the run.editor → writer. Drafts the Fact-checker
or Editor sends back are rewritten, at most MAX_REVISIONS times.AUTO_PUBLISH=false, the human_review node calls LangGraph’s
interrupt(). The run pauses, and its full state is saved to a SQLite checkpoint
(CHECKPOINT_PATH, one thread per run). Approving or rejecting a draft in /admin, or
with python -m touchline_takes.review, resumes that run’s thread with the decision. The
node loops until nothing is pending, then the run ends. The web app and the CLI can resume
runs started by another process.ORCHESTRATOR=sequential (or --orchestrator sequential) runs
the same agents in a plain Python loop, with no checkpoints. A test checks that both
orchestrators make identical editorial decisions.Each agent is a plain Python class with one method:
class Agent(ABC):
name = "agent"
def run(self, state: NewsroomState) -> NewsroomState: ...
NewsroomState (touchline_takes/state.py) is the shared state object. It holds new
stories, classified stories, drafts (with their evidence, claims, feedback and rejection
history), fixtures, metrics and a per-story log. Tools such as the database, LLM, embeddings,
retriever and predictor are passed in through the constructor, so you can test each agent on
its own with fakes.
Agents don’t know about LangGraph: each node wraps an agent’s run method, so every agent
is still testable on its own. The RAG pieces mirror LangChain’s interfaces (Document,
embed_documents/embed_query, vector store, retriever, QA chain), so
langchain_chroma.Chroma and a LangChain retrieval chain can drop in.
Requires Python 3.11+ (verified on 3.13).
python -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt # runtime deps + pytest/pylint
cp .env.example .env # optional; every key is optional
All dependencies are pinned and were verified together with pip check in a clean venv.
requirements.txt holds the runtime dependencies (used by the Docker image), including
gunicorn as the production server. requirements-dev.txt adds the test and lint tools.
Provider SDKs are plain HTTP calls or the official anthropic SDK. LangGraph is the only
LangChain-family dependency (it pulls in langchain-core). The photo tagger uses Keras 3 on
the JAX CPU backend, which is much lighter than TensorFlow; jaxlib is about 90 MB.
Gradio isn’t used, so the FastAPI/Starlette version conflicts don’t come up.
One command runs the whole pipeline once:
python -m touchline_takes.run --offline --auto-publish
Drop --offline to try the real RSS feeds first; it falls back to samples if they’re
unreachable.
Drop --auto-publish to require human approval (the default, AUTO_PUBLISH=false). The run
then pauses at human_review and tells you how many drafts are waiting. Review them on
the /admin page, or from the terminal:
python -m touchline_takes.review list
python -m touchline_takes.review approve <draft id>
python -m touchline_takes.review reject <draft id> --reason "not sharp enough"
Each decision resumes the paused run where it stopped.
Photo tagger (optional): train it once, and later runs tag the photo attached to each
story. Until a model exists, the photo_tagger step logs that it skipped.
python scripts/train_photo_tagger.py
It downloads the ImageNet VGG16 weights (58 MB) on first use and takes about a minute on a
laptop CPU. Add --fine-tune 3 to also fine-tune VGG16’s last block (a few more minutes).
Scheduled mode (every 30 minutes; --max-runs N to stop after N):
python -m touchline_takes.run --schedule 30
Each run prints what every agent did, per story, followed by a JSON summary. The same log goes into SQLite and shows on the review desk.
Website:
flask --app "touchline_takes.web:create_app()" run
| Page | What it does |
|---|---|
/ |
Latest takes, plus upcoming fixtures with the Analyst’s win/draw/loss probabilities |
/article/<slug> |
An article with footnoted citations and a “Sources & data” list |
/ask |
Ask questions about the stored news (RAG with source links) |
/feed.xml |
RSS feed of Touchline Takes’ own articles |
/admin |
Review desk: approve or reject drafts, see the fact-check table for each draft, the rejection log, recent runs and the agent log. You can also trigger a run from here. HTTP Basic auth: user admin, password ADMIN_PASSWORD. With no password set, only localhost can open it. Forms are CSRF-protected. |
See DEPLOY.md. In short:
SITE_MODE=static):
content/articles/*.json. Every article is
re-validated at build time, so a hand edit can’t sneak in an unsourced claim./ask page and no web admin page in this mode.python -m touchline_takes.static run|export|build|validate..github/workflows/newsroom.yml and deploy-site.yml. Both stay off until
the repository variable TOUCHLINE_LIVE=true is set.docker-compose.yml runs them with Caddy for automatic HTTPS on
touchlinetakes.com.all mode) works with one disk.TOUCHLINE_ENV=production the app runs
python -m touchline_takes.preflight at startup. It refuses to start with weak secrets,
plain-HTTP URLs or synthetic/sample data, and never falls back to sample content.python -m pytest
pylint touchline_takes tests scripts
python scripts/evaluate.py
tests/conftest.py and uses fake sessions, FakeLLM, RecordedLLM
(tests/fixtures/recorded_llm.json) and ScriptedWriterLLM, so it needs no network or API
keys.
~/.keras/models; otherwise it’s skipped.pyproject.toml.data/eval/fixture_stories.json (25 labelled
stories with expected outcomes) in two configurations: the offline template writer, and a
scripted fake LLM writer that, for flagged stories, invents claims and swaps facts around
(flipped winners, stats pinned on the wrong team). It also scores the fact-checker on
data/eval/claim_cases.json: 56 labelled claims, including correct paraphrases to measure
false alarms, split into a dev set used while building the checks and a heldout set
written afterwards. The report (reports/evaluation.md / .json) includes:
The script exits non-zero if any published claim has no source, or if the model doesn’t beat the baseline.
Latest snapshot (reports/evaluation.md):
| template writer | scripted LLM writer | |
|---|---|---|
| Claims with a source: first pass / published | 1.00 / 1.00 | 0.88 / 1.00 |
| Unsupported claims caught (of which contradictions) | 0 (0) | 32 (14) |
| Editor rejection rate | 0.42 | 0.42 |
| Retries (total / max per draft) | 10 / 2 | 20 / 2 |
| Expectations passed | 19/19 | 19/19 |
| Fact-checker on labelled claims | Recall on bad claims | False-positive rate |
|---|---|---|
| Token-only (old), held-out 18 | 0.30 | 0.00 |
| Relation-aware (new), held-out 18 | 1.00 | 0.00 |
| Match-outcome model (test set, 278 matches) | Logistic regression | Majority-class baseline |
|---|---|---|
| Accuracy | 0.543 | 0.450 |
| Macro F1 | 0.406 | 0.207 |
| ROC-AUC (one-vs-rest) | 0.671 | 0.500 |
agents/ingest.py, tools/feeds.py)RSS_FEEDS, BBC Sport football by default) with requests and parses
them with feedparser.Story: HTML stripped, ISO date, stable id from the URL.DEDUPE_THRESHOLD, default 0.8) against the batch and the last 300
stored stories. For example, the same match report from two outlets scores 0.90; unrelated
stories score below 0.15.--offline is set, it uses data/sample_feeds/*.xml and logs that.agents/classifier.py, tools/teams.py)LexiconClassifier works offline. It uses a team/alias directory (data/teams.json),
weighted keyword topics, and a sentiment/emotion lexicon with negation.LLMClassifier (CLASSIFIER_PROVIDER=llm) asks the configured hosted LLM for JSON, checks
every label against the allowed set, and falls back to the lexicon on any failure.agents/writer.py)MAX_ARTICLES_PER_RUN.S* stories and related coverage
from the RAG retriever, ST* results-data form and head-to-head, F* fixtures, M*
Analyst predictions) and turns them into short grounded fact sentences, each with a
citation.agents/factcheck.py, agents/editor.py)tools/relations.py), which catch claims that use the source’s words but
get the facts the wrong way round:
FACTCHECK_VERIFIER=llm): one batched call per draft asks
the LLM whether each claim that passed the rules is fully supported by its evidence.
It counts against the LLM budget, and if the call fails, the rule verdicts stand.What happens next:
MAX_REVISIONS=2 revisions. After that the draft is
rejected. Every rejection reason goes to the rejections table, the draft’s
rejection_history and the review desk.tools/rag.py, tools/embeddings.py)StoryLoader → SentenceTextSplitter → embeddings → ChromaVectorStore
(persistent, telemetry off, embeddings computed by us so no model downloads; in-memory
fallback) → Retriever → QAChain.related() to pull in related coverage./ask. With an LLM configured, it writes a cited answer from the retrieved
context. Offline, it gives an extractive answer ranked by TF-IDF.EMBEDDINGS_PROVIDER=tfidf (offline hashed TF-IDF) or openai (any OpenAI-compatible
/embeddings endpoint)LLM_PROVIDER=offline|anthropic|openaiagents/analyst.py, models/match_predictor.py)FOOTBALL_DATA_API_KEY is set, otherwise
data/fixtures/sample_fixtures.json). Predictions show next to the fixtures on the home page
and feed the Writer’s previews as M1 evidence.agents/publisher.py)/admin review or, with AUTO_PUBLISH=true, publishes it.build_rss() serves /feed.xml.agents/photo_tagger.py, models/photo_tagger.py)media:thumbnail,
media:content or an image enclosure) as match_action, crowd, stadium or
press_conference, with probabilities.--fine-tune N), a fresh VGG16 base has its last convolutional block
(block5) unfrozen and is trained end to end with the head, at a low learning rate
with flip and zoom augmentation.reports/photo_tagger.md / .json and is
summarised in reports/evaluation.md.data/.Training data is synthetic for now. No labelled football photo set was reachable from
the build sandbox, so tools/synthetic_photos.py draws simple scenes for the four tags.
On that data the VGG16 model scores 100% on 60 held-out photos, against 80% for the
colour-histogram baseline and 25% for the majority class. That proves the pipeline works; it
says little about real match photos. To train on real ones, put them in
data/photos/<tag>/*.jpg (any tag names; folders are git-ignored) and re-run the training
script.
LLM_MAX_CALLS_PER_RUN uncached calls per run. Past that,
BudgetExceeded is raised and agents fall back to offline behaviour.llm_calls, tokens, USD,
seconds)..env. Nothing secret
is committed.The development sandbox for this build couldn’t reach outside hosts. RSS, football-data and LLM APIs were refused by the network proxy, so the repo ships fallbacks:
| Mocked / sample | Where | How to switch to real |
|---|---|---|
| RSS stories (fictional, “Sample Sports Wire” / “Sample Football Desk”) | data/sample_feeds/ |
Run without --offline; the default RSS_FEEDS is BBC Sport football. |
| Results data (synthetic, Football-Data.co.uk format, 3 seasons) | data/results/sample_E0_*.csv, generated by scripts/make_sample_data.py |
python scripts/download_football_data.py --league E0 --seasons 2223 2324 2425 --replace, then python -m touchline_takes.run --retrain |
| Upcoming fixtures | data/fixtures/sample_fixtures.json |
Set FOOTBALL_DATA_API_KEY (free at football-data.org). |
| LLM writer / classifier / QA | templates, lexicon, extractive QA | LLM_PROVIDER=anthropic + ANTHROPIC_API_KEY (defaults to claude-opus-5-5, with server-side refusal fallback), or LLM_PROVIDER=openai + OPENAI_API_KEY / OPENAI_BASE_URL (works with OpenAI, Ollama, vLLM). Optionally CLASSIFIER_PROVIDER=llm. |
| Embeddings | hashed TF-IDF | EMBEDDINGS_PROVIDER=openai (uses a separate Chroma collection) |
| Photo-tagger training data (synthetic scenes) and the sample feeds’ photos | generated into data/photos_synthetic/; data/sample_photos/ |
Put labelled photos in data/photos/<tag>/ and run python scripts/train_photo_tagger.py. Live RSS photos are tagged automatically once a model exists. |
Articles always say where their numbers come from. While the synthetic data is in place, stats sentences read “In our synthetic sample results, …”.
No IBM watsonx dependency: every provider is optional and has an offline fallback.
See .env.example for every variable. The main ones:
LLM_PROVIDER, LLM_MODEL, LLM_MAX_CALLS_PER_RUNEMBEDDINGS_PROVIDER, VECTOR_STORECLASSIFIER_PROVIDER, FACTCHECK_VERIFIERORCHESTRATOR (langgraph or sequential), CHECKPOINT_PATHSITE_MODE (server or static), TOUCHLINE_ENV (development or production), ALLOW_SAMPLE_DATA, ASK_RATE_LIMIT,
ADMIN_AUTH_RATE_LIMIT, PROXY_HOPS; production-only RESULTS_SEASONS and
SCHEDULE_MINUTES (see deploy/production.env.example)PHOTO_TAGGER, PHOTO_MODEL_DIR, PHOTO_MAX_PER_RUNAUTO_PUBLISH, MAX_REVISIONS, MAX_ARTICLES_PER_RUN, DEDUPE_THRESHOLDADMIN_PASSWORD, SECRET_KEY, DATABASE_PATHtouchline_takes/
agents/ ingest, classifier, writer, factcheck, editor, analyst, publisher (+ base Agent)
tools/ llm (providers, cache, budget, fakes), embeddings, feeds, football_api,
rag, relations, stats, synthetic_photos, teams, text
models/ match_predictor (logistic regression + baselines), photo_tagger (VGG16)
db/ SQLite repository
web/ Flask app factory, views, rate limiter, templates, static
graph.py LangGraph graph pipeline.py wiring + runs review.py review CLI
run.py pipeline CLI evaluation.py evaluation harness
preflight.py production checks wsgi.py gunicorn entry point
static.py static-mode CLI static_site.py site builder
content.py article files github_review.py review PRs
config.py settings from env state.py shared state objects
tests/ unit + end-to-end tests (offline)
data/ sample feeds, synthetic results, fixtures, teams, eval fixtures
scripts/ evaluate.py, train_photo_tagger.py, make_sample_data.py,
make_sample_photos.py, download_football_data.py
reports/ latest evaluation report
content/articles/ published articles (static mode; added by merging review PRs)
.github/workflows/ newsroom, deploy-site, validate-content, CI
Dockerfile, docker/, docker-compose.yml, deploy/ server packaging (see DEPLOY.md)
LLM_PROVIDER, then re-run scripts/evaluate.py and record a
RecordedLLM fixture from real outputs.data/photos/<tag>/
and retrain. Then decide whether the tags should feed the Writer (for example “photo:
celebration” to pick the angle) or only enrich search and the archive.