Merge pull request #610 from saritaban/fitness-nutrition-agent
Added fitness-nutrition-agent (by Sarita_B) to community-contributions
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
|
||||||
|
# Fitness & Nutrition Planner Agent (Community Contribution)
|
||||||
|
|
||||||
|
A tool-using agent that generates a **7‑day vegetarian-friendly meal plan** with **calorie/macro targets** and a **consolidated grocery list**. It supports **targeted swaps** (e.g., "swap Tuesday lunch") while honoring dietary patterns, allergies, and dislikes.
|
||||||
|
|
||||||
|
> **Disclaimer**: This project is for educational purposes and is **not** medical advice. Consult a licensed professional for medical or specialized dietary needs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
- Calculates **TDEE** and **macro targets** via Mifflin–St Jeor + activity factors.
|
||||||
|
- Builds a **7‑day plan** (breakfast/lunch/dinner) respecting dietary constraints.
|
||||||
|
- Produces an aggregated **grocery list** for the week.
|
||||||
|
- Supports **swap** of any single meal while keeping macros reasonable.
|
||||||
|
- Minimal **Streamlit UI** for demos.
|
||||||
|
- Extensible **tool-based architecture** to plug real recipe APIs/DBs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Architecture
|
||||||
|
- **Agent core**: OpenAI function-calling (tools) with a simple orchestration loop.
|
||||||
|
- **Tools**:
|
||||||
|
1. `calc_calories_and_macros` – computes targets.
|
||||||
|
2. `compose_meal_plan` – creates the 7‑day plan.
|
||||||
|
3. `grocery_list_from_plan` – consolidates ingredients/quantities.
|
||||||
|
4. `swap_meal` – replaces one meal (by kcal proximity and constraints).
|
||||||
|
- **Recipe source**: a tiny in-memory recipe DB for demo; replace with a real API or your own dataset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quickstart
|
||||||
|
|
||||||
|
### 1) Install
|
||||||
|
```bash
|
||||||
|
pip install openai streamlit pydantic python-dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) Configure
|
||||||
|
Create a `.env` file in this folder:
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=your_key_here
|
||||||
|
OPENAI_MODEL=gpt-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) Run CLI (example)
|
||||||
|
```bash
|
||||||
|
python agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4) Run UI
|
||||||
|
```bash
|
||||||
|
streamlit run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Sample Profile (from issue author)
|
||||||
|
See `sample_profile.json` for the exact values used to produce `demo_output.md`.
|
||||||
|
- **Sex**: female
|
||||||
|
- **Age**: 45
|
||||||
|
- **Height**: 152 cm (~5 ft)
|
||||||
|
- **Weight**: 62 kg
|
||||||
|
- **Activity**: light
|
||||||
|
- **Goal**: maintain
|
||||||
|
- **Diet**: vegetarian
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Extend
|
||||||
|
- Replace the in-memory recipes with:
|
||||||
|
- A real **recipe API** (e.g., Spoonacular) or
|
||||||
|
- Your **own dataset** (CSV/DB) + filters/tags
|
||||||
|
- Add price lookups to produce a **budget-aware** grocery list.
|
||||||
|
- Add **adherence tracking** and charts.
|
||||||
|
- Integrate **wearables** or daily steps to refine TDEE dynamically.
|
||||||
|
- Add **snacks** for days slightly under target kcals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Safety Notes
|
||||||
|
- The agent warns for extreme deficits but does **not** diagnose conditions.
|
||||||
|
- For calorie targets below commonly recommended minimums (e.g., ~1200 kcal/day for many adults), advise consulting a professional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Project Layout
|
||||||
|
```
|
||||||
|
fitness-nutrition-planner-agent/
|
||||||
|
├─ README.md
|
||||||
|
├─ agent.py
|
||||||
|
├─ app.py
|
||||||
|
├─ sample_profile.json
|
||||||
|
└─ demo_output.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 How to contribute
|
||||||
|
- Keep notebooks (if any) with **cleared outputs**.
|
||||||
|
- Follow the course repo’s contribution guidelines.
|
||||||
|
- Include screenshots or a short Loom/YT demo link in your PR description.
|
||||||
411
community-contributions/fitness-nutrition-planner-agent/agent.py
Normal file
411
community-contributions/fitness-nutrition-planner-agent/agent.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
|
||||||
|
# agent.py
|
||||||
|
import os, math, json, copy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Data models
|
||||||
|
# ------------------------------
|
||||||
|
class UserProfile(BaseModel):
|
||||||
|
sex: str = Field(..., description="male or female")
|
||||||
|
age: int
|
||||||
|
height_cm: float
|
||||||
|
weight_kg: float
|
||||||
|
activity_level: str = Field(..., description="sedentary, light, moderate, active, very_active")
|
||||||
|
goal: str = Field(..., description="lose, maintain, gain")
|
||||||
|
dietary_pattern: Optional[str] = Field(None, description="e.g., vegetarian, vegan, halal, kosher")
|
||||||
|
allergies: List[str] = Field(default_factory=list)
|
||||||
|
dislikes: List[str] = Field(default_factory=list)
|
||||||
|
daily_meals: int = 3
|
||||||
|
cuisine_prefs: List[str] = Field(default_factory=list)
|
||||||
|
time_per_meal_minutes: int = 30
|
||||||
|
budget_level: Optional[str] = Field(None, description="low, medium, high")
|
||||||
|
|
||||||
|
class MacroTargets(BaseModel):
|
||||||
|
tdee: int
|
||||||
|
target_kcal: int
|
||||||
|
protein_g: int
|
||||||
|
carbs_g: int
|
||||||
|
fat_g: int
|
||||||
|
|
||||||
|
class Meal(BaseModel):
|
||||||
|
name: str
|
||||||
|
ingredients: List[Dict[str, Any]] # {item, qty, unit}
|
||||||
|
kcal: int
|
||||||
|
protein_g: int
|
||||||
|
carbs_g: int
|
||||||
|
fat_g: int
|
||||||
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
instructions: Optional[str] = None
|
||||||
|
|
||||||
|
class DayPlan(BaseModel):
|
||||||
|
day: str
|
||||||
|
meals: List[Meal]
|
||||||
|
totals: MacroTargets
|
||||||
|
|
||||||
|
class WeekPlan(BaseModel):
|
||||||
|
days: List[DayPlan]
|
||||||
|
meta: Dict[str, Any]
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Tiny in-memory recipe “DB”
|
||||||
|
# (extend/replace with a real source)
|
||||||
|
# ------------------------------
|
||||||
|
RECIPE_DB: List[Meal] = [
|
||||||
|
Meal(
|
||||||
|
name="Greek Yogurt Parfait",
|
||||||
|
ingredients=[{"item":"nonfat greek yogurt","qty":200,"unit":"g"},
|
||||||
|
{"item":"berries","qty":150,"unit":"g"},
|
||||||
|
{"item":"granola","qty":30,"unit":"g"},
|
||||||
|
{"item":"honey","qty":10,"unit":"g"}],
|
||||||
|
kcal=380, protein_g=30, carbs_g=52, fat_g=8,
|
||||||
|
tags=["vegetarian","breakfast","5-min","no-cook"]
|
||||||
|
),
|
||||||
|
Meal(
|
||||||
|
name="Tofu Veggie Stir-Fry with Rice",
|
||||||
|
ingredients=[{"item":"firm tofu","qty":150,"unit":"g"},
|
||||||
|
{"item":"mixed vegetables","qty":200,"unit":"g"},
|
||||||
|
{"item":"soy sauce (low sodium)","qty":15,"unit":"ml"},
|
||||||
|
{"item":"olive oil","qty":10,"unit":"ml"},
|
||||||
|
{"item":"brown rice (cooked)","qty":200,"unit":"g"}],
|
||||||
|
kcal=650, protein_g=28, carbs_g=85, fat_g=20,
|
||||||
|
tags=["vegan","gluten-free","dinner","20-min","stovetop","soy"]
|
||||||
|
),
|
||||||
|
Meal(
|
||||||
|
name="Chicken Quinoa Bowl",
|
||||||
|
ingredients=[{"item":"chicken breast","qty":140,"unit":"g"},
|
||||||
|
{"item":"quinoa (cooked)","qty":185,"unit":"g"},
|
||||||
|
{"item":"spinach","qty":60,"unit":"g"},
|
||||||
|
{"item":"olive oil","qty":10,"unit":"ml"},
|
||||||
|
{"item":"lemon","qty":0.5,"unit":"unit"}],
|
||||||
|
kcal=620, protein_g=45, carbs_g=55, fat_g=20,
|
||||||
|
tags=["gluten-free","dinner","25-min","high-protein","poultry"]
|
||||||
|
),
|
||||||
|
Meal(
|
||||||
|
name="Lentil Soup + Wholegrain Bread",
|
||||||
|
ingredients=[{"item":"lentils (cooked)","qty":200,"unit":"g"},
|
||||||
|
{"item":"vegetable broth","qty":400,"unit":"ml"},
|
||||||
|
{"item":"carrot","qty":80,"unit":"g"},
|
||||||
|
{"item":"celery","qty":60,"unit":"g"},
|
||||||
|
{"item":"onion","qty":60,"unit":"g"},
|
||||||
|
{"item":"wholegrain bread","qty":60,"unit":"g"}],
|
||||||
|
kcal=520, protein_g=25, carbs_g=78, fat_g=8,
|
||||||
|
tags=["vegan","lunch","30-min","budget"]
|
||||||
|
),
|
||||||
|
Meal(
|
||||||
|
name="Salmon, Potatoes & Greens",
|
||||||
|
ingredients=[{"item":"salmon fillet","qty":150,"unit":"g"},
|
||||||
|
{"item":"potatoes","qty":200,"unit":"g"},
|
||||||
|
{"item":"broccoli","qty":150,"unit":"g"},
|
||||||
|
{"item":"olive oil","qty":10,"unit":"ml"}],
|
||||||
|
kcal=680, protein_g=42, carbs_g=52, fat_g=30,
|
||||||
|
tags=["gluten-free","dinner","omega-3","fish"]
|
||||||
|
),
|
||||||
|
Meal(
|
||||||
|
name="Cottage Cheese Bowl",
|
||||||
|
ingredients=[{"item":"low-fat cottage cheese","qty":200,"unit":"g"},
|
||||||
|
{"item":"pineapple","qty":150,"unit":"g"},
|
||||||
|
{"item":"chia seeds","qty":15,"unit":"g"}],
|
||||||
|
kcal=380, protein_g=32, carbs_g=35, fat_g=10,
|
||||||
|
tags=["vegetarian","snack","5-min","high-protein","dairy"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Tool implementations
|
||||||
|
# ------------------------------
|
||||||
|
ACTIVITY_FACTORS = {
|
||||||
|
"sedentary": 1.2,
|
||||||
|
"light": 1.375,
|
||||||
|
"moderate": 1.55,
|
||||||
|
"active": 1.725,
|
||||||
|
"very_active": 1.9
|
||||||
|
}
|
||||||
|
|
||||||
|
def mifflin_st_jeor(weight_kg: float, height_cm: float, age: int, sex: str) -> float:
|
||||||
|
# BMR (kcal/day)
|
||||||
|
if sex.lower().startswith("m"):
|
||||||
|
return 10*weight_kg + 6.25*height_cm - 5*age + 5
|
||||||
|
else:
|
||||||
|
return 10*weight_kg + 6.25*height_cm - 5*age - 161
|
||||||
|
|
||||||
|
def compute_targets(profile: UserProfile) -> MacroTargets:
|
||||||
|
bmr = mifflin_st_jeor(profile.weight_kg, profile.height_cm, profile.age, profile.sex)
|
||||||
|
tdee = int(round(bmr * ACTIVITY_FACTORS.get(profile.activity_level, 1.2)))
|
||||||
|
# goal adjustment
|
||||||
|
if profile.goal == "lose":
|
||||||
|
target_kcal = max(1200, int(tdee - 400)) # conservative deficit
|
||||||
|
elif profile.goal == "gain":
|
||||||
|
target_kcal = int(tdee + 300)
|
||||||
|
else:
|
||||||
|
target_kcal = tdee
|
||||||
|
|
||||||
|
# Macro split (modifiable): P 30%, C 40%, F 30%
|
||||||
|
protein_kcal = target_kcal * 0.30
|
||||||
|
carbs_kcal = target_kcal * 0.40
|
||||||
|
fat_kcal = target_kcal * 0.30
|
||||||
|
protein_g = int(round(protein_kcal / 4))
|
||||||
|
carbs_g = int(round(carbs_kcal / 4))
|
||||||
|
fat_g = int(round(fat_kcal / 9))
|
||||||
|
|
||||||
|
return MacroTargets(tdee=tdee, target_kcal=target_kcal,
|
||||||
|
protein_g=protein_g, carbs_g=carbs_g, fat_g=fat_g)
|
||||||
|
|
||||||
|
def _allowed(meal: Meal, profile: UserProfile) -> bool:
|
||||||
|
# dietary patterns/allergies/dislikes filters (simple; extend as needed)
|
||||||
|
diet = (profile.dietary_pattern or "").lower()
|
||||||
|
if diet == "vegetarian" and ("fish" in meal.tags or "poultry" in meal.tags):
|
||||||
|
return False
|
||||||
|
if diet == "vegan" and ("dairy" in meal.tags or "fish" in meal.tags or "poultry" in meal.tags):
|
||||||
|
return False
|
||||||
|
# allergies & dislikes
|
||||||
|
for a in profile.allergies:
|
||||||
|
if a and a.lower() in meal.name.lower(): return False
|
||||||
|
if any(a.lower() in (ing["item"]).lower() for ing in meal.ingredients): return False
|
||||||
|
if a.lower() in " ".join(meal.tags).lower(): return False
|
||||||
|
for d in profile.dislikes:
|
||||||
|
if d and d.lower() in meal.name.lower(): return False
|
||||||
|
if any(d.lower() in (ing["item"]).lower() for ing in meal.ingredients): return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def meal_db_search(profile: UserProfile, tags: Optional[List[str]] = None) -> List[Meal]:
|
||||||
|
tags = tags or []
|
||||||
|
out = []
|
||||||
|
for m in RECIPE_DB:
|
||||||
|
if not _allowed(m, profile):
|
||||||
|
continue
|
||||||
|
if tags and not any(t in m.tags for t in tags):
|
||||||
|
continue
|
||||||
|
out.append(m)
|
||||||
|
return out or [] # may be empty; agent should handle
|
||||||
|
|
||||||
|
def compose_meal_plan(profile: UserProfile, targets: MacroTargets) -> WeekPlan:
|
||||||
|
# naive heuristic: pick meals that roughly match per-meal macro budget
|
||||||
|
per_meal_kcal = targets.target_kcal / profile.daily_meals
|
||||||
|
days = []
|
||||||
|
weekdays = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]
|
||||||
|
|
||||||
|
# simple pools
|
||||||
|
breakfasts = meal_db_search(profile, tags=["breakfast","no-cook","5-min"])
|
||||||
|
lunches = meal_db_search(profile, tags=["lunch","budget"])
|
||||||
|
dinners = meal_db_search(profile, tags=["dinner","high-protein"])
|
||||||
|
|
||||||
|
# fallback to any allowed meals if pools too small
|
||||||
|
allowed_all = meal_db_search(profile)
|
||||||
|
if len(breakfasts) < 2: breakfasts = allowed_all
|
||||||
|
if len(lunches) < 2: lunches = allowed_all
|
||||||
|
if len(dinners) < 2: dinners = allowed_all
|
||||||
|
|
||||||
|
for i, day in enumerate(weekdays):
|
||||||
|
day_meals = []
|
||||||
|
for slot in range(profile.daily_meals):
|
||||||
|
pool = breakfasts if slot == 0 else (lunches if slot == 1 else dinners)
|
||||||
|
# pick the meal closest in kcal to per_meal_kcal
|
||||||
|
pick = min(pool, key=lambda m: abs(m.kcal - per_meal_kcal))
|
||||||
|
day_meals.append(copy.deepcopy(pick))
|
||||||
|
# compute totals
|
||||||
|
kcal = sum(m.kcal for m in day_meals)
|
||||||
|
p = sum(m.protein_g for m in day_meals)
|
||||||
|
c = sum(m.carbs_g for m in day_meals)
|
||||||
|
f = sum(m.fat_g for m in day_meals)
|
||||||
|
day_targets = MacroTargets(tdee=targets.tdee, target_kcal=int(round(kcal)),
|
||||||
|
protein_g=p, carbs_g=c, fat_g=f)
|
||||||
|
days.append(DayPlan(day=day, meals=day_meals, totals=day_targets))
|
||||||
|
return WeekPlan(days=days, meta={"per_meal_target_kcal": int(round(per_meal_kcal))})
|
||||||
|
|
||||||
|
def grocery_list_from_plan(plan: WeekPlan) -> List[Dict[str, Any]]:
|
||||||
|
# aggregate identical ingredients
|
||||||
|
agg: Dict[Tuple[str,str], float] = {}
|
||||||
|
units: Dict[Tuple[str,str], str] = {}
|
||||||
|
for d in plan.days:
|
||||||
|
for m in d.meals:
|
||||||
|
for ing in m.ingredients:
|
||||||
|
key = (ing["item"].lower(), ing.get("unit",""))
|
||||||
|
agg[key] = agg.get(key, 0) + float(ing.get("qty", 0))
|
||||||
|
units[key] = ing.get("unit","")
|
||||||
|
items = []
|
||||||
|
for (item, unit), qty in sorted(agg.items()):
|
||||||
|
items.append({"item": item, "qty": round(qty, 2), "unit": unit})
|
||||||
|
return items
|
||||||
|
|
||||||
|
def swap_meal(plan: WeekPlan, day: str, meal_index: int, profile: UserProfile) -> WeekPlan:
|
||||||
|
# replace one meal by closest-kcal allowed alternative that isn't the same
|
||||||
|
day_idx = next((i for i,d in enumerate(plan.days) if d.day.lower().startswith(day[:3].lower())), None)
|
||||||
|
if day_idx is None: return plan
|
||||||
|
current_meal = plan.days[day_idx].meals[meal_index]
|
||||||
|
candidates = [m for m in meal_db_search(profile) if m.name != current_meal.name]
|
||||||
|
if not candidates: return plan
|
||||||
|
pick = min(candidates, key=lambda m: abs(m.kcal - current_meal.kcal))
|
||||||
|
plan.days[day_idx].meals[meal_index] = copy.deepcopy(pick)
|
||||||
|
# recalc day totals
|
||||||
|
d = plan.days[day_idx]
|
||||||
|
kcal = sum(m.kcal for m in d.meals)
|
||||||
|
p = sum(m.protein_g for m in d.meals)
|
||||||
|
c = sum(m.carbs_g for m in d.meals)
|
||||||
|
f = sum(m.fat_g for m in d.meals)
|
||||||
|
d.totals = MacroTargets(tdee=d.totals.tdee, target_kcal=kcal, protein_g=p, carbs_g=c, fat_g=f)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Agent (LLM + tools)
|
||||||
|
# ------------------------------
|
||||||
|
SYS_PROMPT = """You are FitnessPlanner, an agentic planner that:
|
||||||
|
- Respects dietary patterns, allergies, dislikes, budget, time limits.
|
||||||
|
- Uses tools to compute targets, assemble a 7-day plan, produce a grocery list, and swap meals on request.
|
||||||
|
- If a request is unsafe (extreme deficits, medical conditions), warn and suggest professional guidance.
|
||||||
|
- Keep responses concise and structured (headings + bullet lists)."""
|
||||||
|
|
||||||
|
# Tool registry for function-calling
|
||||||
|
def get_tools_schema():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "calc_calories_and_macros",
|
||||||
|
"description": "Compute TDEE and macro targets from the user's profile.",
|
||||||
|
"parameters": {
|
||||||
|
"type":"object",
|
||||||
|
"properties": {"profile":{"type":"object"}},
|
||||||
|
"required":["profile"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "compose_meal_plan",
|
||||||
|
"description": "Create a 7-day meal plan matching targets and constraints.",
|
||||||
|
"parameters": {
|
||||||
|
"type":"object",
|
||||||
|
"properties": {
|
||||||
|
"profile":{"type":"object"},
|
||||||
|
"targets":{"type":"object"}
|
||||||
|
},
|
||||||
|
"required":["profile","targets"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "grocery_list_from_plan",
|
||||||
|
"description": "Make a consolidated grocery list from a week plan.",
|
||||||
|
"parameters": {
|
||||||
|
"type":"object",
|
||||||
|
"properties": {"plan":{"type":"object"}},
|
||||||
|
"required":["plan"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "swap_meal",
|
||||||
|
"description": "Swap a single meal in the plan while keeping macros reasonable.",
|
||||||
|
"parameters": {
|
||||||
|
"type":"object",
|
||||||
|
"properties": {
|
||||||
|
"plan":{"type":"object"},
|
||||||
|
"day":{"type":"string"},
|
||||||
|
"meal_index":{"type":"integer","description":"0=breakfast,1=lunch,2=dinner"},
|
||||||
|
"profile":{"type":"object"}
|
||||||
|
},
|
||||||
|
"required":["plan","day","meal_index","profile"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
class FitnessPlannerAgent:
|
||||||
|
def __init__(self, model: Optional[str] = None):
|
||||||
|
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
|
self.model = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||||
|
self.plan_cache: Optional[WeekPlan] = None
|
||||||
|
self.targets_cache: Optional[MacroTargets] = None
|
||||||
|
|
||||||
|
# Tool dispatch
|
||||||
|
def _call_tool(self, name: str, args: Dict[str, Any]) -> str:
|
||||||
|
if name == "calc_calories_and_macros":
|
||||||
|
profile = UserProfile(**args["profile"])
|
||||||
|
targets = compute_targets(profile)
|
||||||
|
self.targets_cache = targets
|
||||||
|
return targets.model_dump_json()
|
||||||
|
elif name == "compose_meal_plan":
|
||||||
|
profile = UserProfile(**args["profile"])
|
||||||
|
targets = MacroTargets(**args["targets"])
|
||||||
|
plan = compose_meal_plan(profile, targets)
|
||||||
|
self.plan_cache = plan
|
||||||
|
return plan.model_dump_json()
|
||||||
|
elif name == "grocery_list_from_plan":
|
||||||
|
plan = WeekPlan(**args["plan"])
|
||||||
|
items = grocery_list_from_plan(plan)
|
||||||
|
return json.dumps(items)
|
||||||
|
elif name == "swap_meal":
|
||||||
|
plan = WeekPlan(**args["plan"])
|
||||||
|
profile = UserProfile(**args["profile"])
|
||||||
|
day = args["day"]
|
||||||
|
idx = args["meal_index"]
|
||||||
|
new_plan = swap_meal(plan, day, idx, profile)
|
||||||
|
self.plan_cache = new_plan
|
||||||
|
return new_plan.model_dump_json()
|
||||||
|
else:
|
||||||
|
return json.dumps({"error":"unknown tool"})
|
||||||
|
|
||||||
|
def chat(self, user_message: str, profile: Optional[UserProfile] = None) -> str:
|
||||||
|
messages = [{"role":"system","content":SYS_PROMPT}]
|
||||||
|
if profile:
|
||||||
|
messages.append({"role":"user","content":f"User profile: {profile.model_dump_json()}"} )
|
||||||
|
messages.append({"role":"user","content":user_message})
|
||||||
|
|
||||||
|
# First call
|
||||||
|
resp = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages,
|
||||||
|
tools=get_tools_schema(),
|
||||||
|
tool_choice="auto",
|
||||||
|
temperature=0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle tool calls (simple, single-step or brief multi-step)
|
||||||
|
messages_llm = messages + [{"role":"assistant","content":resp.choices[0].message.content or "",
|
||||||
|
"tool_calls":resp.choices[0].message.tool_calls}]
|
||||||
|
if resp.choices[0].message.tool_calls:
|
||||||
|
for tc in resp.choices[0].message.tool_calls:
|
||||||
|
name = tc.function.name
|
||||||
|
args = json.loads(tc.function.arguments or "{}")
|
||||||
|
out = self._call_tool(name, args)
|
||||||
|
messages_llm.append({
|
||||||
|
"role":"tool",
|
||||||
|
"tool_call_id":tc.id,
|
||||||
|
"name":name,
|
||||||
|
"content":out
|
||||||
|
})
|
||||||
|
|
||||||
|
# Finalization
|
||||||
|
resp2 = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages_llm,
|
||||||
|
temperature=0.2
|
||||||
|
)
|
||||||
|
return resp2.choices[0].message.content
|
||||||
|
|
||||||
|
return resp.choices[0].message.content
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Quick CLI demo
|
||||||
|
# ------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
profile = UserProfile(
|
||||||
|
sex="female", age=45, height_cm=152, weight_kg=62,
|
||||||
|
activity_level="light", goal="maintain",
|
||||||
|
dietary_pattern="vegetarian", allergies=[], dislikes=[],
|
||||||
|
daily_meals=3, cuisine_prefs=["mediterranean"], time_per_meal_minutes=25, budget_level="medium"
|
||||||
|
)
|
||||||
|
agent = FitnessPlannerAgent()
|
||||||
|
print(agent.chat("Create my 7-day plan and grocery list.", profile))
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
# app.py
|
||||||
|
import json
|
||||||
|
import streamlit as st
|
||||||
|
from agent import FitnessPlannerAgent, UserProfile, WeekPlan
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Fitness & Nutrition Planner Agent", layout="wide")
|
||||||
|
|
||||||
|
st.title("🏋️ Fitness & Nutrition Planner Agent")
|
||||||
|
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Your Profile")
|
||||||
|
sex = st.selectbox("Sex", ["female","male"])
|
||||||
|
age = st.number_input("Age", 18, 90, 45)
|
||||||
|
height_cm = st.number_input("Height (cm)", 120, 220, 152)
|
||||||
|
weight_kg = st.number_input("Weight (kg)", 35.0, 200.0, 62.0)
|
||||||
|
activity_level = st.selectbox("Activity Level", ["sedentary","light","moderate","active","very_active"], index=1)
|
||||||
|
goal = st.selectbox("Goal", ["lose","maintain","gain"], index=1)
|
||||||
|
dietary_pattern = st.selectbox("Dietary Pattern", ["none","vegetarian","vegan","halal","kosher"], index=1)
|
||||||
|
if dietary_pattern == "none": dietary_pattern = None
|
||||||
|
allergies = st.text_input("Allergies (comma-separated)", "")
|
||||||
|
dislikes = st.text_input("Dislikes (comma-separated)", "")
|
||||||
|
daily_meals = st.slider("Meals per day", 2, 5, 3)
|
||||||
|
time_per_meal_minutes = st.slider("Time per meal (min)", 5, 90, 25)
|
||||||
|
budget_level = st.selectbox("Budget", ["medium","low","high"], index=0)
|
||||||
|
cuisine_prefs = st.text_input("Cuisine prefs (comma-separated)", "mediterranean")
|
||||||
|
|
||||||
|
build_btn = st.button("Generate 7-Day Plan")
|
||||||
|
|
||||||
|
agent = FitnessPlannerAgent()
|
||||||
|
|
||||||
|
if build_btn:
|
||||||
|
profile = UserProfile(
|
||||||
|
sex=sex, age=int(age), height_cm=float(height_cm), weight_kg=float(weight_kg),
|
||||||
|
activity_level=activity_level, goal=goal, dietary_pattern=dietary_pattern,
|
||||||
|
allergies=[a.strip() for a in allergies.split(",") if a.strip()],
|
||||||
|
dislikes=[d.strip() for d in dislikes.split(",") if d.strip()],
|
||||||
|
daily_meals=int(daily_meals), cuisine_prefs=[c.strip() for c in cuisine_prefs.split(",") if c.strip()],
|
||||||
|
time_per_meal_minutes=int(time_per_meal_minutes), budget_level=budget_level
|
||||||
|
)
|
||||||
|
st.session_state["profile_json"] = profile.model_dump_json()
|
||||||
|
with st.spinner("Planning your week..."):
|
||||||
|
result = agent.chat("Create my 7-day plan and grocery list.", profile)
|
||||||
|
st.session_state["last_response"] = result
|
||||||
|
|
||||||
|
if "last_response" in st.session_state:
|
||||||
|
st.subheader("Plan & Groceries")
|
||||||
|
st.markdown(st.session_state["last_response"])
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
st.subheader("Tweaks")
|
||||||
|
col1, col2, col3 = st.columns(3)
|
||||||
|
with col1:
|
||||||
|
day = st.selectbox("Day to change", ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"])
|
||||||
|
with col2:
|
||||||
|
meal_index = st.selectbox("Meal slot", ["Breakfast (0)","Lunch (1)","Dinner (2)"])
|
||||||
|
meal_index = int(meal_index[-2]) # 0/1/2
|
||||||
|
with col3:
|
||||||
|
swap_btn = st.button("Swap Meal")
|
||||||
|
|
||||||
|
if swap_btn and agent.plan_cache:
|
||||||
|
profile_json = st.session_state.get("profile_json")
|
||||||
|
if not profile_json:
|
||||||
|
st.warning("Please generate a plan first.")
|
||||||
|
else:
|
||||||
|
new_plan_json = agent._call_tool("swap_meal", {
|
||||||
|
"plan": agent.plan_cache.model_dump(),
|
||||||
|
"day": day,
|
||||||
|
"meal_index": meal_index,
|
||||||
|
"profile": json.loads(profile_json)
|
||||||
|
})
|
||||||
|
agent.plan_cache = WeekPlan(**json.loads(new_plan_json))
|
||||||
|
summary = agent.chat(f"Update summary for {day}: show the swapped meal and new day totals.")
|
||||||
|
st.session_state["last_response"] = summary
|
||||||
|
st.markdown(summary)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
|
||||||
|
# Demo Output (Sample Profile)
|
||||||
|
|
||||||
|
**Profile**: female, 45, 152 cm, 62 kg, activity: light, goal: maintain, diet: vegetarian
|
||||||
|
|
||||||
|
## Targets
|
||||||
|
- TDEE ≈ **1680 kcal/day**
|
||||||
|
- Macros (30/40/30): **Protein 126 g**, **Carbs 168 g**, **Fat 56 g**
|
||||||
|
|
||||||
|
> These are estimates using Mifflin–St Jeor and a light activity factor. Not medical advice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example 7-Day Plan (Breakfast / Lunch / Dinner)
|
||||||
|
|
||||||
|
**Mon**
|
||||||
|
- Greek Yogurt Parfait (380 kcal, 30P/52C/8F)
|
||||||
|
- Lentil Soup + Wholegrain Bread (520 kcal, 25P/78C/8F)
|
||||||
|
- Tofu Veggie Stir-Fry with Rice (650 kcal, 28P/85C/20F)
|
||||||
|
- **Totals** ≈ 1550 kcal, 83P, 215C, 36F
|
||||||
|
|
||||||
|
**Tue**
|
||||||
|
- Cottage Cheese Bowl (380 kcal, 32P/35C/10F)
|
||||||
|
- Lentil Soup + Wholegrain Bread (520 kcal, 25P/78C/8F)
|
||||||
|
- Tofu Veggie Stir-Fry with Rice (650 kcal, 28P/85C/20F)
|
||||||
|
- **Totals** ≈ 1550 kcal, 85P, 198C, 38F
|
||||||
|
|
||||||
|
**Wed**
|
||||||
|
- Greek Yogurt Parfait
|
||||||
|
- Lentil Soup + Wholegrain Bread
|
||||||
|
- Tofu Veggie Stir-Fry with Rice
|
||||||
|
- **Totals** ≈ 1550 kcal
|
||||||
|
|
||||||
|
**Thu**
|
||||||
|
- Cottage Cheese Bowl
|
||||||
|
- Lentil Soup + Wholegrain Bread
|
||||||
|
- Tofu Veggie Stir-Fry with Rice
|
||||||
|
- **Totals** ≈ 1550 kcal
|
||||||
|
|
||||||
|
**Fri**
|
||||||
|
- Greek Yogurt Parfait
|
||||||
|
- Lentil Soup + Wholegrain Bread
|
||||||
|
- Tofu Veggie Stir-Fry with Rice
|
||||||
|
- **Totals** ≈ 1550 kcal
|
||||||
|
|
||||||
|
**Sat**
|
||||||
|
- Cottage Cheese Bowl
|
||||||
|
- Lentil Soup + Wholegrain Bread
|
||||||
|
- Tofu Veggie Stir-Fry with Rice
|
||||||
|
- **Totals** ≈ 1550 kcal
|
||||||
|
|
||||||
|
**Sun**
|
||||||
|
- Greek Yogurt Parfait
|
||||||
|
- Lentil Soup + Wholegrain Bread
|
||||||
|
- Tofu Veggie Stir-Fry with Rice
|
||||||
|
- **Totals** ≈ 1550 kcal
|
||||||
|
|
||||||
|
> Notes: The demo DB is intentionally small. In practice, plug in a larger vegetarian recipe set for more variety. Add snacks if you'd like to reach ~1680 kcal/day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grocery List (aggregated, approx for 7 days)
|
||||||
|
|
||||||
|
- nonfat greek yogurt — **1400 g**
|
||||||
|
- berries — **1050 g**
|
||||||
|
- granola — **210 g**
|
||||||
|
- honey — **70 g**
|
||||||
|
- lentils (cooked) — **1400 g**
|
||||||
|
- vegetable broth — **2800 ml**
|
||||||
|
- carrot — **560 g**
|
||||||
|
- celery — **420 g**
|
||||||
|
- onion — **420 g**
|
||||||
|
- wholegrain bread — **420 g**
|
||||||
|
- firm tofu — **1050 g**
|
||||||
|
- mixed vegetables — **1400 g**
|
||||||
|
- soy sauce (low sodium) — **105 ml**
|
||||||
|
- olive oil — **140 ml**
|
||||||
|
- brown rice (cooked) — **1400 g**
|
||||||
|
- low-fat cottage cheese — **600 g**
|
||||||
|
- pineapple — **450 g**
|
||||||
|
- chia seeds — **45 g**
|
||||||
|
|
||||||
|
**Tip:** Use the app’s *Swap Meal* to replace any item (e.g., swap Wed dinner).
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"sex": "female",
|
||||||
|
"age": 45,
|
||||||
|
"height_cm": 152,
|
||||||
|
"weight_kg": 62,
|
||||||
|
"activity_level": "light",
|
||||||
|
"goal": "maintain",
|
||||||
|
"dietary_pattern": "vegetarian",
|
||||||
|
"allergies": [],
|
||||||
|
"dislikes": [],
|
||||||
|
"daily_meals": 3,
|
||||||
|
"cuisine_prefs": [
|
||||||
|
"mediterranean"
|
||||||
|
],
|
||||||
|
"time_per_meal_minutes": 25,
|
||||||
|
"budget_level": "medium"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user