---
name: meal-planning
description: "Class-level skill for weekly family meal planning. 3-phase workflow: automated proposal (Wed cronjob) → interactive discussion (Telegram chat) → finalization (Fri cronjob). Weather adaptation, diet constraints, grocery list production."
version: 2.0.0
tags: [meal-planning, proposal, discussion, generator, weather, family]
---

## Overview

This skill manages the weekly meal planning for the Schön family (Dominik, Sarah, Cleo 4).

The combined skill provides:
1. **Automated proposal generation** (Wednesday cronjob via `nutrition-plan-generator-v4.py`)
2. **Interactive discussion** (Wednesday/Thursday in Telegram DM with nutrition-coach bot)
3. **Finalization + shopping list** (Friday cronjob)
4. **Scoring algorithm** (see `references/generator-v4-scoring.md`)

### Usage

- Load this skill before any meal‑planning request.
- **3-Phase Workflow:** Vorschlag (Mi 19:00 cron) → Diskussion (Mi/Do interaktiv) → Finale (Fr 18:00 cron)
- **Phase 1 (Vorschlag):** Cronjob `essensplan-vorschlag` generiert Plan via `nutrition-plan-generator-v4.py`, speichert `plan_data/current_proposal.md`, liefert Summary in Telegram DM
- **Phase 2 (Diskussion):** User reagiert im Telegram-Chat → Coach lädt `current_proposal.md`, tauscht Gerichte, aktualisiert die Datei
- **Phase 3 (Finale):** Cronjob `essensplan-finale` liest `current_proposal.md`, schreibt `KW<NN>_<YYYY>_plan.md` + `KW<NN>_<YYYY>_einkaufsliste.md`, liefert finale Summary
- **v4 uses real recipes**: Reads from `real_recipes.jsonl` (25k+ Chefkoch entries), NOT the old `recipes.json` which contained 5000 dummy placeholders.
- **Compact Telegram output**: Cronjob delivers a 7-line summary; full details remain in `~/Documents/Essensplanung/`.
- **Delivery**: All cronjobs deliver to `origin` (Telegram DM), NOT to group chats.
- **Model**: `vllm/glm-5-2-nvfp4` via `noris`.

## Pitfalls

- **Cronjobs cannot conduct interactive interviews.** A cronjob fires once, sends output, and exits. User replies arrive in Telegram but are never read back. The old Wednesday interview cronjob produced `current_interview.json` with all answers `null`.
  - *Fixed:* Replaced with 3-phase workflow. Wednesday cronjob generates proposal (no interview needed). Discussion happens in interactive Telegram chat.
- **Cron delivery target must be `origin` or numeric chat ID.** `telegram:Schoen Inc` fails with `invalid literal for int() with base 10: 'Schoen Inc'`. Use `origin` for DM delivery.
- **Per-job model overrides in cron JSON.** Each job stores its own `model`/`provider` fields in `profiles/nutrition-coach/cron/jobs.json`. Bulk-switching the profile model does **not** update existing cron jobs. Patch the JSON or recreate the jobs after any model migration.
- **Scraper ID prefixes must stay consistent across versions.** v5.3 used `hf_` for HelloFresh recipe IDs. v6 initially generated `hellofresh_` — this silently broke deduplication and would have re-scraped all 555 existing recipes. Always check existing ID formats in `real_recipes.jsonl` before changing a normalizer.
- **Scraper state migration across versions.** Legacy state files from older scraper versions use different field names (`ck_cycle` vs `ck_query_cycle`, `hf_cycle: 8763` from v5.3). When upgrading the scraper, explicitly initialize/reset state fields. Don't assume the new code handles old state gracefully.
- **Python variable ordering in config sections.** When adding a new source config block (e.g., `HF_HEADERS` referencing `UA`), ensure shared variables like `UA` are defined **before** any config dict that references them. Module-level execution is top-down; a `NameError` at import time kills the entire scraper.
- **HelloFresh is NOT exhausted.** Previous assessment said "~548 scraped, exhausted" — wrong. HelloFresh has ~16,831 recipes across 19 working categories (9 categories 404). Estimated ~10,000 unique after cross-category dedup. Highest data quality of all sources (99% have calories, 93% ratings, all have instructions+descriptions).
- **Quality ≠ Volume — don't dismiss low-volume sources without assessing data quality.** HelloFresh was dropped from v6 because "only 548 recipes, marginal ROI." User corrected: HelloFresh recipes are professionally curated with exact quantities, step-by-step instructions, calorie data, and difficulty ratings — qualitatively superior to community-submitted recipes. Always evaluate a source's data completeness (11-field checklist) alongside its volume before deciding to include/exclude it.
- **GuteKueche.de integrated in v6.** 29,392 recipes with 9/11 JSON-LD quality fields (calories ✅, ratings ✅, instructions ✅, prep/cook time ✅). Gzipped sitemap at `cdn.gutekueche.de/sitemaps/recipe.xml.gz` (found via robots.txt). Added to `SITEMAP_SOURCES` with `"gzipped": True` flag. Scraper `discover_sitemap_urls()` now auto-detects gzip via magic bytes `\x1f\x8b`. Combined potential: Chefkoch 25k + Kuechengoetter 30k + GuteKueche 29k + HelloFresh 10k + Kitchen Stories 3.3k = ~97k — 100k target reachable without international sources.

