


My dynasty fantasy football dashboard started as a fairly traditional AI pipeline: collect player data, gather news and injuries, send every player to a language model, and display the resulting recommendations.
It worked—but it was doing far more AI work than necessary.
A healthy bench player with no recent news received essentially the same treatment as an injured starter whose role had materially changed. Players appearing in multiple leagues could generate repeated analysis. Even when nothing changed, the system risked paying to reach the same conclusion again.
I redesigned the pipeline around a simpler principle:
Python should manage facts and state. The language model should only handle decisions that genuinely require reasoning.
The result is a provider-independent system that makes fewer model calls, sends smaller prompts, produces less output, and caches results more safely.
The original architecture
The pipeline gathers data from several sources:
- Sleeper rosters and player information
- FantasyPros rankings
- Dynasty trade values
- Contract information
- Player news and injury reports
Originally, the reasoning stage analysed individual players and returned a detailed object for each one:
{
"trend": "UP",
"confidence": "MEDIUM",
"summary": "The key development affecting the player.",
"fantasy_impact": "SHORT",
"recommendation": "Hold and monitor usage.",
"dynasty_note": "Long-term value remains stable.",
"contract_note": "Under contract through 2027.",
"roster_status_note": "Competing for the WR2 role.",
"flags": ["depth_chart"]
}
That output is useful for an important player. It is wasteful for every player on every roster.
The system prompt, player metadata, contract details and news text all consumed input tokens. Requiring several generated fields per player also created a substantial output-token cost.
Moving from player analysis to league analysis
The biggest change was replacing per-player model calls with one compact request per materially changed league.
Instead of asking the model to analyse everybody, Python builds a league snapshot:
{
"league": {
"id": "123",
"name": "The League",
"season": "2026",
"format": "dynasty"
},
"roster": [
{
"id": "9509",
"n": "Example Player",
"p": "RB",
"age": 24,
"role": "RB1",
"starter": true,
"ir": false,
"taxi": false,
"value": "Early 1st"
}
]
}
Only players with a meaningful signal receive additional injury or news fields.
The model then returns a league overview and a short list of actionable exceptions:
{
"overview": "The roster remains strong at running back but has two injury situations to monitor.",
"actions": [
{
"player_id": "9509",
"trend": "WATCH",
"confidence": "MEDIUM",
"action": "Hold and monitor practice participation.",
"reason": "A new injury designation creates short-term uncertainty.",
"flags": ["injury"]
}
]
}
Stable players are omitted. Python supplies deterministic defaults for them, so they consume no output tokens.
The response is capped at eight actions and 900 output tokens per changed league.
Storing player facts once
Another improvement was separating global player facts from league-specific context.
The pipeline now maintains three layers:
player_store.json
Canonical facts stored once per player
league_snapshots/
Small league-specific roster and status records
league_analysis_cache.json
Successful model analysis for each league
A player’s name, age, NFL team, contract, trade value, injury and news belong in the canonical player store.
Fields such as starter status, taxi status, IR status and roster designation belong in the league snapshot.
This prevents shared facts from being copied into every league’s stored data while still allowing recommendations to consider league-specific context.
Reducing news tokens
Scraped news can be surprisingly verbose. A single source may provide a headline, article body and separate analysis section. Multiple sites may report the same event using slightly different wording.
Sending all of that to the model is rarely useful.
The new pipeline:
- Deduplicates news by normalized headline.
- Includes at most two news events per player.
- Caps headline length.
- Excludes article bodies and generic commentary.
- Adds news fields only to signal-bearing players.
The model generally needs the material fact, its source and its date—not several paragraphs of fantasy prose written by someone else.
Skipping quiet leagues
Before making a provider request, Python checks whether the league contains any material signals.
Signals include:
- New player news
- Injury designations
- IR status
- Taxi-squad status
If none exist, the model is skipped completely:
Skipping AI call for league=The League:
quiet league with no material signals
The dashboard receives deterministic text instead:
No material roster news or injury changes this cycle.
An empty roster also skips the provider.
This is an important distinction: prompt optimization makes calls cheaper, but avoiding unnecessary calls altogether is better.
Building a safer cache
Caching AI output sounds straightforward until failures enter the picture.
Each league analysis is fingerprinted using:
- Selected provider
- Selected model
- Exact league payload
If all three are unchanged, the cached analysis is reused.
Including the provider and model is important. Switching from one model to another should produce a fresh analysis rather than silently serving text generated by the previous configuration.
I also found and fixed a more subtle failure mode.
An earlier implementation stored whatever analysis was displayed after a provider attempt—even an error fallback—under the new fingerprint. That meant a temporary outage could produce:
Analysis unavailable; showing current roster facts.
The fallback would then become a valid cache hit. Future runs with the same payload would skip the provider indefinitely.
The corrected rule is simple:
Only successful provider responses are written to the analysis cache.
If a request fails:
- An older valid analysis may still be displayed.
- The previous cache entry remains completely unchanged.
- A fallback can be displayed when no prior result exists.
- The next identical run retries the provider.
This prevents transient failures from poisoning the cache.
Supporting multiple AI providers
The reasoning layer originally depended directly on Anthropic. I replaced that assumption with a small provider adapter.
The provider is now selected through environment variables:
AI_PROVIDER=openai
OPENAI_API_KEY=...
OPENAI_MODEL=gpt-5-mini
Or:
AI_PROVIDER=anthropic
ANTHROPIC_API_KEY=...
ANTHROPIC_MODEL=claude-haiku-4-5
OpenAI is the default, but the rest of the pipeline does not know which provider is active. Both paths return the same validated internal structure.
Model selection follows a defined precedence:
- Provider-specific override
- Shared
AI_MODELoverride - Legacy model setting
- Sensible provider default
This makes it possible to compare providers or change models without editing application code.
Making OpenAI responses more reliable
When I first tested the OpenAI Responses API, the request returned HTTP 200 but contained no visible JSON. The application then attempted to parse an empty string.
To make that path more reliable, I added:
- Minimal reasoning effort
- Low text verbosity
- A strict JSON schema
- Explicit empty-output detection
- Diagnostics for incomplete responses
- Reporting of response status and output item types
The strict schema ensures that both providers ultimately feed the same application contract, while minimal reasoning preserves more of the 900-token budget for visible output.
Logging what the pipeline is doing
Optimizing an AI pipeline is difficult if its decisions are invisible.
Before a real request, the pipeline now logs:
Calling AI provider=openai model=gpt-5-mini for league=The League
It also logs the exact JSON payload being sent.
Afterwards, it logs the final overview and its source:
Overall league analysis for league=The League source=model:
The roster is stable, with one injury situation worth monitoring.
Other possible sources include:
cachequietempty_rosterstale_cache_after_errorerror_fallback
This makes it easy to confirm whether displayed text came from a fresh model response, an earlier result or deterministic application logic.
Protecting the architecture with tests
Several of these improvements were vulnerable to being accidentally lost during merges, so I added regression tests around the behavior rather than relying on comments.
The tests verify that:
- OpenAI is the default provider.
- Anthropic remains selectable.
- Provider-specific model overrides work.
- Identical payloads reuse successful analysis.
- Changing the model invalidates the cache.
- Changing the provider invalidates the cache.
- Failed requests are never cached.
- An older valid cache entry survives a failure unchanged.
- Quiet leagues make no provider call.
- The orchestrator calls the active league reasoning agent.
The complete project suite now contains more than 150 passing tests.
The broader lesson
The most effective token optimization was not a shorter prompt. It was moving responsibility out of the prompt.
Python is better suited to:
- Retrieving and normalizing data
- Deduplicating news
- Detecting changes
- Managing cache fingerprints
- Formatting known contract information
- Producing defaults for quiet players
The model is most useful when it receives a small set of meaningful facts and answers a focused question:
Given this league and these material changes, what should the manager pay attention to?
That shift made the system cheaper, easier to inspect and less dependent on any particular provider. More importantly, it made the resulting dashboard more useful: instead of generating commentary for everybody, it highlights the decisions that may actually require action.
The GitHub repo is here: https://github.com/lukecampbell-cf/DynastyDashboard/

