Add AI-powered nutrition and plan modules

Introduces DSPy-based nutrition and plan generation modules, including image analysis for nutritional info and personalized diet/exercise plans. Adds new API endpoints for health metrics/goals, nutrition image analysis, and plan management. Updates models, schemas, and backend structure to support these features, and includes initial training data and configuration for prompt optimization.
This commit is contained in:
Carlos Escalante
2026-01-18 17:14:56 -06:00
parent 5dc6dc88f7
commit 184c8330a7
36 changed files with 2868 additions and 110 deletions

View File

@@ -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

View File

@@ -0,0 +1,147 @@
{
"extract.predict": {
"traces": [],
"train": [],
"demos": [
{
"augmented": true,
"description": "Blueberry Muffin (Bakery size)",
"reasoning": "A typical bakery-sized blueberry muffin is generally larger than a standard homemade muffin and is made from ingredients such as flour, sugar, butter, eggs, milk, blueberries, and baking powder. The estimated calorie count for a large blueberry muffin is about 400-500 calories, primarily derived from carbohydrates (mainly from flour and sugar), fats (from butter), and a moderate amount of protein. \n\nFor nutritional breakdown:\n- Carbs: Approximately 60g derived from the flour and sugar.\n- Fats: Approximately 20g from the butter.\n- Protein: Roughly 6g from the flour and egg content.\n- The muffin may also contain vitamins and minerals from the blueberries.\n\nOverall, a bakery-sized blueberry muffin is calorie-dense due to the combination of ingredients used, especially sugar and butter.",
"nutritional_info": {
"reasoning": "Bakery-sized muffin has ingredients like flour, sugar, butter, and blueberries. Estimated 450 cal, with ~60g carbs, ~20g fat, and ~6g protein.",
"name": "Blueberry Muffin",
"calories": 450.0,
"protein": 6.0,
"carbs": 60.0,
"fats": 20.0,
"micros": null
}
},
{
"description": "Philly Cheesesteak",
"nutritional_info": {
"reasoning": "Roll (250 cal). Fatty steak (400 cal). Cheese whiz/provolone (150 cal). Oil (100 cal).",
"name": "Cheesesteak",
"calories": 900.0,
"protein": 40.0,
"carbs": 50.0,
"fats": 55.0,
"micros": null
}
},
{
"description": "Grilled salmon with asparagus and roasted potatoes",
"nutritional_info": {
"reasoning": "6oz Salmon fillet (350 cal). Oil for cooking (60 cal). Asparagus (30 cal) + oil (30 cal). 1 cup roasted potatoes (150 cal) + oil (60 cal). Total ~680 cal.",
"name": "Salmon Dinner",
"calories": 680.0,
"protein": 40.0,
"carbs": 25.0,
"fats": 45.0,
"micros": null
}
},
{
"description": "Tacos - 3 beef tacos with cheese and sour cream",
"nutritional_info": {
"reasoning": "3 corn tortillas (150 cal). Ground beef filling (250 cal - cooked with fat). Cheese (110 cal). Sour cream (60 cal). Total ~570 cal.",
"name": "Beef Tacos",
"calories": 570.0,
"protein": 25.0,
"carbs": 45.0,
"fats": 30.0,
"micros": null
}
}
],
"signature": {
"instructions": "Extract nutritional information from a food description.\n\nYou must first provide a detailed step-by-step reasoning analysis of the ingredients,\nportions, AND preparation methods (cooking oils, butter, sauces) before estimating values.\nVerify if the caloric totals match the sum of macros (multiplying protein/carbs by 4, fats by 9).",
"fields": [
{
"prefix": "Description:",
"description": "Description of the food or meal"
},
{
"prefix": "Reasoning: Let's think step by step in order to",
"description": "${reasoning}"
},
{
"prefix": "Nutritional Info:",
"description": "Nutritional information with reasoning"
}
]
},
"lm": null
},
"analyze_image.predict": {
"traces": [],
"train": [],
"demos": [
{
"description": "Philly Cheesesteak",
"nutritional_info": {
"reasoning": "Roll (250 cal). Fatty steak (400 cal). Cheese whiz/provolone (150 cal). Oil (100 cal).",
"name": "Cheesesteak",
"calories": 900.0,
"protein": 40.0,
"carbs": 50.0,
"fats": 55.0,
"micros": null
}
},
{
"description": "Grilled salmon with asparagus and roasted potatoes",
"nutritional_info": {
"reasoning": "6oz Salmon fillet (350 cal). Oil for cooking (60 cal). Asparagus (30 cal) + oil (30 cal). 1 cup roasted potatoes (150 cal) + oil (60 cal). Total ~680 cal.",
"name": "Salmon Dinner",
"calories": 680.0,
"protein": 40.0,
"carbs": 25.0,
"fats": 45.0,
"micros": null
}
},
{
"description": "Tacos - 3 beef tacos with cheese and sour cream",
"nutritional_info": {
"reasoning": "3 corn tortillas (150 cal). Ground beef filling (250 cal - cooked with fat). Cheese (110 cal). Sour cream (60 cal). Total ~570 cal.",
"name": "Beef Tacos",
"calories": 570.0,
"protein": 25.0,
"carbs": 45.0,
"fats": 30.0,
"micros": null
}
}
],
"signature": {
"instructions": "Analyze the food image to estimate nutritional content.\n\n1. Identify all food items and estimated portion sizes.\n2. CRITICAL: Account for hidden calories from cooking fats, oils, and sauces (searing, frying).\n3. Reason step-by-step about the total composition before summing macros.",
"fields": [
{
"prefix": "Image:",
"description": "The food image"
},
{
"prefix": "Description:",
"description": "Additional user description"
},
{
"prefix": "Reasoning: Let's think step by step in order to",
"description": "${reasoning}"
},
{
"prefix": "Nutritional Info:",
"description": "Nutritional information with reasoning"
}
]
},
"lm": null
},
"metadata": {
"dependency_versions": {
"python": "3.11",
"dspy": "3.1.0",
"cloudpickle": "3.1"
}
}
}