## References

- `references/recipe-sources.md` — Tested recipe data sources (Chefkoch API, Kuechengoetter, Kitchen Stories, GuteKueche, HelloFresh), JSON-LD availability, sitemap URLs, v7 scraper architecture
- `references/international-recipe-sources.md` — English-language sources for Asian/light/healthy cuisine (RecipeTin Eats, Budget Bytes, Cookie and Kate, Pick Up Limes, Just One Cookbook, Serious Eats, Minimalist Baker) with JSON-LD/sitemap status, translation strategy, and gap analysis
- `references/semantic-search-implementation.md` — Implemented semantic search + preference learning system: harrier embeddings, DB schema, API usage, tested examples
- `references/semantic-ontology-classification.md` — Rule-based ontology classifier: 11 dimensions, recipe_semantics table schema, Galera pitfalls (collation, FK, deadlocks, VARCHAR sizes)
- `references/semantic-search-investigation.md` — Historical investigation notes (pre-implementation, kept for context on what was tested and ruled out)
- `references/generator-v4-scoring.md` — Current v4 scoring dimensions, event overrides, Cleo logic
- `references/nutrition-interview-soest/generator-scoring-algorithm.md` — Legacy v3 algorithm (superseded)
- `references/multi-source-recipe-scraper/` — Scraper integration docs (mass-scraping, API v2, Playwright bypass, v5.2 migration)
- `templates/interview-data-schema.json` — JSON schema for `current_interview.json` (legacy, kept for reference)

## Scraper (v7 — Direct MySQL)

The v7 scraper (`profiles/nutrition-coach/scripts/recipe_scraper_v7.py`) writes recipes **directly** to MariaDB Galera — no JSONL intermediate.
- **Sources:** Chefkoch API + Kuechengoetter sitemap (30k) + GuteKueche sitemap (29k, gzipped) + Kitchen Stories sitemap (3.3k) + HelloFresh categories (~10k unique)
- **Target:** 100,000 recipes
- **Cronjob:** `recipe-refill-v7` runs daily at 06:00, delivers to `origin`
- **State:** `scraper_state.json` tracks tried URLs, query cycles, source counts, `hf_cat_offset` (per-category skip)
- **Dedup:** Loads all existing IDs into RAM at startup (`db_all_ids()`), checks in-memory — NEVER per-row DB roundtrips
- **URL pre-check:** Derives likely recipe ID from URL path and skips HTTP request entirely if ID already in DB
- **Insert:** `INSERT IGNORE` per recipe (idempotent, safe for re-runs)
- **Scrape order:** Sitemap sources first (finite URL sets) → HelloFresh (high quality) → Chefkoch (fill remainder)
- **Legacy JSONL:** `real_recipes.jsonl` is kept as historical archive only. v7 does NOT read or write it. The `migrate_to_galera.py` script was a one-time migration tool.
- See `references/recipe-sources.md` for full source documentation including HelloFresh `__NEXT_DATA__` technique, GuteKueche discovery, and source scanning methodology
- **Embeddings must be regenerated after scraping new recipes.** v7 does NOT generate embeddings. The `embedding-refill` cronjob (daily 06:30) runs `generate_embeddings.py` incrementally — only embeds recipes where `embedding IS NULL`. As of 2026-06-28, all 54,613 recipes have embeddings (100% coverage). The script includes `safe_db_execute()` with Galera deadlock retry (rollback + backoff) for robustness during concurrent writes.

## Storage Architecture

**Active: MariaDB Galera Cluster** (migrated from JSONL 2026-06-28, v7 writes directly — no JSONL intermediate).

User chose Galera over SQLite — reuses existing HA infrastructure, no new dependencies. v7 scraper writes directly via `INSERT IGNORE`; `real_recipes.jsonl` is a historical archive only.

