"""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