# Semantic Ontology Classification — Implementation

Date: 2026-06-28
Status: **Implemented, running in background**

## Overview

Rule-based semantic classifier that annotates every recipe across 11 ontology dimensions. Pure Python keyword matching — no LLM/API calls needed. Processes ~500 recipes/batch at ~50-500/s throughput.

## Script

`profiles/nutrition-coach/scripts/semantic_classifier.py`

- Incremental: `LEFT JOIN recipe_semantics rs ON r.id = rs.recipe_id ... WHERE rs.recipe_id IS NULL`
- Batch insert: 500 recipes per `executemany()`
- Fallback to one-by-one insert on batch error
- Confidence: 0.85 (rule-based, not LLM-derived)
- Runtime: ~10 min for 54k recipes

## Database Table: `recipe_semantics`

```sql
CREATE TABLE recipe_semantics (
    recipe_id VARCHAR(255) PRIMARY KEY,  -- ⚠️ 255, not 128!
    diet_type ENUM('vegan','vegetarian','pescatarian','flexitarian','omnivore') NOT NULL,
    meal_course JSON NOT NULL,          -- breakfast,starter,soup,salad,main,side,dessert,snack,beverage,bread
    cuisine JSON NOT NULL,              -- italian,french,german,asian,mexican,thai,japanese,chinese,greek,spanish,turkish,middle_eastern,american,nordic,african,caribbean,austrian,swiss
    cooking_method JSON NOT NULL,       -- baking,frying,boiling,grilling,steaming,stewing,roasting,braising,raw,microwaving
    protein_source JSON NOT NULL,       -- beef,pork,poultry,fish,seafood,plant,dairy,egg,none
    allergens JSON NOT NULL,            -- gluten,eggs,dairy,nuts,soy,fish,shellfish,peanuts,sesame,celery,mustard,sulphites,lupin,molluscs
    flavor_profile JSON NOT NULL,       -- sweet,savory,salty,sour,bitter,umami,spicy,smoky,creamy,fresh
    complexity ENUM('quick','simple','moderate','elaborate') NOT NULL,
    season JSON NOT NULL,               -- spring,summer,autumn,winter,year_round
    caloric_density ENUM('light','balanced','hearty','indulgent') NOT NULL,
    preparation_style JSON NOT NULL,    -- one_pot,sheet_pan,pan_fried,oven_baked,grilled,no_cook,slow_cooked,meal_prep
    confidence FLOAT DEFAULT 0.85,
    analyzed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

## Ontology Dimensions

### 1. Diet Type (ENUM, single value)
Priority: explicit tags (vegan/vegetarisch) → meat detection → fish/seafood → dairy/egg → default omnivore.

### 2. Meal Course (JSON array)
Keyword matching on title+category+tags+ingredients+description. Fallback to category field if no match. Default: `main`.

### 3. Cuisine (JSON array)
18 cuisine types with German+English keywords. Also checks tags for country names. Default: `["german"]` if `deutsch` in tags.

### 4. Cooking Method (JSON array)
10 methods. Default: `["boiling"]` if no match.

### 5. Protein Source (JSON array)
Ordered priority: beef → pork → poultry → fish → seafood → egg → dairy → plant → none. Multiple can match.

### 6. Allergens (JSON array)
14 EU-regulated allergens. Keyword-based, broad coverage (German+English).

### 7. Flavor Profile (JSON array)
10 flavor dimensions. Desserts get `sweet` added if not already detected. Default: `["savory"]`.

### 8. Complexity (ENUM)
Based on max(prep_time, cook_time, total_time): ≤15min=quick, ≤30min=simple, ≤60min=moderate, >60min=elaborate. Falls back to tags (schnell/einfach).

### 9. Season (JSON array)
Keyword-based. Default: `["year_round"]`.

### 10. Caloric Density (ENUM)
From calories: <300=light, <500=balanced, <800=hearty, ≥800=indulgent. Falls back to keyword estimation (kalorienarm, deftig, dessert→indulgent).

### 11. Preparation Style (JSON array)
8 styles. Falls back to cooking method if no direct match.

## Cronjob

`semantic-classifier` — daily at 07:00, delivers to `telegram:223926918`. Runs after scraper (06:00) and embeddings (06:30).

## Pitfalls (Galera-Specific)

### 1. Foreign Key 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: either omit the FK (preferred — no cascade needed) or specify `COLLATE utf8mb4_unicode_ci` on the new table.

### 2. JOINs between tables with different collations fail
`LEFT JOIN recipe_semantics rs ON r.id = rs.recipe_id` fails with `Illegal mix of collations (utf8mb4_unicode_ci,IMPLICIT) and (utf8mb4_general_ci,IMPLICIT)`. Fix: add `COLLATE utf8mb4_unicode_ci` to the JOIN condition.

### 3. 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.

### 4. Time and calorie columns contain string values
Some `prep_time`, `cook_time`, `total_time`, and `calories` values are stored as strings (e.g., `"PT20M"` or `"450 kcal"`), not integers. Always wrap with a `safe_int()` helper that catches `ValueError`/`TypeError` before using in comparisons or arithmetic.

### 5. Concurrent writes cause Galera deadlocks
Running the semantic classifier and embedding generator simultaneously causes `Deadlock found when trying to get lock (1213)`. Fix: add retry logic with rollback+backoff (`safe_db_execute()`), or run sequentially. The daily cronjob schedule (06:00 scraper → 06:30 embeddings → 07:00 classifier) ensures sequential execution.

### 6. Galera FK constraints are fragile
Even without collation issues, Galera's certification-based replication can reject DDL that works on standalone MySQL. Prefer logical relationships (application-level integrity checks) over physical FKs when working with Galera.
