diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-08 03:24:36 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-08 03:24:36 -0700 |
| commit | 3de7c5eed5ba262abf0d746211e33800db6d66df (patch) | |
| tree | 6fddb5381fb178423eac34894add5b611babe300 /routers | |
| parent | f361e7599d9a11ad3397b7b6bffee151ab9bdde9 (diff) | |
Add recipe suggestions, chat tab, and major workflow improvements
- Replace 7-day grid menu with browsable recipe suggestion cards (swap, remove, make this)
- Add Chat tab: conversational AI with full pantry/menu/grocery context
- Grocery list works without a menu (pantry-only mode)
- Individual grocery checkboxes auto-add items to pantry
- Swap modal with optional preference input
- User notes textarea on menu and grocery generation
- Clear button for menu and grocery list
- LLM notes/summary displayed after generation, persisted to DB
- Favicon linked in HTML
- Category dropdown styled for dark theme
- System prompt configurable via SYSTEM_PROMPT in .env
- Fix startup error (ollama_timeout default), DB migration for menu_plans.notes
- Simplify: batch N+1 queries, extract _current_monday(), merge chat sync fns,
asyncio.get_running_loop(), fix currentGroceryId bug, cap chat history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'routers')
| -rw-r--r-- | routers/grocery.py | 135 | ||||
| -rw-r--r-- | routers/menus.py | 191 |
2 files changed, 242 insertions, 84 deletions
diff --git a/routers/grocery.py b/routers/grocery.py index cde2068..17215d2 100644 --- a/routers/grocery.py +++ b/routers/grocery.py @@ -12,6 +12,11 @@ router = APIRouter(prefix="/api/grocery", tags=["grocery"]) ai_router = APIRouter(prefix="/api/ai", tags=["ai"]) +def _current_monday(): + today = datetime.utcnow().date() + return today - timedelta(days=today.weekday()) + + @router.get("") async def list_grocery_lists(db: Session = Depends(get_db)): """List all grocery lists.""" @@ -22,8 +27,7 @@ async def list_grocery_lists(db: Session = Depends(get_db)): @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()) + monday = _current_monday() grocery_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first() if not grocery_list: @@ -41,57 +45,49 @@ async def get_grocery_list(id: int, db: Session = Depends(get_db)): @router.post("/generate") -async def generate_grocery_list(db: Session = Depends(get_db)): +async def generate_grocery_list(request: dict = Body(default={}), db: Session = Depends(get_db)): """Generate a grocery list for the current week.""" + user_notes = request.get("user_notes") if request else None + # Get current week's Monday - today = datetime.utcnow().date() - monday = today - timedelta(days=today.weekday()) + monday = _current_monday() # 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") + pantry_context = pantry_service.build_pantry_context(db) - # 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) + try: + if menu_plan: + # Parse the plan JSON (now a flat array of recipe IDs) + try: + recipe_ids = json.loads(menu_plan.plan) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid menu plan data") - recipes = db.query(Recipe).filter(Recipe.id.in_(recipe_ids)).all() if recipe_ids else [] - recipe_map = {r.id: r for r in recipes} + 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, - } + recipes_for_ai = [] + for recipe_id in recipe_ids: + recipe = recipe_map.get(recipe_id) + if recipe: + try: + ingredients = json.loads(recipe.ingredients) + except (json.JSONDecodeError, TypeError): + ingredients = [] + recipes_for_ai.append({ + "name": recipe.name, + "meal_type": recipe.meal_type, + "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) + ai_result = await ai_service.generate_grocery_list(recipes_for_ai, pantry_context, user_notes) + else: + ai_result = await ai_service.generate_grocery_list_from_pantry(pantry_context, user_notes) except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) - except (ConnectionError, Exception) as e: + except Exception as e: raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") # Upsert GroceryList @@ -122,6 +118,18 @@ async def generate_grocery_list(db: Session = Depends(get_db)): } +@router.delete("/current") +async def delete_current_grocery_list(db: Session = Depends(get_db)): + """Delete the grocery list for the current week.""" + monday = _current_monday() + 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") + db.delete(grocery_list) + db.commit() + return {"status": "deleted"} + + @router.put("/{id}/purchased") async def mark_purchased(id: int, db: Session = Depends(get_db)): """Mark a grocery list as purchased and update pantry.""" @@ -175,7 +183,7 @@ async def suggest_recipe_endpoint(db: Session = Depends(get_db)): return result except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) - except (ConnectionError, Exception) as e: + except Exception as e: raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") @@ -184,3 +192,46 @@ async def list_models(): """List available Ollama models.""" models = await ai_service.get_available_models() return {"models": models} + + +@ai_router.post("/chat") +async def chat_endpoint(request: dict = Body(default={}), db: Session = Depends(get_db)): + """Chat with Commis using current kitchen context.""" + message = request.get("message", "").strip() + if not message: + raise HTTPException(status_code=400, detail="Message is required") + + history = request.get("history", []) + + # Build pantry context + pantry_context = pantry_service.build_pantry_context(db) + + # Get current week's menu recipes + monday = _current_monday() + + menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + menu_context = [] + if menu_plan: + try: + plan_ids = json.loads(menu_plan.plan) + recipes = db.query(Recipe).filter(Recipe.id.in_(plan_ids)).all() if plan_ids else [] + menu_context = [{"name": r.name, "meal_type": r.meal_type} for r in recipes] + except Exception: + menu_context = [] + + # Get current grocery list items + grocery_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first() + grocery_context = [] + if grocery_list: + try: + grocery_context = json.loads(grocery_list.items) + except Exception: + grocery_context = [] + + try: + response = await ai_service.chat_with_commis(message, history, pantry_context, menu_context, grocery_context) + return {"response": response} + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") diff --git a/routers/menus.py b/routers/menus.py index 49c4654..9ec1d1d 100644 --- a/routers/menus.py +++ b/routers/menus.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Body from sqlalchemy.orm import Session from datetime import datetime, timedelta, date import json @@ -12,11 +12,15 @@ from config import settings router = APIRouter(prefix="/api/menus", tags=["menus"]) +def _current_monday(): + today = datetime.utcnow().date() + return today - timedelta(days=today.weekday()) + + @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()) + monday = _current_monday() menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() if not menu: @@ -37,67 +41,64 @@ async def generate_menu(request: dict = None, db: Session = Depends(get_db)): 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()) + week_start = _current_monday() # 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) + user_notes = request.get("user_notes") if request and isinstance(request, dict) else None + ai_result = await ai_service.generate_weekly_menu(pantry_context, user_notes) except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) - except (ConnectionError, Exception) as e: + except 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"] + # Save recipes and build plan list (flat array of IDs) + recipes_ai = ai_result.get("recipes", []) + plan_ids = [] - # 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 + valid_recipes = [r for r in recipes_ai if isinstance(r, dict) and "name" in r] + all_names = [r["name"] for r in valid_recipes] + existing = db.query(Recipe).filter(Recipe.name.in_(all_names)).all() + existing_by_name = {r.name: r for r in existing} - plan_dict[day][meal_type] = recipe_id + for recipe_data in valid_recipes: + meal_name = recipe_data["name"] + if meal_name in existing_by_name: + recipe_id = existing_by_name[meal_name].id + else: + new_recipe = Recipe( + name=meal_name, + meal_type=recipe_data.get("meal_type", "dinner"), + ingredients=json.dumps(recipe_data.get("ingredients", [])), + instructions=recipe_data.get("instructions", ""), + estimated_time_minutes=recipe_data.get("time_minutes", 30), + servings=recipe_data.get("serves", 2), + source="ai", + ) + db.add(new_recipe) + db.flush() + recipe_id = new_recipe.id + plan_ids.append(recipe_id) - # Upsert MenuPlan + ai_notes = ai_result.get("notes", "") existing_plan = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first() if existing_plan: - existing_plan.plan = json.dumps(plan_dict) + existing_plan.plan = json.dumps(plan_ids) existing_plan.generated_at = datetime.utcnow() existing_plan.model_used = settings.model_name + existing_plan.notes = ai_notes menu = existing_plan else: menu = MenuPlan( week_start=week_start, - plan=json.dumps(plan_dict), + plan=json.dumps(plan_ids), generated_at=datetime.utcnow(), model_used=settings.model_name, + notes=ai_notes, ) db.add(menu) @@ -106,8 +107,8 @@ async def generate_menu(request: dict = None, db: Session = Depends(get_db)): return { "menu_plan": MenuPlanRead.from_orm(menu), - "week_plan": week_plan, - "notes": ai_result.get("notes", ""), + "recipes": recipes_ai, + "notes": menu.notes or "", } @@ -127,6 +128,112 @@ async def get_menu(week_start: date, db: Session = Depends(get_db)): return MenuPlanRead.from_orm(menu) +@router.delete("/current") +async def delete_current_menu(db: Session = Depends(get_db)): + """Delete the menu plan for the current week.""" + monday = _current_monday() + 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") + db.delete(menu) + db.commit() + return {"status": "deleted"} + + +@router.delete("/current/recipes/{recipe_id}") +async def remove_recipe_from_menu(recipe_id: int, db: Session = Depends(get_db)): + """Remove a single recipe from the current week's plan.""" + monday = _current_monday() + 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") + + try: + plan_ids = json.loads(menu.plan) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid menu plan data") + + if recipe_id not in plan_ids: + raise HTTPException(status_code=404, detail="Recipe not in current menu plan") + + plan_ids.remove(recipe_id) + menu.plan = json.dumps(plan_ids) + db.commit() + return {"status": "removed", "plan": plan_ids} + + +@router.post("/current/recipes/{recipe_id}/swap") +async def swap_recipe_in_menu(recipe_id: int, request: dict = Body(default={}), db: Session = Depends(get_db)): + """Swap a single recipe in the current week's plan with a new AI suggestion.""" + monday = _current_monday() + 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") + + try: + plan_ids = json.loads(menu.plan) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid menu plan data") + + # Find the recipe being swapped + old_recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first() + if not old_recipe: + raise HTTPException(status_code=404, detail="Recipe not found") + + meal_type = old_recipe.meal_type + + # Get names of all other recipes in the plan to avoid repeats + other_ids = [rid for rid in plan_ids if rid != recipe_id] + other_recipes = db.query(Recipe).filter(Recipe.id.in_(other_ids)).all() if other_ids else [] + existing_names = [r.name for r in other_recipes] + + # Generate replacement + user_notes = request.get("user_notes") if request else None + try: + pantry_context = pantry_service.build_pantry_context(db) + ai_result = await ai_service.generate_replacement_recipe(meal_type, existing_names, pantry_context, user_notes) + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") + + recipe_data = ai_result.get("recipe", {}) + if not recipe_data or "name" not in recipe_data: + raise HTTPException(status_code=503, detail="AI returned invalid recipe data") + + # Save new recipe + new_recipe = Recipe( + name=recipe_data["name"], + meal_type=recipe_data.get("meal_type", meal_type), + ingredients=json.dumps(recipe_data.get("ingredients", [])), + instructions=recipe_data.get("instructions", ""), + estimated_time_minutes=recipe_data.get("time_minutes", 30), + servings=recipe_data.get("serves", 2), + source="ai", + ) + db.add(new_recipe) + db.flush() + + # Swap the ID in the plan + idx = plan_ids.index(recipe_id) + plan_ids[idx] = new_recipe.id + menu.plan = json.dumps(plan_ids) + db.commit() + db.refresh(new_recipe) + + return { + "recipe": { + "id": new_recipe.id, + "name": new_recipe.name, + "meal_type": new_recipe.meal_type, + "ingredients": recipe_data.get("ingredients", []), + "instructions": new_recipe.instructions, + "estimated_time_minutes": new_recipe.estimated_time_minutes, + "servings": new_recipe.servings, + } + } + + @router.delete("/{id}") async def delete_menu(id: int, db: Session = Depends(get_db)): """Delete a menu plan.""" |