34
backend/app/ai/plans.py Normal file
View File

@@ -0,0 +1,34 @@
import dspy
from pydantic import BaseModel, Field
class PlanOutput(BaseModel):
reasoning: str = Field(description="Reasoning behind the selected plan based on user goals")
title: str = Field(description="Title of the plan")
summary: str = Field(description="Brief summary of the plan")
diet_plan: list[str] = Field(description="List of daily diet recommendations")
exercise_plan: list[str] = Field(description="List of daily exercise routines")
tips: list[str] = Field(description="Additional health tips")
class GeneratePlan(dspy.Signature):
"""Generate a personalized diet and exercise plan based on user goal and details.
Analyze the user's profile and goal, explain your reasoning, and then generate the plan.
"""
user_profile: str = dspy.InputField(desc="User details (age, weight, height, etc)")
goal: str = dspy.InputField(desc="Specific user goal")
plan: PlanOutput = dspy.OutputField(desc="Structured plan with reasoning")
class PlanModule(dspy.Module):
def __init__(self):
super().__init__()
self.generate = dspy.ChainOfThought(GeneratePlan)
def forward(self, user_profile: str, goal: str):
return self.generate(user_profile=user_profile, goal=goal)
plan_module = PlanModule()

View File

@@ -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)]

View File

@@ -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"])

View File

@@ -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

View File

@@ -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",
}

View File

@@ -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(
*,

View File

@@ -0,0 +1,67 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session, select
from app.ai.plans import plan_module
from app.api import deps
from app.models.plan import Plan
router = APIRouter()
class PlanRequest(BaseModel):
goal: str
user_details: str # e.g., "Male, 30, 80kg"
@router.post("/generate", response_model=Plan)
def generate_plan(
*,
current_user: deps.CurrentUser,
request: PlanRequest,
session: Session = Depends(deps.get_session),
) -> Any:
"""
Generate a new diet/exercise plan using AI.
"""
try:
# Generate plan using DSPy
generated = plan_module(user_profile=request.user_details, goal=request.goal)
# Determine content string (markdown representation)
content_md = (
f"# {generated.plan.title}\n\n{generated.plan.summary}\n\n## Diet\n"
+ "\n".join([f"- {item}" for item in generated.plan.diet_plan])
+ "\n\n## Exercise\n"
+ "\n".join([f"- {item}" for item in generated.plan.exercise_plan])
+ "\n\n## Tips\n"
+ "\n".join([f"- {item}" for item in generated.plan.tips])
)
plan = Plan(
user_id=current_user.id,
goal=request.goal,
content=content_md,
structured_content=generated.plan.model_dump(),
)
session.add(plan)
session.commit()
session.refresh(plan)
return plan
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/", response_model=List[Plan])
def read_plans(
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""
Get all plans for the current user.
"""
statement = select(Plan).where(Plan.user_id == current_user.id).order_by(Plan.created_at.desc())
plans = session.exec(statement).all()
return plans

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"))

View File

@@ -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"}

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,13 @@
from datetime import datetime
from typing import Optional
from sqlmodel import JSON, Field, SQLModel
class Plan(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
goal: str = Field(index=True) # e.g., "lose weight", "gain muscle"
content: str = Field(description="The full plan content in markdown or text")
structured_content: dict = Field(default={}, sa_type=JSON) # For UI rendering
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -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)

View File

@@ -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

View File

@@ -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