Public Access
feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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