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
+54 -1
View File
@@ -1,11 +1,18 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import ScrapeLog, EmailLog, MealPlan
from app.models import ScrapeLog, EmailLog, MealPlan, Recipe
from app.security import require_admin
from app.services.scraper_service import ScraperService, enqueue_scrape
from app.services.feedback_analyzer import FeedbackAnalyzer
from app.services.recipe_discovery import RecipeDiscoveryService
from app.services.recipe_ingestion import RecipeIngestionService
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import UUID
import logging
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_admin)])
@@ -179,4 +186,50 @@ def get_stats(db: Session = Depends(get_db)):
"recipes": recipe_count,
"ingredients": ingredient_count,
"meal_plans": plan_count
}
@router.post("/trigger-discovery", status_code=200)
def trigger_discovery(
family_profile_id: UUID,
force: bool = False,
db: Session = Depends(get_db),
):
from uuid import UUID as UUIDType
profile = (
db.query(FamilyProfile)
.filter(FamilyProfile.id == family_profile_id)
.first()
)
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
analyzer = FeedbackAnalyzer(lookback_weeks=4)
analysis = analyzer.analyze(db, profile.id)
if not analysis.discovery_queries and not force:
return {"status": "skipped", "reason": "No discovery queries and force=false"}
discovery = RecipeDiscoveryService()
if not analysis.discovery_queries and force:
queries = ["popular recipes"]
else:
queries = analysis.discovery_queries
candidates = discovery.discover(queries)
if not candidates:
return {"status": "no_candidates"}
ingestion = RecipeIngestionService()
added = ingestion.ingest(
db, profile.id, candidates, analysis.to_dict()
)
logger.info("Manual discovery: added %d recipes for family %s", added, profile.id)
return {
"status": "success",
"candidates_found": len(candidates),
"recipes_added": added,
"discovery_queries": queries,
"confidence": analysis.confidence,
}
+51 -1
View File
@@ -16,6 +16,7 @@ from app.schemas import (
)
from app.security import require_session
from app.services import approval as approval_service
from app.services.feedback_analyzer import FeedbackAnalyzer
from uuid import UUID
from typing import List, Optional
from datetime import datetime, timedelta
@@ -473,4 +474,53 @@ def move_meal_item(
db.commit()
db.refresh(item)
return {"message": "Meal moved", "item": item}
return {"message": "Meal moved", "item": item}
# ── Dashboard badge: newly discovered recipes ─────────────────────────
@router.get("/dashboard/discovered-count")
def discovered_count(db: Session = Depends(get_db)):
"""Return number of recipes discovered in the last 7 days for the current family."""
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
since = datetime.utcnow() - timedelta(days=7)
count = (
db.query(Recipe)
.filter(
Recipe.family_profile_id == profile.id,
Recipe.external_source.isnot(None),
Recipe.created_at >= since,
)
.count()
)
return {"discovered_count": count}
@router.get("/dashboard/discovery-insights")
def discovery_insights(db: Session = Depends(get_db)):
"""Return latest feedback analysis and top signals."""
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
latest_run = (
db.query(WeeklyRun)
.filter(WeeklyRun.family_id == profile.id)
.order_by(WeeklyRun.week_start_date.desc())
.first()
)
if not latest_run or not latest_run.feedback_analysis:
return {"has_analysis": False}
analysis = latest_run.feedback_analysis
return {
"has_analysis": True,
"confidence": analysis.get("confidence", 0),
"positive_signals": analysis.get("positive_signals", []),
"negative_signals": analysis.get("negative_signals", []),
"top_rated_recipes": analysis.get("top_rated_recipes", []),
}
+3
View File
@@ -60,6 +60,9 @@ def _serialize(row: Recipe) -> dict:
"ingredients": row.ingredients or [],
"instructions": list(row.instructions or []),
"source_url": row.source_url,
"external_source": row.external_source,
"external_id": row.external_id,
"discovery_reason": row.discovery_reason,
}
+4
View File
@@ -181,6 +181,9 @@ class Recipe(Base):
source_url = Column(Text)
scraped_at = Column(DateTime(timezone=True))
is_manually_added = Column(Boolean, default=False)
external_source = Column(String(50))
external_id = Column(String(100))
discovery_reason = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -429,6 +432,7 @@ class WeeklyRun(Base):
reminded_at = Column(DateTime(timezone=True))
deadline_passed_at = Column(DateTime(timezone=True))
finalized_at = Column(DateTime(timezone=True))
feedback_analysis = Column(JSONB, nullable=True)
used_stale_data = Column(Boolean, nullable=False, default=False)
error_step = Column(String(50))
error_message = Column(Text)
+3
View File
@@ -53,6 +53,9 @@ class RecipeUpdate(BaseModel):
class RecipeRead(RecipeBase):
id: UUID
external_source: Optional[str] = None
external_id: Optional[str] = None
discovery_reason: Optional[str] = None
model_config = {"from_attributes": True}
+305
View File
@@ -0,0 +1,305 @@
"""Feedback Analyzer — turns family feedback signals into recipe discovery queries.
Reads the past N weeks of feedback (ratings, text, denial reasons, never-suggest)
and produces structured positive/negative signals plus external API search queries.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import List, Optional, Dict, Any, Set
from uuid import UUID
from sqlalchemy.orm import Session
from app.models import (
FamilyProfile,
Feedback,
MealPlanItem,
Recipe,
DenialReason,
NeverSuggest,
)
logger = logging.getLogger(__name__)
_DEFAULT_LOOKBACK_WEEKS = 4
_MIN_FEEDBACK_COUNT = 3
_MIN_AVG_RATING_FOR_POSITIVE = 4.0
_TOP_RATED_COUNT = 3
@dataclass
class NegativeSignal:
type: str # e.g. "avoid_ingredient", "avoid_tag", "too_spicy"
value: str
count: int
sources: Set[str] = field(default_factory=set)
@dataclass
class PositiveSignal:
type: str # e.g. "prefer_cuisine", "prefer_protein"
value: str
count: int
avg_rating: float
@dataclass
class FeedbackAnalysis:
family_id: UUID
lookback_start: date
lookback_end: date
total_feedback_count: int
positive_signals: List[PositiveSignal]
negative_signals: List[NegativeSignal]
top_rated_recipe_ids: List[UUID]
top_rated_recipe_names: List[str]
discovery_queries: List[str]
confidence: float = 0.0
def to_dict(self) -> dict[str, Any]:
return {
"family_id": str(self.family_id),
"lookback_start": self.lookback_start.isoformat(),
"lookback_end": self.lookback_end.isoformat(),
"total_feedback_count": self.total_feedback_count,
"positive_signals": [
{"type": s.type, "value": s.value, "count": s.count, "avg_rating": s.avg_rating}
for s in self.positive_signals
],
"negative_signals": [
{"type": s.type, "value": s.value, "count": s.count}
for s in self.negative_signals
],
"top_rated_recipe_ids": [str(r) for r in self.top_rated_recipe_ids],
"top_rated_recipe_names": self.top_rated_recipe_names,
"discovery_queries": self.discovery_queries,
"confidence": self.confidence,
}
class FeedbackAnalyzer:
"""Analyze family feedback and produce recipe discovery signals."""
def __init__(self, lookback_weeks: int = _DEFAULT_LOOKBACK_WEEKS) -> None:
self.lookback_weeks = lookback_weeks
def analyze(self, db: Session, family_id: UUID, today: date | None = None) -> FeedbackAnalysis:
today = today or date.today()
lookback_start = today - timedelta(weeks=self.lookback_weeks)
# Pull feedback from lookback window with recipe context
feedbacks = (
db.query(Feedback, MealPlanItem, Recipe)
.join(MealPlanItem, MealPlanItem.id == Feedback.meal_plan_item_id)
.join(Recipe, Recipe.id == MealPlanItem.recipe_id)
.filter(
Feedback.family_profile_id == family_id,
Feedback.created_at >= lookback_start,
)
.all()
)
total = len(feedbacks)
if total < _MIN_FEEDBACK_COUNT:
logger.info(
"FeedbackAnalyzer: only %d feedbacks in last %d weeks (< %d minimum). "
"Skipping discovery.",
total, self.lookback_weeks, _MIN_FEEDBACK_COUNT,
)
return FeedbackAnalysis(
family_id=family_id,
lookback_start=lookback_start,
lookback_end=today,
total_feedback_count=total,
positive_signals=[],
negative_signals=[],
top_rated_recipe_ids=[],
top_rated_recipe_names=[],
discovery_queries=[],
confidence=0.0,
)
# Aggregate ratings per recipe
recipe_ratings: dict[UUID, list[int]] = {}
recipe_names: dict[UUID, str] = {}
tag_ratings: dict[str, list[int]] = {}
protein_ratings: dict[str, list[int]] = {}
never_suggest_recipe_ids: set[UUID] = set()
never_suggest_ingredient_ids: set[UUID] = set()
for feedback, meal_item, recipe in feedbacks:
rid = recipe.id
recipe_names[rid] = recipe.name
recipe_ratings.setdefault(rid, []).append(feedback.rating or 3)
# Aggregate cuisine tags
for tag in (recipe.cuisine_tags or []):
tag.lower()
tag_ratings.setdefault(tag.lower(), []).append(feedback.rating or 3)
# Aggregate protein
if recipe.protein_type:
protein_ratings.setdefault(recipe.protein_type.lower(), []).append(feedback.rating or 3)
# Pull never_suggest rules
ns_rows = (
db.query(NeverSuggest)
.filter(NeverSuggest.family_profile_id == family_id)
.all()
)
for ns in ns_rows:
if ns.recipe_id:
never_suggest_recipe_ids.add(ns.recipe_id)
if ns.ingredient_id:
never_suggest_ingredient_ids.add(ns.ingredient_id)
# Build positive signals (high-rated cuisines/proteins)
positive_signals: list[PositiveSignal] = []
for tag, ratings in tag_ratings.items():
avg = sum(ratings) / len(ratings)
if avg >= _MIN_AVG_RATING_FOR_POSITIVE and len(ratings) >= 2:
positive_signals.append(
PositiveSignal(
type="prefer_cuisine",
value=tag,
count=len(ratings),
avg_rating=round(avg, 2),
)
)
for protein, ratings in protein_ratings.items():
avg = sum(ratings) / len(ratings)
if avg >= _MIN_AVG_RATING_FOR_POSITIVE and len(ratings) >= 2:
positive_signals.append(
PositiveSignal(
type="prefer_protein",
value=protein,
count=len(ratings),
avg_rating=round(avg, 2),
)
)
# Sort by avg_rating desc, then count desc
positive_signals.sort(key=lambda s: (-s.avg_rating, -s.count))
# Build negative signals from denial reasons + never_suggest
negative_signals: list[NegativeSignal] = []
denial_counts: dict[str, int] = {}
for feedback, meal_item, recipe in feedbacks:
if feedback.denial_reason:
key = f"denial_{feedback.denial_reason.value}"
denial_counts[key] = denial_counts.get(key, 0) + 1
for reason, count in denial_counts.items():
sig_type = reason.replace("denial_", "")
negative_signals.append(
NegativeSignal(
type=f"denial_{sig_type}",
value=sig_type,
count=count,
)
)
# Top rated recipes (for "similar to X" queries)
recipe_avgs = {
rid: sum(ratings) / len(ratings)
for rid, ratings in recipe_ratings.items()
}
top_rated = sorted(
recipe_avgs.items(), key=lambda x: -x[1]
)[:_TOP_RATED_COUNT]
top_rated_ids = [rid for rid, _ in top_rated]
top_rated_names = [recipe_names[rid] for rid in top_rated_ids]
# Build discovery queries
queries = _build_discovery_queries(
positive_signals=positive_signals,
top_rated_names=top_rated_names,
negative_signals=negative_signals,
)
# Confidence = proportion of positive signals that are well-supported
confidence = 0.0
if positive_signals:
well_supported = sum(1 for s in positive_signals if s.count >= 2)
confidence = well_supported / len(positive_signals)
logger.info(
"FeedbackAnalyzer: family=%s feedbacks=%d positives=%d negatives=%d queries=%d confidence=%.2f",
family_id, total, len(positive_signals), len(negative_signals), len(queries), confidence,
)
return FeedbackAnalysis(
family_id=family_id,
lookback_start=lookback_start,
lookback_end=today,
total_feedback_count=total,
positive_signals=positive_signals,
negative_signals=negative_signals,
top_rated_recipe_ids=top_rated_ids,
top_rated_recipe_names=top_rated_names,
discovery_queries=queries,
confidence=confidence,
)
def _build_discovery_queries(
positive_signals: List[PositiveSignal],
top_rated_names: List[str],
negative_signals: List[NegativeSignal],
) -> List[str]:
"""Convert signals into Spoonacular search queries.
Strategy:
1. Combine top cuisine + top protein → "mexican shrimp"
2. High-rated specific recipes → search by name
3. Cap at 5 queries to stay within free quota
"""
queries = []
seen = set()
# Extract top cuisines and proteins
cuisines = [s.value for s in positive_signals if s.type == "prefer_cuisine"]
proteins = [s.value for s in positive_signals if s.type == "prefer_protein"]
# Cross product of top cuisine + protein
for cuisine in cuisines[:2]:
for protein in proteins[:2]:
q = f"{cuisine} {protein}"
if q not in seen:
queries.append(q)
seen.add(q)
if len(queries) >= 5:
return queries
# Single cuisine or protein queries
for cuisine in cuisines[:2]:
if cuisine not in seen:
queries.append(cuisine)
seen.add(cuisine)
if len(queries) >= 5:
return queries
for protein in proteins[:2]:
if protein not in seen:
queries.append(protein)
seen.add(protein)
if len(queries) >= 5:
return queries
# Top-rated recipe names (people liked these, find similar)
for name in top_rated_names[:2]:
if name not in seen:
queries.append(name)
seen.add(name)
if len(queries) >= 5:
return queries
return queries
@@ -24,6 +24,10 @@ from app.services.orchestrator.alerts import send_admin_alert
from app.services.planner.generate import generate_meal_plan
from app.services.scraper_service import ScraperService
from app.services.feedback_analyzer import FeedbackAnalyzer
from app.services.recipe_discovery import RecipeDiscoveryService
from app.services.recipe_ingestion import RecipeIngestionService
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from app.models import WeeklyRun
@@ -456,6 +460,32 @@ def step_finalize(run: "WeeklyRun", db: "Session") -> None:
run.finalized_at = datetime.now(timezone.utc)
run.status = "completed"
# --- Feedback-driven recipe discovery ---
try:
analyzer = FeedbackAnalyzer(lookback_weeks=4)
analysis = analyzer.analyze(db, run.family_id)
run.feedback_analysis = analysis.to_dict()
if (
analysis.discovery_queries and
analysis.confidence >= 0.5
):
discovery = RecipeDiscoveryService()
candidates = discovery.discover(analysis.discovery_queries)
if candidates:
ingestion = RecipeIngestionService()
added = ingestion.ingest(db, run.family_id, candidates, analysis.to_dict())
logger.info(
"step_finalize: recipe discovery added %d new recipes for family %s",
added, run.family_id,
)
# ingestion deliberately does not commit so orchestrator
# can keep everything in one transaction
db.commit()
except Exception as exc:
# Discovery is best-effort; never block finalization
logger.warning("step_finalize: recipe discovery failed: %s", exc)
db.commit()
logger.info("step_finalize: done, %d approved meals", len(approved_items))
+226
View File
@@ -0,0 +1,226 @@
"""Recipe Discovery — queries external APIs (Spoonacular, TheMealDB) for recipes.
Takes discovery queries from FeedbackAnalyzer and fetches normalized recipe candidates.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from decimal import Decimal
from typing import List, Optional, Any
import requests
from app.config import settings
logger = logging.getLogger(__name__)
_SPOONACULAR_SEARCH_URL = "https://api.spoonacular.com/recipes/complexSearch"
_SPOONACULAR_INFO_URL = "https://api.spoonacular.com/recipes/{id}/information"
_THEMEALDB_SEARCH_URL = "https://www.themealdb.com/api/json/v1/1/search.php"
_RATE_LIMIT_SECS = 1.0 # polite gap between calls
_MAX_RESULTS_PER_QUERY = 5 # cap to stay within free quota
@dataclass
class ExternalRecipe:
name: str
external_source: str
external_id: str
image_url: Optional[str]
description: Optional[str]
prep_time_minutes: Optional[int]
cook_time_minutes: Optional[int]
servings: int
cuisine_tags: List[str]
dietary_tags: List[str]
protein_type: Optional[str]
calories_per_serving: Optional[int]
ingredients: List[dict] # [{"name": str, "qty": float, "unit": str}]
instructions: List[str]
source_url: Optional[str]
class RecipeDiscoveryService:
"""Fetch recipes from external sources."""
def __init__(self) -> None:
self.api_key = getattr(settings, "SPOONACULAR_API_KEY", "")
self.enabled = bool(self.api_key)
self._points_used = 0
def discover(self, queries: List[str]) -> List[ExternalRecipe]:
"""Run all discovery queries and return unique recipes."""
if not self.enabled:
logger.warning("RecipeDiscovery: SPOONACULAR_API_KEY not set — skipping")
return []
all_recipes: List[ExternalRecipe] = []
seen_ids: set[str] = set()
for query in queries:
if self._points_used >= 140: # stay under 150/day free tier
logger.warning("RecipeDiscovery: quota near limit (%d/150), stopping", self._points_used)
break
recipes = self._search_spoonacular(query)
for r in recipes:
key = f"{r.external_source}:{r.external_id}"
if key not in seen_ids:
seen_ids.add(key)
all_recipes.append(r)
time.sleep(_RATE_LIMIT_SECS)
logger.info("RecipeDiscovery: %d unique recipes from %d queries", len(all_recipes), len(queries))
return all_recipes
def _search_spoonacular(self, query: str) -> List[ExternalRecipe]:
"""Search Spoonacular and return normalized recipes."""
params = {
"apiKey": self.api_key,
"query": query,
"number": _MAX_RESULTS_PER_QUERY,
"addRecipeInformation": "true",
"fillIngredients": "true",
"instructionsRequired": "true",
}
try:
resp = requests.get(_SPOONACULAR_SEARCH_URL, params=params, timeout=30)
resp.raise_for_status()
except requests.RequestException as exc:
logger.warning("Spoonacular search failed for %r: %s", query, exc)
return []
data = resp.json()
results = data.get("results", [])
total = data.get("totalResults", 0)
# complexSearch = 1 point + 0.01 per result
self._points_used += 1 + len(results) * 0.01
logger.info("Spoonacular: %r%d/%d results", query, len(results), total)
out = []
for item in results:
ext_id = str(item.get("id"))
if not ext_id:
continue
# Try to get full info for ingredients + instructions
full = self._fetch_recipe_info(ext_id)
if full:
normalized = self._normalize_spoonacular(item, full)
if normalized:
out.append(normalized)
time.sleep(0.5) # between info calls
else:
# Fallback: info endpoint failed, use search summary only
normalized = self._normalize_spoonacular(item, item)
if normalized:
out.append(normalized)
return out
def _fetch_recipe_info(self, recipe_id: str) -> dict | None:
"""Fetch detailed recipe info from Spoonacular."""
url = _SPOONACULAR_INFO_URL.format(id=recipe_id)
params = {
"apiKey": self.api_key,
"includeNutrition": "false",
}
try:
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()
except requests.RequestException as exc:
logger.warning("Spoonacular info failed for %s: %s", recipe_id, exc)
return None
# info endpoint = 1 point
self._points_used += 1
return resp.json()
def _normalize_spoonacular(self, summary: dict, full: dict) -> ExternalRecipe | None:
"""Convert Spoonacular response into our ExternalRecipe dataclass."""
title = summary.get("title") or full.get("title")
if not title:
return None
# Ingredients from full info
ingredients = []
for ing in full.get("extendedIngredients", []):
qty = ing.get("amount")
unit = ing.get("unit", "")
name = ing.get("name", "")
if qty is not None and name:
ingredients.append({"name": name, "qty": float(qty), "unit": unit})
# Instructions
instructions = []
analyzed = full.get("analyzedInstructions", [])
if analyzed:
for step in analyzed[0].get("steps", []):
instructions.append(step.get("step", ""))
else:
raw = full.get("instructions", "")
if raw:
instructions = [raw] # single blob
# Cuisines + diets
cuisines = [c.lower() for c in (summary.get("cuisines") or full.get("cuisines", [])) if c]
diets = [d.lower() for d in (summary.get("diets") or full.get("diets", [])) if d]
# Protein type inference from ingredient names or summary tags
protein = _infer_protein(food=full)
# Times
prep = full.get("preparationMinutes")
cook = full.get("cookingMinutes")
if prep is None and "readyInMinutes" in full:
prep = full["readyInMinutes"] # use total as proxy
return ExternalRecipe(
name=title,
external_source="spoonacular",
external_id=str(summary.get("id") or full.get("id")),
image_url=summary.get("image") or full.get("image"),
description=full.get("summary"), # HTML summary; caller strips tags
prep_time_minutes=int(prep) if prep else None,
cook_time_minutes=int(cook) if cook else None,
servings=int(full.get("servings", 4)),
cuisine_tags=cuisines,
dietary_tags=diets,
protein_type=protein,
calories_per_serving=None, # would require nutrition endpoint
ingredients=ingredients,
instructions=instructions or ["See source for instructions."],
source_url=full.get("sourceUrl") or full.get("spoonacularSourceUrl"),
)
def _infer_protein(food: dict) -> Optional[str]:
"""Infer protein_type from recipe data."""
title = (food.get("title") or "").lower()
ings = " ".join(
i.get("name", "").lower()
for i in food.get("extendedIngredients", [])
)
proteins = {
"chicken": ["chicken"],
"beef": ["beef", "steak", "ground beef"],
"pork": ["pork", "bacon", "ham"],
"fish": ["salmon", "tilapia", "cod", "fish fillet"],
"shrimp": ["shrimp", "prawn"],
"turkey": ["turkey"],
"lamb": ["lamb"],
"vegetarian": ["tofu", "tempeh", "vegetarian"],
}
for ptype, keywords in proteins.items():
for kw in keywords:
if kw in title or kw in ings:
return ptype
return None
+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