Public Access
feat(meals): enforce three lower-calorie dinners with media
This commit is contained in:
+140
-23
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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", []),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user