Wiring Up a Spaghetti Policy Machine
I read a news article about fans noticing MLB food policies are fairly lax, and some have taken to bringing in the fixings for spaghetti in homage to a fully hilarious Always Sunny episode. My partner and I have noted that our local Great American Ballpark is very accepting of outside food. So while we’ve yet to bring in ziploc bags of pasta ourselves, it is one of the wonderful things about a day at the park, even if you do end up buying a hot dog for nostalgia’s sake.
I figured this would be a fun exercise in content generation via LLM. I have a pretty beefy graphics card, so I can run ~30GB models locally, and gemma4:31b has proven quite capable of simple tasks with solid prose at that size. The whole thing is built to run on either provider: a free local model for endless iteration, or Anthropic (Claude Sonnet / Haiku) for a clean final pass, toggled by a single LLM_PROVIDER env var. This post outlines the basic steps for building the site.
The mental model is an assembly line. Each team flows through five discrete stages — urls → text → analyze → spaghetti → render — and every stage writes its output to disk before the next one reads it. That sounds fussy, but it’s the whole trick: scraping is slow and inference is slower, so you never want to redo a stage you’ve already paid for.
The Data Model
The first step was using SQLAlchemy to put together the models that store everything. I used a SQLite DB to keep things simple and local.
The important decision here was storing the raw policy text alongside the structured analysis, as separate columns. Fetching a page and reducing it to clean text is cheap but flaky (stadium sites love to throw 406s at anything that smells like a bot). Once that text is captured, re-running the LLM extraction is a local operation against cached input — no network, no re-scraping. Iterating on a prompt costs you inference time, not a full re-crawl.
class PolicyRecord(Base):
__tablename__ = "policies"
__table_args__ = (UniqueConstraint("league", "team"),)
team = Column(String, nullable=False)
league = Column(String, nullable=False, server_default="MLB")
url = Column(String, nullable=False)
raw_text = Column(Text) # the cached, cleaned page text
analysis = Column(Text) # JSON-encoded PolicyAnalysis
spaghetti_policy = Column(Text) # the in-character joke textThe shape of the extraction is defined as a Pydantic model, which doubles as the structured-output contract for the LLM. Five categories, each a small allowed/exceptions/prohibited verdict plus a details string:
class Policy(BaseModel):
permissable: Literal["yes", "exceptions_only", "no"] | None
details: str | None
class PolicyAnalysis(BaseModel):
outside_food: Policy
outside_beverages: Policy
outside_alcohol: Policy
cooler_policy: Policy
bag_policy: PolicyIt was important that the policy itself was stored and that the extracted facts were stored separately, so that iterating would take fewer LLM runs. While free, local inference can take a minute, especially with structured outputs.
The LLM models
chatlas is a beautiful package made by the Posit team that I have used extensively. It gives you one interface over many providers and, crucially, a chat_structured method that hands the model your Pydantic schema and gives you back a validated object. The entire extraction stage is essentially one line:
def analyze_policy_text(text: str, *, chat=None) -> PolicyAnalysis:
chat = chat or _default_chat()
return chat.chat_structured(text, data_model=PolicyAnalysis)The provider switch lives in one place, so the rest of the pipeline never knows or cares whether it’s talking to a 31B model on my desk or to Claude:
def chat_gen(model=ANTHROPIC_SONNET_MODEL, **kwargs):
if os.environ.get("LLM_PROVIDER") == "ollama":
return chatlas.ChatOllama(model="gemma4:31b", **kwargs)
return chatlas.ChatAnthropic(model=model, **kwargs)Structured outputs do a lot of quiet work here. They force the model to fill every category, they normalize fuzzy legalese into three states I can render consistently, and they make the output trivially storable as JSON. The prompt’s main job is just nailing the one distinction that actually matters for a fan: does “allowed with conditions” count as yes (it does), and is “prohibited except for medical/infant needs” a yes or a no (its own exceptions_only bucket).
The namesake bit gets its own stage and its own persona. After extraction, a second model reads the structured rules and knots them into a single overconfident ruling on the only question that matters: can you bring spaghetti in? The constraint that keeps it from being pure nonsense is that every real rule has to stay factually correct underneath the joke — if outside alcohol is prohibited, the Spaghetti Policy still forbids it. Reasoning from clean structured facts rather than raw HTML is what makes that reliable.
Rendering
Here is where I really let Claude run wild. I have no design chops in my bones, but I do know what I want a page to look like. The output is a fully static site — no framework, no client-side rendering, just Python f-strings writing HTML to disk from the database. For a directory of ~60 mostly-static pages, that’s the right amount of machinery: nothing to host but files.
The detail I’m proud of is that the page is driven by the same PolicyAnalysis model the LLM fills. The renderer iterates the model’s fields, so adding a category to the analysis automatically surfaces it on every team page — the data model is the single source of truth from extraction all the way to the rendered card.
for key in PolicyAnalysis.model_fields:
item = facts.get(key) or {}
label, icon = _field_meta(key)
# ...render a status pill + details for each categoryI also had some legal constraints and disclaimers to honor: a prominent “this is an unofficial fan resource” box, and a direct “View official policy” link on every page pointing back to the exact source. That’s partly giving credit where it’s due, and partly a pressure valve — if the robots missed a step, the real policy is one click away.
SEO Implementation
A directory site only earns its keep if people can find it, so the last stage is almost entirely about being legible to search engines. A few of the moves:
- League siloing. URLs are bucketed by subdirectory (
/mlb/...,/nfl/...) with the root acting as a portal, so each league owns a clean topical cluster. - Structured data everywhere. Every team page emits JSON-LD: a
FAQPagebuilt from the policy categories (“Can you bring outside food into …?”), aStadiumOrArenanode with the parsed address, and aBreadcrumbList. Hubs getItemList+Organization. This is the samePolicyAnalysisdata, projected into schema.org instead of HTML. - The boring essentials, generated not hand-maintained. Canonical URLs, Open Graph / Twitter cards, per-page meta descriptions, a
sitemap.xmlwith reallastmoddates pulled from each record’s analysis timestamp, and arobots.txt. Because it’s all derived from the database, it can never drift out of sync with the actual pages.
The through-line of the whole project: one structured model, captured once, then projected over and over — into a database row, a rendered card, a JSON-LD graph, and a joke about whether you can smuggle marinara past security.