mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 10:48:46 +01:00
Add AI-powered nutrition and plan modules
Introduces DSPy-based nutrition and plan generation modules, including image analysis for nutritional info and personalized diet/exercise plans. Adds new API endpoints for health metrics/goals, nutrition image analysis, and plan management. Updates models, schemas, and backend structure to support these features, and includes initial training data and configuration for prompt optimization.
This commit is contained in:
@@ -1,25 +1,107 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
import dspy
|
import dspy
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
class NutritionalInfo(BaseModel):
|
class NutritionalInfo(BaseModel):
|
||||||
name: str
|
reasoning: str = Field(description="Step-by-step reasoning for the nutritional estimates")
|
||||||
calories: float
|
name: str = Field(description="Name of the food item")
|
||||||
protein: float
|
calories: float = Field(description="Estimated calories")
|
||||||
carbs: float
|
protein: float = Field(description="Estimated protein in grams")
|
||||||
fats: float
|
carbs: float = Field(description="Estimated carbohydrates in grams")
|
||||||
|
fats: float = Field(description="Estimated fats in grams")
|
||||||
micros: dict | None = None
|
micros: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
class ExtractNutrition(dspy.Signature):
|
class ExtractNutrition(dspy.Signature):
|
||||||
"""Extract nutritional information from a food description."""
|
"""Extract nutritional information from a food description.
|
||||||
|
|
||||||
|
You must first provide a detailed step-by-step reasoning analysis of the ingredients,
|
||||||
|
portions, AND preparation methods (cooking oils, butter, sauces) before estimating values.
|
||||||
|
Verify if the caloric totals match the sum of macros (multiplying protein/carbs by 4, fats by 9).
|
||||||
|
"""
|
||||||
|
|
||||||
description: str = dspy.InputField(desc="Description of the food or meal")
|
description: str = dspy.InputField(desc="Description of the food or meal")
|
||||||
nutritional_info: NutritionalInfo = dspy.OutputField(desc="Nutritional information as a structured object")
|
nutritional_info: NutritionalInfo = dspy.OutputField(desc="Nutritional information with reasoning")
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeFoodImage(dspy.Signature):
|
||||||
|
"""Analyze the food image to estimate nutritional content.
|
||||||
|
|
||||||
|
1. Identify all food items and estimated portion sizes.
|
||||||
|
2. CRITICAL: Account for hidden calories from cooking fats, oils, and sauces (searing, frying).
|
||||||
|
3. Reason step-by-step about the total composition before summing macros.
|
||||||
|
"""
|
||||||
|
|
||||||
|
image: dspy.Image = dspy.InputField(desc="The food image")
|
||||||
|
description: str = dspy.InputField(desc="Additional user description", default="")
|
||||||
|
nutritional_info: NutritionalInfo = dspy.OutputField(desc="Nutritional information with reasoning")
|
||||||
|
|
||||||
|
|
||||||
class NutritionModule(dspy.Module):
|
class NutritionModule(dspy.Module):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.extract = dspy.ChainOfThought(ExtractNutrition)
|
self.extract = dspy.ChainOfThought(ExtractNutrition)
|
||||||
|
self.analyze_image = dspy.ChainOfThought(AnalyzeFoodImage)
|
||||||
|
|
||||||
|
# Load optimized prompts if available
|
||||||
|
import os
|
||||||
|
|
||||||
|
compiled_path = os.path.join(os.path.dirname(__file__), "nutrition_compiled.json")
|
||||||
|
if os.path.exists(compiled_path):
|
||||||
|
self.load(compiled_path)
|
||||||
|
print(f"Loaded optimized DSPy prompts from {compiled_path}")
|
||||||
|
else:
|
||||||
|
print("No optimized prompts found, using default zero-shot.")
|
||||||
|
|
||||||
def forward(self, description: str):
|
def forward(self, description: str):
|
||||||
return self.extract(description=description)
|
pred = self.extract(description=description)
|
||||||
|
|
||||||
|
# Assertion: Check Macro Consistency
|
||||||
|
calc_cals = (
|
||||||
|
(pred.nutritional_info.protein * 4) + (pred.nutritional_info.carbs * 4) + (pred.nutritional_info.fats * 9)
|
||||||
|
)
|
||||||
|
|
||||||
|
# dspy.Suggest is not available in dspy>=3.1.0
|
||||||
|
# dspy.Suggest(
|
||||||
|
# abs(calc_cals - pred.nutritional_info.calories) < (pred.nutritional_info.calories * 0.20),
|
||||||
|
# f"The sum of macros ({calc_cals:.1f}) should match the total calories "
|
||||||
|
# f"({pred.nutritional_info.calories}). Check your math.",
|
||||||
|
# )
|
||||||
|
return pred
|
||||||
|
|
||||||
|
def forward_image(self, image_url: str, description: str = ""):
|
||||||
|
image = dspy.Image(image_url)
|
||||||
|
pred = self.analyze_image(image=image, description=description)
|
||||||
|
|
||||||
|
# Assertion: Check Macro Consistency
|
||||||
|
calc_cals = (
|
||||||
|
(pred.nutritional_info.protein * 4) + (pred.nutritional_info.carbs * 4) + (pred.nutritional_info.fats * 9)
|
||||||
|
)
|
||||||
|
|
||||||
|
# dspy.Suggest is not available in dspy>=3.1.0
|
||||||
|
# dspy.Suggest(
|
||||||
|
# abs(calc_cals - pred.nutritional_info.calories) < (pred.nutritional_info.calories * 0.20),
|
||||||
|
# f"The sum of macros ({calc_cals:.1f}) should match the total calories "
|
||||||
|
# f"({pred.nutritional_info.calories}). Check your math.",
|
||||||
|
# )
|
||||||
|
return pred
|
||||||
|
|
||||||
|
|
||||||
nutrition_module = NutritionModule()
|
nutrition_module = NutritionModule()
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_nutrition_from_image(image_bytes: bytes, description: str = "") -> NutritionalInfo:
|
||||||
|
if not settings.OPENAI_API_KEY:
|
||||||
|
raise ValueError("OpenAI API Key not set")
|
||||||
|
|
||||||
|
# Convert to base64 data URI
|
||||||
|
base64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||||
|
image_url = f"data:image/jpeg;base64,{base64_image}"
|
||||||
|
|
||||||
|
# Use DSPy module
|
||||||
|
result = nutrition_module.forward_image(image_url=image_url, description=description)
|
||||||
|
return result.nutritional_info
|
||||||
|
|||||||
147
backend/app/ai/nutrition_compiled.json
Normal file
147
backend/app/ai/nutrition_compiled.json
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{
|
||||||
|
"extract.predict": {
|
||||||
|
"traces": [],
|
||||||
|
"train": [],
|
||||||
|
"demos": [
|
||||||
|
{
|
||||||
|
"augmented": true,
|
||||||
|
"description": "Blueberry Muffin (Bakery size)",
|
||||||
|
"reasoning": "A typical bakery-sized blueberry muffin is generally larger than a standard homemade muffin and is made from ingredients such as flour, sugar, butter, eggs, milk, blueberries, and baking powder. The estimated calorie count for a large blueberry muffin is about 400-500 calories, primarily derived from carbohydrates (mainly from flour and sugar), fats (from butter), and a moderate amount of protein. \n\nFor nutritional breakdown:\n- Carbs: Approximately 60g derived from the flour and sugar.\n- Fats: Approximately 20g from the butter.\n- Protein: Roughly 6g from the flour and egg content.\n- The muffin may also contain vitamins and minerals from the blueberries.\n\nOverall, a bakery-sized blueberry muffin is calorie-dense due to the combination of ingredients used, especially sugar and butter.",
|
||||||
|
"nutritional_info": {
|
||||||
|
"reasoning": "Bakery-sized muffin has ingredients like flour, sugar, butter, and blueberries. Estimated 450 cal, with ~60g carbs, ~20g fat, and ~6g protein.",
|
||||||
|
"name": "Blueberry Muffin",
|
||||||
|
"calories": 450.0,
|
||||||
|
"protein": 6.0,
|
||||||
|
"carbs": 60.0,
|
||||||
|
"fats": 20.0,
|
||||||
|
"micros": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Philly Cheesesteak",
|
||||||
|
"nutritional_info": {
|
||||||
|
"reasoning": "Roll (250 cal). Fatty steak (400 cal). Cheese whiz/provolone (150 cal). Oil (100 cal).",
|
||||||
|
"name": "Cheesesteak",
|
||||||
|
"calories": 900.0,
|
||||||
|
"protein": 40.0,
|
||||||
|
"carbs": 50.0,
|
||||||
|
"fats": 55.0,
|
||||||
|
"micros": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Grilled salmon with asparagus and roasted potatoes",
|
||||||
|
"nutritional_info": {
|
||||||
|
"reasoning": "6oz Salmon fillet (350 cal). Oil for cooking (60 cal). Asparagus (30 cal) + oil (30 cal). 1 cup roasted potatoes (150 cal) + oil (60 cal). Total ~680 cal.",
|
||||||
|
"name": "Salmon Dinner",
|
||||||
|
"calories": 680.0,
|
||||||
|
"protein": 40.0,
|
||||||
|
"carbs": 25.0,
|
||||||
|
"fats": 45.0,
|
||||||
|
"micros": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Tacos - 3 beef tacos with cheese and sour cream",
|
||||||
|
"nutritional_info": {
|
||||||
|
"reasoning": "3 corn tortillas (150 cal). Ground beef filling (250 cal - cooked with fat). Cheese (110 cal). Sour cream (60 cal). Total ~570 cal.",
|
||||||
|
"name": "Beef Tacos",
|
||||||
|
"calories": 570.0,
|
||||||
|
"protein": 25.0,
|
||||||
|
"carbs": 45.0,
|
||||||
|
"fats": 30.0,
|
||||||
|
"micros": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"signature": {
|
||||||
|
"instructions": "Extract nutritional information from a food description.\n\nYou must first provide a detailed step-by-step reasoning analysis of the ingredients,\nportions, AND preparation methods (cooking oils, butter, sauces) before estimating values.\nVerify if the caloric totals match the sum of macros (multiplying protein/carbs by 4, fats by 9).",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"prefix": "Description:",
|
||||||
|
"description": "Description of the food or meal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "Reasoning: Let's think step by step in order to",
|
||||||
|
"description": "${reasoning}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "Nutritional Info:",
|
||||||
|
"description": "Nutritional information with reasoning"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lm": null
|
||||||
|
},
|
||||||
|
"analyze_image.predict": {
|
||||||
|
"traces": [],
|
||||||
|
"train": [],
|
||||||
|
"demos": [
|
||||||
|
{
|
||||||
|
"description": "Philly Cheesesteak",
|
||||||
|
"nutritional_info": {
|
||||||
|
"reasoning": "Roll (250 cal). Fatty steak (400 cal). Cheese whiz/provolone (150 cal). Oil (100 cal).",
|
||||||
|
"name": "Cheesesteak",
|
||||||
|
"calories": 900.0,
|
||||||
|
"protein": 40.0,
|
||||||
|
"carbs": 50.0,
|
||||||
|
"fats": 55.0,
|
||||||
|
"micros": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Grilled salmon with asparagus and roasted potatoes",
|
||||||
|
"nutritional_info": {
|
||||||
|
"reasoning": "6oz Salmon fillet (350 cal). Oil for cooking (60 cal). Asparagus (30 cal) + oil (30 cal). 1 cup roasted potatoes (150 cal) + oil (60 cal). Total ~680 cal.",
|
||||||
|
"name": "Salmon Dinner",
|
||||||
|
"calories": 680.0,
|
||||||
|
"protein": 40.0,
|
||||||
|
"carbs": 25.0,
|
||||||
|
"fats": 45.0,
|
||||||
|
"micros": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Tacos - 3 beef tacos with cheese and sour cream",
|
||||||
|
"nutritional_info": {
|
||||||
|
"reasoning": "3 corn tortillas (150 cal). Ground beef filling (250 cal - cooked with fat). Cheese (110 cal). Sour cream (60 cal). Total ~570 cal.",
|
||||||
|
"name": "Beef Tacos",
|
||||||
|
"calories": 570.0,
|
||||||
|
"protein": 25.0,
|
||||||
|
"carbs": 45.0,
|
||||||
|
"fats": 30.0,
|
||||||
|
"micros": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"signature": {
|
||||||
|
"instructions": "Analyze the food image to estimate nutritional content.\n\n1. Identify all food items and estimated portion sizes.\n2. CRITICAL: Account for hidden calories from cooking fats, oils, and sauces (searing, frying).\n3. Reason step-by-step about the total composition before summing macros.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"prefix": "Image:",
|
||||||
|
"description": "The food image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "Description:",
|
||||||
|
"description": "Additional user description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "Reasoning: Let's think step by step in order to",
|
||||||
|
"description": "${reasoning}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "Nutritional Info:",
|
||||||
|
"description": "Nutritional information with reasoning"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lm": null
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"dependency_versions": {
|
||||||
|
"python": "3.11",
|
||||||
|
"dspy": "3.1.0",
|
||||||
|
"cloudpickle": "3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/app/ai/plans.py
Normal file
34
backend/app/ai/plans.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import dspy
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PlanOutput(BaseModel):
|
||||||
|
reasoning: str = Field(description="Reasoning behind the selected plan based on user goals")
|
||||||
|
title: str = Field(description="Title of the plan")
|
||||||
|
summary: str = Field(description="Brief summary of the plan")
|
||||||
|
diet_plan: list[str] = Field(description="List of daily diet recommendations")
|
||||||
|
exercise_plan: list[str] = Field(description="List of daily exercise routines")
|
||||||
|
tips: list[str] = Field(description="Additional health tips")
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratePlan(dspy.Signature):
|
||||||
|
"""Generate a personalized diet and exercise plan based on user goal and details.
|
||||||
|
|
||||||
|
Analyze the user's profile and goal, explain your reasoning, and then generate the plan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_profile: str = dspy.InputField(desc="User details (age, weight, height, etc)")
|
||||||
|
goal: str = dspy.InputField(desc="Specific user goal")
|
||||||
|
plan: PlanOutput = dspy.OutputField(desc="Structured plan with reasoning")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanModule(dspy.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.generate = dspy.ChainOfThought(GeneratePlan)
|
||||||
|
|
||||||
|
def forward(self, user_profile: str, goal: str):
|
||||||
|
return self.generate(user_profile=user_profile, goal=goal)
|
||||||
|
|
||||||
|
|
||||||
|
plan_module = PlanModule()
|
||||||
@@ -1,34 +1,32 @@
|
|||||||
from typing import Generator
|
from typing import Annotated, Generator
|
||||||
from sqlmodel import Session
|
|
||||||
from app.db import engine
|
|
||||||
|
|
||||||
from typing import Generator, Annotated
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import jwt, JWTError
|
from jose import JWTError, jwt
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.core import security
|
||||||
from app.db import engine
|
from app.db import engine
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.core import security
|
|
||||||
from app.config import settings
|
|
||||||
from app.schemas.token import TokenPayload
|
from app.schemas.token import TokenPayload
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token")
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> Generator[Session, None, None]:
|
def get_session() -> Generator[Session, None, None]:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
SessionDep = Annotated[Session, Depends(get_session)]
|
SessionDep = Annotated[Session, Depends(get_session)]
|
||||||
TokenDep = Annotated[str, Depends(oauth2_scheme)]
|
TokenDep = Annotated[str, Depends(oauth2_scheme)]
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[security.ALGORITHM])
|
||||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
|
||||||
)
|
|
||||||
token_data = TokenPayload(**payload)
|
token_data = TokenPayload(**payload)
|
||||||
except (JWTError, ValidationError):
|
except (JWTError, ValidationError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -40,4 +38,5 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.v1.endpoints import users, login, nutrition, health
|
|
||||||
|
from app.api.v1.endpoints import health, login, nutrition, plans, users
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(login.router, tags=["login"])
|
api_router.include_router(login.router, tags=["login"])
|
||||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||||
api_router.include_router(nutrition.router, prefix="/nutrition", tags=["nutrition"])
|
api_router.include_router(nutrition.router, prefix="/nutrition", tags=["nutrition"])
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
|
api_router.include_router(plans.router, prefix="/plans", tags=["plans"])
|
||||||
|
|||||||
@@ -1,35 +1,80 @@
|
|||||||
|
from datetime import datetime
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlmodel import Session, select
|
from fastapi import APIRouter, Depends
|
||||||
from app.api import deps
|
|
||||||
from app.models.health import HealthMetric
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.api import deps
|
||||||
|
from app.models.health import HealthGoal, HealthMetric
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class HealthMetricCreate(BaseModel):
|
class HealthMetricCreate(BaseModel):
|
||||||
metric_type: str
|
metric_type: str
|
||||||
value: float
|
value: float
|
||||||
unit: str
|
unit: str
|
||||||
user_id: int # TODO: remove when auth is fully integrated
|
|
||||||
|
|
||||||
@router.post("/", response_model=HealthMetric)
|
|
||||||
|
class HealthGoalCreate(BaseModel):
|
||||||
|
goal_type: str
|
||||||
|
target_value: float
|
||||||
|
target_date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/metrics", response_model=HealthMetric)
|
||||||
def create_metric(
|
def create_metric(
|
||||||
*,
|
*,
|
||||||
session: Session = Depends(deps.get_session),
|
session: Session = Depends(deps.get_session),
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
metric_in: HealthMetricCreate,
|
metric_in: HealthMetricCreate,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
metric = HealthMetric(metric_type=metric_in.metric_type, value=metric_in.value, unit=metric_in.unit, user_id=metric_in.user_id)
|
metric = HealthMetric(
|
||||||
|
metric_type=metric_in.metric_type, value=metric_in.value, unit=metric_in.unit, user_id=current_user.id
|
||||||
|
)
|
||||||
session.add(metric)
|
session.add(metric)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(metric)
|
session.refresh(metric)
|
||||||
return metric
|
return metric
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=List[HealthMetric])
|
|
||||||
|
@router.get("/metrics", response_model=List[HealthMetric])
|
||||||
def read_metrics(
|
def read_metrics(
|
||||||
user_id: int,
|
current_user: deps.CurrentUser,
|
||||||
session: Session = Depends(deps.get_session),
|
session: Session = Depends(deps.get_session),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
statement = select(HealthMetric).where(HealthMetric.user_id == user_id)
|
statement = (
|
||||||
|
select(HealthMetric).where(HealthMetric.user_id == current_user.id).order_by(HealthMetric.timestamp.desc())
|
||||||
|
)
|
||||||
metrics = session.exec(statement).all()
|
metrics = session.exec(statement).all()
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/goals", response_model=HealthGoal)
|
||||||
|
def create_goal(
|
||||||
|
*,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
goal_in: HealthGoalCreate,
|
||||||
|
) -> Any:
|
||||||
|
goal = HealthGoal(
|
||||||
|
goal_type=goal_in.goal_type,
|
||||||
|
target_value=goal_in.target_value,
|
||||||
|
target_date=goal_in.target_date,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
session.add(goal)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(goal)
|
||||||
|
return goal
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/goals", response_model=List[HealthGoal])
|
||||||
|
def read_goals(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
statement = select(HealthGoal).where(HealthGoal.user_id == current_user.id)
|
||||||
|
goals = session.exec(statement).all()
|
||||||
|
return goals
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
from app.core import security
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.core import security
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.token import Token
|
from app.schemas.token import Token
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login/access-token", response_model=Token)
|
@router.post("/login/access-token", response_model=Token)
|
||||||
def login_access_token(
|
def login_access_token(
|
||||||
session: Session = Depends(deps.get_session),
|
session: Session = Depends(deps.get_session), form_data: OAuth2PasswordRequestForm = Depends()
|
||||||
form_data: OAuth2PasswordRequestForm = Depends()
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
OAuth2 compatible token login, get an access token for future requests
|
OAuth2 compatible token login, get an access token for future requests
|
||||||
@@ -28,8 +29,6 @@ def login_access_token(
|
|||||||
|
|
||||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
return {
|
return {
|
||||||
"access_token": security.create_access_token(
|
"access_token": security.create_access_token(user.id, expires_delta=access_token_expires),
|
||||||
user.id, expires_delta=access_token_expires
|
|
||||||
),
|
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
|
import litellm
|
||||||
|
import dspy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.ai.nutrition import NutritionalInfo, analyze_nutrition_from_image, nutrition_module
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
from app.ai.nutrition import nutrition_module, NutritionalInfo
|
from app.models.food import FoodLog # Added FoodItem
|
||||||
from app.core.security import create_access_token # Just ensuring we have auth imports if needed later
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class AnalyzeRequest(BaseModel):
|
class AnalyzeRequest(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
|
|
||||||
from app.models.food import FoodLog, FoodItem
|
|
||||||
from app.api.deps import get_session
|
|
||||||
from app.core.security import get_password_hash # Not needed
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
@router.post("/analyze", response_model=NutritionalInfo)
|
@router.post("/analyze", response_model=NutritionalInfo)
|
||||||
def analyze_food(
|
def analyze_food(
|
||||||
@@ -30,6 +30,24 @@ def analyze_food(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze/image", response_model=NutritionalInfo)
|
||||||
|
async def analyze_food_image(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
description: str = Form(""),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Analyze food image and return nutritional info.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
contents = await file.read()
|
||||||
|
return analyze_nutrition_from_image(contents, description)
|
||||||
|
except litellm.exceptions.BadRequestError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid image or request: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/log", response_model=FoodLog)
|
@router.post("/log", response_model=FoodLog)
|
||||||
def log_food(
|
def log_food(
|
||||||
*,
|
*,
|
||||||
|
|||||||
67
backend/app/api/v1/endpoints/plans.py
Normal file
67
backend/app/api/v1/endpoints/plans.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.ai.plans import plan_module
|
||||||
|
from app.api import deps
|
||||||
|
from app.models.plan import Plan
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PlanRequest(BaseModel):
|
||||||
|
goal: str
|
||||||
|
user_details: str # e.g., "Male, 30, 80kg"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=Plan)
|
||||||
|
def generate_plan(
|
||||||
|
*,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
request: PlanRequest,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Generate a new diet/exercise plan using AI.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Generate plan using DSPy
|
||||||
|
generated = plan_module(user_profile=request.user_details, goal=request.goal)
|
||||||
|
|
||||||
|
# Determine content string (markdown representation)
|
||||||
|
content_md = (
|
||||||
|
f"# {generated.plan.title}\n\n{generated.plan.summary}\n\n## Diet\n"
|
||||||
|
+ "\n".join([f"- {item}" for item in generated.plan.diet_plan])
|
||||||
|
+ "\n\n## Exercise\n"
|
||||||
|
+ "\n".join([f"- {item}" for item in generated.plan.exercise_plan])
|
||||||
|
+ "\n\n## Tips\n"
|
||||||
|
+ "\n".join([f"- {item}" for item in generated.plan.tips])
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = Plan(
|
||||||
|
user_id=current_user.id,
|
||||||
|
goal=request.goal,
|
||||||
|
content=content_md,
|
||||||
|
structured_content=generated.plan.model_dump(),
|
||||||
|
)
|
||||||
|
session.add(plan)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(plan)
|
||||||
|
return plan
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Plan])
|
||||||
|
def read_plans(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Get all plans for the current user.
|
||||||
|
"""
|
||||||
|
statement = select(Plan).where(Plan.user_id == current_user.id).order_by(Plan.created_at.desc())
|
||||||
|
plans = session.exec(statement).all()
|
||||||
|
return plans
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ from app.schemas.user import UserCreate, UserRead
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=UserRead)
|
@router.post("/", response_model=UserRead)
|
||||||
def create_user(
|
def create_user(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
DATABASE_URL: str
|
DATABASE_URL: str
|
||||||
OPENAI_API_KEY: str | None = None
|
OPENAI_API_KEY: str | None = None
|
||||||
SECRET_KEY: str = "changethis"
|
SECRET_KEY: str = "changethis"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import dspy
|
import dspy
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
def configure_dspy():
|
def configure_dspy():
|
||||||
if settings.OPENAI_API_KEY:
|
if settings.OPENAI_API_KEY:
|
||||||
lm = dspy.LM("openai/gpt-4o-mini", api_key=settings.OPENAI_API_KEY)
|
lm = dspy.LM("openai/gpt-4o-mini", api_key=settings.OPENAI_API_KEY)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Union
|
from typing import Any, Union
|
||||||
|
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
|
def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.utcnow() + expires_delta
|
expire = datetime.utcnow() + expires_delta
|
||||||
@@ -17,8 +20,10 @@ def create_access_token(subject: Union[str, Any], expires_delta: timedelta = Non
|
|||||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
def get_password_hash(password: str) -> str:
|
||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
from sqlmodel import SQLModel, create_engine, Session, text
|
from sqlmodel import Session, SQLModel, create_engine, text
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
engine = create_engine(settings.DATABASE_URL)
|
engine = create_engine(settings.DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
def get_session():
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
session.exec(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
session.exec(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.v1.api import api_router
|
from app.api.v1.api import api_router
|
||||||
from app.db import init_db
|
|
||||||
from app.core.ai_config import configure_dspy
|
from app.core.ai_config import configure_dspy
|
||||||
|
from app.db import init_db
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -14,7 +14,6 @@ async def lifespan(app: FastAPI):
|
|||||||
configure_dspy()
|
configure_dspy()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
app = FastAPI(title="Healthy Fit API", lifespan=lifespan)
|
app = FastAPI(title="Healthy Fit API", lifespan=lifespan)
|
||||||
|
|
||||||
@@ -28,6 +27,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return {"message": "Welcome to Healthy Fit API"}
|
return {"message": "Welcome to Healthy Fit API"}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, Dict
|
from typing import Dict, List, Optional
|
||||||
from sqlmodel import Field, SQLModel, JSON
|
|
||||||
from pgvector.sqlalchemy import Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy import Column
|
from sqlalchemy import Column
|
||||||
|
from sqlmodel import JSON, Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
class FoodItem(SQLModel, table=True):
|
class FoodItem(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
@@ -12,13 +14,14 @@ class FoodItem(SQLModel, table=True):
|
|||||||
carbs: float
|
carbs: float
|
||||||
fats: float
|
fats: float
|
||||||
micros: Dict = Field(default={}, sa_column=Column(JSON))
|
micros: Dict = Field(default={}, sa_column=Column(JSON))
|
||||||
embedding: List[float] = Field(sa_column=Column(Vector(1536))) # OpenAI embedding size
|
embedding: List[float] = Field(sa_column=Column(Vector(1536))) # OpenAI embedding size
|
||||||
|
|
||||||
|
|
||||||
class FoodLog(SQLModel, table=True):
|
class FoodLog(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
user_id: int = Field(foreign_key="user.id")
|
user_id: int = Field(foreign_key="user.id")
|
||||||
food_item_id: Optional[int] = Field(default=None, foreign_key="fooditem.id")
|
food_item_id: Optional[int] = Field(default=None, foreign_key="fooditem.id")
|
||||||
name: str # In case no food item is linked or custom entry
|
name: str # In case no food item is linked or custom entry
|
||||||
calories: float
|
calories: float
|
||||||
protein: float
|
protein: float
|
||||||
carbs: float
|
carbs: float
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
class HealthMetric(SQLModel, table=True):
|
class HealthMetric(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
user_id: int = Field(foreign_key="user.id")
|
user_id: int = Field(foreign_key="user.id")
|
||||||
metric_type: str = Field(index=True) # e.g., "weight", "cholesterol", "testosterone"
|
metric_type: str = Field(index=True) # e.g., "weight", "cholesterol", "testosterone"
|
||||||
value: float
|
value: float
|
||||||
unit: str
|
unit: str
|
||||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class HealthGoal(SQLModel, table=True):
|
class HealthGoal(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
user_id: int = Field(foreign_key="user.id")
|
user_id: int = Field(foreign_key="user.id")
|
||||||
goal_type: str # e.g., "lose_weight", "gain_muscle"
|
goal_type: str # e.g., "lose_weight", "gain_muscle"
|
||||||
target_value: float
|
target_value: float
|
||||||
target_date: Optional[datetime] = None
|
target_date: Optional[datetime] = None
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|||||||
13
backend/app/models/plan.py
Normal file
13
backend/app/models/plan.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import JSON, Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class Plan(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
goal: str = Field(index=True) # e.g., "lose weight", "gain muscle"
|
||||||
|
content: str = Field(description="The full plan content in markdown or text")
|
||||||
|
structured_content: dict = Field(default={}, sa_type=JSON) # For UI rendering
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
class User(SQLModel, table=True):
|
class User(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
username: str = Field(index=True, unique=True)
|
username: str = Field(index=True, unique=True)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
class Token(SQLModel):
|
class Token(SQLModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
class TokenPayload(SQLModel):
|
class TokenPayload(SQLModel):
|
||||||
sub: Optional[str] = None
|
sub: Optional[str] = None
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlmodel import SQLModel, Field
|
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
class UserBase(SQLModel):
|
class UserBase(SQLModel):
|
||||||
email: str
|
email: str
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserRead(UserBase):
|
class UserRead(UserBase):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(SQLModel):
|
class UserUpdate(SQLModel):
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
|
|||||||
13
backend/pyproject.toml
Normal file
13
backend/pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "W"]
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
line-ending = "auto"
|
||||||
@@ -12,3 +12,5 @@ bcrypt==4.0.1
|
|||||||
pytest
|
pytest
|
||||||
httpx
|
httpx
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
ruff
|
||||||
|
|
||||||
|
|||||||
0
backend/scripts/__init__.py
Normal file
0
backend/scripts/__init__.py
Normal file
513
backend/scripts/nutrition_data.py
Normal file
513
backend/scripts/nutrition_data.py
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
import dspy
|
||||||
|
|
||||||
|
from app.ai.nutrition import NutritionalInfo
|
||||||
|
|
||||||
|
# A diverse set of 50 validated examples covering:
|
||||||
|
# - Home cooked meals
|
||||||
|
# - Restaurant items
|
||||||
|
# - Snacks
|
||||||
|
# - Complex dishes with hidden calories
|
||||||
|
|
||||||
|
train_examples = [
|
||||||
|
# --- Breakfast ---
|
||||||
|
dspy.Example(
|
||||||
|
description="Oatmeal with almonds, blueberries, and honey",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1 cup cooked oats (150 cal). 1 oz almonds (160 cal). "
|
||||||
|
"1/2 cup blueberries (40 cal). 1 tbsp honey (60 cal). Total ~410 cal.",
|
||||||
|
name="Oatmeal Bowl",
|
||||||
|
calories=410,
|
||||||
|
protein=10,
|
||||||
|
carbs=65,
|
||||||
|
fats=16,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Two eggs over easy with two slices of bacon and buttered toast",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="2 eggs (140 cal) cooked in fat (+20 cal). 2 slices bacon (90 cal). "
|
||||||
|
"1 slice huge toast (100 cal) + 1 tsp butter (35 cal). Total ~385 cal.",
|
||||||
|
name="Eggs & Bacon Breakfast",
|
||||||
|
calories=385,
|
||||||
|
protein=20,
|
||||||
|
carbs=15,
|
||||||
|
fats=26,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Greek yogurt parfait with granola and strawberries",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1 cup non-fat greek yogurt (130 cal). 1/2 cup granola (200 cal). "
|
||||||
|
"1 cup sliced strawberries (50 cal). Total ~380 cal.",
|
||||||
|
name="Yogurt Parfait",
|
||||||
|
calories=380,
|
||||||
|
protein=24,
|
||||||
|
carbs=55,
|
||||||
|
fats=8,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Avocado toast with a poached egg",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1 slice artisan bread (110 cal). 1/2 avocado (120 cal). "
|
||||||
|
"1 poached egg (70 cal). Drizzle of oil/seasoning (20 cal). Total ~320 cal.",
|
||||||
|
name="Avocado Toast",
|
||||||
|
calories=320,
|
||||||
|
protein=10,
|
||||||
|
carbs=20,
|
||||||
|
fats=22,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Spinach and feta omelette containing 3 eggs",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="3 eggs (210 cal). 1 tsp oil/butter (40 cal). "
|
||||||
|
"1 cup spinach (10 cal). 1 oz feta (75 cal). Total ~335 cal.",
|
||||||
|
name="Spinach Feta Omelette",
|
||||||
|
calories=335,
|
||||||
|
protein=22,
|
||||||
|
carbs=4,
|
||||||
|
fats=25,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
# --- Lunch ---
|
||||||
|
dspy.Example(
|
||||||
|
description="Grilled chicken breast sandwich with mayo, lettuce, tomato",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Bun (180 cal). Chicken breast 4oz (160 cal). "
|
||||||
|
"1 tbsp mayo (90 cal). Veggies (10 cal). Total ~440 cal.",
|
||||||
|
name="Grilled Chicken Sandwich",
|
||||||
|
calories=440,
|
||||||
|
protein=30,
|
||||||
|
carbs=35,
|
||||||
|
fats=18,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Caesar salad with grilled chicken",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Romaine lettuce (20 cal). 4oz chicken (160 cal). 2 tbsp dressing (170 cal). "
|
||||||
|
"Croutons (100 cal). Parmesan (60 cal). Total ~510 cal.",
|
||||||
|
name="Chicken Caesar Salad",
|
||||||
|
calories=510,
|
||||||
|
protein=35,
|
||||||
|
carbs=15,
|
||||||
|
fats=35,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Turkey club sandwich with bacon and cheese",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="3 slices bread (240 cal). Turkey (60 cal). Bacon (90 cal). "
|
||||||
|
"Cheese (110 cal). Mayo (90 cal). Lettuce / Tomato. Total ~590 cal.",
|
||||||
|
name="Turkey Club",
|
||||||
|
calories=590,
|
||||||
|
protein=30,
|
||||||
|
carbs=45,
|
||||||
|
fats=32,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Quinoa bowl with black beans, corn, and avocado",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1 cup cooked quinoa (220 cal). 1/2 cup black beans (110 cal). "
|
||||||
|
"1/2 cup corn (70 cal). 1/4 avocado (60 cal). Lime dressing (50 cal). Total ~510 cal.",
|
||||||
|
name="Veggie Quinoa Bowl",
|
||||||
|
calories=510,
|
||||||
|
protein=18,
|
||||||
|
carbs=85,
|
||||||
|
fats=12,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Tuna salad sushi roll (6 pieces) and miso soup",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Sushi roll (rice, tuna, mayo) ~300 cal. Miso soup ~40 cal. Total ~340 cal.",
|
||||||
|
name="Sushi Lunch",
|
||||||
|
calories=340,
|
||||||
|
protein=15,
|
||||||
|
carbs=45,
|
||||||
|
fats=8,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
# --- Dinner (Complex) ---
|
||||||
|
dspy.Example(
|
||||||
|
description="Spaghetti bolognaise with parmesan cheese",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="2 cups pasta cooked (400 cal). 1 cup meat sauce/beef (300 cal). "
|
||||||
|
"1 tbsp oil in cooking (120 cal). 2 tbsp parmesan (40 cal). Total ~860 cal.",
|
||||||
|
name="Spaghetti Bolognese",
|
||||||
|
calories=860,
|
||||||
|
protein=35,
|
||||||
|
carbs=100,
|
||||||
|
fats=35,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Grilled salmon with asparagus and roasted potatoes",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="6oz Salmon fillet (350 cal). Oil for cooking (60 cal). "
|
||||||
|
"Asparagus (30 cal) + oil (30 cal). 1 cup roasted potatoes (150 cal) + oil (60 cal). Total ~680 cal.",
|
||||||
|
name="Salmon Dinner",
|
||||||
|
calories=680,
|
||||||
|
protein=40,
|
||||||
|
carbs=25,
|
||||||
|
fats=45,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Beef stir fry with rice",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1 cup rice (200 cal). 4oz Beef strips (250 cal). "
|
||||||
|
"Oil for frying 2 tbsp (240 cal). Veggies (50 cal). Sauce (50 cal). Total ~790 cal.",
|
||||||
|
name="Beef Stir Fry",
|
||||||
|
calories=790,
|
||||||
|
protein=30,
|
||||||
|
carbs=50,
|
||||||
|
fats=50,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Cheeseburger with fries",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Bun (200 cal). 4oz Patty 80/20 (280 cal). Cheese (100 cal). "
|
||||||
|
"Condiments (50 cal). Small fries (300 cal). Total ~930 cal.",
|
||||||
|
name="Burger and Fries",
|
||||||
|
calories=930,
|
||||||
|
protein=35,
|
||||||
|
carbs=90,
|
||||||
|
fats=45,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Chicken Tikka Masala with Naan and Rice",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Curry with cream/butter/chicken (600 cal). "
|
||||||
|
"1 cup Rice (200 cal). 1 piece Naan (250 cal). Total ~1050 cal.",
|
||||||
|
name="Chicken Tikka Meal",
|
||||||
|
calories=1050,
|
||||||
|
protein=45,
|
||||||
|
carbs=120,
|
||||||
|
fats=45,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="2 slices of pepperoni pizza",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="2 slices (300 cal each). Total ~600 cal. High fat/carbs.",
|
||||||
|
name="2 Pizza Slices",
|
||||||
|
calories=600,
|
||||||
|
protein=24,
|
||||||
|
carbs=70,
|
||||||
|
fats=26,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Tacos - 3 beef tacos with cheese and sour cream",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="3 corn tortillas (150 cal). Ground beef filling (250 cal - cooked with fat). "
|
||||||
|
"Cheese (110 cal). Sour cream (60 cal). Total ~570 cal.",
|
||||||
|
name="Beef Tacos",
|
||||||
|
calories=570,
|
||||||
|
protein=25,
|
||||||
|
carbs=45,
|
||||||
|
fats=30,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Ribeye steak (10oz) with mashed potatoes",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="10oz Ribeye (fatty cut) ~750 cal. "
|
||||||
|
"Mashed potatoes with butter/cream (1 cup) ~300 cal. Total ~1050 cal.",
|
||||||
|
name="Ribeye Steak Dinner",
|
||||||
|
calories=1050,
|
||||||
|
protein=60,
|
||||||
|
carbs=35,
|
||||||
|
fats=75,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
# --- Snacks/Others ---
|
||||||
|
dspy.Example(
|
||||||
|
description="Medium Banana",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Standard fruit size.", name="Banana", calories=105, protein=1.3, carbs=27, fats=0.3
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Protein Shake (Whey)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1 scoop whey (120 cal). Water (0 cal).",
|
||||||
|
name="Whey Protein Shake",
|
||||||
|
calories=120,
|
||||||
|
protein=24,
|
||||||
|
carbs=3,
|
||||||
|
fats=1,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Apple with peanut butter",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1 apple (95 cal). 2 tbsp peanut butter (190 cal). Total ~285 cal.",
|
||||||
|
name="Apple & PB",
|
||||||
|
calories=285,
|
||||||
|
protein=8,
|
||||||
|
carbs=30,
|
||||||
|
fats=16,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Bag of potato chips (small)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Standard vending machine size (1.5 oz/42g). Fried.",
|
||||||
|
name="Potato Chips",
|
||||||
|
calories=220,
|
||||||
|
protein=3,
|
||||||
|
carbs=22,
|
||||||
|
fats=14,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Hummus and carrot sticks",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1/4 cup hummus (150 cal). 2 carrots (50 cal). Total ~200 cal.",
|
||||||
|
name="Hummus Snack",
|
||||||
|
calories=200,
|
||||||
|
protein=5,
|
||||||
|
carbs=25,
|
||||||
|
fats=9,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Chocolate chip cookie (Subway style)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1 large cookie, heavy on sugar/butter.",
|
||||||
|
name="Large Cookie",
|
||||||
|
calories=220,
|
||||||
|
protein=2,
|
||||||
|
carbs=30,
|
||||||
|
fats=10,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Blueberry Muffin (Bakery size)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Large bakery muffin is notoriously high cal. Flour, sugar, oil.",
|
||||||
|
name="Bakery Muffin",
|
||||||
|
calories=450,
|
||||||
|
protein=6,
|
||||||
|
carbs=65,
|
||||||
|
fats=18,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
# --- Add 25 more diverse items to reach 50 ---
|
||||||
|
dspy.Example(
|
||||||
|
description="Hard boiled egg",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="One large egg.", name="Egg", calories=78, protein=6, carbs=0.6, fats=5
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Slice of cheddar cheese",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="1 oz slice.", name="Cheddar", calories=110, protein=7, carbs=0.4, fats=9
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Glass of whole milk (8oz)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Full fat dairy.", name="Whole Milk", calories=150, protein=8, carbs=12, fats=8
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Coca Cola (12oz can)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="High sugar soda.", name="Coke", calories=140, protein=0, carbs=39, fats=0
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Orange Juice (8oz)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Natural sugars.", name="OJ", calories=110, protein=2, carbs=26, fats=0
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Kind Bar (Dark Chocolate Nuts)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Nut based bar.", name="Nut Bar", calories=200, protein=6, carbs=16, fats=13
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Bowl of Beef Chili (1 cup)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Ground beef, beans, tomato base.", name="Chili", calories=300, protein=20, carbs=25, fats=15
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Pork Chop (baked) with green beans",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="6oz pork chop (250 cal). Steam beans (30 cal). Total ~280.",
|
||||||
|
name="Pork Chop Meal",
|
||||||
|
calories=280,
|
||||||
|
protein=35,
|
||||||
|
carbs=10,
|
||||||
|
fats=12,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Clam Chowder Bowl",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Cream based soup (heavy cream). 1.5 cups.",
|
||||||
|
name="Clam Chowder",
|
||||||
|
calories=450,
|
||||||
|
protein=12,
|
||||||
|
carbs=40,
|
||||||
|
fats=28,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Philly Cheesesteak",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Roll (250 cal). Fatty steak (400 cal). Cheese whiz/provolone (150 cal). Oil (100 cal).",
|
||||||
|
name="Cheesesteak",
|
||||||
|
calories=900,
|
||||||
|
protein=40,
|
||||||
|
carbs=50,
|
||||||
|
fats=55,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Fish and Chips (3 pieces)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Deep fried batter fits + fried chips. Very high oil absorption.",
|
||||||
|
name="Fish and Chips",
|
||||||
|
calories=950,
|
||||||
|
protein=30,
|
||||||
|
carbs=90,
|
||||||
|
fats=55,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Cobb Salad with ranch",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Greens, bacon, egg, avocado, blue cheese, ranch dressing. Salad is low cal, toppings are high.",
|
||||||
|
name="Cobb Salad",
|
||||||
|
calories=750,
|
||||||
|
protein=35,
|
||||||
|
carbs=15,
|
||||||
|
fats=60,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Hot Dog with bun, mustard, ketchup",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Processed meat link (150 cal). Bun (120 cal). Condiments (20 cal).",
|
||||||
|
name="Hot Dog",
|
||||||
|
calories=290,
|
||||||
|
protein=10,
|
||||||
|
carbs=25,
|
||||||
|
fats=16,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Pad Thai with Shrimp",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Rice noodles stir fried in oil and sugar based sauce. Peanuts.",
|
||||||
|
name="Pad Thai",
|
||||||
|
calories=800,
|
||||||
|
protein=25,
|
||||||
|
carbs=110,
|
||||||
|
fats=30,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Burrito (Chipotle style - Chicken, Rice, Beans, Cheese, Guac)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Tortilla (300). Rice (200). Beans (150). Chicken (180). Cheese (100). Guac (230!).",
|
||||||
|
name="Burrito",
|
||||||
|
calories=1160,
|
||||||
|
protein=55,
|
||||||
|
carbs=110,
|
||||||
|
fats=55,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Smoothie (Berry, Banana, Yogurt)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Healthy but sugar dense fruits + yogurt.",
|
||||||
|
name="Fruit Smoothie",
|
||||||
|
calories=300,
|
||||||
|
protein=8,
|
||||||
|
carbs=60,
|
||||||
|
fats=2,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Falafel Wrap",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Fried chickpea balls (250), pita (150), tahini sauce (100).",
|
||||||
|
name="Falafel Wrap",
|
||||||
|
calories=550,
|
||||||
|
protein=15,
|
||||||
|
carbs=70,
|
||||||
|
fats=25,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Macaroni and Cheese (1 cup homemade)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Pasta + Roux + Milk + lots of Cheese.",
|
||||||
|
name="Mac & Cheese",
|
||||||
|
calories=500,
|
||||||
|
protein=18,
|
||||||
|
carbs=45,
|
||||||
|
fats=28,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Ice Cream (2 scoops vanilla)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Sugar and Cream.", name="Ice Cream", calories=350, protein=6, carbs=40, fats=20
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Cottage Cheese (1 cup)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Low fat high protein dairy.", name="Cottage Cheese", calories=180, protein=25, carbs=10, fats=5
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Beef Jerky (1 bag / 3oz)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Dried meat, lean protein.", name="Beef Jerky", calories=240, protein=35, carbs=15, fats=4
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Edamame (1 cup in pod)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Soybeans.", name="Edamame", calories=190, protein=17, carbs=15, fats=8
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Popcorn (movie theater small, buttered)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Corn + oil popping + butter topping.",
|
||||||
|
name="Movie Popcorn",
|
||||||
|
calories=600,
|
||||||
|
protein=6,
|
||||||
|
carbs=60,
|
||||||
|
fats=40,
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Veggie Pizza Slice",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Cheese + Dough + Veggies.", name="Veggie Pizza", calories=260, protein=10, carbs=32, fats=10
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
dspy.Example(
|
||||||
|
description="Salmon Nigiri (2 pcs)",
|
||||||
|
nutritional_info=NutritionalInfo(
|
||||||
|
reasoning="Rice ball + Slice of raw fish.", name="Salmon Nigiri", calories=120, protein=10, carbs=15, fats=3
|
||||||
|
),
|
||||||
|
).with_inputs("description"),
|
||||||
|
]
|
||||||
41
backend/scripts/optimize_nutrition.py
Normal file
41
backend/scripts/optimize_nutrition.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from dspy.teleprompt import BootstrapFewShot
|
||||||
|
|
||||||
|
from app.ai.nutrition import nutrition_module
|
||||||
|
from app.core.ai_config import configure_dspy
|
||||||
|
from scripts.nutrition_data import train_examples
|
||||||
|
|
||||||
|
# 0. Configure DSPy
|
||||||
|
configure_dspy()
|
||||||
|
|
||||||
|
# 1. Define Validated Examples (The "Train Set")
|
||||||
|
# ... (rest of the file) ...
|
||||||
|
|
||||||
|
|
||||||
|
# 2. Define a Metric
|
||||||
|
def validate_nutrition(example, pred, trace=None):
|
||||||
|
# Check if the predicted calories are within 15% of the actual calories
|
||||||
|
actual_cals = example.nutritional_info.calories
|
||||||
|
pred_cals = pred.nutritional_info.calories
|
||||||
|
|
||||||
|
threshold = 0.15
|
||||||
|
lower = actual_cals * (1 - threshold)
|
||||||
|
upper = actual_cals * (1 + threshold)
|
||||||
|
|
||||||
|
return lower <= pred_cals <= upper
|
||||||
|
|
||||||
|
|
||||||
|
# 3. Setup the Optimizer
|
||||||
|
teleprompter = BootstrapFewShot(metric=validate_nutrition, max_bootstrapped_demos=8, max_labeled_demos=8)
|
||||||
|
|
||||||
|
# 4. Compile (Optimize) the Module
|
||||||
|
print("Optimizing... (this calls the LLM for each example)")
|
||||||
|
compiled_nutrition = teleprompter.compile(nutrition_module, trainset=train_examples)
|
||||||
|
|
||||||
|
# 5. Save validity
|
||||||
|
# Correct path relative to backend/ directory
|
||||||
|
compiled_nutrition.save("app/ai/nutrition_compiled.json")
|
||||||
|
print("Optimization complete! Saved to app/ai/nutrition_compiled.json")
|
||||||
|
|
||||||
|
# 6. Usage
|
||||||
|
# To use the optimized version in production, you would load it:
|
||||||
|
# nutrition_module.load("backend/app/ai/nutrition_compiled.json")
|
||||||
58
backend/scripts/optimize_nutrition_v2.py
Normal file
58
backend/scripts/optimize_nutrition_v2.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from dspy.teleprompt import BootstrapFewShotWithRandomSearch
|
||||||
|
|
||||||
|
from app.ai.nutrition import nutrition_module
|
||||||
|
from app.core.ai_config import configure_dspy
|
||||||
|
from scripts.nutrition_data import train_examples
|
||||||
|
|
||||||
|
# 0. Configure DSPy
|
||||||
|
configure_dspy()
|
||||||
|
|
||||||
|
|
||||||
|
# 1. Define Advanced Metric
|
||||||
|
def validate_nutrition_v2(example, pred, trace=None):
|
||||||
|
# Condition A: Accuracy (within 15% of ground truth)
|
||||||
|
actual_cals = example.nutritional_info.calories
|
||||||
|
pred_cals = pred.nutritional_info.calories
|
||||||
|
|
||||||
|
threshold = 0.15
|
||||||
|
lower = actual_cals * (1 - threshold)
|
||||||
|
upper = actual_cals * (1 + threshold)
|
||||||
|
is_accurate_count = lower <= pred_cals <= upper
|
||||||
|
|
||||||
|
# Condition B: Consistency (Macros match Calories within 20%)
|
||||||
|
# This prevents "hallucinated" numbers that don't satisfy physics
|
||||||
|
p = pred.nutritional_info.protein
|
||||||
|
c = pred.nutritional_info.carbs
|
||||||
|
f = pred.nutritional_info.fats
|
||||||
|
|
||||||
|
calculated_cals = (p * 4) + (c * 4) + (f * 9)
|
||||||
|
# Using a slightly looser bounds (20%) for fiber/rounding
|
||||||
|
consistency_threshold = 0.20
|
||||||
|
is_consistent_math = abs(calculated_cals - pred_cals) < (pred_cals * consistency_threshold)
|
||||||
|
|
||||||
|
# We want BOTH to be true
|
||||||
|
return is_accurate_count and is_consistent_math
|
||||||
|
|
||||||
|
|
||||||
|
# 2. Setup Advanced Optimizer
|
||||||
|
# RandomSearch is more expensive but finds better reasoning traces by randomizing
|
||||||
|
# the selection of few-shot examples.
|
||||||
|
# num_candidate_programs=10 means it will try 10 different combinations of prompts/examples
|
||||||
|
print("Configuring RandomSearch Optimizer...")
|
||||||
|
teleprompter = BootstrapFewShotWithRandomSearch(
|
||||||
|
metric=validate_nutrition_v2,
|
||||||
|
max_bootstrapped_demos=4,
|
||||||
|
max_labeled_demos=4,
|
||||||
|
num_candidate_programs=5, # Reduced to 5 for speed in this demo, typically 10-20
|
||||||
|
num_threads=1, # Sequential for stability, increase for parallelism
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Compile (Optimize) the Module
|
||||||
|
print("Optimizing V2 (this includes random search and macro checks)...")
|
||||||
|
# Note: assertions are compiled into the pipeline automatically in newer DSPy,
|
||||||
|
# acting as soft constraints during the search.
|
||||||
|
compiled_nutrition = teleprompter.compile(nutrition_module, trainset=train_examples)
|
||||||
|
|
||||||
|
# 4. Save
|
||||||
|
compiled_nutrition.save("app/ai/nutrition_compiled.json")
|
||||||
|
print("Optimization V2 complete! Overwrote app/ai/nutrition_compiled.json")
|
||||||
2
check_dspy.py
Normal file
2
check_dspy.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import dspy
|
||||||
|
print(f"Has Image: {hasattr(dspy, 'Image')}")
|
||||||
@@ -49,6 +49,14 @@ cleanup() {
|
|||||||
exit
|
exit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
echo "Running code formatting and linting..."
|
||||||
|
ruff format backend
|
||||||
|
ruff check backend --fix
|
||||||
|
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
|
||||||
# Trap signals for cleanup
|
# Trap signals for cleanup
|
||||||
trap cleanup SIGINT SIGTERM
|
trap cleanup SIGINT SIGTERM
|
||||||
|
|
||||||
|
|||||||
1180
frontend/package-lock.json
generated
1180
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.6.0"
|
"recharts": "^3.6.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { AuthProvider } from './context/AuthContext';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Nutrition from './pages/Nutrition';
|
import Nutrition from './pages/Nutrition';
|
||||||
|
import Health from './pages/Health';
|
||||||
|
import Plans from './pages/Plans';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -22,7 +24,16 @@ function App() {
|
|||||||
<Nutrition />
|
<Nutrition />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
{/* Add Health route later */}
|
<Route path="/health" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Health />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/plans" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Plans />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const Dashboard = () => {
|
|||||||
<h2 className="text-2xl font-bold mb-2">Health Metrics</h2>
|
<h2 className="text-2xl font-bold mb-2">Health Metrics</h2>
|
||||||
<p className="text-gray-400">Track weight and blood indicators.</p>
|
<p className="text-gray-400">Track weight and blood indicators.</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/plans" className="p-6 bg-gray-800 rounded-lg shadow-lg hover:bg-gray-700 transition">
|
||||||
|
<h2 className="text-2xl font-bold mb-2">AI Coach</h2>
|
||||||
|
<p className="text-gray-400">Get personalized diet & workout plans.</p>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
240
frontend/src/pages/Health.jsx
Normal file
240
frontend/src/pages/Health.jsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import client from '../api/client';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
const Health = () => {
|
||||||
|
const [metrics, setMetrics] = useState([]);
|
||||||
|
const [goals, setGoals] = useState([]);
|
||||||
|
const [newMetric, setNewMetric] = useState({ metric_type: 'weight', value: '', unit: 'kg' });
|
||||||
|
const [newGoal, setNewGoal] = useState({ goal_type: 'lose_weight', target_value: '', target_date: '' });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedMetricType, setSelectedMetricType] = useState('weight');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [metricsRes, goalsRes] = await Promise.all([
|
||||||
|
client.get('/health/metrics'),
|
||||||
|
client.get('/health/goals')
|
||||||
|
]);
|
||||||
|
setMetrics(metricsRes.data);
|
||||||
|
setGoals(goalsRes.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch health data', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMetric = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await client.post('/health/metrics', newMetric);
|
||||||
|
setNewMetric({ ...newMetric, value: '' });
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Failed to add metric');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddGoal = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await client.post('/health/goals', {
|
||||||
|
...newGoal,
|
||||||
|
target_date: newGoal.target_date || null
|
||||||
|
});
|
||||||
|
setNewGoal({ ...newGoal, target_value: '', target_date: '' });
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Failed to add goal');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
return metrics
|
||||||
|
.filter(m => m.metric_type === selectedMetricType)
|
||||||
|
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
||||||
|
.map(m => ({
|
||||||
|
date: new Date(m.timestamp).toLocaleDateString(),
|
||||||
|
value: m.value
|
||||||
|
}));
|
||||||
|
}, [metrics, selectedMetricType]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl mx-auto animated-fade-in">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-white">Health Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
{/* Metrics Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
||||||
|
<h2 className="text-xl font-bold mb-4 text-blue-400">Track New Metric</h2>
|
||||||
|
<form onSubmit={handleAddMetric} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
|
||||||
|
value={newMetric.metric_type}
|
||||||
|
onChange={(e) => setNewMetric({ ...newMetric, metric_type: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="weight">Weight</option>
|
||||||
|
<option value="cholesterol">Cholesterol</option>
|
||||||
|
<option value="vitamin_d">Vitamin D</option>
|
||||||
|
<option value="testosterone">Testosterone</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 mb-1">Value</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
|
||||||
|
value={newMetric.value}
|
||||||
|
onChange={(e) => setNewMetric({ ...newMetric, value: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 mb-1">Unit</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
|
||||||
|
value={newMetric.unit}
|
||||||
|
onChange={(e) => setNewMetric({ ...newMetric, unit: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 p-2 rounded text-white font-bold transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
{loading ? 'Adding...' : 'Add Metric'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-white">Progress Chart</h2>
|
||||||
|
<select
|
||||||
|
className="bg-gray-700 text-sm text-gray-300 rounded p-1 border border-gray-600 outline-none"
|
||||||
|
value={selectedMetricType}
|
||||||
|
onChange={(e) => setSelectedMetricType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="weight">Weight</option>
|
||||||
|
<option value="cholesterol">Cholesterol</option>
|
||||||
|
<option value="vitamin_d">Vitamin D</option>
|
||||||
|
<option value="testosterone">Testosterone</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="h-64 w-full">
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis dataKey="date" stroke="#9CA3AF" />
|
||||||
|
<YAxis stroke="#9CA3AF" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#1F2937', border: 'none', color: '#F9FAFB' }}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="value" stroke="#3B82F6" strokeWidth={2} dot={{ r: 4 }} activeDot={{ r: 8 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex items-center justify-center text-gray-500">
|
||||||
|
No data available for this metric
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Goals Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
||||||
|
<h2 className="text-xl font-bold mb-4 text-purple-400">Set New Goal</h2>
|
||||||
|
<form onSubmit={handleAddGoal} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 mb-1">Goal Type</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
||||||
|
value={newGoal.goal_type}
|
||||||
|
onChange={(e) => setNewGoal({ ...newGoal, goal_type: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="lose_weight">Lose Weight</option>
|
||||||
|
<option value="gain_muscle">Gain Muscle</option>
|
||||||
|
<option value="improve_health">Improve Indicators</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 mb-1">Target Value</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
||||||
|
value={newGoal.target_value}
|
||||||
|
onChange={(e) => setNewGoal({ ...newGoal, target_value: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 mb-1">Target Date (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
||||||
|
value={newGoal.target_date}
|
||||||
|
onChange={(e) => setNewGoal({ ...newGoal, target_date: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 p-2 rounded text-white font-bold transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
{loading ? 'Setting...' : 'Set Goal'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
||||||
|
<h2 className="text-xl font-bold mb-4 text-white">Active Goals</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{goals.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No active goals.</p>
|
||||||
|
) : (
|
||||||
|
goals.map((g) => (
|
||||||
|
<div key={g.id} className="bg-gray-700/50 p-4 rounded border-l-4 border-purple-500 hover:bg-gray-700 transition-colors">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<span className="font-bold text-white capitalize">{g.goal_type.replace('_', ' ')}</span>
|
||||||
|
<span className="text-purple-300 font-mono text-lg">{g.target_value}</span>
|
||||||
|
</div>
|
||||||
|
{g.target_date && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Target: {new Date(g.target_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Health;
|
||||||
@@ -1,15 +1,37 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import client from '../api/client';
|
import client from '../api/client';
|
||||||
|
|
||||||
const Nutrition = () => {
|
const Nutrition = () => {
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [analysis, setAnalysis] = useState(null);
|
const [analysis, setAnalysis] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
setSelectedFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await client.post('/nutrition/analyze', { description });
|
let res;
|
||||||
|
if (selectedFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
if (description) {
|
||||||
|
formData.append('description', description);
|
||||||
|
}
|
||||||
|
res = await client.post('/nutrition/analyze/image', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await client.post('/nutrition/analyze', { description });
|
||||||
|
}
|
||||||
setAnalysis(res.data);
|
setAnalysis(res.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -22,11 +44,12 @@ const Nutrition = () => {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!analysis) return;
|
if (!analysis) return;
|
||||||
try {
|
try {
|
||||||
// Backend extracts user from token via params/dependency
|
|
||||||
await client.post('/nutrition/log', analysis);
|
await client.post('/nutrition/log', analysis);
|
||||||
alert('Saved!');
|
alert('Saved!');
|
||||||
setAnalysis(null);
|
setAnalysis(null);
|
||||||
setDescription('');
|
setDescription('');
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert('Failed to save');
|
alert('Failed to save');
|
||||||
@@ -35,51 +58,89 @@ const Nutrition = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-4xl mx-auto">
|
<div className="p-8 max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6">Nutrition Tracker</h1>
|
<h1 className="text-3xl font-bold mb-6 text-white">Nutrition Tracker</h1>
|
||||||
<div className="bg-gray-800 p-6 rounded-lg mb-8">
|
<div className="bg-gray-800 p-6 rounded-lg mb-8 shadow-lg">
|
||||||
<textarea
|
<div className="mb-4">
|
||||||
className="w-full p-4 bg-gray-700 rounded mb-4 text-white"
|
<label className="block text-gray-300 mb-2 font-medium">Describe your meal or upload a photo</label>
|
||||||
rows="4"
|
<textarea
|
||||||
placeholder="Describe your meal (e.g. 'A chicken breast with a cup of rice')..."
|
className="w-full p-4 bg-gray-700 rounded border border-gray-600 focus:border-blue-500 focus:outline-none text-white transition-colors"
|
||||||
value={description}
|
rows="3"
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
placeholder="E.g. 'A chicken breast with a cup of rice'..."
|
||||||
/>
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
id="food-image-upload"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="food-image-upload"
|
||||||
|
className={`cursor-pointer inline-flex items-center px-4 py-2 rounded-lg border transition-all ${selectedFile
|
||||||
|
? 'bg-blue-900 border-blue-500 text-blue-200'
|
||||||
|
: 'bg-gray-700 border-gray-600 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{selectedFile ? selectedFile.name : 'Upload Photo'}
|
||||||
|
</label>
|
||||||
|
{selectedFile && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}}
|
||||||
|
className="ml-2 text-red-400 hover:text-red-300 text-sm"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
disabled={loading}
|
disabled={loading || (!description && !selectedFile)}
|
||||||
className="bg-blue-600 px-6 py-2 rounded text-white font-bold"
|
className={`px-8 py-3 rounded-lg font-bold text-white transition-all transform hover:scale-105 ${loading || (!description && !selectedFile)
|
||||||
|
? 'bg-gray-600 cursor-not-allowed'
|
||||||
|
: 'bg-gradient-to-r from-blue-600 to-indigo-600 shadow-lg hover:shadow-blue-500/50'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{loading ? 'Analyzing...' : 'Analyze'}
|
{loading ? 'Analyzing...' : 'Analyze Meal'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{analysis && (
|
{analysis && (
|
||||||
<div className="bg-gray-800 p-6 rounded-lg">
|
<div className="bg-gray-800 p-6 rounded-lg shadow-lg animated-fade-in">
|
||||||
<h2 className="text-2xl font-bold mb-4">Analysis Result</h2>
|
<h2 className="text-2xl font-bold mb-6 text-white">Analysis Result</h2>
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<div className="p-4 bg-gray-700 rounded">
|
{[
|
||||||
<span className="block text-gray-400">Calories</span>
|
{ label: 'Calories', value: analysis.calories, unit: '' },
|
||||||
<span className="text-xl font-bold">{analysis.calories}</span>
|
{ label: 'Protein', value: analysis.protein, unit: 'g' },
|
||||||
</div>
|
{ label: 'Carbs', value: analysis.carbs, unit: 'g' },
|
||||||
<div className="p-4 bg-gray-700 rounded">
|
{ label: 'Fats', value: analysis.fats, unit: 'g' }
|
||||||
<span className="block text-gray-400">Protein</span>
|
].map((item) => (
|
||||||
<span className="text-xl font-bold">{analysis.protein}g</span>
|
<div key={item.label} className="p-4 bg-gray-700/50 rounded-lg border border-gray-600 text-center">
|
||||||
</div>
|
<span className="block text-gray-400 text-sm">{item.label}</span>
|
||||||
<div className="p-4 bg-gray-700 rounded">
|
<span className="text-2xl font-bold text-white">{item.value}{item.unit}</span>
|
||||||
<span className="block text-gray-400">Carbs</span>
|
</div>
|
||||||
<span className="text-xl font-bold">{analysis.carbs}g</span>
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-gray-700 rounded">
|
<div className="flex justify-end">
|
||||||
<span className="block text-gray-400">Fats</span>
|
<button
|
||||||
<span className="text-xl font-bold">{analysis.fats}g</span>
|
onClick={handleSave}
|
||||||
</div>
|
className="bg-green-600 hover:bg-green-700 px-8 py-2 rounded-lg text-white font-bold transition-colors shadow-lg hover:shadow-green-500/30"
|
||||||
|
>
|
||||||
|
Save to Log
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="bg-green-600 px-6 py-2 rounded text-white font-bold"
|
|
||||||
>
|
|
||||||
Save to Log
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
198
frontend/src/pages/Plans.jsx
Normal file
198
frontend/src/pages/Plans.jsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import client from '../api/client';
|
||||||
|
import ReactMarkdown from 'react-markdown'; // Assuming we might want md support, but for now I'll use structured display or simple whitespace pre-line
|
||||||
|
|
||||||
|
const Plans = () => {
|
||||||
|
const [plans, setPlans] = useState([]);
|
||||||
|
const [goal, setGoal] = useState('');
|
||||||
|
const [userDetails, setUserDetails] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
try {
|
||||||
|
const res = await client.get('/plans/');
|
||||||
|
setPlans(res.data);
|
||||||
|
if (res.data.length > 0) setSelectedPlan(res.data[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plans', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await client.post('/plans/generate', { goal, user_details: userDetails });
|
||||||
|
setPlans([res.data, ...plans]);
|
||||||
|
setSelectedPlan(res.data);
|
||||||
|
setGoal('');
|
||||||
|
setUserDetails('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Failed to generate plan');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto animated-fade-in">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-white">AI Coach</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Left Column: Generator & History */}
|
||||||
|
<div className="space-y-6 lg:col-span-1">
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
||||||
|
<h2 className="text-xl font-bold mb-4 text-purple-400">Request New Plan</h2>
|
||||||
|
<form onSubmit={handleGenerate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 mb-1">Your Goal</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
||||||
|
placeholder="e.g. Lose 5kg in 2 months"
|
||||||
|
value={goal}
|
||||||
|
onChange={(e) => setGoal(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 mb-1">Your Details</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Male, 30, 80kg, access to gym..."
|
||||||
|
value={userDetails}
|
||||||
|
onChange={(e) => setUserDetails(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 p-2 rounded text-white font-bold transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
{loading ? 'Generating Plan...' : 'Generate Plan'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700 h-96 overflow-y-auto">
|
||||||
|
<h2 className="text-xl font-bold mb-4 text-white">History</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{plans.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setSelectedPlan(p)}
|
||||||
|
className={`p-4 rounded cursor-pointer transition-colors border-l-4 ${selectedPlan?.id === p.id
|
||||||
|
? 'bg-gray-700 border-purple-500'
|
||||||
|
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-bold text-white truncate">{p.goal}</p>
|
||||||
|
<p className="text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{plans.length === 0 && <p className="text-gray-500">No plans yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Plan View */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{selectedPlan ? (
|
||||||
|
<div className="bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-700 min-h-[600px]">
|
||||||
|
{loading && selectedPlan === plans[0] && plans.length > 0 ? (
|
||||||
|
// Show loading logic if we just added it - actually layout handles this ok
|
||||||
|
// But better to check loading state
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-8 bg-gray-700 rounded w-1/3"></div>
|
||||||
|
<div className="h-4 bg-gray-700 rounded w-full"></div>
|
||||||
|
<div className="h-4 bg-gray-700 rounded w-full"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-start mb-6 border-b border-gray-700 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">
|
||||||
|
{selectedPlan.structured_content?.title || selectedPlan.goal}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 mt-2">{selectedPlan.structured_content?.summary}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500 bg-gray-900 px-3 py-1 rounded-full">
|
||||||
|
{new Date(selectedPlan.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedPlan.structured_content ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-blue-400 mb-3 flex items-center">
|
||||||
|
<span className="mr-2">🥗</span> Diet Plan
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{selectedPlan.structured_content.diet_plan?.map((item, i) => (
|
||||||
|
<li key={i} className="flex items-start text-gray-300">
|
||||||
|
<span className="mr-2 text-blue-500">•</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-green-400 mb-3 flex items-center">
|
||||||
|
<span className="mr-2">💪</span> Exercise Routine
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{selectedPlan.structured_content.exercise_plan?.map((item, i) => (
|
||||||
|
<li key={i} className="flex items-start text-gray-300">
|
||||||
|
<span className="mr-2 text-green-500">•</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-yellow-400 mb-3 flex items-center">
|
||||||
|
<span className="mr-2">💡</span> Coach Tips
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{selectedPlan.structured_content.tips?.map((item, i) => (
|
||||||
|
<div key={i} className="bg-gray-700/50 p-4 rounded border border-gray-600">
|
||||||
|
<p className="text-gray-300">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-300 whitespace-pre-wrap font-mono text-sm">
|
||||||
|
{selectedPlan.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-700 h-full flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="text-6xl mb-4">🤖</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Welcome to AI Coach</h2>
|
||||||
|
<p className="text-gray-400 max-w-md">
|
||||||
|
Describe your goals and get a personalized nutrition and workout plan generated by our advanced AI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Plans;
|
||||||
Reference in New Issue
Block a user