docs: public-facing README + generic configuration docs

Rewrite the README for external users, neutralize example identities in
code comments, generalize the OIDC group note, and simplify the gitleaks
workflow header.
This commit is contained in:
Sulkta 2026-06-28 13:25:25 -07:00
parent b746eacb3e
commit 8a410b0c3c
12 changed files with 118 additions and 58 deletions

View file

@ -1,16 +1,6 @@
# .forgejo/workflows/gitleaks.yml # Scans the repository for committed secrets with gitleaks on every push and
# # pull request. Make it a required status check in branch-protection rules to
# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at # block merges that introduce credentials.
# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered
# (task #295).
#
# Pairs with the pre-receive hook installed on every bare repo — that one is
# the strict enforcement layer (rejects the push); this one provides the
# per-PR red ✗ that branch-protection rules can require before merge.
#
# Layer 1 (this workflow): visible per-PR status, can be a required check.
# Layer 2 (pre-receive hook): strict enforcement at the server.
# Layer 3 (scheduled cron sweep): nightly full-history sweep across all repos.
name: gitleaks name: gitleaks

119
README.md
View file

@ -1,35 +1,90 @@
# cauldron # Cauldron
Mealie-backed meal planner + shopping-list aggregator. Wraps a Mealie Cauldron is a self-hosted companion app for [Mealie](https://mealie.io) that
instance with: an ingredient sterilizer (free-form quantities → structured adds AI-assisted meal planning and a smart, household-shared shopping list.
parses), a weekly meal-plan generator, and a household shopping list that Mealie stays the source of truth for your recipes, plans and shopping lists;
collapses cross-recipe duplicates. Cauldron layers extra automation on top and stores only per-user preferences
and cached metadata.
## Features
- **Ingredient sterilizer** — turns free-form quantities ("about 2 cups",
"a couple handfuls") into structured `{qty, unit, food}` parses and writes
them back to Mealie, so downstream tooling has clean data to work with.
- **Weekly meal-plan generator** — builds a per-week plan from your recipe
library, honouring pinned picks, dietary targets (calories / macros) and
allergen exclusions.
- **Household shopping list** — aggregates ingredients across every recipe in
a plan and collapses duplicates into single lines (e.g. "2 cups rice +
1.25 lb rice" → "~2 lb rice") using a USDA-derived food-density table.
- **Recipe discovery, dedupe and enrichment** — scrape-and-import new recipes,
cluster and merge duplicates, and enrich recipes with structured metadata
for smarter planning.
- **Multi-user, OIDC-secured** — every user connects their own Mealie token
(encrypted at rest); an optional operator tier exposes the destructive admin
tools.
## Stack ## Stack
- Flask + gunicorn, Python 3.12 - Flask + gunicorn (Python 3.12+)
- Authentik (or any OIDC provider) for sessions - Any OpenID Connect provider for authentication (tested with Authentik)
- MariaDB for per-user prefs + Fernet-encrypted Mealie tokens - MariaDB for per-user preferences and Fernet-encrypted Mealie tokens
- [clawdforge](https://github.com/Sulkta-Coop/clawdforge) for the AI layer - An LLM runner reachable over HTTP for the AI features. This project targets
[clawdforge](https://github.com/Sulkta-Coop/clawdforge), but any service
exposing the same `POST /run` contract works.
Mealie remains the source of truth for recipes / plans / shopping lists. ## Quick start
Cauldron stores per-user prefs + cached metadata only.
## Run Cauldron ships as a container and is configured entirely through environment
variables.
```bash ```bash
cp .env.example .env # fill in secrets cp .env.example .env # then fill in the blanks (see Configuration)
docker compose up -d --build docker compose up -d --build
curl http://localhost:7790/healthz curl http://localhost:7790/healthz
``` ```
`CAULDRON_ENV_FILE=/path/to/secrets.env docker compose up -d` if your env If you prefer to keep your secrets file outside the repo:
file lives outside the repo.
```bash
CAULDRON_ENV_FILE=/path/to/cauldron.env docker compose up -d
```
### Requirements
- A running Mealie instance and an API token.
- An OIDC provider with a client configured for Cauldron's redirect URI.
- A MariaDB database and user.
- An LLM HTTP runner for the AI features (sterilize / plan / discover /
enrich). Without it the app still runs; AI-backed endpoints return an error.
## Configuration
All settings are read from the environment. See `.env.example` for the full,
documented list. The most important variables:
| Variable | Purpose |
| --- | --- |
| `SECRET_KEY` | Flask session signing key |
| `BIND_HOST` / `BIND_PORT` | Listen address (defaults `0.0.0.0:7790`) |
| `MEALIE_BASE_URL` | Base URL of your Mealie instance |
| `MEALIE_API_TOKEN` | System Mealie token for admin batch operations |
| `CLAWDFORGE_URL` / `CLAWDFORGE_TOKEN` | LLM runner endpoint + auth |
| `OIDC_ISSUER` / `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` / `OIDC_REDIRECT_URI` | OpenID Connect login |
| `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` / `DB_PASSWORD` | MariaDB connection |
| `CAULDRON_FERNET_KEY` | Key used to encrypt per-user Mealie tokens at rest |
| `ADMIN_BEARER` | Bearer token for the batch (`/api/...`) endpoints |
| `CAULDRON_ADMIN_SUBS` | OIDC subjects granted the operator-tier admin tools |
| `CAULDRON_BASE_URL` / `CAULDRON_BEHIND_TLS` / `CAULDRON_TRUSTED_PROXIES` | Public-deploy hardening (CSRF origin guard, HSTS, secure cookies, trusted-proxy `X-Forwarded-*` handling) |
When deployed behind a reverse proxy, set `CAULDRON_BASE_URL`,
`CAULDRON_BEHIND_TLS=true` and list the proxy peer in
`CAULDRON_TRUSTED_PROXIES` so forwarded headers are honoured safely.
## Endpoints ## Endpoints
``` ```
GET /healthz liveness + clawdforge upstream GET /healthz liveness + LLM-runner upstream check
GET /login /auth/callback OIDC flow GET /login /auth/callback OIDC flow
GET /me account + integration status GET /me account + integration status
GET /plan /list household plan + shopping list GET /plan /list household plan + shopping list
@ -40,21 +95,37 @@ POST /api/sterilize/apply/<slug> (admin bearer) write parses back
Admin-bearer endpoints expect `Authorization: Bearer $ADMIN_BEARER`. Admin-bearer endpoints expect `Authorization: Bearer $ADMIN_BEARER`.
## Layout ## Project layout
``` ```
cauldron/ cauldron/
config.py env-driven config config.py env-driven configuration
forge.py clawdforge HTTP client forge.py LLM-runner HTTP client
mealie.py Mealie API client mealie.py Mealie API client
sterilizer.py ingredient parse + apply pipeline sterilizer.py ingredient parse + apply pipeline
aggregator.py cross-recipe shopping aggregator aggregator.py cross-recipe shopping-list aggregator
server.py Flask app foods.py USDA-seeded food catalogue + density lookup
recipe_index.py local fuzzy recipe search index
server.py Flask app + routes
data/ seed data (USDA SR Legacy foods)
scripts/ scripts/
build_foods_seed.py USDA → foods seed build_foods_seed.py build the foods seed from a USDA dataset
clean_foods_seed.py clawdforge-curated cleanup pass clean_foods_seed.py LLM-curated cleanup pass over the seed
tests/ unit tests
``` ```
## Development
```bash
pip install -r requirements.txt
python -m pytest # run the test suite
```
## Contributing
Issues and pull requests are welcome. Please keep changes focused, add tests
for new behaviour, and make sure `pytest` passes before opening a PR.
## License ## License
MIT. MIT — see [LICENSE](LICENSE).

View file

@ -1,6 +1,6 @@
"""Unit-aware shopping list aggregator. """Unit-aware shopping list aggregator.
Alice's killer feature: take ingredients from N recipes, return a single The killer feature: take ingredients from N recipes, return a single
consolidated shopping list with per-food totals. consolidated shopping list with per-food totals.
Examples: Examples:
@ -290,9 +290,9 @@ def _aggregate_one_food(
# qty=None safety net: if every contributor was a no-qty ingredient # qty=None safety net: if every contributor was a no-qty ingredient
# (Mealie's parser couldn't extract a number), nothing else above # (Mealie's parser couldn't extract a number), nothing else above
# produced a line. Emit a placeholder so the food APPEARS on the # produced a line. Emit a placeholder so the food APPEARS on the
# shopping list — Bob still needs to know to buy onions even if the # shopping list — the cook still needs to know to buy onions even if the
# recipe just said "1 onion, chopped". UI surfaces this as a # recipe just said "1 onion, chopped". UI surfaces this as a
# "qty unspecified" hint, nudging Alice to run sterilize. # "qty unspecified" hint, nudging the user to run sterilize.
if no_qty_items and not lines: if no_qty_items and not lines:
lines.append(ShoppingLine( lines.append(ShoppingLine(
food=food, qty=None, unit="ea", food=food, qty=None, unit="ea",

View file

@ -33,7 +33,7 @@ log = logging.getLogger(__name__)
def _ingredient_needs_sterilizing(ing: dict) -> bool: def _ingredient_needs_sterilizing(ing: dict) -> bool:
"""Heuristic: an ingredient row needs work if it has display/note content """Heuristic: an ingredient row needs work if it has display/note content
but no resolved food. Already-parsed rows (food.id present) are skipped but no resolved food. Already-parsed rows (food.id present) are skipped
so we don't waste clawdforge calls or risk regressing Alice's manual so we don't waste clawdforge calls or risk regressing the user's manual
cleanup.""" cleanup."""
food = ing.get("food") or {} food = ing.get("food") or {}
food_name = food.get("name") if isinstance(food, dict) else None food_name = food.get("name") if isinstance(food, dict) else None

View file

@ -11,7 +11,7 @@ class Config:
mealie_api_url: str # internal URL cauldron uses for HTTP calls (LAN-internal) mealie_api_url: str # internal URL cauldron uses for HTTP calls (LAN-internal)
mealie_public_url: str # external URL shown to users for token-mint UI mealie_public_url: str # external URL shown to users for token-mint UI
mealie_api_token: str # system token (Alice's "Cauldron" token, used for admin batch ops) mealie_api_token: str # system token (the service "Cauldron" token, used for admin batch ops)
clawdforge_url: str clawdforge_url: str
clawdforge_token: str clawdforge_token: str

View file

@ -81,7 +81,7 @@ def _foods_in_household(mealie: Mealie, household_id: str) -> list[dict]:
elif not hh: elif not hh:
# Some Mealie versions don't tag foods with householdId at the # Some Mealie versions don't tag foods with householdId at the
# food level — they're group-scoped instead. In that case, we # food level — they're group-scoped instead. In that case, we
# consolidate across the whole group (Alice is admin so writes # consolidate across the whole group (an admin user can write
# land regardless). # land regardless).
out.append(f) out.append(f)
return out return out
@ -94,7 +94,7 @@ def _cluster(
) -> list[list[dict]]: ) -> list[list[dict]]:
"""Pair-based: emit one 2-food candidate per (i, j) where token_set_ratio """Pair-based: emit one 2-food candidate per (i, j) where token_set_ratio
>= threshold. Replaces the original single-link agglomerative which >= threshold. Replaces the original single-link agglomerative which
produced a 50+ food megacluster on Alice's catalog by chaining weak produced a 50+ food megacluster on a real catalog by chaining weak
similarities (`2% milk` `acai berry` `acai berry juice` ...). similarities (`2% milk` `acai berry` `acai berry juice` ...).
Each emitted pair is a clean Sonnet-decision unit easier prompt, Each emitted pair is a clean Sonnet-decision unit easier prompt,

View file

@ -11,7 +11,7 @@ Pipeline per URL:
Same daemon-thread + cancel + stuck-recovery pattern as enrich/sterilize. Same daemon-thread + cancel + stuck-recovery pattern as enrich/sterilize.
Seed sources are hardcoded URL lists per source_seed (allrecipes-popular, Seed sources are hardcoded URL lists per source_seed (allrecipes-popular,
bbc-popular, smitten-kitchen-recent, ...). Alice supplies a seed name OR bbc-popular, smitten-kitchen-recent, ...). The user supplies a seed name OR
a literal list of URLs via the admin endpoint. Either way, the runner a literal list of URLs via the admin endpoint. Either way, the runner
walks the list, scrapeinsertenrich each, and emits progress. walks the list, scrapeinsertenrich each, and emits progress.
""" """

View file

@ -261,7 +261,7 @@ class Forge:
fp = meta.get("flavor_profile") or [] fp = meta.get("flavor_profile") or []
if fp: if fp:
extras.append("flavor:" + "/".join(fp[:3])) extras.append("flavor:" + "/".join(fp[:3]))
# Kid-fit signal for households with kids (Alice has Dana + Evan) # Kid-fit signal for households with kids (e.g. two children)
kf = meta.get("kid_friendly_score") kf = meta.get("kid_friendly_score")
if isinstance(kf, int) and kf > 0: if isinstance(kf, int) and kf > 0:
extras.append(f"kid={kf}") extras.append(f"kid={kf}")
@ -362,7 +362,7 @@ class Forge:
"\nPICKER PROFILES — per-member historical picking patterns:\n" "\nPICKER PROFILES — per-member historical picking patterns:\n"
+ "\n".join(lines) + "\n\n" + "\n".join(lines) + "\n\n"
"Use these to bias AI-chosen slots toward each member's " "Use these to bias AI-chosen slots toward each member's "
"preferences. e.g., if Alice's profile shows cuisines=[asian:6, " "preferences. e.g., if a user's profile shows cuisines=[asian:6, "
"mexican:4] and proteins=[chicken:8], lean toward asian-chicken " "mexican:4] and proteins=[chicken:8], lean toward asian-chicken "
"recipes for the AI-filled slots when other constraints permit. " "recipes for the AI-filled slots when other constraints permit. "
"Picks still take precedence over profile bias.\n" "Picks still take precedence over profile bias.\n"

View file

@ -1,12 +1,11 @@
"""Authentik OIDC integration via Authlib. """Authentik OIDC integration via Authlib.
Cauldron is gated to the Authentik 'Cauldron' application, which is bound Cauldron is gated to an OIDC application that is bound to whichever group
to the 'Example Household' group. Authentik enforces the group membership; we you choose in your identity provider. The provider enforces the group
just trust the userinfo response. membership; we just trust the userinfo response.
We use 'sub_mode=user_email' on the Authentik provider, so the OIDC `sub` We use a provider configured so the OIDC `sub` claim is the user's email —
claim is the user's email — stable, human-readable, and matches our stable, human-readable, and a convenient stable key for per-user prefs.
existing Sulkta SSO pattern.
""" """
from authlib.integrations.flask_client import OAuth from authlib.integrations.flask_client import OAuth

View file

@ -84,7 +84,7 @@ def _parse_dt(s):
def refresh_household_index(*, mealie_client, db, household_id: int) -> int: def refresh_household_index(*, mealie_client, db, household_id: int) -> int:
"""Pull every recipe in the user's household, flatten, replace the """Pull every recipe in the user's household, flatten, replace the
local index. Returns row count. Mealie's perPage caps high (Alice has local index. Returns row count. Mealie's perPage caps high (a large library can have
226 recipes single page works); we paginate just in case.""" 226 recipes single page works); we paginate just in case."""
all_rows = [] all_rows = []
page = 1 page = 1
@ -115,7 +115,7 @@ def _norm(s: str | None) -> str:
def search_index(rows: list[dict], q: str, *, limit: int = 80) -> list[dict]: def search_index(rows: list[dict], q: str, *, limit: int = 80) -> list[dict]:
"""Score each row against the query across multiple fields and return """Score each row against the query across multiple fields and return
top-N. Alice-style: typo-tolerant, phrase-aware, multi-field weighted. top-N. Fuzzy matching: typo-tolerant, phrase-aware, multi-field weighted.
Field weights (max-of-pre-weighted scores wins): Field weights (max-of-pre-weighted scores wins):
name × 1.00 name × 1.00

View file

@ -53,7 +53,7 @@ forge = Forge(
default_model=cfg.default_model, default_model=cfg.default_model,
default_timeout=cfg.default_timeout_secs, default_timeout=cfg.default_timeout_secs,
) )
# System-tier Mealie client (Alice's "Cauldron" token; admin batch ops only) # System-tier Mealie client (the service "Cauldron" token; admin batch ops only)
system_mealie = Mealie(base_url=cfg.mealie_api_url, api_token=cfg.mealie_api_token) system_mealie = Mealie(base_url=cfg.mealie_api_url, api_token=cfg.mealie_api_token)
system_sterilizer = Sterilizer(mealie=system_mealie, forge=forge, model=cfg.default_model) system_sterilizer = Sterilizer(mealie=system_mealie, forge=forge, model=cfg.default_model)
@ -142,7 +142,7 @@ def create_app() -> Flask:
# One-time backfill: re-key the legacy cauldron_foods (USDA + curated) # One-time backfill: re-key the legacy cauldron_foods (USDA + curated)
# densities into cauldron_food_metadata, keyed by Mealie food.id. Runs # densities into cauldron_food_metadata, keyed by Mealie food.id. Runs
# only when the new metadata table is empty. The system MEALIE_API_TOKEN # only when the new metadata table is empty. The system MEALIE_API_TOKEN
# may be expired (Alice's "Cauldron" token was minted long ago); fall # may be expired (the service "Cauldron" token may have been minted long ago); fall
# back to any stored per-user token since household admins can list # back to any stored per-user token since household admins can list
# all foods anyway. # all foods anyway.
def _resolve_backfill_mealie() -> "Mealie": def _resolve_backfill_mealie() -> "Mealie":

View file

@ -2,7 +2,7 @@
structured (qty, unit, food, note) so shopping-list aggregation works. 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 Why this exists: Mealie has its own CRF parser, but it's mediocre and produces
inconsistent results. Alice's hand-typed recipes have lots of "about 2 cups inconsistent results. Hand-typed recipes have lots of "about 2 cups
cooked white rice" / "1 small handful kale" / "a pinch of salt" etc. that 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 slip past the parser. We send these to Sonnet via clawdforge and get back
clean structured form. clean structured form.