From 130f96a34fdc12341cef00ee20142830261517d5 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 16:59:11 -0700 Subject: [PATCH] =?UTF-8?q?v0.1=20=E2=80=94=20backend=20bones=20+=20ingred?= =?UTF-8?q?ient=20sterilizer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LAN-only Flask API that consumes Mealie (source of truth for recipes / plans / lists) and clawdforge (centralized claude -p runner) to do AI work. v0.1 surface: GET /healthz liveness + clawdforge upstream GET /api/recipes proxy Mealie recipe list POST /api/sterilize/preview/ dry-run AI parse, return proposals POST /api/sterilize/apply/ write parses back to Mealie Why sterilizer first: Mealie's CRF parser is mediocre and Cobb's hand-typed recipes have lots of free-form ingredient strings ("about 2 cups cooked white rice", "a pinch of salt") that don't aggregate cleanly into a shopping list. We batch all ingredients of one recipe into a single Sonnet call via clawdforge, get back parallel structured parses, then on apply link each to Mealie food/unit records (creating missing by name) and PUT the recipe back. Preview is non-destructive. No UI in v0.1 — bearer-auth API only. Frontend + Authentik OIDC + Abby's swamp/meadow/forest palette arrives in v0.2. Auth: simple shared bearer in env (ADMIN_BEARER) until OIDC lands. LAN-only deploy means the bearer is the only gate; no public exposure. Stack: python:3.12-slim + Flask 3 + gunicorn + requests. No DB in v0.1. --- .env.example | 23 ++++ .gitignore | 20 ++++ Dockerfile | 19 +++ README.md | 99 ++++++++++++++- cauldron/__init__.py | 0 cauldron/config.py | 35 ++++++ cauldron/forge.py | 64 ++++++++++ cauldron/mealie.py | 92 ++++++++++++++ cauldron/server.py | 95 +++++++++++++++ cauldron/sterilizer.py | 265 +++++++++++++++++++++++++++++++++++++++++ compose.yml | 20 ++++ requirements.txt | 3 + 12 files changed, 734 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cauldron/__init__.py create mode 100644 cauldron/config.py create mode 100644 cauldron/forge.py create mode 100644 cauldron/mealie.py create mode 100644 cauldron/server.py create mode 100644 cauldron/sterilizer.py create mode 100644 compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6e6b416 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Cauldron — copy to /mnt/cache/appdata/secrets/cauldron.env on Lucy +# (chmod 600, root:root). Some values are already populated by the deploy +# bootstrap (CLAWDFORGE_*); fill in the rest before first start. + +# Flask +SECRET_KEY=change-me-32-bytes-of-entropy + +# Bind +BIND_HOST=0.0.0.0 +BIND_PORT=7790 + +# Mealie (recipes.sulkta.com is already wired with Authentik OIDC) +MEALIE_BASE_URL=https://recipes.sulkta.com +MEALIE_API_TOKEN= + +# clawdforge (centralized claude-runner on Lucy) +CLAWDFORGE_URL=http://192.168.0.5:8800 +CLAWDFORGE_TOKEN= +DEFAULT_MODEL=sonnet +DEFAULT_TIMEOUT_SECS=120 + +# Local dev/admin bearer (used until Authentik OIDC lands in v0.2) +ADMIN_BEARER=change-me-this-is-the-cauldron-api-token diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f5cd64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +venv/ +*.sqlite +*.db +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.log +.idea/ +.vscode/ +dist/ +build/ +*.egg-info/ +instance/ +node_modules/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a86d4ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY cauldron /app/cauldron + +EXPOSE 7790 + +CMD ["gunicorn", "cauldron.server:app", \ + "-b", "0.0.0.0:7790", \ + "--workers", "2", \ + "--timeout", "180", \ + "--access-logfile", "-"] diff --git a/README.md b/README.md index db83ba9..16a6835 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,100 @@ # cauldron -Mealie-backed AI meal planner + shopping list for the family \ No newline at end of file +Mealie-backed AI meal planner + shopping list for the family. LAN-only, +internal tool. Mealie at `recipes.sulkta.com` is the source of truth for +recipes / meal plans / shopping lists; cauldron is the AI layer + Abby's +branded UI on top. + +## Status + +**v0.1 — backend bones (current).** Ingredient sterilizer endpoint working. +No UI yet; bearer-auth API only. Frontend + Authentik OIDC arrives in v0.2. +Native Kotlin Android in v0.5. + +## Surface (v0.1) + +``` +GET /healthz liveness + clawdforge upstream +GET /api/recipes list Mealie recipes (paginated) +POST /api/sterilize/preview/ dry-run AI parse, return proposals +POST /api/sterilize/apply/ write parses back to Mealie +``` + +All routes except `/healthz` require `Authorization: Bearer `. + +## Architecture + +``` +Abby's phone (later: Kotlin app) + │ + ▼ + cauldron (Flask, port 7790, LAN-only) + ├─ Mealie API client ─── recipes.sulkta.com (source of truth) + ├─ clawdforge client ─── 192.168.0.5:8800 (claude -p runner) + └─ Authentik OIDC (v0.2) +``` + +cauldron does NOT hold its own database in v0.1 — all state lives in Mealie. +A small Postgres/MariaDB schema lands in v0.2 for Abby-specific prefs + +chat history. + +## Ingredient sterilizer + +Mealie's CRF parser is mediocre. Cobb's hand-typed recipes have lots of +free-form quantity strings ("about 2 cups cooked white rice", "1 small +handful kale", "a pinch of salt") that don't aggregate cleanly into a +shopping list. + +The sterilizer batches all ingredients of one recipe into a single Sonnet +call (via clawdforge), gets back parallel structured parses, then on apply +links each parse to existing Mealie food/unit records (creating any missing +by name) and PUTs the recipe back. + +Preview is non-destructive — review proposals before apply. + +```bash +# Dry-run preview +curl -sS -X POST -H "Authorization: Bearer $ADMIN_BEARER" \ + http://192.168.0.5:7790/api/sterilize/preview/spaghetti-bolognese | jq . + +# Apply (creates missing foods/units by default) +curl -sS -X POST -H "Authorization: Bearer $ADMIN_BEARER" \ + http://192.168.0.5:7790/api/sterilize/apply/spaghetti-bolognese | jq . +``` + +## Deploy + +1. `ssh lucy` +2. `cd /mnt/user/appdata && git clone cauldron && cd cauldron/build` + (or wherever the deploy convention lands) +3. Drop `.env` at `/mnt/cache/appdata/secrets/cauldron.env` (chmod 600 root:root) + - `CLAWDFORGE_TOKEN` is already populated by the bootstrap (see `memory/2026-04-28.md`) + - `MEALIE_API_TOKEN` — mint at `recipes.sulkta.com` → user → API tokens + - `ADMIN_BEARER` — pick 32 bytes of entropy + - `SECRET_KEY` — 32 bytes for Flask sessions +4. `docker compose up -d --build` +5. Smoke: `curl http://192.168.0.5:7790/healthz` + +## Roadmap + +- v0.1 ✓ — sterilizer backend + Flask shell +- v0.2 — Authentik OIDC, Abby-branded web UI, palette CSS, postgres for prefs +- v0.3 — meal plan generator (week → Mealie meal plan write) +- v0.4 — shopping list aggregator (read meal plan → consolidated grocery list) +- v0.5 — native Kotlin + Compose Android app (read-only shopping list + plan view) + +## Repo layout + +``` +cauldron/ +├─ cauldron/ +│ ├─ config.py env-driven config +│ ├─ forge.py clawdforge HTTP client +│ ├─ mealie.py Mealie API client +│ ├─ sterilizer.py ingredient parse + apply pipeline +│ └─ server.py Flask app +├─ Dockerfile +├─ compose.yml +├─ requirements.txt +└─ .env.example +``` diff --git a/cauldron/__init__.py b/cauldron/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cauldron/config.py b/cauldron/config.py new file mode 100644 index 0000000..71abb62 --- /dev/null +++ b/cauldron/config.py @@ -0,0 +1,35 @@ +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Config: + secret_key: str + + bind_host: str + bind_port: int + + mealie_base_url: str + mealie_api_token: str + + clawdforge_url: str + clawdforge_token: str + default_model: str + default_timeout_secs: int + + admin_bearer: str + + +def load() -> Config: + return Config( + secret_key=os.environ["SECRET_KEY"], + bind_host=os.environ.get("BIND_HOST", "0.0.0.0"), + bind_port=int(os.environ.get("BIND_PORT", "7790")), + mealie_base_url=os.environ["MEALIE_BASE_URL"].rstrip("/"), + mealie_api_token=os.environ["MEALIE_API_TOKEN"], + clawdforge_url=os.environ["CLAWDFORGE_URL"].rstrip("/"), + clawdforge_token=os.environ["CLAWDFORGE_TOKEN"], + default_model=os.environ.get("DEFAULT_MODEL", "sonnet"), + default_timeout_secs=int(os.environ.get("DEFAULT_TIMEOUT_SECS", "120")), + admin_bearer=os.environ["ADMIN_BEARER"], + ) diff --git a/cauldron/forge.py b/cauldron/forge.py new file mode 100644 index 0000000..7e2c9e9 --- /dev/null +++ b/cauldron/forge.py @@ -0,0 +1,64 @@ +"""Thin HTTP client for clawdforge — we're a consumer.""" +import requests + + +class ForgeError(RuntimeError): + pass + + +class Forge: + def __init__(self, *, base_url: str, token: str, default_model: str, default_timeout: int): + self.base_url = base_url.rstrip("/") + self.token = token + self.default_model = default_model + self.default_timeout = default_timeout + + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self.token}"} + + def healthz(self) -> dict: + r = requests.get(f"{self.base_url}/healthz", headers=self._headers(), timeout=10) + r.raise_for_status() + return r.json() + + def run( + self, + prompt: str, + *, + model: str | None = None, + system: str | None = None, + files: list[str] | None = None, + timeout_secs: int | None = None, + ) -> dict: + """POST /run. Returns parsed result dict on success. + + Raises ForgeError on transport or upstream failure. The 'result' field + in the return is whatever clawdforge parsed out of `claude -p` — usually + a dict (when the prompt asked for JSON), occasionally a string. + """ + body = {"prompt": prompt, "model": model or self.default_model} + if system: + body["system"] = system + if files: + body["files"] = files + if timeout_secs: + body["timeout_secs"] = timeout_secs + + # HTTP timeout = subprocess timeout + a 30s margin so we don't bail + # while clawdforge is still doing work for us. + http_timeout = (timeout_secs or self.default_timeout) + 30 + + try: + r = requests.post( + f"{self.base_url}/run", + headers=self._headers(), + json=body, + timeout=http_timeout, + ) + except requests.RequestException as e: + raise ForgeError(f"transport: {e}") from e + + if r.status_code >= 400: + raise ForgeError(f"upstream {r.status_code}: {r.text[:500]}") + + return r.json() diff --git a/cauldron/mealie.py b/cauldron/mealie.py new file mode 100644 index 0000000..ece4972 --- /dev/null +++ b/cauldron/mealie.py @@ -0,0 +1,92 @@ +"""Mealie API client — only the surface cauldron actually uses. + +Mealie ingredient schema (relevant fields): + - quantity: float | None + - unit: {id, name, ...} | None + - food: {id, name, plural_name, ...} | None + - note: str (free-form addendum) + - display: str (rendered) + - is_food: bool (false = section header) + - original_text: str + +Cauldron's sterilizer reads recipes, asks clawdforge to parse free-form +ingredient strings into structured form, then maps the parse to existing +food/unit IDs (creating them if missing) and writes back. +""" +import requests + + +class MealieError(RuntimeError): + pass + + +class Mealie: + def __init__(self, *, base_url: str, api_token: str): + self.base_url = base_url.rstrip("/") + self.api_token = api_token + self.session = requests.Session() + self.session.headers.update( + { + "Authorization": f"Bearer {api_token}", + "Accept": "application/json", + } + ) + + # --- low-level ---------------------------------------------------------- + + def _get(self, path: str, **params) -> dict: + r = self.session.get(f"{self.base_url}{path}", params=params, timeout=30) + if r.status_code >= 400: + raise MealieError(f"GET {path} -> {r.status_code}: {r.text[:300]}") + return r.json() + + def _put(self, path: str, body: dict) -> dict: + r = self.session.put(f"{self.base_url}{path}", json=body, timeout=30) + if r.status_code >= 400: + raise MealieError(f"PUT {path} -> {r.status_code}: {r.text[:300]}") + return r.json() + + def _post(self, path: str, body: dict) -> dict: + r = self.session.post(f"{self.base_url}{path}", json=body, timeout=30) + if r.status_code >= 400: + raise MealieError(f"POST {path} -> {r.status_code}: {r.text[:300]}") + return r.json() + + # --- recipes ------------------------------------------------------------ + + def list_recipes(self, *, page: int = 1, per_page: int = 50) -> dict: + return self._get("/api/recipes", page=page, perPage=per_page) + + def get_recipe(self, slug: str) -> dict: + return self._get(f"/api/recipes/{slug}") + + def update_recipe(self, slug: str, body: dict) -> dict: + return self._put(f"/api/recipes/{slug}", body) + + # --- foods / units ------------------------------------------------------ + + def list_foods(self, *, search: str | None = None, per_page: int = 200) -> dict: + return self._get("/api/foods", search=search or "", perPage=per_page) + + def create_food(self, name: str, *, plural_name: str | None = None) -> dict: + body = {"name": name} + if plural_name: + body["pluralName"] = plural_name + return self._post("/api/foods", body) + + def list_units(self, *, search: str | None = None, per_page: int = 200) -> dict: + return self._get("/api/units", search=search or "", perPage=per_page) + + def create_unit(self, name: str, *, abbreviation: str | None = None) -> dict: + body = {"name": name} + if abbreviation: + body["abbreviation"] = abbreviation + return self._post("/api/units", body) + + # --- meal plans / shopping (used in v0.3+) ------------------------------ + + def list_meal_plans(self, *, start: str, end: str) -> dict: + return self._get("/api/households/mealplans", start_date=start, end_date=end) + + def list_shopping_lists(self) -> dict: + return self._get("/api/households/shopping/lists") diff --git a/cauldron/server.py b/cauldron/server.py new file mode 100644 index 0000000..1b078a7 --- /dev/null +++ b/cauldron/server.py @@ -0,0 +1,95 @@ +"""Flask app — v0.1 surface. + +Auth is a simple shared bearer in env (ADMIN_BEARER) until Authentik OIDC +lands in v0.2. LAN-only deploy means the bearer is the only gate. + +Routes: + GET /healthz — liveness, no auth + GET /api/recipes — proxy Mealie list (paginated) + POST /api/sterilize/preview/ — dry-run a recipe through Sonnet + POST /api/sterilize/apply/ — write the parses back to Mealie +""" +from functools import wraps + +from flask import Flask, jsonify, request + +from .config import load +from .forge import Forge +from .mealie import Mealie +from .sterilizer import Sterilizer + + +cfg = load() +forge = Forge( + base_url=cfg.clawdforge_url, + token=cfg.clawdforge_token, + default_model=cfg.default_model, + default_timeout=cfg.default_timeout_secs, +) +mealie = Mealie(base_url=cfg.mealie_base_url, api_token=cfg.mealie_api_token) +sterilizer = Sterilizer(mealie=mealie, forge=forge, model=cfg.default_model) + + +def create_app() -> Flask: + app = Flask(__name__) + app.secret_key = cfg.secret_key + + def require_bearer(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return jsonify({"error": "missing bearer"}), 401 + token = auth[7:].strip() + if not _const_eq(token, cfg.admin_bearer): + return jsonify({"error": "forbidden"}), 403 + return fn(*args, **kwargs) + return wrapper + + @app.get("/healthz") + def healthz(): + upstream = {} + try: + upstream["clawdforge"] = forge.healthz() + except Exception as e: + upstream["clawdforge_error"] = str(e) + return jsonify({"ok": True, "upstream": upstream}) + + @app.get("/api/recipes") + @require_bearer + def list_recipes(): + page = int(request.args.get("page", "1")) + per_page = min(int(request.args.get("per_page", "50")), 200) + return jsonify(mealie.list_recipes(page=page, per_page=per_page)) + + @app.post("/api/sterilize/preview/") + @require_bearer + def sterilize_preview(slug: str): + try: + return jsonify(sterilizer.preview_recipe(slug)) + except Exception as e: + return jsonify({"error": str(e)}), 502 + + @app.post("/api/sterilize/apply/") + @require_bearer + def sterilize_apply(slug: str): + create_missing = request.args.get("create_missing", "true").lower() == "true" + try: + return jsonify(sterilizer.apply_recipe(slug, create_missing=create_missing)) + except Exception as e: + return jsonify({"error": str(e)}), 502 + + return app + + +def _const_eq(a: str, b: str) -> bool: + if len(a) != len(b): + return False + diff = 0 + for x, y in zip(a.encode(), b.encode()): + diff |= x ^ y + return diff == 0 + + +# gunicorn entrypoint +app = create_app() diff --git a/cauldron/sterilizer.py b/cauldron/sterilizer.py new file mode 100644 index 0000000..fe0ebcd --- /dev/null +++ b/cauldron/sterilizer.py @@ -0,0 +1,265 @@ +"""Ingredient sterilizer — turn Mealie's free-form ingredient strings into +structured (qty, unit, food, note) so shopping-list aggregation works. + +Why this exists: Mealie has its own CRF parser, but it's mediocre and produces +inconsistent results. Cobb's hand-typed recipes have lots of "about 2 cups +cooked white rice" / "1 small handful kale" / "a pinch of salt" etc. that +slip past the parser. We send these to Sonnet via clawdforge and get back +clean structured form. + +Flow: + 1. Fetch the recipe from Mealie + 2. Build a single batched prompt with all ingredients (one Sonnet call/recipe) + 3. Get back a parallel array of {quantity, unit, food, note} + 4. (preview) return the proposal + 5. (apply) link each parse to existing Mealie food/unit (create if missing), + then PUT the updated recipe back +""" +import json +from dataclasses import dataclass, asdict + +from .forge import Forge, ForgeError +from .mealie import Mealie, MealieError + + +STERILIZE_SYSTEM = """You are a precise recipe ingredient parser. You ONLY output valid JSON. +You receive a list of free-form ingredient strings and must return a parallel +array where each item is parsed into structured form. + +Output schema (per item): +{ + "quantity": , # numeric amount, fractions converted to decimals (1/2 -> 0.5) + "unit": , # singular canonical form: "cup", "tbsp", "tsp", "oz", "lb", "g", "kg", "ml", "l", "clove", "slice", "can", "package", "piece", "pinch", "dash", "handful". null if no unit (e.g. "1 onion"). + "food": , # the core food noun in singular canonical form: "onion", "garlic", "rice", "olive oil". Strip prep state ("chopped", "diced") -- those go in note. + "note": , # prep state, brand, color, modifier: "chopped", "extra virgin", "yellow", "to taste" + "approx": # true if the input said "about" / "a pinch" / "to taste" / vague qty +} + +Rules: +- Convert fractions: "1/2" -> 0.5, "1 1/4" -> 1.25 +- "a pinch", "a dash", "to taste" -> {quantity: null, approx: true, note: "to taste"} +- "1 small onion" -> {quantity: 1, unit: null, food: "onion", note: "small"} +- "2 cloves garlic, minced" -> {quantity: 2, unit: "clove", food: "garlic", note: "minced"} +- Section headers like "For the sauce:" -> all fields null EXCEPT note: "
" +- If you genuinely cannot parse, set all fields null and put the original in note. +- DO NOT add fields not in the schema. +- DO NOT wrap output in markdown fences. +- DO NOT include any prose before or after the JSON. + +You will be given a JSON object: {"ingredients": ["str", "str", ...]} +You return: {"parses": [{...}, {...}, ...]} -- same length, same order. +""" + + +@dataclass +class IngredientParse: + quantity: float | None + unit: str | None + food: str | None + note: str | None + approx: bool + + +@dataclass +class IngredientProposal: + """One ingredient before vs after.""" + index: int + original_display: str + original_quantity: float | None + original_unit_name: str | None + original_food_name: str | None + original_note: str | None + parsed: IngredientParse + + +class Sterilizer: + def __init__(self, *, mealie: Mealie, forge: Forge, model: str = "sonnet"): + self.mealie = mealie + self.forge = forge + self.model = model + + # --- public ------------------------------------------------------------- + + def preview_recipe(self, slug: str) -> dict: + """Dry-run: parse all ingredients, return proposals without writing.""" + recipe = self.mealie.get_recipe(slug) + ingredients = recipe.get("recipeIngredient") or [] + if not ingredients: + return {"slug": slug, "name": recipe.get("name"), "proposals": []} + + strings = [_render_ingredient_for_parse(ing) for ing in ingredients] + parses = self._parse_batch(strings) + + proposals: list[IngredientProposal] = [] + for i, (ing, parse) in enumerate(zip(ingredients, parses)): + proposals.append( + IngredientProposal( + index=i, + original_display=ing.get("display") or "", + original_quantity=ing.get("quantity"), + original_unit_name=(ing.get("unit") or {}).get("name") if ing.get("unit") else None, + original_food_name=(ing.get("food") or {}).get("name") if ing.get("food") else None, + original_note=ing.get("note"), + parsed=parse, + ) + ) + + return { + "slug": slug, + "name": recipe.get("name"), + "ingredient_count": len(ingredients), + "proposals": [_proposal_to_dict(p) for p in proposals], + } + + def apply_recipe(self, slug: str, *, create_missing: bool = True) -> dict: + """Run preview, then write changes back to Mealie. + + For each ingredient we resolve (or create) Mealie food/unit by name, + then assemble the new recipeIngredient list and PUT the recipe. + """ + preview = self.preview_recipe(slug) + proposals = preview["proposals"] + if not proposals: + return {"slug": slug, "updated": 0, "skipped": 0, "created_foods": [], "created_units": []} + + recipe = self.mealie.get_recipe(slug) + food_index = self._build_name_index(self.mealie.list_foods()) + unit_index = self._build_name_index(self.mealie.list_units()) + created_foods: list[str] = [] + created_units: list[str] = [] + + new_ingredients: list[dict] = [] + for orig_ing, prop in zip(recipe.get("recipeIngredient") or [], proposals): + parsed = prop["parsed"] + new_ing = dict(orig_ing) # preserve id, refId, original_text + + new_ing["quantity"] = parsed["quantity"] + + food_name = (parsed.get("food") or "").strip() + if food_name: + food_id = food_index.get(food_name.lower()) + if not food_id and create_missing: + created = self.mealie.create_food(food_name) + food_id = created.get("id") + food_index[food_name.lower()] = food_id + created_foods.append(food_name) + if food_id: + new_ing["food"] = {"id": food_id, "name": food_name} + new_ing["isFood"] = True + else: + # Section header style — clear food, mark not-food + new_ing["food"] = None + new_ing["isFood"] = False + + unit_name = (parsed.get("unit") or "").strip() + if unit_name: + unit_id = unit_index.get(unit_name.lower()) + if not unit_id and create_missing: + created = self.mealie.create_unit(unit_name) + unit_id = created.get("id") + unit_index[unit_name.lower()] = unit_id + created_units.append(unit_name) + if unit_id: + new_ing["unit"] = {"id": unit_id, "name": unit_name} + else: + new_ing["unit"] = None + + new_ing["note"] = parsed.get("note") or "" + new_ingredients.append(new_ing) + + recipe["recipeIngredient"] = new_ingredients + self.mealie.update_recipe(slug, recipe) + + return { + "slug": slug, + "updated": len(new_ingredients), + "created_foods": created_foods, + "created_units": created_units, + } + + # --- private ------------------------------------------------------------ + + def _parse_batch(self, strings: list[str]) -> list[IngredientParse]: + prompt = json.dumps({"ingredients": strings}, ensure_ascii=False) + try: + resp = self.forge.run( + prompt=prompt, + model=self.model, + system=STERILIZE_SYSTEM, + timeout_secs=120, + ) + except ForgeError as e: + raise RuntimeError(f"clawdforge failed: {e}") from e + + result = resp.get("result") + if not isinstance(result, dict) or "parses" not in result: + raise RuntimeError(f"unexpected response shape: {str(result)[:200]}") + + parses_raw = result["parses"] + if not isinstance(parses_raw, list) or len(parses_raw) != len(strings): + raise RuntimeError( + f"parse count mismatch: got {len(parses_raw)}, expected {len(strings)}" + ) + + out: list[IngredientParse] = [] + for p in parses_raw: + out.append( + IngredientParse( + quantity=_coerce_float(p.get("quantity")), + unit=_clean_str(p.get("unit")), + food=_clean_str(p.get("food")), + note=_clean_str(p.get("note")), + approx=bool(p.get("approx")), + ) + ) + return out + + @staticmethod + def _build_name_index(listing: dict) -> dict[str, str]: + index: dict[str, str] = {} + items = listing.get("items") or listing.get("data") or [] + for item in items: + if name := item.get("name"): + index[name.lower()] = item["id"] + if plural := item.get("pluralName"): + index[plural.lower()] = item["id"] + return index + + +def _render_ingredient_for_parse(ing: dict) -> str: + """Best string representation of a Mealie ingredient for sending to Claude.""" + if ing.get("originalText"): + return ing["originalText"] + if ing.get("display"): + return ing["display"] + parts: list[str] = [] + if (q := ing.get("quantity")) is not None: + parts.append(str(q)) + if u := ing.get("unit"): + parts.append(u.get("name") or "") + if f := ing.get("food"): + parts.append(f.get("name") or "") + if note := ing.get("note"): + parts.append(note) + return " ".join(p for p in parts if p).strip() or "(empty)" + + +def _coerce_float(v) -> float | None: + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _clean_str(v) -> str | None: + if v is None: + return None + s = str(v).strip() + return s or None + + +def _proposal_to_dict(p: IngredientProposal) -> dict: + d = asdict(p) + return d diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..0290241 --- /dev/null +++ b/compose.yml @@ -0,0 +1,20 @@ +services: + cauldron: + build: + context: . + dockerfile: Dockerfile + image: cauldron:local + container_name: cauldron + restart: unless-stopped + env_file: + - /mnt/cache/appdata/secrets/cauldron.env + ports: + # LAN-only. Same pattern as cwho on 7777. + - "192.168.0.5:7790:7790" + - "127.0.0.1:7790:7790" + networks: + - sulkta + +networks: + sulkta: + external: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6171215 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +requests==2.32.3 +gunicorn==23.0.0