"""Recipe Ingestion — normalize and persist discovered recipes. Takes ExternalRecipe objects, deduplicates, maps ingredients, and inserts into DB. """ from __future__ import annotations import html import logging import uuid as _uuid_mod from typing import List, Optional from rapidfuzz import fuzz from sqlalchemy.dialects.postgresql import insert as _pg_insert from sqlalchemy.orm import Session from app.models import Ingredient, Recipe logger = logging.getLogger(__name__) _INGREDIENT_FUZZY_THRESHOLD = 70 _MAX_INGREDIENTS = 20 class RecipeIngestionService: """Ingest external recipes into our database.""" def ingest( self, db: Session, family_profile_id: _uuid_mod.UUID, candidates: List, analysis_dict: dict, ) -> int: """Insert candidates, skipping duplicates. Returns count added.""" added = 0 seen_external: set[str] = set() for ext in candidates: # Skip if already in DB by external_source+external_id ext_key = f"{ext.external_source}:{ext.external_id}" if ext_key in seen_external: continue existing = ( db.query(Recipe) .filter( Recipe.external_source == ext.external_source, Recipe.external_id == ext.external_id, ) .first() ) if existing: logger.info("RecipeIngestion: duplicate external recipe %s", ext_key) continue # Skip if name is very similar to an existing recipe (basic fuzzy dedup) dup = ( db.query(Recipe) .filter(Recipe.name.ilike(f"%{ext.name[:30]}%")) .first() ) if dup and fuzz.ratio(dup.name.lower(), ext.name.lower()) > 85: logger.info("RecipeIngestion: fuzzy duplicate with %s", dup.name) continue # Normalize instructions instructions = [] for step in (ext.instructions or []): # Strip Spoonacular HTML tags plain = html.unescape(step).replace("\r", "") instructions.append(plain) # Map ingredients to canonical names/IDs mapped_ingredients = self._map_ingredients(db, ext.ingredients) # Build discovery_reason from analysis queries = analysis_dict.get("discovery_queries", []) top_signals = [s["value"] for s in analysis_dict.get("positive_signals", [])] discovery_reason = f"Matched queries: {', '.join(queries[:2])}. Signals: {', '.join(top_signals[:2])}." recipe = Recipe( id=_uuid_mod.uuid4(), family_profile_id=family_profile_id, name=ext.name, description=ext.description, image_url=ext.image_url, image_source=ext.external_source, prep_time_minutes=ext.prep_time_minutes, cook_time_minutes=ext.cook_time_minutes, servings=ext.servings or 4, cuisine_tags=ext.cuisine_tags, dietary_tags=ext.dietary_tags, protein_type=ext.protein_type, calories_per_serving=ext.calories_per_serving, ingredients=mapped_ingredients, instructions=instructions, source_url=ext.source_url, external_source=ext.external_source, external_id=ext.external_id, discovery_reason=discovery_reason, is_manually_added=False, ) db.add(recipe) added += 1 seen_external.add(ext_key) logger.info("RecipeIngestion: added %r", ext.name) # Flush so the recipe is visible to queries within the same session db.flush() return added def _map_ingredients( self, db: Session, external_ingredients: List[dict], ) -> List[dict]: """Match external ingredient names to canonical Ingredient rows. Strategy: 1. Exact name match (case-insensitive) 2. Fuzzy match above threshold 3. If no match, create a new Ingredient row with is_system=False """ out = [] db_ingredients = {i.name.lower(): i for i in db.query(Ingredient).all()} for ing in external_ingredients[:_MAX_INGREDIENTS]: name = ing.get("name", "").strip().lower() if not name: continue # Exact match canon = db_ingredients.get(name) if not canon: # Fuzzy fallback best = None best_score = 0 for other_name, other in db_ingredients.items(): score = fuzz.ratio(name, other_name) if score > best_score: best_score = score best = other if best and best_score >= _INGREDIENT_FUZZY_THRESHOLD: canon = best if canon: out.append({ "ingredient_id": str(canon.id), "name": canon.name, "qty": ing.get("qty"), "unit": ing.get("unit", ""), }) else: # Create Ingredient from external data (unverified) name_title = ing["name"].strip().title() new_ing = Ingredient( id=_uuid_mod.uuid4(), name=name_title, name_lower=name_title.lower(), aliases=[], ) db.add(new_ing) # Flush to get ID db.flush() db_ingredients[name] = new_ing out.append({ "ingredient_id": str(new_ing.id), "name": new_ing.name, "qty": ing.get("qty"), "unit": ing.get("unit", ""), }) logger.debug("RecipeIngestion: created unmapped ingredient %r", new_ing.name) return out