Public Access
feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
This commit is contained in:
@@ -16,7 +16,8 @@ if config.config_file_name is not None:
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL.replace("postgresql://", "postgresql+psycopg2://"))
|
||||
_driver = "postgresql+pg8000://" if "pg8000" in settings.DATABASE_URL else "postgresql+psycopg2://"
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL.replace("postgresql://", _driver).replace("postgresql+psycopg2://", _driver))
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Add feedback_analysis to weekly_run and external fields to recipe
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-05-23
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add feedback_analysis JSONB to weekly_run
|
||||
op.add_column(
|
||||
"weekly_run",
|
||||
sa.Column("feedback_analysis", postgresql.JSONB, nullable=True),
|
||||
)
|
||||
|
||||
# Add external source fields to recipe
|
||||
op.add_column(
|
||||
"recipe",
|
||||
sa.Column("external_source", sa.String(50), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"recipe",
|
||||
sa.Column("external_id", sa.String(100), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"recipe",
|
||||
sa.Column("discovery_reason", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
# Index for deduplication lookups
|
||||
op.create_index(
|
||||
"idx_recipe_external",
|
||||
"recipe",
|
||||
["external_source", "external_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("external_source IS NOT NULL AND external_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_recipe_external", table_name="recipe")
|
||||
op.drop_column("recipe", "discovery_reason")
|
||||
op.drop_column("recipe", "external_id")
|
||||
op.drop_column("recipe", "external_source")
|
||||
op.drop_column("weekly_run", "feedback_analysis")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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", []),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -46,15 +46,16 @@ def _resolve_test_dsn() -> str | None:
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL")
|
||||
if not dsn:
|
||||
return None
|
||||
if not dsn.startswith(("postgresql://", "postgresql+psycopg2://")):
|
||||
if not dsn.startswith(("postgresql://", "postgresql+psycopg2://", "postgresql+pg8000://")):
|
||||
return None
|
||||
return dsn
|
||||
|
||||
|
||||
def _postgres_reachable(dsn: str) -> bool:
|
||||
try:
|
||||
driver = "postgresql+pg8000://" if "pg8000" in dsn else "postgresql+psycopg2://"
|
||||
eng = create_engine(
|
||||
dsn.replace("postgresql://", "postgresql+psycopg2://"),
|
||||
dsn.replace("postgresql://", driver).replace("postgresql+psycopg2://", driver),
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
with eng.connect() as conn:
|
||||
@@ -130,8 +131,9 @@ def _engine(_schema):
|
||||
if not _PG_AVAILABLE:
|
||||
yield None
|
||||
return
|
||||
driver = "postgresql+pg8000://" if "pg8000" in _DSN else "postgresql+psycopg2://"
|
||||
eng = create_engine(
|
||||
_DSN.replace("postgresql://", "postgresql+psycopg2://"),
|
||||
_DSN.replace("postgresql://", driver).replace("postgresql+psycopg2://", driver),
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
yield eng
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Tests for app.services.feedback_analyzer."""
|
||||
from datetime import date, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import (
|
||||
DenialReason,
|
||||
FamilyProfile,
|
||||
FamilyMember,
|
||||
FamilyMemberRole,
|
||||
Feedback,
|
||||
MealPlan,
|
||||
MealPlanItem,
|
||||
MealPlanItemStatus,
|
||||
MealType,
|
||||
NeverSuggest,
|
||||
Recipe,
|
||||
WeeklyRun,
|
||||
)
|
||||
from app.services.feedback_analyzer import FeedbackAnalyzer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_family(db):
|
||||
def _make():
|
||||
fp = FamilyProfile(
|
||||
id=uuid4(),
|
||||
name="TestFamily",
|
||||
household_size=2,
|
||||
adult_count=2,
|
||||
child_count=0,
|
||||
pending_approval_policy="approve",
|
||||
)
|
||||
db.add(fp)
|
||||
db.flush()
|
||||
return fp
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_member(db, make_family):
|
||||
def _make(family=None):
|
||||
fp = family or make_family()
|
||||
m = FamilyMember(
|
||||
id=uuid4(),
|
||||
family_profile_id=fp.id,
|
||||
name="Alice",
|
||||
role=FamilyMemberRole.ADULT,
|
||||
)
|
||||
db.add(m)
|
||||
db.flush()
|
||||
return m
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_recipe(db):
|
||||
def _make(**kw):
|
||||
r = Recipe(
|
||||
id=uuid4(),
|
||||
name=kw.get("name", "Test Recipe"),
|
||||
servings=kw.get("servings", 4),
|
||||
ingredients=[{"name": "ing", "qty": 1, "unit": "cup"}],
|
||||
instructions=["cook"],
|
||||
cuisine_tags=kw.get("cuisine_tags", []),
|
||||
protein_type=kw.get("protein_type", None),
|
||||
)
|
||||
db.add(r)
|
||||
db.flush()
|
||||
return r
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_meal_plan(db, make_family):
|
||||
def _make(family=None, week_start=None):
|
||||
week = week_start or date.today()
|
||||
mp = MealPlan(
|
||||
id=uuid4(),
|
||||
family_profile_id=(family or make_family()).id,
|
||||
week_start_date=week,
|
||||
)
|
||||
db.add(mp)
|
||||
db.flush()
|
||||
return mp
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_item(db, make_meal_plan, make_recipe):
|
||||
def _make(meal_plan=None, recipe=None, status=MealPlanItemStatus.pending, day_of_week=1):
|
||||
mp = meal_plan or make_meal_plan()
|
||||
r = recipe or make_recipe()
|
||||
item = MealPlanItem(
|
||||
id=uuid4(),
|
||||
meal_plan_id=mp.id,
|
||||
recipe_id=r.id,
|
||||
day_of_week=day_of_week,
|
||||
meal_type=MealType.DINNER,
|
||||
approval_status=status,
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
return item
|
||||
return _make
|
||||
|
||||
|
||||
class TestFeedbackAnalyzer:
|
||||
def test_insufficient_feedback(self, db, make_family):
|
||||
family = make_family()
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
today = date.today()
|
||||
result = analyzer.analyze(db, family.id, today=today)
|
||||
assert result.total_feedback_count == 0
|
||||
assert result.confidence == 0.0
|
||||
assert result.discovery_queries == []
|
||||
|
||||
def test_positive_cuisine_signal(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
recipe = make_recipe(cuisine_tags=["mexican"], protein_type="chicken")
|
||||
mp = make_meal_plan(family=family)
|
||||
item = make_item(meal_plan=mp, recipe=recipe)
|
||||
|
||||
# 3 feedbacks, avg rating 4 (>= threshold, >= 2 samples)
|
||||
for _ in range(3):
|
||||
f = Feedback(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
meal_plan_item_id=item.id,
|
||||
rating=4,
|
||||
)
|
||||
db.add(f)
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
assert result.total_feedback_count == 3
|
||||
assert result.confidence == 1.0
|
||||
|
||||
pos = result.positive_signals
|
||||
assert len(pos) == 2 # cuisine + protein
|
||||
assert any(s.type == "prefer_cuisine" and s.value == "mexican" for s in pos)
|
||||
assert any(s.type == "prefer_protein" and s.value == "chicken" for s in pos)
|
||||
|
||||
def test_never_suggest_blocks_recipe(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
recipe = make_recipe(cuisine_tags=["indian"], protein_type="lamb")
|
||||
mp = make_meal_plan(family=family)
|
||||
item = make_item(meal_plan=mp, recipe=recipe)
|
||||
|
||||
# Deny with never-suggest
|
||||
f = Feedback(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
meal_plan_item_id=item.id,
|
||||
rating=1,
|
||||
never_suggest=True,
|
||||
denial_reason=DenialReason.DISLIKED_INGREDIENT,
|
||||
)
|
||||
db.add(f)
|
||||
db.flush()
|
||||
|
||||
ns = NeverSuggest(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
recipe_id=recipe.id,
|
||||
)
|
||||
db.add(ns)
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
pos = result.positive_signals
|
||||
# 1 feedback < min, therefore no positive signals
|
||||
assert len(pos) == 0
|
||||
|
||||
def test_denial_reason_aggregated(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
recipe = make_recipe()
|
||||
mp = make_meal_plan(family=family)
|
||||
item = make_item(meal_plan=mp, recipe=recipe)
|
||||
|
||||
for _ in range(3):
|
||||
f = Feedback(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
meal_plan_item_id=item.id,
|
||||
rating=2,
|
||||
denial_reason=DenialReason.TOO_EXPENSIVE,
|
||||
)
|
||||
db.add(f)
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
negatives = result.negative_signals
|
||||
assert any(n.type == "denial_too_expensive" for n in negatives)
|
||||
assert negatives[0].count == 3
|
||||
|
||||
def test_discovery_queries_capped(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
cuisines = ["mexican", "italian", "chinese"]
|
||||
proteins = ["chicken", "beef", "shrimp"]
|
||||
for i, (c, p) in enumerate(zip(cuisines, proteins)):
|
||||
recipe = make_recipe(name=f"R{i}", cuisine_tags=[c], protein_type=p)
|
||||
mp = make_meal_plan(family=family, week_start=date.today() - timedelta(weeks=i))
|
||||
item = make_item(meal_plan=mp, recipe=recipe)
|
||||
for _ in range(2):
|
||||
db.add(Feedback(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
meal_plan_item_id=item.id,
|
||||
rating=5,
|
||||
))
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
queries = result.discovery_queries
|
||||
assert len(queries) <= 5
|
||||
# At least one cuisine+protein cross query
|
||||
assert any(" " in q for q in queries)
|
||||
|
||||
def test_top_rated_sorted(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
r1 = make_recipe(name="Awesome Dish")
|
||||
r2 = make_recipe(name="Meh Dish")
|
||||
mp = make_meal_plan(family=family)
|
||||
item1 = make_item(meal_plan=mp, recipe=r1)
|
||||
item2 = make_item(meal_plan=mp, recipe=r2, day_of_week=2)
|
||||
|
||||
db.add(Feedback(id=uuid4(), family_profile_id=family.id, meal_plan_item_id=item1.id, rating=5))
|
||||
db.add(Feedback(id=uuid4(), family_profile_id=family.id, meal_plan_item_id=item1.id, rating=5))
|
||||
db.add(Feedback(id=uuid4(), family_profile_id=family.id, meal_plan_item_id=item2.id, rating=3))
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
assert result.top_rated_recipe_names[0] == "Awesome Dish"
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Tests for app.services.recipe_discovery and app.services.recipe_ingestion.
|
||||
|
||||
These are unit tests using mocked HTTP responses; no network calls.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.requires_postgres
|
||||
|
||||
|
||||
class MockResponse:
|
||||
"""Minimal stand-in for requests.Response."""
|
||||
|
||||
def __init__(self, json_data: dict | None = None, status_code: int = 200):
|
||||
self._json = json_data or {}
|
||||
self.status_code = status_code
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise Exception(f"HTTP {self.status_code}")
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
|
||||
class TestRecipeDiscoveryService:
|
||||
@patch("app.services.recipe_discovery.requests.get")
|
||||
def test_disabled_without_api_key(self, mock_get):
|
||||
from app.services.recipe_discovery import RecipeDiscoveryService
|
||||
|
||||
svc = RecipeDiscoveryService()
|
||||
svc.enabled = False
|
||||
recipes = svc.discover(["chicken"])
|
||||
assert recipes == []
|
||||
mock_get.assert_not_called()
|
||||
|
||||
@patch("app.services.recipe_discovery.requests.get")
|
||||
def test_quota_stop(self, mock_get):
|
||||
from app.services.recipe_discovery import RecipeDiscoveryService
|
||||
|
||||
svc = RecipeDiscoveryService()
|
||||
svc.enabled = True
|
||||
# Simulate quota already consumed
|
||||
svc._points_used = 140
|
||||
|
||||
recipes = svc.discover(["query1", "query2"])
|
||||
assert recipes == []
|
||||
mock_get.assert_not_called()
|
||||
|
||||
@patch("app.services.recipe_discovery.requests.get")
|
||||
def test_single_result_normalization(self, mock_get):
|
||||
from app.services.recipe_discovery import RecipeDiscoveryService, ExternalRecipe
|
||||
|
||||
svc = RecipeDiscoveryService()
|
||||
svc.enabled = True
|
||||
|
||||
search_resp = {
|
||||
"results": [
|
||||
{
|
||||
"id": 123,
|
||||
"title": "Spicy Thai Basil Chicken",
|
||||
"image": "http://img/1.jpg",
|
||||
"cuisines": ["Thai"],
|
||||
"diets": ["gluten free"],
|
||||
"servings": 4,
|
||||
}
|
||||
],
|
||||
"totalResults": 1,
|
||||
}
|
||||
|
||||
info_resp = {
|
||||
"id": 123,
|
||||
"title": "Spicy Thai Basil Chicken",
|
||||
"extendedIngredients": [
|
||||
{"amount": 1.5, "unit": "lb", "name": "chicken breast"},
|
||||
{"amount": 2, "unit": "tbsp", "name": "basil"},
|
||||
],
|
||||
"analyzedInstructions": [{"steps": [{"step": "Cook chicken"}, {"step": "Add basil"}]}],
|
||||
"preparationMinutes": 10,
|
||||
"cookingMinutes": 20,
|
||||
"readyInMinutes": 30,
|
||||
"servings": 4,
|
||||
"sourceUrl": "http://example.com/recipe",
|
||||
"image": "http://img/1.jpg",
|
||||
}
|
||||
|
||||
def _make_resp(*a, **kw):
|
||||
url = a[0] if a else ""
|
||||
if "complexSearch" in url:
|
||||
return MockResponse(search_resp)
|
||||
if "information" in url:
|
||||
return MockResponse(info_resp)
|
||||
return MockResponse({})
|
||||
|
||||
mock_get.side_effect = _make_resp
|
||||
|
||||
recipes = svc.discover(["thai chicken"])
|
||||
assert len(recipes) == 1
|
||||
r = recipes[0]
|
||||
assert isinstance(r, ExternalRecipe)
|
||||
assert r.name == "Spicy Thai Basil Chicken"
|
||||
assert r.external_source == "spoonacular"
|
||||
assert r.external_id == "123"
|
||||
assert r.cuisine_tags == ["thai"]
|
||||
assert r.dietary_tags == ["gluten free"]
|
||||
assert r.servings == 4
|
||||
assert len(r.ingredients) == 2
|
||||
assert r.ingredients[0] == {"name": "chicken breast", "qty": 1.5, "unit": "lb"}
|
||||
assert r.instructions == ["Cook chicken", "Add basil"]
|
||||
assert r.prep_time_minutes == 10
|
||||
assert r.cook_time_minutes == 20
|
||||
assert r.source_url == "http://example.com/recipe"
|
||||
|
||||
@patch("app.services.recipe_discovery.requests.get")
|
||||
def test_deduplication_across_queries(self, mock_get):
|
||||
from app.services.recipe_discovery import RecipeDiscoveryService
|
||||
|
||||
svc = RecipeDiscoveryService()
|
||||
svc.enabled = True
|
||||
|
||||
resp = {
|
||||
"results": [
|
||||
{"id": 1, "title": "A", "image": "", "cuisines": [], "diets": [], "servings": 2}
|
||||
],
|
||||
"totalResults": 1,
|
||||
}
|
||||
|
||||
def _make_resp(*a, **kw):
|
||||
return MockResponse(resp)
|
||||
|
||||
mock_get.side_effect = _make_resp
|
||||
|
||||
recipes = svc.discover(["q1", "q2"])
|
||||
# Same recipe ID should only appear once even though both queries returned it
|
||||
assert len(recipes) == 1
|
||||
|
||||
@patch("app.services.recipe_discovery.requests.get")
|
||||
def test_search_failure_graceful(self, mock_get):
|
||||
from app.services.recipe_discovery import RecipeDiscoveryService
|
||||
import requests
|
||||
|
||||
svc = RecipeDiscoveryService()
|
||||
svc.enabled = True
|
||||
mock_get.side_effect = requests.ConnectionError("network error")
|
||||
|
||||
recipes = svc.discover(["chicken"])
|
||||
assert recipes == []
|
||||
|
||||
|
||||
class TestRecipeIngestionService:
|
||||
def test_ingest_skips_duplicate_external(self, db):
|
||||
from app.services.recipe_ingestion import RecipeIngestionService
|
||||
from app.services.recipe_discovery import ExternalRecipe
|
||||
from app.models import Recipe, FamilyProfile
|
||||
|
||||
svc = RecipeIngestionService()
|
||||
family = FamilyProfile(
|
||||
id=uuid.uuid4(),
|
||||
name="IngestFamily",
|
||||
household_size=2,
|
||||
adult_count=2,
|
||||
child_count=0,
|
||||
pending_approval_policy="approve",
|
||||
)
|
||||
db.add(family)
|
||||
db.flush()
|
||||
|
||||
# Seed existing row with same external_source+external_id
|
||||
existing = Recipe(
|
||||
id=uuid.uuid4(),
|
||||
family_profile_id=family.id,
|
||||
name="Already There",
|
||||
servings=4,
|
||||
ingredients=[],
|
||||
instructions=["cook"],
|
||||
external_source="spoonacular",
|
||||
external_id="99",
|
||||
)
|
||||
db.add(existing)
|
||||
db.flush()
|
||||
|
||||
ext = ExternalRecipe(
|
||||
name="Already There",
|
||||
external_source="spoonacular",
|
||||
external_id="99",
|
||||
image_url=None,
|
||||
description=None,
|
||||
prep_time_minutes=None,
|
||||
cook_time_minutes=None,
|
||||
servings=2,
|
||||
cuisine_tags=[],
|
||||
dietary_tags=[],
|
||||
protein_type=None,
|
||||
calories_per_serving=None,
|
||||
ingredients=[],
|
||||
instructions=["cook"],
|
||||
source_url=None,
|
||||
)
|
||||
added = svc.ingest(db, family.id, [ext], {"discovery_queries": [], "positive_signals": []})
|
||||
assert added == 0
|
||||
|
||||
def test_ingest_fuzzy_duplicate_skips(self, db):
|
||||
from app.services.recipe_ingestion import RecipeIngestionService
|
||||
from app.services.recipe_discovery import ExternalRecipe
|
||||
from app.models import Recipe, FamilyProfile
|
||||
|
||||
svc = RecipeIngestionService()
|
||||
family = FamilyProfile(
|
||||
id=uuid.uuid4(),
|
||||
name="FuzzyFamily",
|
||||
household_size=2,
|
||||
adult_count=2,
|
||||
child_count=0,
|
||||
pending_approval_policy="approve",
|
||||
)
|
||||
db.add(family)
|
||||
db.flush()
|
||||
|
||||
existing = Recipe(
|
||||
id=uuid.uuid4(),
|
||||
family_profile_id=family.id,
|
||||
name="Grilled Salmon with Lemon Butter Sauce",
|
||||
servings=4,
|
||||
ingredients=[],
|
||||
instructions=["cook"],
|
||||
)
|
||||
db.add(existing)
|
||||
db.flush()
|
||||
|
||||
ext = ExternalRecipe(
|
||||
name="Grilled Salmon with Lemon Butter Sauce and Herbs",
|
||||
external_source="spoonacular",
|
||||
external_id="42",
|
||||
image_url=None,
|
||||
description=None,
|
||||
prep_time_minutes=None,
|
||||
cook_time_minutes=None,
|
||||
servings=2,
|
||||
cuisine_tags=[],
|
||||
dietary_tags=[],
|
||||
protein_type=None,
|
||||
calories_per_serving=None,
|
||||
ingredients=[],
|
||||
instructions=["cook"],
|
||||
source_url=None,
|
||||
)
|
||||
added = svc.ingest(db, family.id, [ext], {"discovery_queries": [], "positive_signals": []})
|
||||
assert added == 0
|
||||
|
||||
def test_ingest_creates_recipe_and_ingredient(self, db):
|
||||
from app.services.recipe_ingestion import RecipeIngestionService
|
||||
from app.services.recipe_discovery import ExternalRecipe
|
||||
from app.models import Recipe, FamilyProfile, Ingredient
|
||||
|
||||
svc = RecipeIngestionService()
|
||||
family = FamilyProfile(
|
||||
id=uuid.uuid4(),
|
||||
name="CreateFamily",
|
||||
household_size=2,
|
||||
adult_count=2,
|
||||
child_count=0,
|
||||
pending_approval_policy="approve",
|
||||
)
|
||||
db.add(family)
|
||||
db.flush()
|
||||
|
||||
ext = ExternalRecipe(
|
||||
name="Lemon Herb Salmon",
|
||||
external_source="spoonacular",
|
||||
external_id="77",
|
||||
image_url="http://img/salmon.jpg",
|
||||
description="<b>Delicious</b> salmon recipe",
|
||||
prep_time_minutes=10,
|
||||
cook_time_minutes=15,
|
||||
servings=2,
|
||||
cuisine_tags=["mediterranean"],
|
||||
dietary_tags=["pescatarian"],
|
||||
protein_type="fish",
|
||||
calories_per_serving=350,
|
||||
ingredients=[
|
||||
{"name": "salmon fillet", "qty": 2.0, "unit": "lb"},
|
||||
],
|
||||
instructions=["Preheat oven", "Bake till flaky"],
|
||||
source_url="http://example.com/77",
|
||||
)
|
||||
analysis = {
|
||||
"discovery_queries": ["mediterranean fish"],
|
||||
"positive_signals": [{"type": "prefer_cuisine", "value": "mediterranean"}],
|
||||
}
|
||||
|
||||
added = svc.ingest(db, family.id, [ext], analysis)
|
||||
assert added == 1
|
||||
|
||||
recipes = db.query(Recipe).filter(Recipe.external_id == "77").all()
|
||||
assert len(recipes) == 1
|
||||
r = recipes[0]
|
||||
assert r.name == "Lemon Herb Salmon"
|
||||
assert r.external_source == "spoonacular"
|
||||
assert r.external_id == "77"
|
||||
assert r.protein_type == "fish"
|
||||
assert r.is_manually_added is False
|
||||
assert r.discovery_reason is not None
|
||||
assert "mediterranean fish" in r.discovery_reason
|
||||
assert r.cuisine_tags == ["mediterranean"]
|
||||
assert r.dietary_tags == ["pescatarian"]
|
||||
assert r.calories_per_serving == 350
|
||||
assert r.ingredients[0]["name"] == "Salmon Fillet"
|
||||
assert r.instructions == ["Preheat oven", "Bake till flaky"]
|
||||
|
||||
# Ingredient was created
|
||||
ings = db.query(Ingredient).filter(Ingredient.name_lower == "salmon fillet").all()
|
||||
assert len(ings) == 1
|
||||
assert ings[0].name == "Salmon Fillet"
|
||||
|
||||
def test_ingest_maps_to_existing_ingredient(self, db):
|
||||
from app.services.recipe_ingestion import RecipeIngestionService
|
||||
from app.services.recipe_discovery import ExternalRecipe
|
||||
from app.models import Recipe, FamilyProfile, Ingredient
|
||||
|
||||
svc = RecipeIngestionService()
|
||||
family = FamilyProfile(
|
||||
id=uuid.uuid4(),
|
||||
name="MapFamily",
|
||||
household_size=2,
|
||||
adult_count=2,
|
||||
child_count=0,
|
||||
pending_approval_policy="approve",
|
||||
)
|
||||
db.add(family)
|
||||
|
||||
existing_ing = Ingredient(
|
||||
id=uuid.uuid4(),
|
||||
name="Firm Tofu",
|
||||
name_lower="firm tofu",
|
||||
aliases=["tofu"],
|
||||
)
|
||||
db.add(existing_ing)
|
||||
db.flush()
|
||||
|
||||
ext = ExternalRecipe(
|
||||
name="Mapo Tofu",
|
||||
external_source="spoonacular",
|
||||
external_id="88",
|
||||
image_url=None,
|
||||
description=None,
|
||||
prep_time_minutes=None,
|
||||
cook_time_minutes=None,
|
||||
servings=2,
|
||||
cuisine_tags=[],
|
||||
dietary_tags=[],
|
||||
protein_type=None,
|
||||
calories_per_serving=None,
|
||||
ingredients=[
|
||||
{"name": "Firm Tofu", "qty": 1, "unit": "lb"},
|
||||
],
|
||||
instructions=["fry"],
|
||||
source_url=None,
|
||||
)
|
||||
added = svc.ingest(db, family.id, [ext], {"discovery_queries": [], "positive_signals": []})
|
||||
assert added == 1
|
||||
|
||||
recipes = db.query(Recipe).filter(Recipe.external_id == "88").all()
|
||||
assert recipes[0].ingredients[0]["ingredient_id"] == str(existing_ing.id)
|
||||
assert recipes[0].ingredients[0]["name"] == "Firm Tofu"
|
||||
Reference in New Issue
Block a user