""" Orchestrator step functions. Each step receives a WeeklyRun row and an open Session. Steps are idempotent: a non-null timestamp column causes an immediate return. Steps commit their own changes before returning. All service imports are at module level so tests can monkeypatch via `app.services.orchestrator.steps.`. """ from __future__ import annotations import html import logging import uuid as _uuid from datetime import datetime, timezone from typing import TYPE_CHECKING from app.config import settings from app.models import FamilyMember, FamilyProfile, Ingredient, IngredientGroceryMatch, MealPlan, MealPlanItemStatus, MealPlanVote from app.services.approval import issue_token from app.services.email import get_email_backend 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.utils.units import convert_qty 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 logger = logging.getLogger(__name__) def step_scrape(run: "WeeklyRun", db: "Session") -> None: if run.scraped_at is not None: logger.info("step_scrape: already done for %s, skipping", run.week_start_date) return service = ScraperService(db) last_result: dict = {} for attempt in (1, 2): last_result = service.run_scrape() if last_result["status"] == "success": run.scraped_at = datetime.now(timezone.utc) run.used_stale_data = False db.commit() logger.info("step_scrape: success on attempt %d", attempt) return logger.warning( "step_scrape: attempt %d failed: %s", attempt, last_result.get("error") ) # Both attempts failed — mark stale and continue the cycle run.scraped_at = datetime.now(timezone.utc) run.used_stale_data = True db.commit() send_admin_alert( subject=f"Scrape failed week {run.week_start_date}", body=( f"Both scrape attempts failed. Proceeding with stale grocery data.\n" f"Error: {last_result.get('error')}" ), ) logger.warning("step_scrape: both attempts failed; marked stale") def step_generate(run: "WeeklyRun", db: "Session") -> None: if run.generated_at is not None: logger.info("step_generate: already done for %s", run.week_start_date) return try: result = generate_meal_plan( db, family_id=run.family_id, week_start_date=run.week_start_date, ) run.generated_at = datetime.now(timezone.utc) db.commit() logger.info("step_generate: meal_plan %s created", result.meal_plan_id) except Exception as exc: run.error_step = "generate" run.error_message = str(exc) db.commit() send_admin_alert( subject=f"Generate failed week {run.week_start_date}", body=str(exc), ) raise def step_email(run: "WeeklyRun", db: "Session") -> None: if run.emailed_at is not None: logger.info("step_email: already done for %s", run.week_start_date) return plan = ( db.query(MealPlan) .filter( MealPlan.family_profile_id == run.family_id, MealPlan.week_start_date == run.week_start_date, ) .first() ) if plan is None: raise RuntimeError( f"No MealPlan for family {run.family_id} week {run.week_start_date}" ) members = ( db.query(FamilyMember) .filter( FamilyMember.family_profile_id == run.family_id, FamilyMember.email.isnot(None), ) .all() ) stale_banner = "" if run.used_stale_data: stale_banner = ( "

Note: Grocery prices in this plan may be a few " "days old — the Friday scrape failed and stale data was used.

" ) # Preload ingredient names for all pending items ing_ids: set = set() for _item in plan.items: if _item.approval_status == MealPlanItemStatus.pending and _item.recipe: for _ing in (_item.recipe.ingredients or []): if "ingredient_id" in _ing: ing_ids.add(_ing["ingredient_id"]) ingredient_names: dict = {} if ing_ids: _ing_rows = db.query(Ingredient).filter( Ingredient.id.in_([_uuid.UUID(str(i)) for i in ing_ids]) ).all() ingredient_names = {str(r.id): r.name for r in _ing_rows} # Build shopping list preview once (shared across all member emails) all_ingredients: dict[str, tuple[str, str, str, str]] = {} for item in plan.items: if item.approval_status != MealPlanItemStatus.pending: continue for ing in (item.recipe.ingredients or [] if item.recipe else []): ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "unknown")).strip() if not ing_name or ing_name in all_ingredients: continue match = ( db.query(IngredientGroceryMatch) .join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id) .filter(Ingredient.name_lower == ing_name.lower()) .order_by(IngredientGroceryMatch.confidence.desc()) .first() ) grocery_name = match.grocery_item.name if match and match.grocery_item else "—" price = ( f"${float(match.grocery_item.current_price):.2f}" if match and match.grocery_item and match.grocery_item.current_price else "—" ) all_ingredients[ing_name] = ( ing.get("qty", ""), ing.get("unit", ""), grocery_name, price, ) if all_ingredients: shop_rows = "".join( f"{html.escape(name)}" f"{html.escape(str(qty))} {html.escape(str(unit))}" f"{html.escape(str(grocery))}" f"{html.escape(str(price))}" for name, (qty, unit, grocery, price) in all_ingredients.items() ) shopping_preview = ( f"
" f"

