feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled

This commit is contained in:
2026-05-24 13:17:39 -07:00
parent 35f736a052
commit 3885d7d0dc
20 changed files with 1962 additions and 9 deletions
+174
View File
@@ -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