From 2e0a94e88c847a5ed8dc6ad5fa49715cd631bdfe Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Fri, 8 May 2026 01:58:48 -0700 Subject: Initial commit — Commis personal chef app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-powered local chef tool: pantry tracking, meal logging, rotating weekly menu generation, and grocery list optimization via Ollama (llama3). FastAPI backend, SQLite, vanilla JS frontend. Co-Authored-By: Claude Sonnet 4.6 --- routers/__init__.py | 0 routers/grocery.py | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++ routers/meals.py | 97 +++++++++++++++++++++++++++ routers/menus.py | 138 ++++++++++++++++++++++++++++++++++++++ routers/pantry.py | 101 ++++++++++++++++++++++++++++ routers/recipes.py | 50 ++++++++++++++ 6 files changed, 572 insertions(+) create mode 100644 routers/__init__.py create mode 100644 routers/grocery.py create mode 100644 routers/meals.py create mode 100644 routers/menus.py create mode 100644 routers/pantry.py create mode 100644 routers/recipes.py (limited to 'routers') diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/grocery.py b/routers/grocery.py new file mode 100644 index 0000000..cde2068 --- /dev/null +++ b/routers/grocery.py @@ -0,0 +1,186 @@ +from fastapi import APIRouter, Depends, HTTPException, Body +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +import json + +from database import get_db +from schemas import GroceryListRead +from models import GroceryList, MenuPlan, Recipe, Ingredient +from services import pantry_service, ai_service + +router = APIRouter(prefix="/api/grocery", tags=["grocery"]) +ai_router = APIRouter(prefix="/api/ai", tags=["ai"]) + + +@router.get("") +async def list_grocery_lists(db: Session = Depends(get_db)): + """List all grocery lists.""" + lists = db.query(GroceryList).order_by(GroceryList.generated_at.desc()).all() + return [GroceryListRead.from_orm(l) for l in lists] + + +@router.get("/current") +async def get_current_grocery_list(db: Session = Depends(get_db)): + """Get grocery list for current week.""" + today = datetime.utcnow().date() + monday = today - timedelta(days=today.weekday()) + + grocery_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first() + if not grocery_list: + raise HTTPException(status_code=404, detail="No grocery list for current week") + return GroceryListRead.from_orm(grocery_list) + + +@router.get("/{id}") +async def get_grocery_list(id: int, db: Session = Depends(get_db)): + """Get a grocery list by ID.""" + grocery_list = db.query(GroceryList).filter(GroceryList.id == id).first() + if not grocery_list: + raise HTTPException(status_code=404, detail="Grocery list not found") + return GroceryListRead.from_orm(grocery_list) + + +@router.post("/generate") +async def generate_grocery_list(db: Session = Depends(get_db)): + """Generate a grocery list for the current week.""" + # Get current week's Monday + today = datetime.utcnow().date() + monday = today - timedelta(days=today.weekday()) + + # Fetch current MenuPlan + menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if not menu_plan: + raise HTTPException(status_code=404, detail="No menu plan for current week. Generate a menu first.") + + # Parse the plan JSON + try: + plan_dict = json.loads(menu_plan.plan) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid menu plan data") + + # Collect all recipe IDs and fetch recipes + recipe_ids = set() + for day_meals in plan_dict.values(): + if isinstance(day_meals, dict): + for recipe_id in day_meals.values(): + if isinstance(recipe_id, int): + recipe_ids.add(recipe_id) + + recipes = db.query(Recipe).filter(Recipe.id.in_(recipe_ids)).all() if recipe_ids else [] + recipe_map = {r.id: r for r in recipes} + + # Build menu_plan_for_ai + menu_plan_for_ai = {} + for day, day_meals in plan_dict.items(): + menu_plan_for_ai[day] = {} + for meal_type, recipe_id in day_meals.items(): + recipe = recipe_map.get(recipe_id) + if recipe: + try: + ingredients = json.loads(recipe.ingredients) + except (json.JSONDecodeError, TypeError): + ingredients = [] + menu_plan_for_ai[day][meal_type] = { + "name": recipe.name, + "ingredients": ingredients, + } + + # Build pantry context and generate grocery list + try: + pantry_context = pantry_service.build_pantry_context(db) + ai_result = await ai_service.generate_grocery_list(menu_plan_for_ai, pantry_context) + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except (ConnectionError, Exception) as e: + raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") + + # Upsert GroceryList + existing_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first() + if existing_list: + existing_list.items = json.dumps(ai_result.get("items", [])) + existing_list.total_estimate = ai_result.get("total_estimate", 0.0) + existing_list.notes = ai_result.get("shopping_notes", "") + existing_list.generated_at = datetime.utcnow() + grocery_list = existing_list + else: + grocery_list = GroceryList( + generated_for=monday, + items=json.dumps(ai_result.get("items", [])), + total_estimate=ai_result.get("total_estimate", 0.0), + notes=ai_result.get("shopping_notes", ""), + generated_at=datetime.utcnow(), + ) + db.add(grocery_list) + + db.commit() + db.refresh(grocery_list) + + return { + "grocery_list": GroceryListRead.from_orm(grocery_list), + "items": ai_result.get("items", []), + "shopping_notes": ai_result.get("shopping_notes", ""), + } + + +@router.put("/{id}/purchased") +async def mark_purchased(id: int, db: Session = Depends(get_db)): + """Mark a grocery list as purchased and update pantry.""" + grocery_list = db.query(GroceryList).filter(GroceryList.id == id).first() + if not grocery_list: + raise HTTPException(status_code=404, detail="Grocery list not found") + + # Mark as purchased + grocery_list.is_purchased = True + + # Parse items + try: + items = json.loads(grocery_list.items) + except json.JSONDecodeError: + items = [] + + # Update pantry for each item + for item in items: + if isinstance(item, dict): + name = item.get("name") + quantity = item.get("quantity", 0) + unit = item.get("unit", "") + + if name: + # Check if ingredient exists + existing_ingredient = db.query(Ingredient).filter(Ingredient.name == name).first() + if existing_ingredient: + existing_ingredient.quantity += quantity + existing_ingredient.updated_at = datetime.utcnow() + else: + # Create new ingredient + new_ingredient = Ingredient( + name=name, + quantity=quantity, + unit=unit, + category=item.get("store_section"), + ) + db.add(new_ingredient) + + db.commit() + db.refresh(grocery_list) + return GroceryListRead.from_orm(grocery_list) + + +@ai_router.post("/suggest-recipe") +async def suggest_recipe_endpoint(db: Session = Depends(get_db)): + """Suggest a recipe based on current pantry.""" + try: + context = pantry_service.build_pantry_context(db) + result = await ai_service.suggest_recipe(context) + return result + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except (ConnectionError, Exception) as e: + raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") + + +@ai_router.get("/models") +async def list_models(): + """List available Ollama models.""" + models = await ai_service.get_available_models() + return {"models": models} diff --git a/routers/meals.py b/routers/meals.py new file mode 100644 index 0000000..99c5243 --- /dev/null +++ b/routers/meals.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from typing import List, Dict, Any +from collections import defaultdict + +from database import get_db +from models import MealLog, MealIngredient +from schemas import MealLogCreate, MealLogRead, MealIngredientCreate + +router = APIRouter(prefix="/api/meals", tags=["meals"]) + + +@router.get("", response_model=List[MealLogRead]) +def list_meals( + days: int = Query(30), + meal_type: str = Query(None), + db: Session = Depends(get_db) +): + """List meal log entries. Optional query params: ?days=30 (default 30, filters entries from last N days), ?meal_type=dinner. Returns entries ordered by eaten_at desc.""" + cutoff_date = datetime.utcnow() - timedelta(days=days) + query = db.query(MealLog).filter(MealLog.eaten_at >= cutoff_date) + + if meal_type: + query = query.filter(MealLog.meal_type == meal_type) + + return query.order_by(MealLog.eaten_at.desc()).all() + + +@router.get("/stats", response_model=Dict[str, Any]) +def meal_stats(db: Session = Depends(get_db)): + """Return meal frequency stats. Query last 30 days, count occurrences of each meal_name. Return {"meal_frequency": {"Pasta Carbonara": 3, ...}, "total_meals": 15, "by_type": {"dinner": 10, "lunch": 5}}.""" + cutoff_date = datetime.utcnow() - timedelta(days=30) + meals = db.query(MealLog).filter(MealLog.eaten_at >= cutoff_date).all() + + meal_frequency = defaultdict(int) + meal_by_type = defaultdict(int) + + for meal in meals: + meal_frequency[meal.meal_name] += 1 + meal_by_type[meal.meal_type] += 1 + + return { + "meal_frequency": dict(meal_frequency), + "total_meals": len(meals), + "by_type": dict(meal_by_type) + } + + +@router.get("/{id}", response_model=MealLogRead) +def get_meal(id: int, db: Session = Depends(get_db)): + """Get a single meal log entry.""" + meal = db.query(MealLog).filter(MealLog.id == id).first() + if not meal: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Meal not found") + return meal + + +@router.post("", response_model=MealLogRead, status_code=status.HTTP_201_CREATED) +def create_meal(meal: MealLogCreate, db: Session = Depends(get_db)): + """Log a new meal. If ingredients list provided, create MealIngredient records linked to this meal.""" + # Create the MealLog entry + meal_data = meal.model_dump(exclude={"ingredients"}) + if meal_data["eaten_at"] is None: + meal_data["eaten_at"] = datetime.utcnow() + + db_meal = MealLog(**meal_data) + db.add(db_meal) + db.commit() + db.refresh(db_meal) + + # Create MealIngredient entries if provided + if meal.ingredients: + for ingredient in meal.ingredients: + meal_ingredient = MealIngredient( + meal_log_id=db_meal.id, + ingredient_name=ingredient.ingredient_name, + quantity_used=ingredient.quantity_used, + unit=ingredient.unit + ) + db.add(meal_ingredient) + + db.commit() + db.refresh(db_meal) + + return db_meal + + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_meal(id: int, db: Session = Depends(get_db)): + """Delete a meal log entry.""" + meal = db.query(MealLog).filter(MealLog.id == id).first() + if not meal: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Meal not found") + + db.delete(meal) + db.commit() diff --git a/routers/menus.py b/routers/menus.py new file mode 100644 index 0000000..49c4654 --- /dev/null +++ b/routers/menus.py @@ -0,0 +1,138 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime, timedelta, date +import json + +from database import get_db +from schemas import MenuPlanRead +from models import MenuPlan, Recipe +from services import pantry_service, ai_service +from config import settings + +router = APIRouter(prefix="/api/menus", tags=["menus"]) + + +@router.get("/current") +async def get_current_menu(db: Session = Depends(get_db)): + """Get menu plan for current week.""" + today = datetime.utcnow().date() + monday = today - timedelta(days=today.weekday()) + + menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if not menu: + raise HTTPException(status_code=404, detail="No menu plan for current week") + return MenuPlanRead.from_orm(menu) + + +@router.post("/generate") +async def generate_menu(request: dict = None, db: Session = Depends(get_db)): + """Generate a weekly menu using AI.""" + # Parse request body + week_start = None + if request and isinstance(request, dict): + week_start = request.get("week_start") + + # Determine week_start date + if week_start: + try: + if isinstance(week_start, str): + week_start = datetime.fromisoformat(week_start).date() + else: + week_start = week_start + except Exception: + raise HTTPException(status_code=400, detail="Invalid week_start date") + else: + today = datetime.utcnow().date() + week_start = today - timedelta(days=today.weekday()) + + # Build pantry context and generate menu + try: + pantry_context = pantry_service.build_pantry_context(db) + ai_result = await ai_service.generate_weekly_menu(pantry_context) + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except (ConnectionError, Exception) as e: + raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") + + # Save recipes and build plan dict + week_plan = ai_result.get("week_plan", {}) + plan_dict = {} + + for day, meals in week_plan.items(): + plan_dict[day] = {} + for meal_type, meal_data in meals.items(): + if isinstance(meal_data, dict) and "name" in meal_data: + meal_name = meal_data["name"] + + # Check if recipe exists by name + existing_recipe = db.query(Recipe).filter(Recipe.name == meal_name).first() + if existing_recipe: + recipe_id = existing_recipe.id + else: + # Create new recipe + new_recipe = Recipe( + name=meal_name, + meal_type=meal_type, + ingredients=json.dumps(meal_data.get("ingredients", [])), + instructions=meal_data.get("instructions", ""), + estimated_time_minutes=meal_data.get("time_minutes", 30), + servings=meal_data.get("serves", 2), + source="ai", + ) + db.add(new_recipe) + db.flush() + recipe_id = new_recipe.id + + plan_dict[day][meal_type] = recipe_id + + # Upsert MenuPlan + existing_plan = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first() + if existing_plan: + existing_plan.plan = json.dumps(plan_dict) + existing_plan.generated_at = datetime.utcnow() + existing_plan.model_used = settings.model_name + menu = existing_plan + else: + menu = MenuPlan( + week_start=week_start, + plan=json.dumps(plan_dict), + generated_at=datetime.utcnow(), + model_used=settings.model_name, + ) + db.add(menu) + + db.commit() + db.refresh(menu) + + return { + "menu_plan": MenuPlanRead.from_orm(menu), + "week_plan": week_plan, + "notes": ai_result.get("notes", ""), + } + + +@router.get("") +async def list_menus(db: Session = Depends(get_db)): + """List all menu plans.""" + menus = db.query(MenuPlan).order_by(MenuPlan.week_start.desc()).all() + return [MenuPlanRead.from_orm(m) for m in menus] + + +@router.get("/{week_start}") +async def get_menu(week_start: date, db: Session = Depends(get_db)): + """Get menu plan for a specific week.""" + menu = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first() + if not menu: + raise HTTPException(status_code=404, detail="Menu plan not found") + return MenuPlanRead.from_orm(menu) + + +@router.delete("/{id}") +async def delete_menu(id: int, db: Session = Depends(get_db)): + """Delete a menu plan.""" + menu = db.query(MenuPlan).filter(MenuPlan.id == id).first() + if not menu: + raise HTTPException(status_code=404, detail="Menu plan not found") + db.delete(menu) + db.commit() + return {"status": "deleted"} diff --git a/routers/pantry.py b/routers/pantry.py new file mode 100644 index 0000000..d6638f8 --- /dev/null +++ b/routers/pantry.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from typing import List + +from database import get_db +from models import Ingredient +from schemas import IngredientCreate, IngredientRead, IngredientUpdate + +router = APIRouter(prefix="/api/pantry", tags=["pantry"]) + + +@router.get("", response_model=List[IngredientRead]) +def list_ingredients( + expiring_soon: bool = Query(False), + db: Session = Depends(get_db) +): + """List all ingredients. Optional query param ?expiring_soon=true filters to ingredients with expiry_date within 7 days from today.""" + query = db.query(Ingredient) + + if expiring_soon: + today = datetime.utcnow().date() + seven_days_later = today + timedelta(days=7) + query = query.filter( + (Ingredient.expiry_date >= today) & (Ingredient.expiry_date <= seven_days_later) + ) + + return query.all() + + +@router.get("/expiring", response_model=List[IngredientRead]) +def list_expiring_ingredients(db: Session = Depends(get_db)): + """Ingredients expiring within 7 days.""" + today = datetime.utcnow().date() + seven_days_later = today + timedelta(days=7) + + return db.query(Ingredient).filter( + (Ingredient.expiry_date >= today) & (Ingredient.expiry_date <= seven_days_later) + ).all() + + +@router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED) +def create_ingredient(ingredient: IngredientCreate, db: Session = Depends(get_db)): + """Create a new ingredient.""" + db_ingredient = Ingredient(**ingredient.model_dump()) + db.add(db_ingredient) + db.commit() + db.refresh(db_ingredient) + return db_ingredient + + +@router.put("/{id}", response_model=IngredientRead) +def update_ingredient(id: int, ingredient: IngredientUpdate, db: Session = Depends(get_db)): + """Update ingredient fields (partial update).""" + db_ingredient = db.query(Ingredient).filter(Ingredient.id == id).first() + if not db_ingredient: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ingredient not found") + + update_data = ingredient.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_ingredient, field, value) + + db_ingredient.updated_at = datetime.utcnow() + db.commit() + db.refresh(db_ingredient) + return db_ingredient + + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_ingredient(id: int, db: Session = Depends(get_db)): + """Delete an ingredient.""" + db_ingredient = db.query(Ingredient).filter(Ingredient.id == id).first() + if not db_ingredient: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ingredient not found") + + db.delete(db_ingredient) + db.commit() + + +@router.post("/bulk", response_model=List[IngredientRead]) +def bulk_upsert_ingredients(ingredients: List[IngredientCreate], db: Session = Depends(get_db)): + """Upsert list of ingredients. If an ingredient with same name exists, add the quantity (accumulate). If not, create new.""" + result = [] + + for ingredient_data in ingredients: + existing = db.query(Ingredient).filter(Ingredient.name == ingredient_data.name).first() + + if existing: + existing.quantity += ingredient_data.quantity + existing.updated_at = datetime.utcnow() + db.commit() + db.refresh(existing) + result.append(existing) + else: + new_ingredient = Ingredient(**ingredient_data.model_dump()) + db.add(new_ingredient) + db.commit() + db.refresh(new_ingredient) + result.append(new_ingredient) + + return result diff --git a/routers/recipes.py b/routers/recipes.py new file mode 100644 index 0000000..f215062 --- /dev/null +++ b/routers/recipes.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models import Recipe +from schemas import RecipeCreate, RecipeRead, RecipeUpdate + +router = APIRouter(prefix="/api/recipes", tags=["recipes"]) + + +@router.get("", response_model=List[RecipeRead]) +def list_recipes(meal_type: str = Query(None), db: Session = Depends(get_db)): + """List all recipes. Optional ?meal_type=dinner.""" + query = db.query(Recipe) + + if meal_type: + query = query.filter(Recipe.meal_type == meal_type) + + return query.all() + + +@router.post("", response_model=RecipeRead, status_code=status.HTTP_201_CREATED) +def create_recipe(recipe: RecipeCreate, db: Session = Depends(get_db)): + """Create a new recipe.""" + db_recipe = Recipe(**recipe.model_dump()) + db.add(db_recipe) + db.commit() + db.refresh(db_recipe) + return db_recipe + + +@router.get("/{id}", response_model=RecipeRead) +def get_recipe(id: int, db: Session = Depends(get_db)): + """Get a single recipe.""" + recipe = db.query(Recipe).filter(Recipe.id == id).first() + if not recipe: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") + return recipe + + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_recipe(id: int, db: Session = Depends(get_db)): + """Delete a recipe.""" + recipe = db.query(Recipe).filter(Recipe.id == id).first() + if not recipe: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") + + db.delete(recipe) + db.commit() -- cgit v1.3-2-g0d8e