feat(meals): enforce three lower-calorie dinners with media
CI / backend (pytest + alembic) (push) Canceled after 0s
CI / frontend (build) (push) Canceled after 0s

This commit is contained in:
2026-09-01 10:11:36 -07:00
parent 7838c49721
commit 2695ba0232
85 changed files with 8103 additions and 795 deletions
+140 -23
View File
@@ -1,12 +1,32 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import ScrapeLog, EmailLog, MealPlan, Recipe
from app.models import (
FamilyProfile,
ScrapeLog,
EmailLog,
MealPlan,
Recipe,
RecipeImportStaging,
RecipeImportStatus,
)
from app.schemas.recipe_import import (
RecipeImportDetail,
RecipeImportListResponse,
RecipeImportRejectRequest,
)
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 app.services.recipe_import_review import (
RecipeImportConflictError,
RecipeImportNotFoundError,
approve_recipe_import,
reject_recipe_import,
)
from app.services.wikibooks_import import WikibooksImportError, WikibooksImportService
from app.config import settings
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import UUID
@@ -17,6 +37,99 @@ logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_admin)])
def _import_list_item(row: RecipeImportStaging) -> dict:
normalized = row.normalized_recipe if isinstance(row.normalized_recipe, dict) else {}
return {
"id": row.id,
"source": row.source,
"external_id": row.external_id,
"source_url": row.source_url,
"source_revision": row.source_revision,
"name": normalized.get("name"),
"status": row.status.value,
"acquired_at": row.acquired_at,
"reviewed_at": row.reviewed_at,
"reviewed_by": row.reviewed_by,
"recipe_id": row.recipe_id,
}
@router.get("/recipe-imports", response_model=RecipeImportListResponse)
def list_recipe_imports(
import_status: RecipeImportStatus | None = Query(default=None, alias="status"),
source: str | None = Query(default=None, min_length=1, max_length=100),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
query = db.query(RecipeImportStaging)
if import_status is not None:
query = query.filter(RecipeImportStaging.status == import_status)
if source is not None:
query = query.filter(RecipeImportStaging.source == source)
total = query.count()
rows = (
query.order_by(RecipeImportStaging.acquired_at.desc(), RecipeImportStaging.id)
.offset(offset)
.limit(limit)
.all()
)
return {
"items": [_import_list_item(row) for row in rows],
"total": total,
"limit": limit,
"offset": offset,
"has_next": offset + len(rows) < total,
}
@router.get("/recipe-imports/{import_id}", response_model=RecipeImportDetail)
def get_recipe_import(import_id: UUID, db: Session = Depends(get_db)):
staged = db.get(RecipeImportStaging, import_id)
if staged is None:
raise HTTPException(status_code=404, detail="Recipe import not found")
return staged
@router.post("/recipe-imports/{import_id}/approve", response_model=RecipeImportDetail)
def approve_staged_recipe(
import_id: UUID,
response: Response,
db: Session = Depends(get_db),
reviewer: str = Depends(require_admin),
):
try:
staged, created = approve_recipe_import(db, import_id, reviewer)
except RecipeImportNotFoundError as exc:
db.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RecipeImportConflictError as exc:
db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="Recipe publication conflicted") from exc
response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
return staged
@router.post("/recipe-imports/{import_id}/reject", response_model=RecipeImportDetail)
def reject_staged_recipe(
import_id: UUID,
payload: RecipeImportRejectRequest,
db: Session = Depends(get_db),
reviewer: str = Depends(require_admin),
):
try:
return reject_recipe_import(db, import_id, payload.reason, reviewer)
except RecipeImportNotFoundError as exc:
db.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RecipeImportConflictError as exc:
db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/scrape", status_code=202)
def trigger_scrape(
background_tasks: BackgroundTasks,
@@ -214,10 +327,9 @@ def trigger_images(
def trigger_discovery(
family_profile_id: UUID,
force: bool = False,
limit: int = Query(default=5, ge=1, le=25),
db: Session = Depends(get_db),
):
from uuid import UUID as UUIDType
profile = (
db.query(FamilyProfile)
.filter(FamilyProfile.id == family_profile_id)
@@ -232,25 +344,30 @@ def trigger_discovery(
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"}
try:
staged = WikibooksImportService(
settings.WIKIBOOKS_USER_AGENT
).stage_discovered_pages(
db,
limit=limit,
queries=analysis.discovery_queries or None,
)
db.commit()
except WikibooksImportError as exc:
db.rollback()
raise HTTPException(status_code=502, detail="Wikibooks staging failed") from exc
ingestion = RecipeIngestionService()
added = ingestion.ingest(
db, profile.id, candidates, analysis.to_dict()
pending = sum(row.status == RecipeImportStatus.PENDING for row in staged)
quarantined = sum(row.status == RecipeImportStatus.QUARANTINED for row in staged)
logger.info(
"Manual discovery: staged %d Wikibooks candidates for review", len(staged)
)
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,
"candidates_staged": len(staged),
"pending": pending,
"quarantined": quarantined,
"recipes_added": 0,
"discovery_queries": analysis.discovery_queries,
"confidence": analysis.confidence,
}
}
+83 -97
View File
@@ -1,21 +1,16 @@
"""Sprint 13 — F9-lite: free-text meal-plan synthesis via Ollama Cloud.
"""Free-text preferences constrained by the canonical three-dinner planner.
This endpoint lets the user describe what they want for the week
("Italian-inspired, vegetarian", "easy weeknight dinners, no fish")
and asks the configured LLM (kimi-k2.6:cloud on ollama.com) to pick
meals from the local recipe library. The LLM's picks are inserted
into a fresh plan; the remaining slots are filled by the existing
Sprint 6+ `fillEmptySlots` partial-success pattern.
meals from the local recipe library. The canonical planner validates
and persists all picks so LLM output cannot bypass planner constraints.
Reuses:
- The LLM call pattern from `app.services.llm_matcher._ask_ollama`
(POST ${OLLAMA_BASE_URL}/chat/completions, same headers, same
max_tokens=500, temperature=0, strip <think> blocks).
- The Sprint 6+ `meals.create` + `meals.fillEmptySlots` semantics
for the plan create + fill flow.
The LLM call follows `app.services.llm_matcher._ask_ollama`; plan
selection and persistence are delegated to `generate_meal_plan`.
The endpoint never crashes on LLM failure: parse errors, timeout,
or empty picks all fall through to the `fillEmptySlots` path.
Parse errors, timeout, invalid picks, and infeasible picks fall back to
the canonical planner.
"""
from __future__ import annotations
@@ -29,13 +24,20 @@ from typing import List, Optional
import requests
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe
from app.models import (
FamilyProfile,
MealPlan,
MealType,
Recipe,
RecipeCatalogStatus,
)
from app.security import require_session
from app.services.meal_pairings import components_with_suggested_sides
from app.services.planner.generate import generate_meal_plan
logger = logging.getLogger(__name__)
@@ -49,7 +51,7 @@ class LLMPlanRequest(BaseModel):
class LLMPickedItem(BaseModel):
day_of_week: int
meal_type: str # "breakfast" | "lunch" | "dinner"
meal_type: str
recipe_id: str
@@ -82,7 +84,13 @@ def _serialize_library(db: Session, profile_id: uuid.UUID) -> List[dict]:
Sorted alphabetically by name for deterministic LLM input."""
rows = (
db.query(Recipe)
.filter(Recipe.family_profile_id == profile_id)
.filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
or_(
Recipe.family_profile_id == profile_id,
Recipe.family_profile_id.is_(None),
),
)
.order_by(Recipe.name.asc())
.limit(_MAX_LIBRARY_PROMPTED)
.all()
@@ -95,6 +103,7 @@ def _serialize_library(db: Session, profile_id: uuid.UUID) -> List[dict]:
"cuisine_tags": r.cuisine_tags or [],
"dietary_tags": r.dietary_tags or [],
"protein_type": r.protein_type,
"calories_per_serving": r.calories_per_serving,
"total_time_minutes": (r.prep_time_minutes or 0) + (r.cook_time_minutes or 0),
})
return out
@@ -115,7 +124,7 @@ def _ask_llm(prompt: str) -> Optional[str]:
json={
"model": settings.OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 4000, # 47-recipe library: 21 picks × ~100 chars + reasoning + boilerplate ≈ 2100+ chars; 4000 gives 2x headroom
"max_tokens": 4000, # Reasoning models may consume most of the budget before returning JSON.
"temperature": 0,
},
timeout=_LLM_TIMEOUT_SECS,
@@ -171,7 +180,10 @@ def _parse_picks(raw: str) -> List[dict]:
def _validate_picks(picks: List[dict], valid_recipe_ids: set[str]) -> List[LLMPickedItem]:
"""Drop invalid entries: missing fields, out-of-range day_of_week,
unknown meal_type, unknown recipe_id."""
valid_meal_types = {m.value for m in MealType}
valid_days = {1, 3, 5}
valid_meal_types = {MealType.DINNER.value}
used_days: set[int] = set()
used_recipes: set[str] = set()
out: List[LLMPickedItem] = []
for p in picks:
try:
@@ -180,13 +192,17 @@ def _validate_picks(picks: List[dict], valid_recipe_ids: set[str]) -> List[LLMPi
rid = str(p.get("recipe_id", ""))
except (TypeError, ValueError):
continue
if day < 1 or day > 7:
if day not in valid_days or day in used_days:
continue
if meal not in valid_meal_types:
continue
if rid not in valid_recipe_ids:
if rid not in valid_recipe_ids or rid in used_recipes:
continue
out.append(LLMPickedItem(day_of_week=day, meal_type=meal, recipe_id=rid))
used_days.add(day)
used_recipes.add(rid)
if len(out) == 3:
break
return out
@@ -194,14 +210,17 @@ def _validate_picks(picks: List[dict], valid_recipe_ids: set[str]) -> List[LLMPi
def synthesize_plan(
payload: LLMPlanRequest,
db: Session = Depends(get_db),
_user: str = Depends(require_session),
session_family_id: str = Depends(require_session),
) -> LLMPlanResponse:
"""Synthesize a 7-day meal plan from a free-text prompt + the
local recipe library via the configured LLM. Falls through to
the Sprint 6+ library-fill for any slots the LLM didn't pick."""
"""Use LLM preferences only when the canonical planner accepts all three."""
_ensure_ollama_configured()
profile = db.query(FamilyProfile).first()
try:
profile_id = uuid.UUID(session_family_id)
except ValueError:
raise HTTPException(status_code=404, detail="family profile not found")
profile = db.query(FamilyProfile).filter(FamilyProfile.id == profile_id).first()
if not profile:
raise HTTPException(status_code=404, detail="family profile not found")
@@ -234,21 +253,19 @@ def synthesize_plan(
f"cuisine={','.join(r['cuisine_tags']) or 'n/a'} | "
f"diet={','.join(r['dietary_tags']) or 'n/a'} | "
f"protein={r['protein_type'] or 'n/a'} | "
f"calories={r['calories_per_serving'] or 'unknown'} | "
f"time={r['total_time_minutes']}min"
for r in library
)
prompt = (
"You are planning a 7-day meal plan (Monday through Sunday) "
"for a family. Each day has 3 meals: breakfast, lunch, dinner. "
"Pick up to 21 meals total from the recipe library below. "
"If a slot has no good match for the user's request, OMIT it "
"(do not invent a recipe). Use only recipe_ids from the list.\n\n"
"You are choosing exactly 3 dinners for Monday, Wednesday, and Friday "
"(day_of_week 1, 3, and 5). Pick one different recipe for each day. "
"Do not invent recipes and use only recipe_ids from the list.\n\n"
f"USER REQUEST: {payload.prompt}\n\n"
f"RECIPE LIBRARY ({len(library)} recipes):\n{compact}\n\n"
'RETURN FORMAT — valid JSON only, no markdown, no commentary:\n'
'[\n'
' {"day_of_week": 1, "meal_type": "breakfast", "recipe_id": "<uuid>"},\n'
' {"day_of_week": 1, "meal_type": "lunch", "recipe_id": "<uuid>"},\n'
' {"day_of_week": 1, "meal_type": "dinner", "recipe_id": "<uuid>"},\n'
' ...\n'
"]"
)
@@ -258,80 +275,49 @@ def synthesize_plan(
raw_picks = _parse_picks(raw or "")
valid_recipe_ids = {r["id"] for r in library}
picks = _validate_picks(raw_picks, valid_recipe_ids)
recipe_by_id = {
str(r.id): r
for r in db.query(Recipe).filter(Recipe.id.in_([uuid.UUID(rid) for rid in valid_recipe_ids])).all()
}
logger.info(
"LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d",
len(payload.prompt), len(raw_picks), len(picks),
)
# 4) Create the empty plan.
plan = MealPlan(
family_profile_id=profile.id,
week_start_date=payload.week_start,
status="draft",
notes=f"LLM-synthesized from prompt: {payload.prompt[:200]}",
)
db.add(plan)
db.flush()
picked_count = 0
result = None
if len(picks) == 3:
preferred_ids = {uuid.UUID(pick.recipe_id) for pick in picks}
active_ids = {
recipe_id
for (recipe_id,) in db.query(Recipe.id).filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE
).all()
}
result = generate_meal_plan(
db,
family_id=profile.id,
week_start_date=payload.week_start,
exclude_recipe_ids=active_ids - preferred_ids,
)
if len(result.selected) == 3:
picked_count = 3
else:
generated_plan = db.query(MealPlan).filter(MealPlan.id == result.meal_plan_id).first()
if generated_plan is not None:
db.delete(generated_plan)
db.commit()
result = None
# 5) Insert the LLM-picked items.
for pick in picks:
db.add(MealPlanItem(
meal_plan_id=plan.id,
recipe_id=uuid.UUID(pick.recipe_id),
day_of_week=pick.day_of_week,
meal_type=MealType(pick.meal_type),
components=components_with_suggested_sides(
recipe_by_id.get(pick.recipe_id),
pick.meal_type,
),
))
db.flush()
if result is None:
result = generate_meal_plan(
db,
family_id=profile.id,
week_start_date=payload.week_start,
)
picked_count = len(picks)
picked_slots = {(p.day_of_week, p.meal_type) for p in picks}
# 6) Fill the rest from the library (Sprint 6+ pattern).
# Re-implemented inline (not via HTTP) to avoid a self-call.
requested_meal_types = ["breakfast", "lunch", "dinner"]
used_recipe_ids = {uuid.UUID(p.recipe_id) for p in picks}
filled_count = 0
failed_count = 0
for day in range(1, 8):
for mt in requested_meal_types:
if (day, mt) in picked_slots:
continue
# Pick the first library recipe not already used in the plan
# and with a matching meal_type preference (best-effort). The
# LLM call above was the smart pick; the library fill is the
# fallback for any slots the LLM didn't cover.
candidates = [r for r in library if uuid.UUID(r["id"]) not in used_recipe_ids]
if not candidates:
failed_count += 1
continue
chosen = candidates[0]
db.add(MealPlanItem(
meal_plan_id=plan.id,
recipe_id=uuid.UUID(chosen["id"]),
day_of_week=day,
meal_type=MealType(mt),
components=components_with_suggested_sides(
recipe_by_id.get(chosen["id"]),
mt,
),
))
used_recipe_ids.add(uuid.UUID(chosen["id"]))
filled_count += 1
db.commit()
db.refresh(plan)
selected_count = len(result.selected)
filled_count = selected_count - picked_count
failed_count = max(0, 3 - selected_count)
return LLMPlanResponse(
plan_id=str(plan.id),
plan_id=str(result.meal_plan_id),
picked_count=picked_count,
filled_count=filled_count,
failed_count=failed_count,
+51 -1
View File
@@ -14,9 +14,10 @@ from app.schemas.meal_plan_generation import (
GenerationDebug,
GenerationItem,
GenerationResponse,
PublicGenerateRequest,
RegenerateRequest,
)
from app.security import require_admin
from app.security import require_admin, require_session
from app.services.planner.config import DEFAULT
from app.services.planner.generate import generate_meal_plan
from app.services.planner.types import GenerationResult
@@ -82,6 +83,55 @@ def generate(payload: GenerateRequest, db: Session = Depends(get_db)) -> Generat
return _to_response(payload.week_start_date, result.meal_plan_id, items, result)
@public_router.post("/generate", response_model=GenerationResponse, status_code=status.HTTP_201_CREATED)
def generate_for_family(
payload: PublicGenerateRequest,
db: Session = Depends(get_db),
session_family_id: str = Depends(require_session),
) -> GenerationResponse:
try:
family_id = UUID(session_family_id)
except ValueError:
raise HTTPException(status_code=404, detail="family_profile not found")
family = db.query(FamilyProfile).filter(FamilyProfile.id == family_id).first()
if family is None:
raise HTTPException(status_code=404, detail="family_profile not found")
existing = (
db.query(MealPlan)
.filter(
MealPlan.family_profile_id == family_id,
MealPlan.week_start_date == payload.week_start_date,
)
.first()
)
if existing is not None:
has_items = (
db.query(MealPlanItem.id)
.filter(MealPlanItem.meal_plan_id == existing.id)
.first()
is not None
)
if has_items:
raise HTTPException(status_code=409, detail="meal plan already has items")
db.delete(existing)
db.commit()
result = generate_meal_plan(
db,
family_id=family_id,
week_start_date=payload.week_start_date,
)
items = (
db.query(MealPlanItem)
.filter(MealPlanItem.meal_plan_id == result.meal_plan_id)
.order_by(MealPlanItem.day_of_week)
.all()
)
return _to_response(payload.week_start_date, result.meal_plan_id, items, result)
@admin_router.post("/regenerate", response_model=GenerationResponse, status_code=status.HTTP_201_CREATED)
def regenerate(payload: RegenerateRequest, db: Session = Depends(get_db)) -> GenerationResponse:
family = db.query(FamilyProfile).filter(FamilyProfile.id == payload.family_profile_id).first()
+73 -4
View File
@@ -9,6 +9,7 @@ from app.models import (
MealPlan, MealPlanItem, MealPlanVote, Recipe, Ingredient,
FamilyProfile, FamilyMember, ApprovalToken, NeverSuggest, NeverSuggestReason,
MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus,
RecipeCatalogStatus,
)
from app.schemas import (
MealPlanResponse, MealPlanCreate,
@@ -154,6 +155,7 @@ def get_planned_meals(
if not meal_plan:
return None
_enrich_recipe_ingredient_names(db, meal_plan)
return meal_plan
@@ -170,6 +172,23 @@ def create_meal_plan(meal_plan_data: MealPlanCreate, db: Session = Depends(get_d
if existing:
raise HTTPException(status_code=400, detail="Meal plan for this week already exists")
recipe_ids = {item.recipe_id for item in meal_plan_data.items}
if recipe_ids:
active_recipe_ids = {
recipe_id
for (recipe_id,) in db.query(Recipe.id)
.filter(
Recipe.id.in_(recipe_ids),
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
)
.all()
}
if active_recipe_ids != recipe_ids:
raise HTTPException(
status_code=400,
detail="Meal plan contains unavailable recipes",
)
db_meal_plan = MealPlan(
family_profile_id=profile.id,
week_start_date=meal_plan_data.week_start_date,
@@ -201,6 +220,7 @@ def get_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)):
meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
if not meal_plan:
raise HTTPException(status_code=404, detail="Meal plan not found")
_enrich_recipe_ingredient_names(db, meal_plan)
return meal_plan
@@ -221,6 +241,39 @@ _DAY_NAMES = {
}
def _enrich_recipe_ingredient_names(db: Session, meal_plan: MealPlan) -> None:
"""Mutate each embedded Recipe's `ingredients` JSONB in-place to add
a resolved `name` from the canonical Ingredient table. Mirrors the
enrichment the public /api/recipes endpoints do; the meal-plan path
uses pydantic from_attributes which reads the raw JSONB and would
otherwise leave `name=None`. Idempotent: only sets name when
missing.
"""
recipes: list[Recipe] = []
for item in meal_plan.items:
if item.recipe is not None and item.recipe not in recipes:
recipes.append(item.recipe)
if not recipes:
return
all_ids: set = set()
for r in recipes:
for ref in (r.ingredients or []):
iid = ref.get("ingredient_id") if isinstance(ref, dict) else None
if iid:
all_ids.add(iid)
if not all_ids:
return
rows = db.query(Ingredient.id, Ingredient.name).filter(Ingredient.id.in_(all_ids)).all()
name_map = {str(rid): rname for rid, rname in rows}
for r in recipes:
for ref in (r.ingredients or []):
if not isinstance(ref, dict):
continue
iid = str(ref.get("ingredient_id") or "")
if iid and not ref.get("name"):
ref["name"] = name_map.get(iid)
class VoteSubmission(BaseModel):
"""Body for POST /vote/{item_id}?token=...
@@ -556,7 +609,10 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
new_recipe = db.query(Recipe).filter(Recipe.id == new_recipe_id).first()
new_recipe = db.query(Recipe).filter(
Recipe.id == new_recipe_id,
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
).first()
if not new_recipe:
raise HTTPException(status_code=404, detail="New recipe not found")
@@ -668,7 +724,17 @@ def generate_single_item(
raise HTTPException(status_code=400, detail="Slot already occupied")
# Get all recipes, score them, pick the best not already in this plan
all_recipes = db.query(Recipe).all()
all_recipes = db.query(Recipe).filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE
).all()
if not all_recipes:
raise HTTPException(
status_code=409,
detail=(
"No active recipes are available. "
"Approve a staged recipe before generating meals."
),
)
used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
# Simple heuristic: pick a random un-used recipe (we can improve scoring later)
@@ -728,7 +794,9 @@ def fill_empty_slots(
if not requested:
return FillEmptySlotsResult(filled=[], failed=[])
all_recipes = db.query(Recipe).all()
all_recipes = db.query(Recipe).filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE
).all()
if not all_recipes:
# No recipes at all — every requested slot fails.
return FillEmptySlotsResult(
@@ -877,6 +945,7 @@ def discovered_count(db: Session = Depends(get_db)):
db.query(Recipe)
.filter(
Recipe.family_profile_id == profile.id,
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
Recipe.external_source.isnot(None),
Recipe.created_at >= since,
)
@@ -908,4 +977,4 @@ def discovery_insights(db: Session = Depends(get_db)):
"positive_signals": analysis.get("positive_signals", []),
"negative_signals": analysis.get("negative_signals", []),
"top_rated_recipes": analysis.get("top_rated_recipes", []),
}
}
+15 -292
View File
@@ -1,308 +1,31 @@
"""Sprint 12 — external recipe search (Spoonacular) + import.
"""Retired direct third-party recipe search and publication endpoints."""
from fastapi import APIRouter, Depends, HTTPException, Query, status
This is a thin HTTP layer on top of the pre-existing Spoonacular
free-tier API. The service-class `RecipeDiscoveryService` in
`app.services.recipe_discovery` is the bulk-orchestrator used by
the offline FeedbackAnalyzer; we re-implement the call shape here
because the webui wants:
1. A search that returns *summary* data (no info endpoint call) so
10 results cost 1.1 points, not 11.1.
2. An import that fetches the full info for ONE recipe and writes
a local Recipe row.
Quota: Spoonacular free tier = 150 points/day. complexSearch = 1 +
0.01 per result. /information = 1 point. We gate at 140 to leave
a safety margin and return 503 once exhausted.
"""
from __future__ import annotations
import logging
import threading
from typing import List, Optional
import requests
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import FamilyProfile, Ingredient, Recipe
from app.schemas import (
RecipeImportRequest,
RecipeSearchHit,
)
from app.schemas import RecipeImportRequest
from app.security import require_session
logger = logging.getLogger(__name__)
router = APIRouter()
# Quota counter: a per-process point budget. Survives across
# requests in the same uvicorn worker. Process restart resets to 0
# (so the operator can recover by bouncing the backend). Thread-safe
# with a single lock — the counter is touched on every request and
# we don't want a race between two parallel searches.
_quota_lock = threading.Lock()
_points_used: float = 0.0
_DAILY_LIMIT: float = 45.0 # 50 free, leave 5pt safety margin (corrected from 140; Sprint 15 + Sprint 16)
_INFO_URL = "https://api.spoonacular.com/recipes/{id}/information"
_SEARCH_URL = "https://api.spoonacular.com/recipes/complexSearch"
_RETIRED_DETAIL = (
"Direct external recipe ingestion is retired; recipes must be staged and "
"approved through the admin import queue."
)
def _points_available() -> float:
with _quota_lock:
return _DAILY_LIMIT - _points_used
def _charge_points(pts: float) -> None:
global _points_used
with _quota_lock:
_points_used += pts
def _ensure_spoonacular_configured() -> str:
"""Return the API key, or 503 if unconfigured."""
key = settings.SPOONACULAR_API_KEY
if not key:
logger.error("SPOONACULAR_API_KEY not configured")
raise HTTPException(
status_code=503,
detail="SPOONACULAR_API_KEY not configured; set it in the backend env",
)
return key
def _normalize_spoonacular_summary(item: dict) -> Optional[RecipeSearchHit]:
"""Map a complexSearch result item into RecipeSearchHit."""
ext_id = str(item.get("id") or "")
title = item.get("title")
if not ext_id or not title:
return None
cuisines = [str(c).lower() for c in (item.get("cuisines") or []) if c]
diets = [str(d).lower() for d in (item.get("diets") or []) if d]
return RecipeSearchHit(
external_id=ext_id,
external_source="spoonacular",
name=title,
image_url=item.get("image"),
source_url=item.get("sourceUrl") or item.get("spoonacularSourceUrl"),
prep_time_minutes=int(item["preparationMinutes"]) if item.get("preparationMinutes") else None,
cook_time_minutes=int(item["cookingMinutes"]) if item.get("cookingMinutes") else None,
servings=int(item["servings"]) if item.get("servings") else None,
cuisine_tags=cuisines,
dietary_tags=diets,
protein_type=None, # would need to call /information or _infer_protein
)
@router.get("/search", response_model=List[RecipeSearchHit])
@router.get("/search")
def search_recipes(
q: str = Query(..., min_length=1, max_length=200),
limit: int = Query(10, ge=1, le=25),
_user: str = Depends(require_session),
) -> List[RecipeSearchHit]:
"""Search Spoonacular's complexSearch index and return a
summary list (no per-recipe /information call). The frontend
uses this for the "Search the web" panel."""
if _points_available() < 1.05: # 1 base + 0.01 * 5 average
raise HTTPException(
status_code=503,
detail="spoonacular daily quota reached; try again tomorrow",
)
api_key = _ensure_spoonacular_configured()
try:
resp = requests.get(
_SEARCH_URL,
params={
"apiKey": api_key,
"query": q,
"number": limit,
"addRecipeInformation": "true",
"fillIngredients": "true",
"instructionsRequired": "true",
},
timeout=15,
)
resp.raise_for_status()
except requests.RequestException as exc:
logger.warning("Spoonacular search failed for %r: %s", q, exc)
raise HTTPException(
status_code=502,
detail=f"spoonacular search failed: {exc}",
) from exc
data = resp.json()
results = data.get("results", [])
# 1 base + 0.01 per result
cost = 1.0 + len(results) * 0.01
_charge_points(cost)
logger.info("Spoonacular search %r → %d hits (%.2f pts, total %.1f/%.0f)",
q, len(results), cost, _points_used, _DAILY_LIMIT)
out: List[RecipeSearchHit] = []
for item in results:
hit = _normalize_spoonacular_summary(item)
if hit:
out.append(hit)
return out
):
"""Reject the legacy Spoonacular search flow without contacting a vendor."""
raise HTTPException(status_code=status.HTTP_410_GONE, detail=_RETIRED_DETAIL)
def _infer_protein_simple(title: str, ingredient_names: List[str]) -> Optional[str]:
"""Lightweight protein inference; mirrors recipe_discovery._infer_protein
but doesn't import the service (to avoid pulling in the rest of the
offline path)."""
text = (title + " " + " ".join(ingredient_names)).lower()
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, kws in proteins.items():
for kw in kws:
if kw in text:
return ptype
return None
def _upsert_ingredient(db: Session, name: str) -> Ingredient:
"""Idempotent insert by name_lower. Mirrors the public
POST /api/ingredients logic without the HTTP roundtrip."""
name_lower = name.lower()
existing = db.query(Ingredient).filter(Ingredient.name_lower == name_lower).first()
if existing:
return existing
row = Ingredient(name=name, name_lower=name_lower, aliases=[])
db.add(row)
try:
db.commit()
db.refresh(row)
except Exception:
db.rollback()
existing = db.query(Ingredient).filter(Ingredient.name_lower == name_lower).first()
if existing:
return existing
raise
return row
@router.post("/import", response_model=dict, status_code=201)
@router.post("/import")
def import_recipe(
payload: RecipeImportRequest,
db: Session = Depends(get_db),
_user: str = Depends(require_session),
) -> dict:
"""Import a Spoonacular recipe into the local library. One
/information call (1 point) + ingredient upserts + Recipe insert.
Returns the new recipe id."""
if payload.external_source != "spoonacular":
raise HTTPException(status_code=400, detail=f"unknown external_source: {payload.external_source}")
if _points_available() < 1.05:
raise HTTPException(status_code=503, detail="spoonacular daily quota reached; try again tomorrow")
api_key = _ensure_spoonacular_configured()
# 1) Reject duplicates
existing = (
db.query(Recipe)
.filter(Recipe.external_source == "spoonacular", Recipe.external_id == payload.external_id)
.first()
)
if existing:
raise HTTPException(status_code=409, detail=f"recipe already imported: {existing.id}")
# 2) Fetch full info
try:
resp = requests.get(
_INFO_URL.format(id=payload.external_id),
params={"apiKey": api_key, "includeNutrition": "false"},
timeout=15,
)
resp.raise_for_status()
except requests.RequestException as exc:
logger.warning("Spoonacular /information failed for %s: %s", payload.external_id, exc)
raise HTTPException(status_code=502, detail=f"spoonacular info failed: {exc}") from exc
full = resp.json()
_charge_points(1.0)
logger.info("Spoonacular import %s (1 pt, total %.1f/%.0f)",
payload.external_id, _points_used, _DAILY_LIMIT)
# 3) Resolve ingredients (upsert)
ingredient_names: List[str] = []
ingredients_json: List[dict] = []
for ing in full.get("extendedIngredients", []):
name = (ing.get("name") or ing.get("originalName") or "").strip()
if not name:
continue
ingredient_names.append(name)
local = _upsert_ingredient(db, name)
qty = ing.get("amount")
ingredients_json.append({
"ingredient_id": str(local.id),
"name": local.name,
"qty": float(qty) if qty is not None else 1.0,
"unit": ing.get("unit", "") or None,
"notes": None,
})
# 4) Extract instructions
instructions: List[str] = []
analyzed = full.get("analyzedInstructions", [])
if analyzed:
for step in analyzed[0].get("steps", []):
txt = step.get("step", "")
if txt:
instructions.append(txt)
if not instructions:
raw = full.get("instructions", "")
if raw:
instructions = [raw]
# 5) Build the Recipe
title = full.get("title") or "Untitled"
cuisines = [str(c).lower() for c in (full.get("cuisines") or []) if c]
diets = [str(d).lower() for d in (full.get("diets") or []) if d]
# 6) Resolve family_profile_id (require_session auto-fills first profile)
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="family profile not found")
row = Recipe(
family_profile_id=profile.id,
name=title,
description=full.get("summary"),
image_url=full.get("image"),
image_source="spoonacular",
prep_time_minutes=int(full["preparationMinutes"]) if full.get("preparationMinutes") else None,
cook_time_minutes=int(full["cookingMinutes"]) if full.get("cookingMinutes") else None,
servings=int(full.get("servings", 4)),
cuisine_tags=cuisines,
dietary_tags=diets,
protein_type=_infer_protein_simple(title, ingredient_names),
ingredients=ingredients_json,
side_dishes=[],
instructions=instructions or ["See source for instructions."],
source_url=full.get("sourceUrl") or full.get("spoonacularSourceUrl"),
scraped_at=None,
is_manually_added=True,
external_source="spoonacular",
external_id=payload.external_id,
discovery_reason="user imported via webui",
)
db.add(row)
db.commit()
db.refresh(row)
return {
"id": str(row.id),
"name": row.name,
"external_id": row.external_id,
"ingredients_imported": len(ingredients_json),
}
):
"""Reject direct publication; reviewed staging is the sole import path."""
raise HTTPException(status_code=status.HTTP_410_GONE, detail=_RETIRED_DETAIL)
+37 -11
View File
@@ -10,7 +10,13 @@ from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import FamilyProfile, Ingredient, NeverSuggest, Recipe
from app.models import (
FamilyProfile,
Ingredient,
NeverSuggest,
Recipe,
RecipeCatalogStatus,
)
from app.schemas.recipe import (
RecipeCreate,
RecipeRead,
@@ -45,7 +51,19 @@ def _validate_ingredient_ids(db: Session, ingredient_refs) -> None:
)
def _serialize(row: Recipe) -> dict:
def _serialize(row: Recipe, db: Session) -> dict:
raw_ingredients = row.ingredients or []
# Resolve ingredient names from the canonical Ingredient table
ingredient_ids = [ref["ingredient_id"] for ref in raw_ingredients if ref.get("ingredient_id")]
name_map = {}
if ingredient_ids:
name_rows = db.query(Ingredient.id, Ingredient.name).filter(Ingredient.id.in_(ingredient_ids)).all()
name_map = {str(r[0]): r[1] for r in name_rows}
enriched_ingredients = []
for ref in raw_ingredients:
enriched = dict(ref)
enriched["name"] = name_map.get(str(ref.get("ingredient_id")), "unknown ingredient")
enriched_ingredients.append(enriched)
return {
"id": row.id,
"name": row.name,
@@ -59,7 +77,7 @@ def _serialize(row: Recipe) -> dict:
"protein_type": row.protein_type,
"spice_level": row.spice_level,
"calories_per_serving": row.calories_per_serving,
"ingredients": row.ingredients or [],
"ingredients": enriched_ingredients,
"side_dishes": row.side_dishes or [],
"instructions": list(row.instructions or []),
"source_url": row.source_url,
@@ -91,7 +109,9 @@ def list_recommended_recipes(
}
# Start from all recipes
query = db.query(Recipe).filter(~Recipe.id.in_(blocked)) if blocked else db.query(Recipe)
query = db.query(Recipe).filter(Recipe.catalog_status == RecipeCatalogStatus.ACTIVE)
if blocked:
query = query.filter(~Recipe.id.in_(blocked))
# Apply positive signal filters
pos_cuisines = {s.value for s in result.positive_signals if s.type == "prefer_cuisine"}
@@ -119,6 +139,7 @@ def list_recommended_recipes(
top_rated = result.top_rated_recipe_names or []
if top_rated:
top_rows = db.query(Recipe).filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
Recipe.name.in_(top_rated),
(~Recipe.id.in_(blocked) if blocked else True),
).limit(limit).all()
@@ -133,7 +154,7 @@ def list_recommended_recipes(
ids_seen.add(r.id)
ordered.append(r)
return [_serialize(r) for r in ordered]
return [_serialize(r, db) for r in ordered]
@public_router.get("", response_model=List[RecipeRead])
@@ -150,7 +171,9 @@ def list_recipes(
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
):
query = db.query(Recipe)
query = db.query(Recipe).filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE
)
if q:
like = f"%{q.lower()}%"
query = query.filter(Recipe.name.ilike(like))
@@ -194,15 +217,18 @@ def list_recipes(
if blocked:
query = query.filter(~Recipe.id.in_(blocked))
rows = query.order_by(Recipe.name).limit(limit).all()
return [_serialize(r) for r in rows]
return [_serialize(r, db) for r in rows]
@public_router.get("/{recipe_id}", response_model=RecipeRead)
def get_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
row = db.query(Recipe).filter(
Recipe.id == recipe_id,
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
).first()
if row is None:
raise HTTPException(status_code=404, detail="recipe not found")
return _serialize(row)
return _serialize(row, db)
@admin_router.post("", response_model=RecipeRead, status_code=status.HTTP_201_CREATED)
@@ -228,7 +254,7 @@ def create_recipe(payload: RecipeCreate, db: Session = Depends(get_db)):
db.add(row)
db.commit()
db.refresh(row)
return _serialize(row)
return _serialize(row, db)
@admin_router.patch("/{recipe_id}", response_model=RecipeRead)
@@ -245,7 +271,7 @@ def update_recipe(recipe_id: UUID, payload: RecipeUpdate, db: Session = Depends(
setattr(row, field, value)
db.commit()
db.refresh(row)
return _serialize(row)
return _serialize(row, db)
@admin_router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)