Touchline-Takes

Touchline Takes

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:

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).


Architecture

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"]

Orchestration: LangGraph

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__;

Agent interface

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.


Install

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.

Run

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.

Deploy

See DEPLOY.md. In short:

Test, lint, evaluate

python -m pytest
pylint touchline_takes tests scripts
python scripts/evaluate.py

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

The agents

1. Ingest (agents/ingest.py, tools/feeds.py)

2. Classifier (agents/classifier.py, tools/teams.py)

3. Writer (agents/writer.py)

4. Fact-check + Editor (agents/factcheck.py, agents/editor.py)

5. Research tools / RAG (tools/rag.py, tools/embeddings.py)

6. Analyst (agents/analyst.py, models/match_predictor.py)

7. Publisher (agents/publisher.py)

8. Photo tagger (agents/photo_tagger.py, models/photo_tagger.py)

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.


Guardrails and cost controls

Going online

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.

Configuration

See .env.example for every variable. The main ones:

Project layout

touchline_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)

Next steps

  1. Switch on real data and a real LLM. Download the Football-Data.co.uk CSVs and retrain, point at live RSS, set LLM_PROVIDER, then re-run scripts/evaluate.py and record a RecordedLLM fixture from real outputs.
  2. Harden the fact-checker further. The relation checks cover results, team stats and units. Relations involving people (“Hartmann scored” vs “Reyes scored”), number words (“six”) and tables/positions (“second in the league”) are still checked only at the token level. Measure the LLM verifier on the claim set with a real model, and grow the held-out claim set from real articles written by someone who hasn’t seen the rules.
  3. Train the photo tagger on real photos. Collect a few hundred labelled match photos you have the rights to use (club media, agency licence), drop them into 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.