erin.dev

Back to home

🥕 PantryQL

Paste any recipe. Plan a week. Get one grocery list. Powered by chained GPT-4 tool calls.

Source Code

PantryQL chains three operations to turn unstructured recipe text into an actionable grocery list — two powered by LLM function calling, one purely deterministic.

The pipeline:

  1. Extract — Paste messy recipe text or a URL. GPT-4 Turbo, constrained by a strict tool definition with a 23-value unit enum, returns fully normalized structured data. No regex. No templates.
  2. Plan — A second chained function call receives your recipe library as structured context and generates a weekly meal plan respecting dietary restrictions, prep-time constraints, and variety goals.
  3. Aggregate — A deterministic engine collects every ingredient across the plan, scales to per-meal servings, deduplicates by name, sums compatible units via Pint, and flags pantry staples. Zero LLM involvement — just math.

The core design principle: LLMs handle judgment (parsing vague text, choosing varied meals), code handles math (unit conversion, quantity aggregation, smart rounding). The grocery list is 100% reproducible.

The scraper uses a two-path architecture: Schema.org JSON-LD extraction when available (fast, free, no LLM cost), with full text scraping + LLM fallback for unstructured sites. Scaling uses smart rounding rules per unit type — cup rounds to ¼ increments, countable items like cloves round to whole numbers, weight units keep decimals — and displays quantities as Unicode fractions (¾ cup, 2½ lb).

🥕 PantryQL

🛠️ Tech Stack

Python 3.11FastAPISQLAlchemy 2.0 (async)PostgreSQL 16OpenAI GPT-4 Turbo (tools API)Pydantic v2Pint (unit conversion)httpx + BeautifulSoup (scraping)Alembic (migrations)React 18 + TypeScriptTanStack Query v5Tailwind CSSVite@dnd-kit (drag-and-drop)Docker Composepytest

🚀 Features

  • Recipe extraction via function calling — Paste raw text (including vague "grandma-style" recipes) or a URL. GPT-4 Turbo returns structured JSON constrained by a strict tool schema with enforced unit/quantity normalization. "a handful of basil" becomes {"quantity": 0.25, "unit": "cup", "name": "basil"}.

  • Two-path URL scraping — Detects Schema.org JSON-LD markup first (AllRecipes, Serious Eats, NYT Cooking) for near-instant structured extraction. Falls back to BeautifulSoup text scraping + LLM for sites without markup.

  • Smart recipe scaling — Scale any recipe to arbitrary servings with unit-aware rounding: tsp/cup snap to ¼ increments, countable items (cloves, pieces) round to whole numbers, weight units keep decimal precision. Quantities display as Unicode fractions (¾, 1½, ⅓).

  • Weekly meal planning (chained function call) — A second LLM call receives the full recipe library as structured context and generates a day-by-day meal schedule. Respects dietary restrictions, weeknight prep-time limits, cuisine variety, and ingredient overlap to minimize the grocery list.

  • Drag-and-drop plan editing — Swap recipes between meal slots with @dnd-kit. The LLM gives you a starting point; you stay in control.

  • Deterministic grocery aggregation — Collects ingredients across all planned meals, scales to per-slot servings, deduplicates by normalized name, sums compatible units via Pint (volume→cups, mass→grams), and flags 60+ default pantry staples. No LLM — just math.

  • Full CRUD recipe book — Browse, search, filter by cuisine/meal type/dietary tags/max prep time, edit metadata, delete with cascade.

  • Evaluation framework — 15-recipe dataset (including deliberately hard cases: vague grandma recipes, metric-only Thai recipes, multi-section baking) with per-field accuracy metrics, F1 for set-valued fields, and an HTTP-based eval runner that tests the full stack.

🧗‍♂️ Challenges

  • Async SQLAlchemy's lazy-loading trap — The most time-consuming bugs were all the same root cause: async SQLAlchemy forbids lazy-loading relationships, and the error (greenlet_spawn has not been called) doesn't indicate which line triggered it. db.refresh() doesn't eager-load, and parent.children.append() triggers a hidden lazy SELECT. The fix was a consistent three-step pattern across all services: flush → re-fetch with selectinload → serialize. This is the kind of ORM subtlety that only surfaces at runtime with async drivers.

  • LLM output validation at the boundary — The meal planner occasionally hallucinated recipe IDs that didn't exist in the user's library. The fix was a mandatory post-parse validation step before any DB writes — the LLM→deterministic boundary. Everything downstream of this check can assume the data is valid. This shaped the architecture: trust LLMs for judgment, but always validate before persisting.

  • Knowing when NOT to use an LLM — Early designs considered using GPT-4 for grocery list aggregation (unit arithmetic, deduplication). But LLMs hallucinate quantities — "2 tbsp + 1 cup olive oil" might return a wrong total. Deterministic code with Pint is more reliable and free. The rule: LLMs for ambiguous NLP and judgment; deterministic code for math and anything with a ground truth.

  • Normalizing the long tail of recipe formats — "a handful", "2-3 cloves, minced", "one 14oz can", "salt and pepper to taste" — each needs different handling. The strict 23-value unit enum forces the LLM to normalize at the boundary rather than passing freeform strings downstream. Compound ingredients get split, preparation notes get separated from names, and vague quantities map to reasonable defaults.

🧠 Learnings

  • Strict schemas are the best prompt engineering — Defining a tight enum for units (tsp, tbsp, cup, oz, lb, g, ...) in the OpenAI tool schema does more for output quality than paragraphs of prompt instructions. The model must conform to the schema, shifting normalization work from downstream code to the model itself.

  • Temperature as an architecture decision — Extraction uses temperature=0.0 (deterministic — re-extracting the same recipe should produce identical output). Planning uses temperature=0.4 (needs variety in meal selection while staying grounded). This isn't a tuning knob; it's a design choice tied to each operation's requirements.

  • Send minimal context to the LLM — The planner receives RecipeSummary objects (title, cuisine, prep time, top 5 ingredients) rather than full recipes. Sending complete ingredient lists for 15 recipes would bloat the prompt and add noise. The model only needs enough to make scheduling decisions.

  • Async Python patterns compound — Every model, service, and route in this project is async. The payoff is real (non-blocking scraping, concurrent DB access), but the debugging cost is front-loaded. The greenlet_spawn errors taught me to never assume ORM operations are safe in async context without explicit eager-loading.

  • Evaluation design: cover failure modes, not averages — The 15-recipe eval dataset deliberately includes hard cases alongside clean baselines. Measuring only on well-structured recipes makes extraction look better than it is. Per-field metrics (ingredient name accuracy, unit accuracy, quantity accuracy) expose failure modes that a binary pass/fail score hides.