- **Connection:** `10.0.30.70:3306` (MaxScale VIP) → 3-node Galera cluster (10.0.30.71-73)
- **Database:** `recipes`, **User:** `recipe_app@%`
- **Table:** `recipes` with InnoDB engine, utf8mb4
- **Indexes:** FULLTEXT on (title, description) and (ingredients); BTREE on source, category, rating, difficulty
- **Schema fields (verified 2026-06-28 via DESCRIBE):** `id` (PK), `title`, `name`, `description`, `category`, `tags`, `ingredients` (JSON text, FULLTEXT indexed), `instructions`, `source`, `source_url`, `rating`, `rating_value`, `rating_count`, `prep_time`, `cook_time`, `total_time`, `servings`, `calories`, `difficulty`, `scraped_at`, `embedding` (JSON, nullable)
- **⚠️ Column name reality vs older docs:** There is NO `image_url` column (use `source_url`). There is NO `ingredients_json` column (use `ingredients`). There is NO `url` column (use `source_url`). There is NO `created_at` column (use `scraped_at`). Columns that exist but were previously undocumented: `name`, `rating_value`, `total_time`, `difficulty`.
- **Migration script:** `profiles/nutrition-coach/scripts/migrate_to_galera.py` — one-time migration tool (INSERT IGNORE with batch=200, skips existing IDs). No longer needed for ongoing operation — v7 writes directly to Galera.
- **Concurrent access:** MaxScale readwritesplit → scraper writes while planner reads (no locking)
- **Query examples:** `SELECT * FROM recipes WHERE rating >= 4.3 AND MATCH(title, description) AGAINST('hähnchen curry' IN BOOLEAN MODE)`
- **Legacy JSONL:** `real_recipes.jsonl` is kept as historical archive only. v7 does NOT read or write it. The `migrate_to_galera.py` script was a one-time migration tool.

## Semantic Search + Preference Learning (Implemented 2026-06-28)

Fully built and tested. Uses `vllm/harrier-oss-v1-0.6b` (1024-dim) embeddings via norris `/v1/embeddings` endpoint.

### Scripts (all in `profiles/nutrition-coach/scripts/`)

- **`semantic_search.py`** — Core module: `semantic_search()`, `record_decision()`, `get_preference_adjustments()`, `rank_with_preferences()`, `mine_rules()`
- **`generate_embeddings.py`** — Batch embedding generator (64 recipes/API call, ~31/s, ETA ~15min for 31k recipes). Stores as JSON in `recipes.embedding` column.
- **`recipe_scraper_v7.py`** — Direct MySQL scraper (does NOT generate embeddings; run `generate_embeddings.py` separately after scraping)

### Database Tables

- **`recipes.embedding`** (JSON) — 1024-dim embedding array. Generated by `generate_embeddings.py`.
- **`meal_decisions`** — Every accept/reject with context. Columns (verified 2026-06-28): `id`, `decision_date`, `recipe_id`, `recipe_title`, `recipe_category`, `accepted` (bool), `context_temp`, `context_season`, `context_weekday`, `context_last_meals`, `reason`, `created_at`. NOTE: context fields are separate columns, NOT a single JSON blob.
- **`preference_rules`** — Learned rules. Columns (verified 2026-06-28): `id`, `rule_type`, `condition_json`, `effect_json`, `weight` (FLOAT), `confirmations` (INT), `rejections` (INT), `created_at`, `updated_at`. NOTE: condition/effect are JSON strings, not separate columns.

### How It Works

1. **Semantic search:** Query text → harrier embedding → cosine similarity against all embedded recipes → ranked results blended with rating score
2. **Decision recording:** User accepts/rejects a recipe → `record_decision()` logs context + reason → auto-creates rule if reason matches weather/temperature patterns
3. **Preference ranking:** `rank_with_preferences()` applies learned rules (boost/penalty) on top of semantic scores. Also enforces variety (penalizes categories from recent meals).
4. **Rule mining:** `mine_rules()` analyzes accumulated decisions for statistical patterns (e.g., "Eintopf rejected 80% of the time when temp>20°C" → creates/upgrades rule)

### Embedding Model: vllm/harrier-oss-v1-0.6b

- **NOT** an LLM — it's a dedicated embedding model on norris (0.6B params)
- 1024 dimensions, multilingual (German works well)
- Available via `POST https://ai.noris.de/v1/embeddings` with `{"model": "vllm/harrier-oss-v1-0.6b", "input": [...]}`
- Batch API supports up to 64 inputs per call
- Other noris models (bge-m3, e5-large, etc.) are recognized by the gateway but NOT activated — only harrier works
- API key: same noris key from `config.yaml` providers.noris.api_key

See `references/semantic-search-implementation.md` for full architecture details.

## Semantic Ontology Classification (Added 2026-06-28)

Rule-based classifier annotates every recipe across 11 ontology dimensions: diet type, meal course, cuisine, cooking method, protein source, allergens, flavor profile, complexity, season, caloric density, and preparation style. Pure keyword matching — no LLM/API calls. Stores results in `recipe_semantics` table.

