Build-vs-buy, run side by side
The daily-update feature runs the same task — write commentary on the day's tournament action — through two genuinely different kinds of model: a hosted foundation model (Claude) and a self-hosted open-weight model (running on private hardware over a private network). An admin can generate from either, back to back, and compare.
"Using AI" usually means one specific choice: call a vendor's hosted API. This app makes that choice visible by also running the alternative — an "open-weight" model, meaning one whose underlying parameters are published and can be downloaded and run on your own hardware instead of only through the vendor's servers — for the exact same job, so the tradeoff is something you can actually see rather than take on faith.
| Foundation model | Open-weight model | |
|---|---|---|
| What | Claude, via the Anthropic API | gpt-oss:20b, served locally by Ollama |
| Where it runs | Anthropic's servers | "the Spark" — a machine on the developer's own private network |
| Cost model | Pay per token, no hardware to run | No per-token cost, but ties up local hardware and needs the network work described below |
| Code path | daily_update_stream() — direct Anthropic API call, streamed to the browser | daily_update_spark_direct_stream() / the queue path below — HTTP call to Ollama's /api/chat, streamed |
Both paths are called from app/blueprints/admin.py, and both are wired to render on the same admin page from the same prompt and leaderboard data — so a genuine side-by-side comparison is possible, not just a claim that one would work.
mcp_client.py talking to a separate mcp_server.py process) as a way of giving Claude structured, read-only tool access to the database. That MCP hop has since been removed — both the Claude path and the Spark paths now call their model directly. mcp_client.py still exists in the codebase but nothing imports it today; it's inactive, kept as reference rather than deleted mid-tournament.The Daily Update admin page has three buttons that all produce the same kind of output from the same inputs. That's not redundancy — it's the evaluation harness. Running all three on the same day's data is how "is the open-weight model good enough, and how much slower is it" gets answered with real numbers instead of a guess.
A direct, streamed call to the Anthropic API. First measured run: ~3.6s to first token, ~8.3s total. The baseline the other two are judged against.
A real-time streamed call straight to Ollama on the Spark, no queue in between — the fastest path to the open-weight model, used when the Spark is already known to be reachable.
Writes a pending job row instead of calling Ollama directly; a separate poller running on the Spark itself claims the job and does the work. Nothing in the web app connects to the Spark for this path — see §3 for why that matters. Measured steady-state: ~10s per job.
The queue path is also the substrate for where this is headed next: an agentic loop that runs jobs and tool calls entirely on the Spark, reusing the same job_events-style tables and worker API rather than building new plumbing for it.
gpt-oss:20b runs at roughly 35 tokens/second on the Spark (~13s for a typical commentary). A smaller 7–8B parameter model should run roughly 3× faster — that's an open item on the build backlog: swap the model, re-run the same comparison, and see whether commentary quality holds up at the faster speed. It's a config change, not new code.Running your own model only works if it's reachable from the app but not from the open internet. That's a real, solved networking problem, not a footnote — done with Tailscale, a private network overlay (a "tailnet") that only devices you've explicitly added can join.
127.0.0.1:11434 on the Spark — not reachable from any other machine, tailnet or not, by default.Getting this working surfaced a real, debugged infrastructure problem: WSL2 (Windows Subsystem for Linux, used in development) by default only binds published ports to Windows' own localhost, not to other network interfaces — which silently broke reachability over the tailnet. The fix was switching WSL2 to mirrored networking mode (a `.wslconfig` setting) plus a Windows Firewall rule scoped specifically to Tailscale's private address range, after which the tailnet interface became visible and the connection succeeded. That's the kind of infrastructure debugging that doesn't show up in a feature list but is exactly what makes "just self-host a model" actually work in practice.