Public Access
feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user