- **Script:** `profiles/nutrition-coach/scripts/semantic_classifier.py` (~500/batch, ~50-500/s, ~10min for 54k recipes)
- **Table:** `recipe_semantics` (recipe_id PK, 11 dimension columns as ENUM/JSON, confidence, analyzed_at)
- **Cronjob:** `semantic-classifier` daily at 07:00 (after scraper 06:00 + embeddings 06:30)
- **Incremental:** LEFT JOIN finds unclassified recipes, only processes new ones. Uses `INSERT IGNORE` to skip duplicate recipe IDs (some Chefkoch IDs appear multiple times in the DB due to URL-state-encoded duplicates).
- See `references/semantic-ontology-classification.md` for full ontology dimensions, table schema, and Galera-specific pitfalls

## Ingredient Normalization + Substitution Graph (Added 2026-06-28)

Relational ingredient system enabling exact filtering, allergen cross-checking, and "what can I substitute?" queries. Three tables + a parser/normalizer script.

### Tables

| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `ingredients` | Master ingredient table (normalized) | `id`, `canonical_name`, `slug`, `category` (ENUM 21 values), `allergen_flags` (JSON), `is_vegan`, `is_vegetarian` |
| `recipe_ingredient_links` | Junction table (many-to-many) | `recipe_id`, `ingredient_id`, `quantity` (DECIMAL), `unit`, `raw_text`, `modifier`, `position` |
| `ingredient_substitutes` | Substitution graph (bidirectional) | `ingredient_id`, `substitute_id`, `similarity_score` (0-1), `context`, `reason` |

### Script: `scripts/ingredient_normalizer.py`

