Public Access
175 lines
6.1 KiB
Python
175 lines
6.1 KiB
Python
"""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
|