feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled

This commit is contained in:
2026-05-24 13:17:39 -07:00
parent 35f736a052
commit 3885d7d0dc
20 changed files with 1962 additions and 9 deletions
+54 -1
View File
@@ -1,11 +1,18 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import ScrapeLog, EmailLog, MealPlan
from app.models import ScrapeLog, EmailLog, MealPlan, Recipe
from app.security import require_admin
from app.services.scraper_service import ScraperService, enqueue_scrape
from app.services.feedback_analyzer import FeedbackAnalyzer
from app.services.recipe_discovery import RecipeDiscoveryService
from app.services.recipe_ingestion import RecipeIngestionService
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import UUID
import logging
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_admin)])
@@ -179,4 +186,50 @@ def get_stats(db: Session = Depends(get_db)):
"recipes": recipe_count,
"ingredients": ingredient_count,
"meal_plans": plan_count
}
@router.post("/trigger-discovery", status_code=200)
def trigger_discovery(
family_profile_id: UUID,
force: bool = False,
db: Session = Depends(get_db),
):
from uuid import UUID as UUIDType
profile = (
db.query(FamilyProfile)
.filter(FamilyProfile.id == family_profile_id)
.first()
)
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
analyzer = FeedbackAnalyzer(lookback_weeks=4)
analysis = analyzer.analyze(db, profile.id)
if not analysis.discovery_queries and not force:
return {"status": "skipped", "reason": "No discovery queries and force=false"}
discovery = RecipeDiscoveryService()
if not analysis.discovery_queries and force:
queries = ["popular recipes"]
else:
queries = analysis.discovery_queries
candidates = discovery.discover(queries)
if not candidates:
return {"status": "no_candidates"}
ingestion = RecipeIngestionService()
added = ingestion.ingest(
db, profile.id, candidates, analysis.to_dict()
)
logger.info("Manual discovery: added %d recipes for family %s", added, profile.id)
return {
"status": "success",
"candidates_found": len(candidates),
"recipes_added": added,
"discovery_queries": queries,
"confidence": analysis.confidence,
}
+51 -1
View File
@@ -16,6 +16,7 @@ from app.schemas import (
)
from app.security import require_session
from app.services import approval as approval_service
from app.services.feedback_analyzer import FeedbackAnalyzer
from uuid import UUID
from typing import List, Optional
from datetime import datetime, timedelta
@@ -473,4 +474,53 @@ def move_meal_item(
db.commit()
db.refresh(item)
return {"message": "Meal moved", "item": item}
return {"message": "Meal moved", "item": item}
# ── Dashboard badge: newly discovered recipes ─────────────────────────
@router.get("/dashboard/discovered-count")
def discovered_count(db: Session = Depends(get_db)):
"""Return number of recipes discovered in the last 7 days for the current family."""
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
since = datetime.utcnow() - timedelta(days=7)
count = (
db.query(Recipe)
.filter(
Recipe.family_profile_id == profile.id,
Recipe.external_source.isnot(None),
Recipe.created_at >= since,
)
.count()
)
return {"discovered_count": count}
@router.get("/dashboard/discovery-insights")
def discovery_insights(db: Session = Depends(get_db)):
"""Return latest feedback analysis and top signals."""
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
latest_run = (
db.query(WeeklyRun)
.filter(WeeklyRun.family_id == profile.id)
.order_by(WeeklyRun.week_start_date.desc())
.first()
)
if not latest_run or not latest_run.feedback_analysis:
return {"has_analysis": False}
analysis = latest_run.feedback_analysis
return {
"has_analysis": True,
"confidence": analysis.get("confidence", 0),
"positive_signals": analysis.get("positive_signals", []),
"negative_signals": analysis.get("negative_signals", []),
"top_rated_recipes": analysis.get("top_rated_recipes", []),
}
+3
View File
@@ -60,6 +60,9 @@ def _serialize(row: Recipe) -> dict:
"ingredients": row.ingredients or [],
"instructions": list(row.instructions or []),
"source_url": row.source_url,
"external_source": row.external_source,
"external_id": row.external_id,
"discovery_reason": row.discovery_reason,
}