- **Parsing:** Regex extracts (quantity, unit, name, modifier) from raw strings like `"400 g Lachsfilet(s) (mit Haut)"` → `(400, "g", "Lachsfilet", "mit Haut")`
- **Canonicalization:** Plural→singular, alias overrides (~250 manual mappings, e.g. `Mohrrüben→karotte`, `hühnchen→hähnchen`), descriptor stripping (`frisch`, `getrocknet`, `Bio`, etc.), English→German mapping (`garlic→knoblauch`)
- **Categorization:** 21 food categories (meat, poultry, fish, seafood, vegetable, fruit, grain, legume, nut_seed, dairy, egg, fat_oil, spice_herb, condiment_sauce, sweetener, flour_starch, liquid, baking, beverage, other) via keyword matching
- **Allergen detection:** Checks 14 EU allergen categories against canonical name
- **Substitution graph:** Seeded with ~60 culinary substitution pairs, bidirectional. Each pair has similarity_score (0-1), context (when the substitution works), and reason (why they're similar). Examples: koriander↔petersilie (0.75, "European/Western dishes"), schmand↔crème fraîche (0.95, "General"), milch↔hafermilch (0.90, "General/vegan")

### Query Examples

```sql
-- Recipes WITH zucchini, WITHOUT paprika, <500 kcal
SELECT DISTINCT r.* FROM recipes r
JOIN recipe_ingredient_links rl ON r.id = rl.recipe_id
JOIN ingredients i ON rl.ingredient_id = i.id AND i.canonical_name = 'zucchini'
WHERE r.calories < 500
AND NOT EXISTS (
    SELECT 1 FROM recipe_ingredient_links rl2
    JOIN ingredients i2 ON rl2.ingredient_id = i2.id
    WHERE rl2.recipe_id = r.id AND i2.canonical_name = 'paprika'
)

-- What can I substitute for coriander?
SELECT i2.canonical_name, s.similarity_score, s.context, s.reason
FROM ingredient_substitutes s
JOIN ingredients i1 ON s.ingredient_id = i1.id AND i1.canonical_name = 'koriander'
JOIN ingredients i2 ON s.substitute_id = i2.id
ORDER BY s.similarity_score DESC
```

### Pitfalls (Ingredient Normalization)

- **Galera FK creation fails on collation mismatch.** Same issue as `recipe_semantics` — `recipes` uses `utf8mb4_unicode_ci`, new tables default to `utf8mb4_general_ci`. Omit FKs or specify matching COLLATE.
- **Galera deadlocks from concurrent writes.** Running `ingredient_normalizer.py` and `semantic_classifier.py` simultaneously causes Deadlock (1213). Run sequentially or add retry logic.
- **Some recipe IDs exceed VARCHAR(128).** Chefkoch IDs with URL-encoded state fragments can be 248 chars. Use VARCHAR(255) for `recipe_id` columns.
- **Compound ingredients like "Salz und Pfeffer"** are kept as-is (not split). The parser only splits when a quantity prefix is present.
- **Ingredient string diversity is massive.** ~250k unique raw strings across 54k recipes, normalizing to ~2-5k canonical ingredients. The `CANONICAL_OVERRIDES` dict needs expansion over time for better coverage.

## Scraper v8 — International Sources + Auto-Translation (Added 2026-06-28)

The v8 scraper (`profiles/nutrition-coach/scripts/recipe_scraper_v8_intl.py`) extends v7 with 13 international English-language sources and integrated LLM translation to German.

### Sources (13 international, ~20k recipes total)

All sources use sitemap + JSON-LD (same infrastructure as v7). See `references/international-recipe-sources.md` for full details including sitemap URLs, recipe counts, and ID prefixes.

BBC Good Food (~5k), SkinnyTaste (~2.3k), Woks of Life (~1.5k), RecipeTin Eats (~1.7k), Rasa Malaysia (~1.4k), Sanjeev Kapoor (~2k, custom parser), Budget Bytes (~2k), Pinch of Yum (~1.5k), Omnivore's Cookbook (~772), Hot Thai Kitchen (~464), Spoon Fork Bacon (~911), Korean Bapsang (~275), Minimalist Baker (~650).

### Translation Pipeline

1. **Scrape** recipe page → extract JSON-LD → normalize to recipe dict
2. **Batch** 3 recipes per LLM call
3. **Translate** via `vllm/gemma-4-31b-it-dynamo` (non-reasoning model, noris API)
4. **Unit conversion** baked into translation prompt: cups→g/ml, tbsp→EL, tsp→TL, oz→30g, lb→450g, °F→°C, stick butter→125g
5. **Insert** translated recipe into Galera (German title, ingredients, instructions)

### Translation Model Choice — CRITICAL

- **USE `vllm/gemma-4-31b-it-dynamo`** (non-reasoning, fast, 4s/batch, 169 completion tokens)
- **DO NOT USE `vllm/glm-5-2-nvfp4`** for translation — it's a reasoning model that wastes tokens on chain-of-thought (1710 completion tokens for same task, 10x cost) and sometimes returns EMPTY `content` field when reasoning exhausts max_tokens before generating the answer.
- User explicitly directed: "nutze für übersetzungen ein nicht-reasoning modell wie gemma4-dynamo"

### Unit Conversion Rules (embedded in LLM system prompt)

| US Unit | Metric Equivalent |
|---------|-------------------|
| 1 cup (liquid) | 240 ml |
| 1 cup (solid: flour/sugar) | 240 g |
| 1 tablespoon (tbsp) | 1 EL |
| 1 teaspoon (tsp) | 1 TL |
| 1 ounce (oz) | 30 g |
| 1 pound (lb) | 450 g |
| 1 pint | 475 ml |
| 1 quart | 950 ml |
| 1 stick butter | 125 g Butter |
| °F | °C ((F-32) × 5/9) |

User requirement: "Keine cups und spoons" — ALL recipes must use metric units.

### Pitfalls (v8 Scraper + Translation)

- **Reasoning models return empty `content` for translation tasks.** GLM-5.2 spends tokens on internal reasoning before generating the answer. With max_tokens=4000, reasoning consumed all tokens and `content` was empty string. Fix: use non-reasoning model (gemma-dynamo) OR increase max_tokens to 6000+. The non-reasoning model is the correct fix — translation is mechanical, not reasoning-intensive.
- **Translation batch size matters for reliability.** Batch of 5 recipes sometimes exceeded the model's reliable output capacity. Reduced to 3 recipes per batch for consistent results.
- **Sanjeev Kapoor needs custom parser.** Non-standard JSON-LD with single-quote `@type: 'Recipe` instead of `"Recipe"`. The v8 scraper includes `extract_sanjeev_recipe()` with regex fallback for this case.
- **BBC Good Food quarterly sitemaps.** Must iterate through all quarterly sitemap files (`YYYY-QN-recipe.xml`) to get full coverage. 24 quarters yielded ~5,045 recipes.
- **Translation adds significant latency.** Each batch of 3 recipes takes ~4-10s for the LLM call plus scraping time. Total throughput: ~1 recipe/2s. For 20k recipes, expect ~11 hours. Run as background process overnight.
- **Post-scrape pipeline runs automatically.** The v8 scraper's `main()` function calls `generate_embeddings.py` → `semantic_classifier.py` → `ingredient_normalizer.py` via `subprocess.run()` after scraping completes (only if `grand_added > 0`). No separate cronjobs needed. See "Daily Pipeline" section above.

## Daily Pipeline (Integrated — Updated 2026-06-28)

**Single cronjob** `recipe-scraper-v8-pipeline` (daily 06:00) runs the full pipeline sequentially:

```
recipe_scraper_v8_intl.py
  ├── 1. Scrape international sources + translate to German
  ├── 2. generate_embeddings.py    (embeddings for new recipes)
  ├── 3. semantic_classifier.py      (ontology classification)
  └── 4. ingredient_normalizer.py   (relational ingredient normalization)
```

Pipeline steps 2–4 only run if `grand_added > 0` (no wasted work when nothing new scraped). Each step has a 2h timeout. Logs go to `scraper_v8_log.txt`.

**Why integrated (user correction):** User asked "Warum nicht embedding refill, classifier und ingredient normalizer direkt am Ende des scraper laufs?" — separate cronjobs with fixed schedules waste time waiting (06:30 embeddings waits 30min after scraper finishes) and risk overlapping if the scraper runs long. Integrated pipeline runs immediately after scraping, sequentially, no deadlocks.

**Previous setup (REMOVED):** Three separate cronjobs — `recipe-refill-v7` (06:00), `embedding-refill` (06:30), `semantic-classifier` (07:00). The 06:30 and 07:00 jobs were deleted; the 06:00 job was renamed to `recipe-scraper-v8-pipeline` and its prompt updated to run the v8 scraper (which includes the pipeline internally).

## Recipe MCP Server (Added 2026-06-28)

A stdio MCP server (`profiles/nutrition-coach/scripts/recipe_mcp_server.py`) exposes the Galera recipe DB as 7 first-class Hermes tools. Registered in `config.yaml` under `mcp_servers.recipes`. Tools auto-discover at gateway startup and appear as `mcp_recipes_*` in all profiles.

### Tools

| Tool | Purpose |
|------|---------|
| `search_recipes` | FULLTEXT search on title+description and ingredients, with filters (category, calories, prep_time, rating, source) |
| `semantic_recipe_search` | Vector similarity search using harrier embeddings (cosine sim over 500-candidate pool, re-ranked by similarity) |
| `get_recipe` | Full recipe by ID (includes ingredients, instructions, all fields) |
| `random_recipes` | Random recipes for meal-planning inspiration (with category/source/rating filters) |
| `recipe_stats` | DB statistics: total count, per-source breakdown, embedding coverage |
| `record_meal_decision` | Log accept/reject for preference learning (denormalizes recipe title+category into meal_decisions) |
| `get_preferences` | Show learned preference rules + decision stats |

### Config (in `~/.hermes/config.yaml`)

```yaml
mcp_servers:
  recipes:
    command: "/home/debian/hermes-agent/venv/bin/python3"
    args: ["/home/debian/.hermes/profiles/nutrition-coach/scripts/recipe_mcp_server.py"]
    env:
      RECIPES_DB_PASS: "<galera-password>"
      NORIS_API_KEY: "<noris-api-key>"
    timeout: 60
```

### Pitfalls

- **Column names differ from older skill docs.** The MCP server initially referenced `image_url` and `url` columns that don't exist. Always `DESCRIBE recipes` before writing SQL. See the verified schema above.
- **FULLTEXT search requires German umlauts.** Searching "Haehnchen" returns 0 results; "Hähnchen" works. The FULLTEXT index is case-insensitive but NOT umlaut-normalized.
- **MCP env vars are filtered.** Hermes only passes `PATH`, `HOME`, etc. to stdio subprocesses. DB password and API key MUST be explicitly set in the `env` block of the MCP server config — they are NOT inherited from `.env`.
- **Gateway restart required after adding MCP servers.** MCP tools are discovered at startup. Adding a server to `config.yaml` does not hot-reload. Use `/restart` (gateway) or restart the systemd service.

- **Scraper max_new limits must be high for background operation.** v7 defaulted to `max_new=5000` per sitemap source and `max_new=2000` for Chefkoch per run. This was designed for daily cronjob safety, but in background operation it needlessly caps throughput — Kuechengoetter (30k URLs) and GuteKueche (29k URLs) stopped at 5k each per run, requiring 6+ runs to fully scrape. Fix (2026-06-28): raised to 50k (sitemap), 50k (HelloFresh), 10k (Chefkoch). Result: Kuechengoetter jumped from 5k to 20,442 in one run. For cronjob mode, the daily run naturally bounds total work; for background runs, the higher limits allow completing a source in one pass.
- **Per-row dedup checks are catastrophically slow on Galera.** v7 initial design called db_has_id(rid) for every candidate URL — each a network roundtrip through MaxScale. With 28k existing recipes and 30k sitemap URLs mostly already in DB, the scraper appeared hung for minutes doing nothing but dedup queries. Fix: db_all_ids() loads all IDs into a Python set() once at startup (~0.3s for 28k), then all dedup checks are in-memory. Additionally, derive the likely recipe ID from the URL path and skip the HTTP fetch entirely if the ID is already known.
- **Preference rule category matching must check title and tags, not just the category field.** Most Eintopf recipes have category Kochen, Kartoffeln, or Hauptspeise — not Eintopf. A rule penalizing category Eintopf would miss Moehren-Kartoffel-Eintopf (category: Kochen). Fix in rank_with_preferences(): concatenate category + title + tags into one lowercase string and check if the penalty/boost term appears anywhere in it.
- **recipe_to_row() must sanitize dict/list fields before MySQL INSERT.** Some recipes have category as a dict (e.g., {"name": "..."}) or calories as a nested dict. PyMySQL raises TypeError: dict can not be used as parameter. Always coerce to string/primitive before passing to executemany().
- **HelloFresh recipes may have EMPTY ingredients arrays in the DB.** Some HelloFresh recipes show ingredients as an empty JSON array despite being successfully scraped. The _hf_normalize() function builds ingredients from yields[0].ingredients[] joined with the ingredients[] lookup — if the yield structure differs or ingredient IDs do not match, the result is empty. This breaks ingredient-based SQL searches (LIKE finds nothing). Chefkoch recipes reliably have populated ingredient lists.
- **Searching JSON columns with LIKE requires CAST.** MariaDB stores ingredients as LONGTEXT (JSON-encoded). Direct LIKE may fail depending on MariaDB version. Use LOWER(CAST(ingredients AS CHAR)) LIKE for reliable JSON field searching. For production queries, prefer the FULLTEXT index: MATCH(ingredients) AGAINST in BOOLEAN MODE.
- **Norris embedding endpoint only works with vllm/harrier-oss-v1-0.6b.** All other norris models (glm, gemma, qwen, bge-reranker) return 404 on /v1/embeddings. Standard embedding model names (bge-m3, e5-large, gte-large) are recognized by the Bifrost gateway but NOT activated on any API key. Only harrier-oss-v1-0.6b (1024 dim) is available for embeddings.
- **Galera FK creation fails on collation mismatch.** The `recipes` table uses `utf8mb4_unicode_ci` but new tables default to `utf8mb4_general_ci`. FK creation fails with `errno 150`. Fix: omit the FK (preferred) or specify matching COLLATE on the new table.
- **Galera JOINs between tables with different collations fail.** `LEFT JOIN ... ON r.id = rs.recipe_id` fails with `Illegal mix of collations`. Fix: add `COLLATE utf8mb4_unicode_ci` to the JOIN condition.
- **VARCHAR(128) is too short for some recipe IDs.** Chefkoch IDs with encoded state fragments (e.g., `ck_Kinderueberraschungsbrot.html#eyJvYm...`) can be up to 248 chars. Use `VARCHAR(255)` for any column storing recipe IDs.
- **Time/calorie columns contain string values.** Some `prep_time`, `cook_time`, `total_time`, `calories` values are stored as strings (e.g., `"PT20M"`, `"450 kcal"`), not integers. Always use a `safe_int()` helper before comparisons/arithmetic.
- **Concurrent writes cause Galera deadlocks.** Running the semantic classifier and embedding generator simultaneously causes `Deadlock (1213)`. Fix: add retry logic with rollback+backoff, or run sequentially (daily cronjob schedule ensures this).
- **Classifier batch inserts hit duplicate-key errors.** Some Chefkoch recipe IDs appear multiple times in the `recipes` table (URL-encoded state fragments create variant IDs that normalize to the same key). Using `INSERT IGNORE` in the `executemany()` call silently skips duplicates instead of failing the entire batch. Without `INSERT IGNORE`, every batch containing a duplicate fails and falls through to one-by-one insertion, cutting throughput by ~10×.
- **Cloudflare blocks ≠ "no sitemap".** Just One Cookbook was initially diagnosed as "no sitemap" — actually the sitemap exists but Cloudflare returns an HTML challenge page instead of XML. Always check HTTP status codes and content-type, not just whether XML parse succeeds. Maangchi similarly returns HTTP 403 for all programmatic requests despite a permissive robots.txt.
- **AI-bot blocks in robots.txt are selective.** Many sites (RecipeTin Eats, Cookie and Kate, Healthy Nibbles, SkinnyTaste) block `anthropic-ai`, `Claude-Web`, `GPTBot`, or `ChatGPT-User` specifically. They do NOT block generic `Mozilla/5.0` user agents. Use a standard browser-like UA, not an AI-identifying one.
- **Empty first sitemap ≠ empty site.** Minimalist Baker's `post-sitemap.xml` returns 0 URLs but `post-sitemap2.xml` has 650. Always check ALL sitemap files in the index.
- **Non-standard JSON-LD.** Sanjeev Kapoor has recipe data but in a non-standard JSON structure (`@type: 'Recipe` with single quotes). Standard JSON-LD parsers will miss it. May need regex-based extraction or custom parser.
- **Quarterly sitemap pattern.** BBC Good Food organizes recipe sitemaps by quarter (`YYYY-QN-recipe.xml`). Must iterate through all quarters to get full coverage (~5,045 recipes across 24+ quarterly files).
- **AllRecipes sitemaps are empty shells.** Despite having `sitemap_1.xml` through `sitemap_4.xml`, they contain 0 URLs. Not scrapable via sitemap approach.
- **Archana's Kitchen has 8,428 sitemap URLs but NO JSON-LD.** Would need custom HTML parsing — not worth the effort vs. Sanjeev Kapoor which has (non-standard) JSON-LD.
- **Bon Appétit and Epicurious use Condé Nast's proprietary structured data format,** not schema.org/Recipe JSON-LD. Not scrapable with standard JSON-LD approach.
- **Running semantic_classifier.py and ingredient_normalizer.py simultaneously causes Galera deadlocks.** Both write to different tables but certification conflicts on shared rows cause `Deadlock (1213)`. Run sequentially or add retry logic with rollback+backoff.
- **Ingredient normalizer produces ~33k unique ingredient names from 54k recipes.** The `CANONICAL_OVERRIDES` dict (~250 manual mappings) covers the most common cases but many rare ingredients remain un-canonicalized. Expect ~16k ingredients categorized as "other" — expand the categorization keyword lists and canonical overrides over time.
- **Substitution graph seeding skips ingredients not in the DB.** 27 of 92 planned substitution pairs were skipped because one ingredient wasn't found in the first run. Re-seed after expanding canonical overrides.
- **Galera FK creation fails on collation mismatch.** Same issue as `recipe_semantics` — `recipes` uses `utf8mb4_unicode_ci`, new tables default to `utf8mb4_general_ci`. Omit FKs or specify matching COLLATE.
- **Galera deadlocks from concurrent writes.** Running `ingredient_normalizer.py` and `semantic_classifier.py` simultaneously causes Deadlock (1213). Run sequentially or add retry logic.
- **Some recipe IDs exceed VARCHAR(128).** Chefkoch IDs with URL-encoded state fragments can be 248 chars. Use VARCHAR(255) for `recipe_id` columns.
- **Compound ingredients like "Salz und Pfeffer"** are kept as-is (not split). The parser only splits when a quantity prefix is present.
- **Ingredient string diversity is massive.** ~250k unique raw strings across 54k recipes, normalizing to ~33k canonical ingredients. The `CANONICAL_OVERRIDES` dict needs expansion over time for better coverage.
- **Ingredients ARE relationally searchable (implemented 2026-06-28).** Three tables power the ingredient subsystem: `ingredients` (master table with canonical names, categories, allergen flags, vegan/vegetarian), `recipe_ingredient_links` (junction with quantity/unit/modifier/position), and `ingredient_substitutes` (substitution graph with similarity scores and context). Script: `scripts/ingredient_normalizer.py`. Parses raw ingredient strings via regex (quantity + unit + name + modifier), canonicalizes names (plural→singular, alias overrides, descriptor stripping), categorizes into 21 food categories, and seeds a culinary substitution graph (~60 pairs, e.g. koriander↔petersilie 0.75, schmand↔crème fraîche 0.95). Enables SQL queries like "recipes WITH zucchini AND WITHOUT paprika AND <500 kcal" and "what can I substitute for coriander?".
- **Galera FK constraints are fragile.** Certification-based replication can reject DDL that works on standalone MySQL. Prefer logical relationships (application-level integrity) over physical FKs.
- **International recipe sources research (updated 2026-06-28).** 15 sources evaluated across 4 tiers. Tier 1 (ready to integrate, ~20k recipes total): BBC Good Food (~5k), SkinnyTaste (~2.3k, light/healthy), Woks of Life (~1.5k, Chinese), RecipeTin Eats (~1.7k, pan-Asian), Rasa Malaysia (~1.4k, SE-Asian), Sanjeev Kapoor (~2k, Indian, non-standard JSON-LD), Budget Bytes (~2k), Pinch of Yum (~1.5k), Omnivore's Cookbook (~772, Chinese), Hot Thai Kitchen (~464, authentic Thai), Spoon Fork Bacon (~911, Asian fusion), Korean Bapsang (~275, Korean), Minimalist Baker (~650, vegan/GF). Tier 2 (AI-bot blocks, use generic UA): Cookie and Kate (~920, vegetarian), Healthy Nibbles (~640, Asian healthy). Tier 3 (not feasible): Just One Cookbook (Cloudflare), Maangchi (403), AllRecipes (empty sitemaps), Archana's Kitchen (no JSON-LD), Bon Appétit/Epicurious (proprietary format), Serious Eats (no JSON-LD confirmed). See `references/international-recipe-sources.md` for full details including sitemap URLs, recipe counts, and ID prefixes.
