Public Access
feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# Approved Plan — Feedback-Driven Recipe Discovery
|
||||
|
||||
Approved: 2026-05-23
|
||||
User decisions: (1) dashboard badge only for review, (2) 4-week lookback, (3) skip manual review — auto-add on discovery.
|
||||
|
||||
## Phase A — Feedback Analyzer
|
||||
|
||||
- [x] Design complete (see docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md)
|
||||
- [x] Implement `app/services/feedback_analyzer.py`
|
||||
- Reads feedback from past 4 weeks
|
||||
- Aggregates positive signals (preferred cuisines, proteins, high ratings)
|
||||
- Aggregates negative signals (avoided tags, ingredients, denial reasons)
|
||||
- Generates discovery_queries for external APIs
|
||||
- Outputs structured `FeedbackAnalysis` dataclass
|
||||
- [x] Add `feedback_analysis` JSONB column to `weekly_run` table
|
||||
- [x] Unit tests for analyzer logic
|
||||
- [x] Hook into orchestrator `step_finalize`
|
||||
|
||||
## Phase B — Recipe Discovery + Ingestion
|
||||
|
||||
- [x] Implement `app/services/recipe_discovery.py`
|
||||
- Spoonacular client with quota tracking
|
||||
- TheMealDB fallback
|
||||
- Query builder from `FeedbackAnalysis`
|
||||
- [x] Implement `app/services/recipe_ingestion.py`
|
||||
- Normalize external recipe → our schema
|
||||
- Ingredient name mapping (fuzzy + canonical seed data)
|
||||
- Duplicate detection via external_source+external_id and fuzzy name match
|
||||
- Auto-add to `recipe` table (no review queue per user)
|
||||
- [x] Add `external_source`, `external_id`, `discovery_reason` to `recipe` table
|
||||
- [x] Admin endpoint: `POST /api/admin/trigger-discovery` (manual trigger)
|
||||
- [x] Rate-limiting and quota exhaustion handling
|
||||
|
||||
## Phase C — Orchestrator Integration
|
||||
|
||||
- [x] Weekly finalize step: run analyzer → if queries found and quota available → run discovery → ingest → log
|
||||
- [x] Configuration env vars:
|
||||
- `SPOONACULAR_API_KEY` (existing)
|
||||
- `AUTO_DISCOVERY_ENABLED=true`
|
||||
- `AUTO_DISCOVERY_MAX_RECIPES_PER_RUN=5`
|
||||
- [x] Dashboard endpoints: `/api/meals/dashboard/discovered-count` and `/api/meals/dashboard/discovery-insights`
|
||||
|
||||
## Phase D — Verification
|
||||
|
||||
- [x] End-to-end tests: analyzer, discovery, ingestion, duplicate prevention
|
||||
- [x] Test quota exhaustion graceful degradation
|
||||
- [x] Update docs and README
|
||||
|
||||
## Halt conditions
|
||||
- Spoonacular API returns unexpected schema → stop, document, fix mapper
|
||||
- Ingredient mapping consistently wrong → add LLM-assisted mapping or tighten threshold
|
||||
- Duplicate recipes slipping through → improve detection logic
|
||||
|
||||
## Context links
|
||||
- Proposal: docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md
|
||||
- HANDOFF: docs/HANDOFF.md (updated with session notes)
|
||||
@@ -153,3 +153,4 @@ backend/var/
|
||||
# Local verification helper — has weak test credentials, not for the repo
|
||||
.env.test
|
||||
.worktrees/
|
||||
graphify-out/
|
||||
|
||||
@@ -17,7 +17,7 @@ This project was born out of frustration with meal kit services (Blue Apron →
|
||||
- **Shopping List Generation**: Weekly list grouped by store aisles, highlighting sales, with interactive checkboxes to track purchased items
|
||||
- **Pantry Integration**: Specify home items to incorporate into suggestions
|
||||
- **Web UI**: Modern interface for the whole family
|
||||
- **Learning**: Feedback-based meal recommendations
|
||||
- **Learning**: Feedback-based meal recommendations, with weekly auto-discovery of new recipes from external APIs when family preferences are signaled
|
||||
- **Recipe Images**: Scraped from public recipe sites, AI fallback available
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -180,3 +187,49 @@ def get_stats(db: Session = Depends(get_db)):
|
||||
"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
|
||||
@@ -474,3 +475,52 @@ def move_meal_item(
|
||||
db.commit()
|
||||
db.refresh(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"
|
||||
+1
-1
@@ -93,7 +93,7 @@ services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8081:80"
|
||||
- "8082:80"
|
||||
- "8444:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
+34
-1
@@ -286,4 +286,37 @@ backend/app/schemas/__init__.py — RecipeIngredient model_validator q
|
||||
|
||||
Trust the tests. Trust the live runs. Don't trust prose claims that something is "complete" without running the verification gate yourself.
|
||||
|
||||
**Last updated: 2026-05-14** — Spoonacular enrichment complete (102/107 matched, 5 unmatched). Phase 8 Feedback UI built: REST API + frontend. Pydantic validation fixed for recipe ingredients (qty↔quantity schema drift). .env restored after accidental overwrite.
|
||||
**Current open proposals:**
|
||||
- None — feedback-driven recipe discovery implemented and verified.
|
||||
|
||||
**Last updated: 2026-05-24** — Feedback-driven recipe discovery (Phases A–D) complete.
|
||||
|
||||
---
|
||||
|
||||
## New session: 2026-05-23
|
||||
|
||||
### Context
|
||||
User observed that the system is constrained to 30 seed recipes and asked whether feedback triggers new recipe discovery. Investigation confirmed:
|
||||
- **No feedback analysis service exists.** `feedback_text`, `rating`, `denial_reason` are persisted but never read downstream.
|
||||
- **No recipe discovery pipeline exists.** External recipe APIs (Spoonacular, TheMealDB) are only used for image/description enrichment (`scripts/enrich_recipes_spoonacular.py`), not for discovering new recipes based on preferences.
|
||||
- **Planner only reads blocklist + recency.** No signal from free-form feedback reaches `score.py` or `generate.py`.
|
||||
|
||||
### Proposal written
|
||||
A comprehensive proposal for **Feedback-Driven Recipe Discovery** has been authored at `docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md` with:
|
||||
- Feedback Analyzer service (reads feedback → positive/negative signals + discovery queries)
|
||||
- Recipe Discovery Service (queries Spoonacular/TheMealDB)
|
||||
- Recipe Ingestion Pipeline (normalizes external recipes → our schema)
|
||||
- Review Queue table (admin approval gate before recipes enter planner)
|
||||
- Full architecture diagram, API changes, schema changes, cost analysis, risk matrix
|
||||
|
||||
### Files written
|
||||
- `docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md`
|
||||
|
||||
### Files NOT yet modified (blocked on approval)
|
||||
- No code changes. No schema migrations. No API endpoints added.
|
||||
- `backend/app/services/feedback_analyzer.py` — planned
|
||||
- `backend/app/services/recipe_discovery.py` — planned
|
||||
- `backend/alembic/versions/0010_feedback_analysis_and_review_queue.py` — planned
|
||||
|
||||
### Next step
|
||||
Await user approval on the proposal. If approved, create `.agent/plan.md` and begin Phase A (Feedback Analyzer).
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
# Proposal: Feedback-Driven Recipe Discovery
|
||||
|
||||
Date: 2026-05-23
|
||||
Status: Draft — awaiting approval
|
||||
Author: Agent handoff
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The system currently has **30 hardcoded seed recipes** and no automated mechanism to grow the recipe pool based on family feedback. Feedback (`rating`, `feedback_text`, `denial_reason`) is collected via the Phase 8 UI but **never read** by any downstream service. As the family uses the system, preferences evolve ("too spicy", "loved the Thai one", "boring chicken again"), but the planner cannot discover new recipes that better match these signals.
|
||||
|
||||
**Current state:**
|
||||
- 30 recipes → planner selects 3/week → cycle repeats every 10 weeks
|
||||
- Feedback is write-only: stored but not analyzed
|
||||
- Manual recipe creation is the only growth path
|
||||
|
||||
**Desired state:**
|
||||
- Feedback text/ratings automatically analyzed
|
||||
- Low-rated recipes or categories trigger external discovery
|
||||
- New recipes fetched, normalized, and added to the database
|
||||
- Family sees increasing variety aligned with their tastes
|
||||
|
||||
---
|
||||
|
||||
## 2. Design Principles
|
||||
|
||||
1. **Privacy-first:** External API calls only when necessary; no bulk uploads of family data
|
||||
2. **Idempotent:** Re-running discovery with the same feedback produces no duplicate recipes
|
||||
3. **Admin-gated:** New recipes enter as `needs_review` status; admin approves before the planner sees them
|
||||
4. **Budget-aware:** Free tiers prioritized; paid quotas tracked and logged
|
||||
5. **Graceful degradation:** If external APIs fail, the system continues with existing recipes
|
||||
|
||||
---
|
||||
|
||||
## 3. Proposed Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FEEDBACK-DRIVEN RECIPE DISCOVERY │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Feedback │ │ Feedback │ │ Recipe │ │
|
||||
│ │ Collector │───▶│ Analyzer │───▶│ Discovery │ │
|
||||
│ │ (exists) │ │ (new) │ │ Service │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Recipe │ │
|
||||
│ │ Ingestion │ │
|
||||
│ │ Pipeline │ │
|
||||
│ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Review Queue │ │
|
||||
│ │ (new table) │ │
|
||||
│ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.1 Feedback Analyzer
|
||||
|
||||
Periodic job (weekly, post-finalize) that reads all feedback from the past N weeks and produces a structured analysis.
|
||||
|
||||
**Inputs:**
|
||||
- `feedback` table rows for the lookback window
|
||||
- `recipe` metadata (tags, ingredients, cuisine)
|
||||
- `meal_plan_item` history
|
||||
|
||||
**Outputs** (`feedback_analysis` JSONB on `weekly_run`):
|
||||
```json
|
||||
{
|
||||
"negative_signals": [
|
||||
{"type": "avoid_tag': "dietary:vegetarian", "count": 3, "source": "member_A"},
|
||||
{"type": "avoid_ingredient": "mushroom", "count": 2, "source": "member_B"},
|
||||
{"type": "too_spicy", "count": 1, "source": "feedback_text"}
|
||||
],
|
||||
"positive_signals": [
|
||||
{"type": "prefer_cuisine": "mexican", "avg_rating": 5.0, "count": 4},
|
||||
{"type": "prefer_protein": "shrimp", "avg_rating": 4.5, "count": 3}
|
||||
],
|
||||
"discovery_queries": [
|
||||
"mexican shrimp",
|
||||
"mild thai chicken",
|
||||
"vegetarian pasta"
|
||||
],
|
||||
"confidence": 0.82
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Recipe Discovery Service
|
||||
|
||||
Queries external recipe APIs using the discovery queries from the Analyzer.
|
||||
|
||||
**Primary source:** Spoonacular (`/recipes/complexSearch`)
|
||||
- Quota: 150 req/day free tier (1 query per recipe page of 10 results = 15 calls/day)
|
||||
- Rate limit: built-in via API key
|
||||
|
||||
**Fallback source:** TheMealDB
|
||||
- Free, no auth required for basic usage
|
||||
- ~300 recipes total (limited variety)
|
||||
- Good for backup or initial seeding
|
||||
|
||||
**Query construction from feedback signals:**
|
||||
```python
|
||||
def build_queries(analysis: dict) -> list[str]:
|
||||
"""Convert positive signals into API search queries."""
|
||||
queries = []
|
||||
|
||||
# Combine preferred cuisines + proteins
|
||||
for cuisine in analysis.positive_signals["cuisines"]:
|
||||
for protein in analysis.positive_signals["proteins"]:
|
||||
queries.append(f"{cuisine} {protein}")
|
||||
|
||||
# Weight by rating: high-rated specific recipes → "similar to X"
|
||||
for recipe in analysis.top_rated_recipes:
|
||||
queries.append(recipe.name) # Spoonacular search by name
|
||||
|
||||
# Apply negative filters: exclude avoided ingredients/tags
|
||||
for avoid in analysis.negative_signals:
|
||||
if avoid.type == "avoid_ingredient":
|
||||
queries.append(f"-{avoid.value}") # Spoonacular supports excludeIngredients
|
||||
|
||||
return queries[:5] # Cap at 5 queries per run to stay within free quota
|
||||
```
|
||||
|
||||
### 3.3 Recipe Ingestion Pipeline
|
||||
|
||||
Transforms external API responses into our `recipe` schema.
|
||||
|
||||
**Spoonacular → Normalized Recipe:**
|
||||
```python
|
||||
@dataclass
|
||||
class ExternalRecipe:
|
||||
name: str
|
||||
source_url: str
|
||||
image_url: str
|
||||
description: str
|
||||
prep_time_minutes: int
|
||||
cook_time_minutes: int
|
||||
servings: int
|
||||
cuisine_tags: list[str]
|
||||
dietary_tags: list[str]
|
||||
protein_type: str # inferred from ingredients
|
||||
calories_per_serving: int
|
||||
ingredients: list[dict] # {ingredient_id?, name, qty, unit}
|
||||
instructions: list[str]
|
||||
external_source: str # "spoonacular"
|
||||
external_id: str # spoonacular recipe ID
|
||||
```
|
||||
|
||||
**Challenges & Mitigations:**
|
||||
|
||||
| Challenge | Mitigation |
|
||||
|---|---|
|
||||
| External ingredients use different names than our canonical `ingredient` table | LLM-assisted mapping (reuse `llm_matcher.py` pattern) or fuzzy match + admin review |
|
||||
| Units differ (cups vs grams) | Phase 9 spec §7 already flags unit conversion as follow-up; defer ingestion-side conversion |
|
||||
| Conflicting cuisine/protein tags | Normalize via small mapping table ("mexican"↔"mexico", "chicken breast"→"chicken") |
|
||||
| Duplicate external recipes | ON CONFLICT on `external_source` + `external_id` |
|
||||
| Image licensing | Store as URLs only (per ARCHITECTURE.md §6.1), hotlink with attribution |
|
||||
|
||||
### 3.4 Review Queue
|
||||
|
||||
New table to hold externally discovered recipes pending admin approval:
|
||||
|
||||
```sql
|
||||
CREATE TABLE recipe_review_queue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
recipe JSONB NOT NULL, -- full normalized recipe payload
|
||||
external_source TEXT NOT NULL,
|
||||
external_id TEXT,
|
||||
discovery_reason TEXT, -- e.g., "positive_signal: mexican shrimp"
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
similarity_score FLOAT, -- cosine/embedding similarity to existing recipes
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
reviewed_by UUID REFERENCES family_member(id) -- who approved/rejected
|
||||
);
|
||||
|
||||
CREATE INDEX idx_recipe_review_status ON recipe_review_queue(status);
|
||||
```
|
||||
|
||||
**Admin API:**
|
||||
- `GET /api/admin/recipes/review-queue` — list pending
|
||||
- `POST /api/admin/recipes/review-queue/{id}/approve` — move to `recipe` table
|
||||
- `POST /api/admin/recipes/review-queue/{id}/reject` — mark rejected (never re-discover)
|
||||
|
||||
**Similarity gate:** Before adding to queue, compute embedding similarity against existing recipes. Reject if >0.90 similar (exact or near-duplicate). This prevents re-adding recipes the family already has.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Flow
|
||||
|
||||
```
|
||||
1. Weekly finalize step completes
|
||||
→ triggers `analyze_feedback()`
|
||||
|
||||
2. Analyzer reads feedback from past 8 weeks
|
||||
→ produces positive/negative signals + discovery_queries
|
||||
|
||||
3. If confidence > 0.5 and queries exist:
|
||||
→ call Spoonacular complexSearch for each query
|
||||
→ normalize responses
|
||||
→ dedupe by (external_source, external_id)
|
||||
→ similarity check vs existing recipes
|
||||
→ insert into recipe_review_queue with status='pending'
|
||||
|
||||
4. Admin (Peter) reviews queue via UI
|
||||
→ approves → recipe moved to `recipe` table, `is_manually_added=false`
|
||||
→ rejects → stays in queue, marked rejected
|
||||
|
||||
5. Next week's planner generation
|
||||
→ `db.query(Recipe).all()` now includes new approved recipes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API Changes
|
||||
|
||||
### New Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/admin/recipes/discover` | admin | Trigger manual discovery run |
|
||||
| GET | `/api/admin/recipes/review-queue` | admin | List pending recipes |
|
||||
| POST | `/api/admin/recipes/review-queue/{id}/approve` | admin | Approve a queued recipe |
|
||||
| POST | `/api/admin/recipes/review-queue/{id}/reject` | admin | Reject a queued recipe |
|
||||
| GET | `/api/analytics/feedback` | admin | Feedback analysis summary |
|
||||
|
||||
### Schema Changes
|
||||
|
||||
1. **`recipe` table additions:**
|
||||
- `external_source TEXT` — e.g., "spoonacular", "themealdb"
|
||||
- `external_id TEXT` — ID in the external system
|
||||
- `discovery_reason TEXT` — why this recipe was found
|
||||
|
||||
2. **`weekly_run` table additions:**
|
||||
- `feedback_analysis JSONB` — output of the analyzer
|
||||
|
||||
3. **`recipe_review_queue` table:** (new)
|
||||
- As defined in §3.4
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Phases
|
||||
|
||||
### Phase A — Feedback Analyzer (1–2 days)
|
||||
- [ ] Implement `FeedbackAnalyzer` service
|
||||
- [ ] Add `feedback_analysis` JSONB column to `weekly_run`
|
||||
- [ ] Unit tests for analysis logic (mocked feedback data)
|
||||
- [ ] Hook into `step_finalize` (orchestrator)
|
||||
|
||||
### Phase B — Recipe Discovery + Ingestion (2–3 days)
|
||||
- [ ] Implement `RecipeDiscoveryService` (Spoonacular client)
|
||||
- [ ] Implement ingestion normalization pipeline
|
||||
- [ ] Add `recipe_review_queue` table + CRUD API
|
||||
- [ ] Add `external_source`/`external_id`/`discovery_reason` to `recipe`
|
||||
- [ ] Rate-limiting and quota tracking
|
||||
- [ ] Unit + integration tests (mocked Spoonacular responses)
|
||||
|
||||
### Phase C — Review Queue UI (1–2 days)
|
||||
- [ ] Admin page: list pending recipes with cards (image, title, ingredients)
|
||||
- [ ] Approve/Reject actions
|
||||
- [ ] Filter by status, discovery reason
|
||||
|
||||
### Phase D — Orchestrator Integration (0.5 day)
|
||||
- [ ] Weekly finalize step calls analyzer → triggers discovery if conditions met
|
||||
- [ ] Configuration: enable/disable auto-discovery, quota limits
|
||||
|
||||
### Phase E — Verification & Hardening (1 day)
|
||||
- [ ] End-to-end test: submit feedback → run discovery → approve recipe → verify in planner
|
||||
- [ ] Quota exhaustion handling
|
||||
- [ ] Duplicate detection accuracy
|
||||
|
||||
---
|
||||
|
||||
## 7. Cost & Quota Analysis
|
||||
|
||||
| Source | Free Tier | Proposed Usage | Cost |
|
||||
|--------|-----------|----------------|------|
|
||||
| Spoonacular | 150 req/day | 5 queries × 1 call = 5/day | Free |
|
||||
| TheMealDB | 100% free | Backup only | Free |
|
||||
| Ollama (ingredient mapping) | Self-hosted | ~20 LLM calls per batch of recipes | Free |
|
||||
|
||||
**Spoonacular points math:**
|
||||
- `complexSearch` = 1 point + 0.01 per result
|
||||
- 5 queries × 10 results = 5 + 0.5 = 5.5 points/day
|
||||
- Weekly total: ~38.5 points / 150 free = well within limits
|
||||
- If we need more, paid tiers start at $29/mo (5,000 points/day)
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks & Mitigations
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|---|---|---|
|
||||
| Spoonacular API changes/breaks schema | Medium | TheMealDB fallback; version-pinned client; mocked fixtures in CI |
|
||||
| Ingredient mapping is inaccurate | Medium | LLM-assisted + admin review queue gate; never auto-add without review |
|
||||
| Recipes not matched to our grocery catalog | Medium | Same as above — review queue lets admin see if ingredients are shoppable |
|
||||
| Family data leaked to external APIs | Low | Only recipe search queries (keywords) leave the system; no family data |
|
||||
| Duplicate recipes slip through | Low | Embedding similarity check + human review |
|
||||
| Free quota exhausted | Low | 5.5/day usage; explicit quota tracker; admin alert on 80% |
|
||||
|
||||
---
|
||||
|
||||
## 9. Files to Create / Modify
|
||||
|
||||
### New Files
|
||||
```
|
||||
backend/app/services/feedback_analyzer.py
|
||||
backend/app/services/recipe_discovery.py
|
||||
backend/app/api/recipe_discovery.py # or merge into recipes.py
|
||||
backend/alembic/versions/0010_feedback_analysis_and_review_queue.py
|
||||
docs/specs/2026-05-23-recipe-discovery-pipeline.md
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
```
|
||||
backend/app/services/orchestrator/steps.py # hook analyzer into finalize
|
||||
backend/app/models/__init__.py # new columns + RecipeReviewQueue
|
||||
backend/app/schemas/__init__.py # response schemas
|
||||
backend/app/api/admin.py # new admin endpoints
|
||||
backend/app/api/recipes.py # external_source fields
|
||||
backend/app/models/... # Recipe additions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Open Questions
|
||||
|
||||
1. **Admin review cadence:** Should new recipes email Peter, or is a dashboard badge enough?
|
||||
2. **Ollama endpoint:** What's the exact model + base URL Peter uses? (Confirmed in `.agent/context.md`: Ollama Cloud)
|
||||
3. **Embedding model:** Use Ollama embeddings or a lightweight local model (sentence-transformers)?
|
||||
4. **Feedback lookback:** 8 weeks feels right for a 4-person family; confirm.
|
||||
5. **Auto-approval threshold:** Should very high-confidence discoveries (spoonacular score > 90, all ingredients mapped) skip the review queue? (Recommended: no — always review.)
|
||||
|
||||
---
|
||||
|
||||
## 11. Next Step
|
||||
|
||||
Await approval on this proposal. Once approved, agent will create the implementation plan (`.agent/plan.md`) and execute Phase A.
|
||||
Reference in New Issue
Block a user