v0.1 — backend bones + ingredient sterilizer
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/<slug> dry-run AI parse, return proposals
POST /api/sterilize/apply/<slug> 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.
This commit is contained in:
parent
e3277aa2c2
commit
130f96a34f
12 changed files with 734 additions and 1 deletions
23
.env.example
Normal file
23
.env.example
Normal file
|
|
@ -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
|
||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
|
|
@ -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", "-"]
|
||||
99
README.md
99
README.md
|
|
@ -1,3 +1,100 @@
|
|||
# cauldron
|
||||
|
||||
Mealie-backed AI meal planner + shopping list for the family
|
||||
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/<slug> dry-run AI parse, return proposals
|
||||
POST /api/sterilize/apply/<slug> write parses back to Mealie
|
||||
```
|
||||
|
||||
All routes except `/healthz` require `Authorization: Bearer <ADMIN_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 <gitea-url> 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
|
||||
```
|
||||
|
|
|
|||
0
cauldron/__init__.py
Normal file
0
cauldron/__init__.py
Normal file
35
cauldron/config.py
Normal file
35
cauldron/config.py
Normal file
|
|
@ -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"],
|
||||
)
|
||||
64
cauldron/forge.py
Normal file
64
cauldron/forge.py
Normal file
|
|
@ -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()
|
||||
92
cauldron/mealie.py
Normal file
92
cauldron/mealie.py
Normal file
|
|
@ -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")
|
||||
95
cauldron/server.py
Normal file
95
cauldron/server.py
Normal file
|
|
@ -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/<slug> — dry-run a recipe through Sonnet
|
||||
POST /api/sterilize/apply/<slug> — 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/<slug>")
|
||||
@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/<slug>")
|
||||
@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()
|
||||
265
cauldron/sterilizer.py
Normal file
265
cauldron/sterilizer.py
Normal file
|
|
@ -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": <number or null>, # numeric amount, fractions converted to decimals (1/2 -> 0.5)
|
||||
"unit": <string or null>, # 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": <string or null>, # the core food noun in singular canonical form: "onion", "garlic", "rice", "olive oil". Strip prep state ("chopped", "diced") -- those go in note.
|
||||
"note": <string or null>, # prep state, brand, color, modifier: "chopped", "extra virgin", "yellow", "to taste"
|
||||
"approx": <bool> # 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: "<header text>"
|
||||
- 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
|
||||
20
compose.yml
Normal file
20
compose.yml
Normal file
|
|
@ -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
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Flask==3.0.3
|
||||
requests==2.32.3
|
||||
gunicorn==23.0.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue