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:
Kayos 2026-04-28 16:59:11 -07:00
parent e3277aa2c2
commit 130f96a34f
12 changed files with 734 additions and 1 deletions

23
.env.example Normal file
View 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
View 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
View 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", "-"]

View file

@ -1,3 +1,100 @@
# cauldron # 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
View file

35
cauldron/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
Flask==3.0.3
requests==2.32.3
gunicorn==23.0.0