diff --git a/backend/app/ai/nutrition.py b/backend/app/ai/nutrition.py index d146988..c148559 100644 --- a/backend/app/ai/nutrition.py +++ b/backend/app/ai/nutrition.py @@ -1,25 +1,107 @@ +import base64 + import dspy from pydantic import BaseModel, Field +from app.config import settings + + class NutritionalInfo(BaseModel): - name: str - calories: float - protein: float - carbs: float - fats: float + reasoning: str = Field(description="Step-by-step reasoning for the nutritional estimates") + name: str = Field(description="Name of the food item") + calories: float = Field(description="Estimated calories") + protein: float = Field(description="Estimated protein in grams") + carbs: float = Field(description="Estimated carbohydrates in grams") + fats: float = Field(description="Estimated fats in grams") micros: dict | None = None + 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") - 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): def __init__(self): super().__init__() 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): - 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() + + +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 diff --git a/backend/app/ai/nutrition_compiled.json b/backend/app/ai/nutrition_compiled.json new file mode 100644 index 0000000..77d9ac1 --- /dev/null +++ b/backend/app/ai/nutrition_compiled.json @@ -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" + } + } +} diff --git a/backend/app/ai/plans.py b/backend/app/ai/plans.py new file mode 100644 index 0000000..472d116 --- /dev/null +++ b/backend/app/ai/plans.py @@ -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() diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 2433419..c1f56bf 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,34 +1,32 @@ -from typing import Generator -from sqlmodel import Session -from app.db import engine +from typing import Annotated, Generator -from typing import Generator, Annotated -from sqlmodel import Session, select from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer -from jose import jwt, JWTError +from jose import JWTError, jwt 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.models.user import User -from app.core import security -from app.config import settings from app.schemas.token import TokenPayload oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token") + def get_session() -> Generator[Session, None, None]: with Session(engine) as session: yield session + SessionDep = Annotated[Session, Depends(get_session)] TokenDep = Annotated[str, Depends(oauth2_scheme)] + def get_current_user(session: SessionDep, token: TokenDep) -> User: try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]) token_data = TokenPayload(**payload) except (JWTError, ValidationError): raise HTTPException( @@ -40,4 +38,5 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User: raise HTTPException(status_code=404, detail="User not found") return user + CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index daac3ae..81a9953 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,8 +1,10 @@ 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.include_router(login.router, tags=["login"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(nutrition.router, prefix="/nutrition", tags=["nutrition"]) api_router.include_router(health.router, prefix="/health", tags=["health"]) +api_router.include_router(plans.router, prefix="/plans", tags=["plans"]) diff --git a/backend/app/api/v1/endpoints/health.py b/backend/app/api/v1/endpoints/health.py index b716eb3..f922a06 100644 --- a/backend/app/api/v1/endpoints/health.py +++ b/backend/app/api/v1/endpoints/health.py @@ -1,35 +1,80 @@ +from datetime import datetime from typing import Any, List -from fastapi import APIRouter, Depends, HTTPException -from sqlmodel import Session, select -from app.api import deps -from app.models.health import HealthMetric + +from fastapi import APIRouter, Depends from pydantic import BaseModel +from sqlmodel import Session, select + +from app.api import deps +from app.models.health import HealthGoal, HealthMetric router = APIRouter() + class HealthMetricCreate(BaseModel): metric_type: str value: float 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( *, session: Session = Depends(deps.get_session), + current_user: deps.CurrentUser, metric_in: HealthMetricCreate, ) -> 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.commit() session.refresh(metric) return metric -@router.get("/{user_id}", response_model=List[HealthMetric]) + +@router.get("/metrics", response_model=List[HealthMetric]) def read_metrics( - user_id: int, + current_user: deps.CurrentUser, session: Session = Depends(deps.get_session), ) -> 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() 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 diff --git a/backend/app/api/v1/endpoints/login.py b/backend/app/api/v1/endpoints/login.py index b8b3430..fa30de0 100644 --- a/backend/app/api/v1/endpoints/login.py +++ b/backend/app/api/v1/endpoints/login.py @@ -1,35 +1,34 @@ from datetime import timedelta from typing import Any + from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from sqlmodel import Session, select from app.api import deps -from app.core import security from app.config import settings +from app.core import security from app.models.user import User from app.schemas.token import Token router = APIRouter() + @router.post("/login/access-token", response_model=Token) def login_access_token( - session: Session = Depends(deps.get_session), - form_data: OAuth2PasswordRequestForm = Depends() + session: Session = Depends(deps.get_session), form_data: OAuth2PasswordRequestForm = Depends() ) -> Any: """ OAuth2 compatible token login, get an access token for future requests """ statement = select(User).where(User.email == form_data.username) user = session.exec(statement).first() - + if not user or not security.verify_password(form_data.password, user.password_hash): raise HTTPException(status_code=400, detail="Incorrect email or password") - + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) return { - "access_token": security.create_access_token( - user.id, expires_delta=access_token_expires - ), + "access_token": security.create_access_token(user.id, expires_delta=access_token_expires), "token_type": "bearer", } diff --git a/backend/app/api/v1/endpoints/nutrition.py b/backend/app/api/v1/endpoints/nutrition.py index 7b9e1e4..dd7c3fe 100644 --- a/backend/app/api/v1/endpoints/nutrition.py +++ b/backend/app/api/v1/endpoints/nutrition.py @@ -1,21 +1,21 @@ +import litellm +import dspy from typing import Any -from fastapi import APIRouter, Depends, HTTPException + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from pydantic import BaseModel from sqlmodel import Session + +from app.ai.nutrition import NutritionalInfo, analyze_nutrition_from_image, nutrition_module from app.api import deps -from app.ai.nutrition import nutrition_module, NutritionalInfo -from app.core.security import create_access_token # Just ensuring we have auth imports if needed later -from app.models.user import User +from app.models.food import FoodLog # Added FoodItem router = APIRouter() + class AnalyzeRequest(BaseModel): 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) def analyze_food( @@ -30,6 +30,24 @@ def analyze_food( except Exception as 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) def log_food( *, diff --git a/backend/app/api/v1/endpoints/plans.py b/backend/app/api/v1/endpoints/plans.py new file mode 100644 index 0000000..de18d2d --- /dev/null +++ b/backend/app/api/v1/endpoints/plans.py @@ -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 diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 74016a8..caca4e2 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -1,4 +1,5 @@ from typing import Any + from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select @@ -9,6 +10,7 @@ from app.schemas.user import UserCreate, UserRead router = APIRouter() + @router.post("/", response_model=UserRead) def create_user( *, @@ -24,7 +26,7 @@ def create_user( status_code=400, detail="The user with this email already exists in the system", ) - + user = User( email=user_in.email, username=user_in.username, diff --git a/backend/app/config.py b/backend/app/config.py index 58310f4..6ee20e3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,13 +1,15 @@ from pydantic_settings import BaseSettings + class Settings(BaseSettings): DATABASE_URL: str OPENAI_API_KEY: str | None = None 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: env_file = ".env" + extra = "ignore" + settings = Settings() diff --git a/backend/app/core/ai_config.py b/backend/app/core/ai_config.py index c765632..f455c6c 100644 --- a/backend/app/core/ai_config.py +++ b/backend/app/core/ai_config.py @@ -1,6 +1,8 @@ import dspy + from app.config import settings + def configure_dspy(): if settings.OPENAI_API_KEY: lm = dspy.LM("openai/gpt-4o-mini", api_key=settings.OPENAI_API_KEY) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 827301c..6716b22 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,24 +1,29 @@ from datetime import datetime, timedelta from typing import Any, Union + from jose import jwt from passlib.context import CryptContext + from app.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") ALGORITHM = "HS256" + def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str: if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - + to_encode = {"exp": expire, "sub": str(subject)} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt + def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) + def get_password_hash(password: str) -> str: return pwd_context.hash(password) diff --git a/backend/app/db.py b/backend/app/db.py index b2e13b0..fb3cb1b 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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 engine = create_engine(settings.DATABASE_URL) + def get_session(): with Session(engine) as session: yield session + def init_db(): with Session(engine) as session: session.exec(text("CREATE EXTENSION IF NOT EXISTS vector")) diff --git a/backend/app/main.py b/backend/app/main.py index e7cb272..6e1f918 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,12 @@ from contextlib import asynccontextmanager -from dotenv import load_dotenv - -load_dotenv() from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + from app.api.v1.api import api_router -from app.db import init_db from app.core.ai_config import configure_dspy +from app.db import init_db + @asynccontextmanager async def lifespan(app: FastAPI): @@ -14,7 +14,6 @@ async def lifespan(app: FastAPI): configure_dspy() yield -from fastapi.middleware.cors import CORSMiddleware app = FastAPI(title="Healthy Fit API", lifespan=lifespan) @@ -28,6 +27,7 @@ app.add_middleware( app.include_router(api_router, prefix="/api/v1") + @app.get("/") def read_root(): return {"message": "Welcome to Healthy Fit API"} diff --git a/backend/app/models/food.py b/backend/app/models/food.py index a5e2323..c6643bd 100644 --- a/backend/app/models/food.py +++ b/backend/app/models/food.py @@ -1,8 +1,10 @@ from datetime import datetime -from typing import Optional, List, Dict -from sqlmodel import Field, SQLModel, JSON +from typing import Dict, List, Optional + from pgvector.sqlalchemy import Vector from sqlalchemy import Column +from sqlmodel import JSON, Field, SQLModel + class FoodItem(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) @@ -12,13 +14,14 @@ class FoodItem(SQLModel, table=True): carbs: float fats: float 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): id: Optional[int] = Field(default=None, primary_key=True) user_id: int = Field(foreign_key="user.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 protein: float carbs: float diff --git a/backend/app/models/health.py b/backend/app/models/health.py index 11e59b5..d36abfa 100644 --- a/backend/app/models/health.py +++ b/backend/app/models/health.py @@ -1,19 +1,22 @@ from datetime import datetime from typing import Optional + from sqlmodel import Field, SQLModel + class HealthMetric(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) 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 unit: str timestamp: datetime = Field(default_factory=datetime.utcnow) + class HealthGoal(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) 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_date: Optional[datetime] = None created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py new file mode 100644 index 0000000..e9399c5 --- /dev/null +++ b/backend/app/models/plan.py @@ -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) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 506c021..5bd0bc3 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,7 +1,9 @@ from datetime import datetime from typing import Optional + from sqlmodel import Field, SQLModel + class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) username: str = Field(index=True, unique=True) diff --git a/backend/app/schemas/token.py b/backend/app/schemas/token.py index 2a47a36..73711ea 100644 --- a/backend/app/schemas/token.py +++ b/backend/app/schemas/token.py @@ -1,9 +1,12 @@ from typing import Optional + from sqlmodel import SQLModel + class Token(SQLModel): access_token: str token_type: str + class TokenPayload(SQLModel): sub: Optional[str] = None diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3f4e0df..f50425f 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,16 +1,21 @@ from typing import Optional -from sqlmodel import SQLModel, Field + +from sqlmodel import SQLModel + class UserBase(SQLModel): email: str username: str + class UserCreate(UserBase): password: str + class UserRead(UserBase): id: int - + + class UserUpdate(SQLModel): email: Optional[str] = None username: Optional[str] = None diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..d3f75e5 --- /dev/null +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/requirements.txt b/backend/requirements.txt index 78e21d0..ff1cb0c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,3 +12,5 @@ bcrypt==4.0.1 pytest httpx python-dotenv +ruff + diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/nutrition_data.py b/backend/scripts/nutrition_data.py new file mode 100644 index 0000000..a3935db --- /dev/null +++ b/backend/scripts/nutrition_data.py @@ -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"), +] diff --git a/backend/scripts/optimize_nutrition.py b/backend/scripts/optimize_nutrition.py new file mode 100644 index 0000000..b344fd3 --- /dev/null +++ b/backend/scripts/optimize_nutrition.py @@ -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") diff --git a/backend/scripts/optimize_nutrition_v2.py b/backend/scripts/optimize_nutrition_v2.py new file mode 100644 index 0000000..40f535b --- /dev/null +++ b/backend/scripts/optimize_nutrition_v2.py @@ -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") diff --git a/check_dspy.py b/check_dspy.py new file mode 100644 index 0000000..78a1953 --- /dev/null +++ b/check_dspy.py @@ -0,0 +1,2 @@ +import dspy +print(f"Has Image: {hasattr(dspy, 'Image')}") diff --git a/develop.sh b/develop.sh index ed74672..56dd9dc 100755 --- a/develop.sh +++ b/develop.sh @@ -49,6 +49,14 @@ cleanup() { 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 cleanup SIGINT SIGTERM diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 448ffe8..5141cf5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "lucide-react": "^0.562.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.12.0", "recharts": "^3.6.0" }, @@ -1716,13 +1717,39 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1730,11 +1757,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1750,12 +1791,24 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react-swc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", @@ -1918,6 +1971,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2060,6 +2123,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2077,6 +2150,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2156,6 +2269,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2225,7 +2348,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -2353,7 +2475,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2373,6 +2494,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2389,6 +2523,28 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2718,6 +2874,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2734,6 +2900,12 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3067,6 +3239,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -3084,6 +3296,16 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3131,6 +3353,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3140,6 +3368,30 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3169,6 +3421,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3192,6 +3454,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3202,6 +3474,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3353,6 +3637,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3381,6 +3675,159 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3391,6 +3838,448 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3443,7 +4332,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3584,6 +4472,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3824,6 +4737,16 @@ "node": ">= 0.8.0" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3889,6 +4812,33 @@ "license": "MIT", "peer": true }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -4018,6 +4968,39 @@ "redux": "^5.0.0" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -4190,6 +5173,30 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4203,6 +5210,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4380,6 +5405,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4400,6 +5445,93 @@ "node": ">= 0.8.0" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4457,6 +5589,34 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -4653,6 +5813,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 707c2e0..bf6f227 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "lucide-react": "^0.562.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.12.0", "recharts": "^3.6.0" }, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c837ff8..c3a110c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,8 @@ import { AuthProvider } from './context/AuthContext'; import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; import Nutrition from './pages/Nutrition'; +import Health from './pages/Health'; +import Plans from './pages/Plans'; import ProtectedRoute from './components/ProtectedRoute'; function App() { @@ -22,7 +24,16 @@ function App() { } /> - {/* Add Health route later */} + + + + } /> + + + + } /> diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index e4d18cd..31e1e7e 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -13,6 +13,10 @@ const Dashboard = () => {

Health Metrics

Track weight and blood indicators.

+ +

AI Coach

+

Get personalized diet & workout plans.

+ ); diff --git a/frontend/src/pages/Health.jsx b/frontend/src/pages/Health.jsx new file mode 100644 index 0000000..80f8a6f --- /dev/null +++ b/frontend/src/pages/Health.jsx @@ -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 ( +
+

Health Dashboard

+ +
+ {/* Metrics Section */} +
+
+

Track New Metric

+
+
+ + +
+
+
+ + setNewMetric({ ...newMetric, value: e.target.value })} + required + /> +
+
+ + setNewMetric({ ...newMetric, unit: e.target.value })} + required + /> +
+
+ +
+
+ +
+
+

Progress Chart

+ +
+
+ {chartData.length > 0 ? ( + + + + + + + + + + ) : ( +
+ No data available for this metric +
+ )} +
+
+
+ + {/* Goals Section */} +
+
+

Set New Goal

+
+
+ + +
+
+ + setNewGoal({ ...newGoal, target_value: e.target.value })} + required + /> +
+
+ + setNewGoal({ ...newGoal, target_date: e.target.value })} + /> +
+ +
+
+ +
+

Active Goals

+
+ {goals.length === 0 ? ( +

No active goals.

+ ) : ( + goals.map((g) => ( +
+
+ {g.goal_type.replace('_', ' ')} + {g.target_value} +
+ {g.target_date && ( +

+ Target: {new Date(g.target_date).toLocaleDateString()} +

+ )} +
+ )) + )} +
+
+
+
+
+ ); +}; + +export default Health; diff --git a/frontend/src/pages/Nutrition.jsx b/frontend/src/pages/Nutrition.jsx index c2216d3..0aaa303 100644 --- a/frontend/src/pages/Nutrition.jsx +++ b/frontend/src/pages/Nutrition.jsx @@ -1,15 +1,37 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import client from '../api/client'; const Nutrition = () => { const [description, setDescription] = useState(''); const [analysis, setAnalysis] = useState(null); 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 () => { setLoading(true); 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); } catch (error) { console.error(error); @@ -22,11 +44,12 @@ const Nutrition = () => { const handleSave = async () => { if (!analysis) return; try { - // Backend extracts user from token via params/dependency await client.post('/nutrition/log', analysis); alert('Saved!'); setAnalysis(null); setDescription(''); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; } catch (error) { console.error(error); alert('Failed to save'); @@ -35,51 +58,89 @@ const Nutrition = () => { return (
-

Nutrition Tracker

-
-