From 3de7c5eed5ba262abf0d746211e33800db6d66df Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Fri, 8 May 2026 03:24:36 -0700 Subject: 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 --- routers/grocery.py | 141 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 96 insertions(+), 45 deletions(-) (limited to 'routers/grocery.py') 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") - - # 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 + pantry_context = pantry_service.build_pantry_context(db) + try: - pantry_context = pantry_service.build_pantry_context(db) - ai_result = await ai_service.generate_grocery_list(menu_plan_for_ai, pantry_context) + 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_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, + }) + + 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)}") -- cgit v1.3-2-g0d8e