Estimated shopping list

" f"" f"" f"" f"" f"" f"" f"{shop_rows}
IngredientQtyAt LuckyPrice
" ) else: shopping_preview = "" backend = get_email_backend() for member in members: item_html_parts = [] for item in plan.items: if item.approval_status != MealPlanItemStatus.pending: continue token = issue_token(item.id, member.id) vote_url = ( f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}" ) recipe_name = html.escape( item.recipe.name if item.recipe else str(item.recipe_id) ) # Image block img_block = ( f'' if item.recipe and item.recipe.image_url else "" ) # Ingredients list ingredients = item.recipe.ingredients or [] if item.recipe else [] ing_rows = "".join( f"
  • {html.escape(str(ing.get('qty', '')))}" f" {html.escape(str(ing.get('unit', '')))}" f" {html.escape(ingredient_names.get(str(ing.get('ingredient_id', '')), ing.get('name', 'unknown')))}
  • " for ing in ingredients ) ing_block = ( f"" if ing_rows else "" ) # Cooking instructions (collapsed by default) instructions = item.recipe.instructions or [] if item.recipe else [] instr_rows = "".join( f"
  • {html.escape(str(step))}
  • " for step in instructions ) instructions_block = ( f"
    " f"" f"Cooking steps ({len(instructions)})" f"
      {instr_rows}
    " f"
    " if instr_rows else "" ) # Estimated cost: sum top-confidence grocery match prices ÷ servings est_cost_total = 0.0 for ing in ingredients: ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "")).lower() match = ( db.query(IngredientGroceryMatch) .join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id) .filter(Ingredient.name_lower == ing_name) .order_by(IngredientGroceryMatch.confidence.desc()) .first() ) if match and match.grocery_item and match.grocery_item.current_price: qty = ing.get("qty", 1.0) unit = ing.get("unit", "") gunit = match.grocery_item.unit or "" converted = convert_qty( qty, unit, gunit, ingredient_name_lower=ing_name, ) est_cost_total += float(match.grocery_item.current_price) * float(converted) recipe_servings = (item.recipe.servings or 4) if item.recipe else 4 est_cost_per_serving = est_cost_total / recipe_servings cost_block = ( f"

    Est. ~${est_cost_per_serving:.2f}/serving

    " if est_cost_total > 0 else "" ) item_html_parts.append( f'
    ' f'{img_block}' f'

    {recipe_name}

    ' f'{ing_block}' f'{instructions_block}' f'{cost_block}' f'
    ' f'' f'Approve' f'' f'Deny this week' f'' f'Never again' f'
    ' f'
    ' f'Open vote page (all 3 options)
    ' f'
    ' ) if not item_html_parts: continue email_html = ( f"
    " f"

    This week's meal suggestions

    " f"{stale_banner}" f"

    Hi {html.escape(member.name)}, please vote on this week's meals by Fri 17:00 PT. " f"Silence = approved. Any denial removes that meal.

    " f"{''.join(item_html_parts)}" f"{shopping_preview}" f"
    " ) backend.send( to=member.email, subject=f"Meal plan for week of {run.week_start_date}", html=email_html, ) logger.info("step_email: sent to %s", member.email) run.emailed_at = datetime.now(timezone.utc) db.commit() def step_deadline(run: "WeeklyRun", db: "Session") -> None: if run.deadline_passed_at is not None: logger.info("step_deadline: already done for %s", run.week_start_date) return plan = ( db.query(MealPlan) .filter( MealPlan.family_profile_id == run.family_id, MealPlan.week_start_date == run.week_start_date, ) .first() ) if plan is None: run.deadline_passed_at = datetime.now(timezone.utc) db.commit() return family = db.query(FamilyProfile).filter(FamilyProfile.id == run.family_id).first() policy = family.pending_approval_policy if family else "approve" resolved = 0 for item in plan.items: if item.approval_status == MealPlanItemStatus.pending: item.approval_status = ( MealPlanItemStatus.approved if policy == "approve" else MealPlanItemStatus.denied ) resolved += 1 run.deadline_passed_at = datetime.now(timezone.utc) db.commit() logger.info( "step_deadline: resolved %d pending items with policy=%s", resolved, policy ) def step_finalize(run: "WeeklyRun", db: "Session") -> None: if run.finalized_at is not None: logger.info("step_finalize: already done for %s", run.week_start_date) return import uuid as _uuid_fin plan = ( db.query(MealPlan) .filter( MealPlan.family_profile_id == run.family_id, MealPlan.week_start_date == run.week_start_date, ) .first() ) approved_items = [ item for item in (plan.items if plan else []) if item.approval_status == MealPlanItemStatus.approved ] members = ( db.query(FamilyMember) .filter( FamilyMember.family_profile_id == run.family_id, FamilyMember.email.isnot(None), ) .all() ) # Preload ingredient names for approved items _fin_ids: set = set() for _item in approved_items: if _item.recipe: for _ing in (_item.recipe.ingredients or []): if "ingredient_id" in _ing: _fin_ids.add(_ing["ingredient_id"]) _fin_names: dict = {} if _fin_ids: _fin_rows = db.query(Ingredient).filter( Ingredient.id.in_([_uuid_fin.UUID(str(i)) for i in _fin_ids]) ).all() _fin_names = {str(r.id): r.name for r in _fin_rows} if approved_items: total_cost = 0.0 sections_html = "" for item in approved_items: if not item.recipe: continue recipe_name = html.escape(item.recipe.name) rows_html = "" for ing in (item.recipe.ingredients or []): ing_id = str(ing.get("ingredient_id", "")) ing_name = _fin_names.get(ing_id, "") if not ing_name: continue qty = ing.get("qty", "") unit = ing.get("unit", "") match = ( db.query(IngredientGroceryMatch) .join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id) .filter(Ingredient.id == _uuid_fin.UUID(ing_id)) .order_by(IngredientGroceryMatch.confidence.desc()) .first() ) if match and match.grocery_item: g_item = match.grocery_item lucky_name = html.escape(g_item.name or "") price = float(g_item.current_price or 0) qty = ing.get("qty", 1.0) unit = ing.get("unit", "") gunit = g_item.unit or "" converted = convert_qty( qty, unit, gunit, ingredient_name_lower=ing_name, ) total_cost += price * float(converted) price_str = f"${price * float(converted):.2f}" else: lucky_name = "—" price_str = "—" rows_html += ( f"" f"{html.escape(ing_name)}" f"{html.escape(str(qty))} {html.escape(str(unit))}" f"{lucky_name}" f"{price_str}" f"" ) sections_html += ( f"

    {recipe_name}

    " f"" f"" f"" f"" f"" f"" f"" f"{rows_html}" f"
    IngredientQtyAt LuckyPrice
    " ) email_html = ( f"
    " f"

    Shopping list — week of {run.week_start_date}

    " f"

    {len(approved_items)} meal(s) approved.

    " f"{sections_html}" f"

    Estimated total: ${total_cost:.2f}

    " f"
    " ) else: email_html = f"

    No meals were approved for week of {run.week_start_date}.

    " backend = get_email_backend() for member in members: backend.send( to=member.email, subject=f"Shopping list — week of {run.week_start_date}", html=email_html, ) logger.info("step_finalize: shopping list sent to %s", member.email) 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)) def step_reminder(run: "WeeklyRun", db: "Session") -> None: if run.reminded_at is not None: logger.info("step_reminder: already done for %s", run.week_start_date) return if run.emailed_at is None: logger.info("step_reminder: proposal not sent yet for %s, skipping", run.week_start_date) return plan = ( db.query(MealPlan) .filter( MealPlan.family_profile_id == run.family_id, MealPlan.week_start_date == run.week_start_date, ) .first() ) if plan is None: run.reminded_at = datetime.now(timezone.utc) db.commit() return pending_item_ids = [ item.id for item in plan.items if item.approval_status == MealPlanItemStatus.pending ] if not pending_item_ids: run.reminded_at = datetime.now(timezone.utc) db.commit() return members = ( db.query(FamilyMember) .filter( FamilyMember.family_profile_id == run.family_id, FamilyMember.email.isnot(None), ) .all() ) backend = get_email_backend() for member in members: voted_ids = { v.meal_plan_item_id for v in db.query(MealPlanVote) .filter( MealPlanVote.meal_plan_item_id.in_(pending_item_ids), MealPlanVote.family_member_id == member.id, ) .all() } unvoted = [ item for item in plan.items if item.approval_status == MealPlanItemStatus.pending and item.id not in voted_ids ] if not unvoted: continue item_html_parts = [] for item in unvoted: token = issue_token(item.id, member.id) vote_url = ( f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}" ) recipe_name = html.escape( item.recipe.name if item.recipe else str(item.recipe_id) ) item_html_parts.append( f'
  • {recipe_name} — Vote
  • ' ) email_html = ( f"

    Vote closes in 1 hour!

    " f"

    Hi {html.escape(member.name)}, the meal plan vote closes at Fri 17:00 PT. " f"You haven't voted on:

    " f"" ) backend.send( to=member.email, subject=f"Meal plan vote closes in 1 hour — week of {run.week_start_date}", html=email_html, ) logger.info("step_reminder: sent to %s", member.email) run.reminded_at = datetime.now(timezone.utc) db.commit() logger.info("step_reminder: done for %s", run.week_start_date)