diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md new file mode 100644 index 0000000..ddd2dad --- /dev/null +++ b/AGENT_GUIDE.md @@ -0,0 +1,99 @@ +# Diligence — AI Agent Guide + +You are connected to your human's fitness tracking platform via MCP. +Your role is to be a helpful, knowledgeable fitness companion — not a +drill sergeant, not a cheerleader, but a partner who understands their +goals and helps them stay consistent. + +## The System + +Your human uses a points-based accountability system: +- They earn points by completing healthy activities (workouts, food + logging, hitting step goals, screen-free time, daily check-ins) +- They configure their own rewards (gaming time, takeout, a movie — + whatever they choose) +- There is a daily gate: rewards stay locked until they earn enough + points that day +- Points reset weekly. Each week is a fresh start. + +Use `get_context()` at the start of each conversation to understand +their current state and motivation profile. + +## How to Help + +When they mention exercise or physical activity: + Log it using log_activity(). If they're vague ("I went for a walk"), + ask enough to fill in duration. Don't interrogate — a reasonable + estimate is fine. + +When they ask what workout to do today: + Call get_program_schedule() to show them their scheduled workout + from their active program. After they complete it, log it with + log_activity(category="workout", program_day=N). + +When they mention food or eating: + Offer to log it with log_food(). Help estimate calories and macros + if they don't know. Use search_food() to look up common items. Don't + judge their food choices — just log accurately. + +When they ask how they're doing: + Call get_today() or get_week() and give them a clear picture. + Celebrate when the gate is passed. When it isn't, tell them what's + left without guilt-tripping. + +When they haven't mentioned fitness in a while: + You can gently check in. "Want me to check your fitness status?" is + fine. Don't nag. Don't open every conversation with it. + +When they want to redeem a reward: + Use redeem_reward(). If the gate isn't passed, tell them what's left + to earn — frame it as "you're X points away" not "you can't." + +When they want a meal plan or diet: + Generate the plan yourself using your own knowledge, tailored to their + goals, preferences, and dietary restrictions. Then call load_meal_plan() + to put it into the tracker. Use get_meal_plan() to show them what's + planned for today. + +When they want to connect a device or service: + Call get_integration_status() to see what's available and what's + already connected. Guide them through getting developer credentials + for their provider. Use configure_integration() to store the credentials. + Walk them through the entire process conversationally. + +## Tone Calibration + +The app collects motivation data during onboarding (BREQ-2 assessment). +Use get_context() to see their motivation profile: + +- High intrinsic motivation ("I exercise because it's fun"): + Focus on variety, new challenges, celebrating personal bests. + They don't need pushing — they need logistics help. + +- High identified motivation ("I value the health benefits"): + Connect activities to long-term goals. Reference progress trends. + They respond to data and trajectory. + +- High introjected motivation ("I feel guilty when I don't"): + Be careful. They're already hard on themselves. Normalize rest days. + Emphasize consistency over perfection. Never add guilt. + +- High external motivation ("others say I should"): + Help build internal motivation over time. Celebrate small wins. + Help them find activities they actually enjoy. + +- High amotivation ("I don't see the point"): + Start very small. Don't push big commitments. Celebrate any + engagement. The points system helps here — small daily actions + accumulate visibly. + +## What You Don't Do + +- Don't provide medical advice. If they mention pain, injury, or + health concerns, suggest they consult a professional. +- Don't override their point rules or reward configuration. +- Don't fabricate logs. Only log what they actually did. +- Don't be preachy about nutrition. Log what they eat, help them + understand it, but don't lecture. +- Don't read back integration credentials. You can check status but + never retrieve stored secrets. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b684b9f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DiligenceWorks Pte. Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/app/main.py b/backend/app/main.py index 070ffcf..9af6e47 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -63,7 +63,7 @@ async def lifespan(app: FastAPI): logger.info("Fitness Rewards backend shutting down") -app = FastAPI(title="Fitness Rewards", version="0.3.0", lifespan=lifespan) +app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -84,6 +84,7 @@ from app.routers.programs import router as programs_router from app.routers.catalog import router as catalog_router from app.routers.support import router as support_router from app.routers.nutrition import router as nutrition_router +from app.routers.meal_plans import router as meal_plans_router app.include_router(auth_router) app.include_router(onboarding_router) @@ -96,11 +97,12 @@ app.include_router(catalog_router) app.include_router(support_router) app.include_router(programs_router) app.include_router(nutrition_router) +app.include_router(meal_plans_router) @app.get("/api/health") async def health(): - return {"status": "ok", "version": "0.3.0"} + return {"status": "ok", "version": "1.0.0"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4039a48..8af76b0 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,8 @@ from app.models.resource import Resource from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog from app.models.support import SupportThread, SupportMessage from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog +from app.models.integration_config import IntegrationConfig +from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance __all__ = [ "User", "UserProfile", "Program", "ActivityLog", "FoodLog", @@ -18,4 +20,5 @@ __all__ = [ "ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog", "SupportThread", "SupportMessage", "NutritionGoal", "Fast", "ElectrolyteLog", + "IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance", ] diff --git a/backend/app/models/integration_config.py b/backend/app/models/integration_config.py new file mode 100644 index 0000000..39893bf --- /dev/null +++ b/backend/app/models/integration_config.py @@ -0,0 +1,25 @@ +"""Integration configuration model — encrypted credential storage.""" +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class IntegrationConfig(Base): + __tablename__ = "integration_configs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + provider: Mapped[str] = mapped_column(String(50), nullable=False) + config_key: Mapped[str] = mapped_column(String(100), nullable=False) + config_value: Mapped[str] = mapped_column(Text, nullable=False) # Fernet encrypted + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + __table_args__ = ( + UniqueConstraint("user_id", "provider", "config_key", name="uq_integration_config"), + ) diff --git a/backend/app/models/meal_plan.py b/backend/app/models/meal_plan.py new file mode 100644 index 0000000..280ddd3 --- /dev/null +++ b/backend/app/models/meal_plan.py @@ -0,0 +1,65 @@ +"""Meal plan models — plans, items, and compliance tracking.""" +from __future__ import annotations + +import uuid +from datetime import datetime, date, timezone +from decimal import Decimal +from sqlalchemy import String, Integer, Date, DateTime, Text, ForeignKey, DECIMAL +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB +from app.database import Base + + +class MealPlan(Base): + __tablename__ = "meal_plans" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + diet_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + daily_calories: Mapped[int | None] = mapped_column(Integer, nullable=True) + daily_protein_g: Mapped[int | None] = mapped_column(Integer, nullable=True) + daily_carbs_g: Mapped[int | None] = mapped_column(Integer, nullable=True) + daily_fat_g: Mapped[int | None] = mapped_column(Integer, nullable=True) + restrictions: Mapped[dict] = mapped_column(JSONB, default=list) + duration_days: Mapped[int] = mapped_column(Integer, nullable=False) + start_date: Mapped[date] = mapped_column(Date, nullable=False) + status: Mapped[str] = mapped_column(String(20), default="active") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + items: Mapped[list["MealPlanItem"]] = relationship(back_populates="plan", cascade="all, delete-orphan", lazy="selectin") + + +class MealPlanItem(Base): + __tablename__ = "meal_plan_items" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id", ondelete="CASCADE"), nullable=False) + day_number: Mapped[int] = mapped_column(Integer, nullable=False) + meal_type: Mapped[str] = mapped_column(String(20), nullable=False) + food_name: Mapped[str] = mapped_column(String(300), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + calories: Mapped[int | None] = mapped_column(Integer, nullable=True) + protein_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True) + carbs_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True) + fat_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True) + fiber_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True) + serving_size: Mapped[str | None] = mapped_column(String(100), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + plan: Mapped["MealPlan"] = relationship(back_populates="items") + + +class MealCompliance(Base): + __tablename__ = "meal_compliance" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id"), nullable=False) + plan_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + compliance_date: Mapped[date] = mapped_column(Date, nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False) # followed, substituted, skipped + substitution: Mapped[str | None] = mapped_column(Text, nullable=True) + food_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/routers/meal_plans.py b/backend/app/routers/meal_plans.py new file mode 100644 index 0000000..c89fe65 --- /dev/null +++ b/backend/app/routers/meal_plans.py @@ -0,0 +1,295 @@ +"""Meal plans router — CRUD, compliance tracking, progress.""" +from __future__ import annotations + +import uuid as uuid_mod +from datetime import date, datetime, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User +from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance +from app.utils.auth import get_current_user + +router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"]) + + +# ── Schemas ────────────────────────────────────────────────────────────── + +class MealItemCreate(BaseModel): + day_number: int + meal_type: str + food_name: str + description: str | None = None + calories: int | None = None + protein_g: float | None = None + carbs_g: float | None = None + fat_g: float | None = None + fiber_g: float | None = None + serving_size: str | None = None + sort_order: int = 0 + + +class MealPlanCreate(BaseModel): + name: str + diet_type: str | None = None + daily_calories: int | None = None + daily_protein_g: int | None = None + daily_carbs_g: int | None = None + daily_fat_g: int | None = None + restrictions: list[str] = [] + duration_days: int + start_date: date | None = None + meals: list[MealItemCreate] = [] + + +class ComplianceCreate(BaseModel): + plan_item_id: str | None = None + compliance_date: date | None = None + status: str # followed, substituted, skipped + substitution: str | None = None + + +# ── Endpoints ──────────────────────────────────────────────────────────── + +@router.get("") +async def list_meal_plans( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + result = await db.execute( + select(MealPlan) + .where(MealPlan.user_id == user.id) + .order_by(MealPlan.created_at.desc()) + ) + plans = result.scalars().all() + return [_plan_summary(p) for p in plans] + + +@router.post("", status_code=201) +async def create_meal_plan( + body: MealPlanCreate, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + # Deactivate any existing active plan + result = await db.execute( + select(MealPlan) + .where(MealPlan.user_id == user.id, MealPlan.status == "active") + ) + for old in result.scalars().all(): + old.status = "completed" + + plan = MealPlan( + user_id=user.id, + name=body.name, + diet_type=body.diet_type, + daily_calories=body.daily_calories, + daily_protein_g=body.daily_protein_g, + daily_carbs_g=body.daily_carbs_g, + daily_fat_g=body.daily_fat_g, + restrictions=body.restrictions, + duration_days=body.duration_days, + start_date=body.start_date or date.today(), + ) + db.add(plan) + await db.flush() + + for item in body.meals: + db.add(MealPlanItem( + plan_id=plan.id, + day_number=item.day_number, + meal_type=item.meal_type, + food_name=item.food_name, + description=item.description, + calories=item.calories, + protein_g=item.protein_g, + carbs_g=item.carbs_g, + fat_g=item.fat_g, + fiber_g=item.fiber_g, + serving_size=item.serving_size, + sort_order=item.sort_order, + )) + + return {"id": str(plan.id), "name": plan.name, "items_count": len(body.meals)} + + +@router.get("/today") +async def get_today_meals( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + result = await db.execute( + select(MealPlan) + .where(MealPlan.user_id == user.id, MealPlan.status == "active") + ) + plan = result.scalar_one_or_none() + if not plan: + return {"active_plan": None} + + day_num = (date.today() - plan.start_date).days + 1 + if day_num < 1 or day_num > plan.duration_days: + return {"active_plan": plan.name, "day": day_num, "meals": [], "message": "No meals planned for today"} + + items_result = await db.execute( + select(MealPlanItem) + .where(MealPlanItem.plan_id == plan.id, MealPlanItem.day_number == day_num) + .order_by(MealPlanItem.sort_order) + ) + items = items_result.scalars().all() + + return { + "active_plan": plan.name, + "diet_type": plan.diet_type, + "day": day_num, + "duration_days": plan.duration_days, + "daily_calories": plan.daily_calories, + "meals": [ + { + "id": str(i.id), + "meal_type": i.meal_type, + "food_name": i.food_name, + "description": i.description, + "calories": i.calories, + "protein_g": float(i.protein_g) if i.protein_g else None, + "carbs_g": float(i.carbs_g) if i.carbs_g else None, + "fat_g": float(i.fat_g) if i.fat_g else None, + } + for i in items + ], + } + + +@router.get("/{plan_id}") +async def get_meal_plan( + plan_id: str, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + result = await db.execute( + select(MealPlan) + .where(MealPlan.id == uuid_mod.UUID(plan_id), MealPlan.user_id == user.id) + ) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(404, "Plan not found") + + return {**_plan_summary(plan), "items": [_item_detail(i) for i in plan.items]} + + +@router.patch("/{plan_id}") +async def update_meal_plan_status( + plan_id: str, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], + status: str = "completed", +): + result = await db.execute( + select(MealPlan) + .where(MealPlan.id == uuid_mod.UUID(plan_id), MealPlan.user_id == user.id) + ) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(404, "Plan not found") + plan.status = status + return {"id": str(plan.id), "status": plan.status} + + +@router.post("/compliance", status_code=201) +async def log_compliance( + body: ComplianceCreate, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + # Find active plan + result = await db.execute( + select(MealPlan).where(MealPlan.user_id == user.id, MealPlan.status == "active") + ) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(400, "No active meal plan") + + entry = MealCompliance( + user_id=user.id, + plan_id=plan.id, + plan_item_id=uuid_mod.UUID(body.plan_item_id) if body.plan_item_id else None, + compliance_date=body.compliance_date or date.today(), + status=body.status, + substitution=body.substitution, + ) + db.add(entry) + return {"status": body.status, "date": str(entry.compliance_date)} + + +@router.get("/{plan_id}/progress") +async def get_plan_progress( + plan_id: str, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + result = await db.execute( + select(MealPlan) + .where(MealPlan.id == uuid_mod.UUID(plan_id), MealPlan.user_id == user.id) + ) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(404, "Plan not found") + + comp_result = await db.execute( + select(MealCompliance) + .where(MealCompliance.plan_id == plan.id, MealCompliance.user_id == user.id) + ) + entries = comp_result.scalars().all() + + total = len(entries) + followed = sum(1 for e in entries if e.status == "followed") + substituted = sum(1 for e in entries if e.status == "substituted") + skipped = sum(1 for e in entries if e.status == "skipped") + + days_elapsed = (date.today() - plan.start_date).days + 1 + compliance_pct = (followed + substituted) / total * 100 if total > 0 else 0 + + return { + "plan": plan.name, + "days_elapsed": min(days_elapsed, plan.duration_days), + "duration_days": plan.duration_days, + "total_entries": total, + "followed": followed, + "substituted": substituted, + "skipped": skipped, + "compliance_pct": round(compliance_pct, 1), + } + + +# ── Helpers ────────────────────────────────────────────────────────────── + +def _plan_summary(p: MealPlan) -> dict: + return { + "id": str(p.id), + "name": p.name, + "diet_type": p.diet_type, + "daily_calories": p.daily_calories, + "duration_days": p.duration_days, + "start_date": str(p.start_date), + "status": p.status, + } + + +def _item_detail(i: MealPlanItem) -> dict: + return { + "id": str(i.id), + "day_number": i.day_number, + "meal_type": i.meal_type, + "food_name": i.food_name, + "description": i.description, + "calories": i.calories, + "protein_g": float(i.protein_g) if i.protein_g else None, + "carbs_g": float(i.carbs_g) if i.carbs_g else None, + "fat_g": float(i.fat_g) if i.fat_g else None, + "fiber_g": float(i.fiber_g) if i.fiber_g else None, + "serving_size": i.serving_size, + } diff --git a/backend/app/services/provider_registry.py b/backend/app/services/provider_registry.py new file mode 100644 index 0000000..99e2b15 --- /dev/null +++ b/backend/app/services/provider_registry.py @@ -0,0 +1,89 @@ +"""Provider registry — drives integration UI cards and agent help text.""" +from __future__ import annotations + +PROVIDER_REGISTRY: dict[str, dict] = { + "strava": { + "name": "Strava", + "type": "oauth2", + "fields": ["client_id", "client_secret"], + "auth_url": "https://www.strava.com/oauth/authorize", + "token_url": "https://www.strava.com/oauth/token", + "scopes": "read,activity:read_all", + "help_url": "https://developers.strava.com/", + "help_text": "Create an app at developers.strava.com. Set the callback URL to {BASE_URL}/api/integrations/strava/callback", + }, + "polar": { + "name": "Polar", + "type": "oauth2", + "fields": ["client_id", "client_secret"], + "auth_url": "https://flow.polar.com/oauth2/authorization", + "token_url": "https://polarremote.com/v2/oauth2/token", + "help_url": "https://admin.polaraccesslink.com/", + "help_text": "Register at admin.polaraccesslink.com", + }, + "garmin": { + "name": "Garmin Connect", + "type": "oauth2", + "fields": ["client_id", "client_secret"], + "help_url": "https://developer.garmin.com/gc-developer-program/", + "help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks", + }, + "fitbit": { + "name": "Fitbit", + "type": "oauth2", + "fields": ["client_id", "client_secret"], + "auth_url": "https://www.fitbit.com/oauth2/authorize", + "token_url": "https://api.fitbit.com/oauth2/token", + "help_url": "https://dev.fitbit.com/", + "help_text": "Register at dev.fitbit.com", + }, + "withings": { + "name": "Withings", + "type": "oauth2", + "fields": ["client_id", "client_secret"], + "help_url": "https://developer.withings.com/", + "help_text": "Register at developer.withings.com — body metrics, scales, blood pressure", + }, + "whoop": { + "name": "WHOOP", + "type": "oauth2", + "fields": ["client_id", "client_secret"], + "help_url": "https://developer.whoop.com/", + "help_text": "Apply at developer.whoop.com — recovery, strain, sleep", + }, + "oura": { + "name": "Oura Ring", + "type": "oauth2", + "fields": ["client_id", "client_secret"], + "help_url": "https://cloud.ouraring.com/v2/docs", + "help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV", + }, + "usda": { + "name": "USDA FoodData Central", + "type": "api_key", + "fields": ["api_key"], + "help_url": "https://fdc.nal.usda.gov/api-key-signup", + "help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.", + }, + "nutritionix": { + "name": "Nutritionix", + "type": "api_key", + "fields": ["app_id", "api_key"], + "help_url": "https://developer.nutritionix.com/", + "help_text": "Free tier: 1K calls/month. Natural language food parsing.", + }, + "groq": { + "name": "Groq (Program Research)", + "type": "api_key", + "fields": ["api_key"], + "help_url": "https://console.groq.com/keys", + "help_text": "Free API key. Enables AI-powered workout program extraction.", + }, + "telegram": { + "name": "Telegram Notifications", + "type": "api_key", + "fields": ["bot_token", "chat_id"], + "help_url": "https://core.telegram.org/bots#botfather", + "help_text": "Create a bot via @BotFather, then get your chat ID", + }, +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 30324b6..18e70bb 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,32 @@ server { root /usr/share/nginx/html; index index.html; + # B2A discovery files + location = /llms.txt { + default_type text/plain; + } + + location /.well-known/ { + default_type application/json; + } + + location /.well-known/skills/ { + default_type text/plain; + } + + # MCP proxy — routes /mcp to the MCP connector container + location /mcp { + proxy_pass http://mcp-connector:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + } + + # Backend API location /api { proxy_pass http://backend:8000; proxy_set_header Host $host; @@ -12,6 +38,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # SPA catch-all location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/public/llms.txt b/frontend/public/llms.txt new file mode 100644 index 0000000..961f6ba --- /dev/null +++ b/frontend/public/llms.txt @@ -0,0 +1,31 @@ +# Diligence — Self-Hosted Fitness Rewards Platform + +> Data sovereignty for people. Self-hosted fitness tracker with a points-based +> behavioral economy and an MCP connector for AI agent integration. +> MIT-licensed. Deploy with a single `docker compose up -d`. + +Diligence is a self-hosted fitness web app built around a points-based reward +system. Complete fitness activities and food logging to earn points, spend +points to unlock configurable real-world rewards. Science-based onboarding +(PAR-Q+, TTM stages of change, BREQ-2 motivation assessment). + +## For AI Agents +- MCP Server: /mcp (Streamable HTTP/SSE, 14 tools) +- Agent Card: /.well-known/agent-card.json +- Skills: /.well-known/skills/diligence-fitness/SKILL.md + +## MCP Tools +- get_context, get_today, get_week (status) +- log_activity, log_food, search_food (tracking) +- get_program_schedule (programs) +- redeem_reward (rewards) +- load_meal_plan, get_meal_plan, update_meal_compliance, get_plan_progress (meal plans) +- configure_integration, get_integration_status (device sync) + +## Technical +- Source: https://github.com/diligenceworks/diligence (MIT) +- Stack: FastAPI + PostgreSQL + React/Vite + FastMCP + +## About +Built by DiligenceWorks (https://diligenceworks.online) — adversarial AI +deal-diligence platform for institutional investors. diff --git a/mcp-connector/Dockerfile b/mcp-connector/Dockerfile new file mode 100644 index 0000000..64fb0a0 --- /dev/null +++ b/mcp-connector/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 3001 +CMD ["python", "server.py"] diff --git a/mcp-connector/requirements.txt b/mcp-connector/requirements.txt new file mode 100644 index 0000000..6238817 --- /dev/null +++ b/mcp-connector/requirements.txt @@ -0,0 +1,3 @@ +mcp[cli]>=1.0.0 +httpx>=0.28.0 +uvicorn>=0.34.0 diff --git a/mcp-connector/server.py b/mcp-connector/server.py new file mode 100644 index 0000000..5d73d19 --- /dev/null +++ b/mcp-connector/server.py @@ -0,0 +1,288 @@ +"""Diligence MCP Connector — 14 tools for AI agent integration. + +Runs as a separate Docker container on port 3001. Proxies all requests +to the backend FastAPI service via internal Docker network. +""" +from __future__ import annotations + +import os +import json +import httpx +from datetime import date +from mcp.server.fastmcp import FastMCP + +FITNESS_API_URL = os.getenv("FITNESS_API_URL", "http://backend:8000") + +mcp = FastMCP("Diligence Fitness", port=3001) + +# Internal HTTP client — talks to the backend on the Docker network +_client: httpx.AsyncClient | None = None + +async def api(method: str, path: str, **kwargs) -> dict: + global _client + if _client is None: + _client = httpx.AsyncClient(base_url=FITNESS_API_URL, timeout=30) + # TODO: add service account API key auth header + resp = await getattr(_client, method)(f"/api{path}", **kwargs) + resp.raise_for_status() + return resp.json() + + +# ── CONTEXT & STATUS (3 tools) ─────────────────────────────────────────── + +@mcp.tool() +async def get_context() -> str: + """Get full user profile, motivation type, program status, point rules, rewards, and integration status. Call this at the start of each conversation to understand the user's current state.""" + data = await api("get", "/points/today") + profile = await api("get", "/onboarding/profile") + rewards = await api("get", "/points/rewards") + integrations = await api("get", "/integrations/status") + meal_plan = None + try: + meal_plan = await api("get", "/meal-plans/today") + except Exception: + pass + + return json.dumps({ + "user": profile, + "today": data, + "rewards": rewards, + "integrations": integrations, + "active_meal_plan": meal_plan, + }, indent=2, default=str) + + +@mcp.tool() +async def get_today(date_str: str | None = None) -> str: + """Get today's points earned, daily gate status (passed/not), breakdown by category, and activities logged/remaining. Optionally pass a date (YYYY-MM-DD) to check a different day.""" + params = {"date": date_str} if date_str else {} + data = await api("get", "/points/today", params=params) + return json.dumps(data, indent=2, default=str) + + +@mcp.tool() +async def get_week(start_date: str | None = None) -> str: + """Get weekly summary including total points, active days count, target progress, and day-by-day breakdown.""" + params = {"start_date": start_date} if start_date else {} + data = await api("get", "/points/week", params=params) + return json.dumps(data, indent=2, default=str) + + +# ── ACTIVITY LOGGING (1 tool) ──────────────────────────────────────────── + +@mcp.tool() +async def log_activity( + category: str, + title: str, + duration_minutes: int, + notes: str | None = None, + date_str: str | None = None, + program_day: int | None = None, +) -> str: + """Log a workout or activity. Returns points earned and daily total. + + Args: + category: Type of activity (workout, cardio, yoga, walking, cycling, swimming, other) + title: Description of what was done (e.g. "Bench press and squats") + duration_minutes: How long the activity lasted + notes: Optional additional notes + date_str: Optional date (YYYY-MM-DD), defaults to today + program_day: Optional program day number to mark a program workout complete + """ + payload = { + "category": category, + "title": title, + "duration_minutes": duration_minutes, + } + if notes: + payload["notes"] = notes + if date_str: + payload["date"] = date_str + if program_day: + payload["program_day"] = program_day + + data = await api("post", "/activities/log", json=payload) + return json.dumps(data, indent=2, default=str) + + +# ── FOOD & NUTRITION (2 tools) ─────────────────────────────────────────── + +@mcp.tool() +async def log_food( + meal_type: str, + food_name: str, + calories: int | None = None, + protein_g: float | None = None, + carbs_g: float | None = None, + fat_g: float | None = None, + servings: float | None = None, + plan_item_id: str | None = None, +) -> str: + """Log a food item with optional nutrition data. + + Args: + meal_type: One of breakfast, lunch, dinner, snack + food_name: What was eaten + calories: Estimated calories + protein_g: Grams of protein + carbs_g: Grams of carbohydrates + fat_g: Grams of fat + servings: Number of servings (default 1) + plan_item_id: Optional meal plan item ID to link compliance + """ + payload = {"meal_type": meal_type, "food_name": food_name} + if calories is not None: + payload["calories"] = calories + if protein_g is not None: + payload["protein_g"] = protein_g + if carbs_g is not None: + payload["carbs_g"] = carbs_g + if fat_g is not None: + payload["fat_g"] = fat_g + if servings is not None: + payload["servings"] = servings + if plan_item_id: + payload["plan_item_id"] = plan_item_id + + data = await api("post", "/food/log", json=payload) + return json.dumps(data, indent=2, default=str) + + +@mcp.tool() +async def search_food(query: str) -> str: + """Search for food items in Open Food Facts and USDA FoodData Central. Returns nutrition data for matching items.""" + data = await api("get", "/food/search", params={"q": query}) + return json.dumps(data, indent=2, default=str) + + +# ── PROGRAMS (1 tool) ──────────────────────────────────────────────────── + +@mcp.tool() +async def get_program_schedule(date_str: str | None = None) -> str: + """Get today's scheduled workout from the active program, including exercises with sets, reps, and weight progression. Shows upcoming workouts and overall completion percentage.""" + params = {"date": date_str} if date_str else {} + data = await api("get", "/programs/schedule", params=params) + return json.dumps(data, indent=2, default=str) + + +# ── REWARDS (1 tool) ───────────────────────────────────────────────────── + +@mcp.tool() +async def redeem_reward(reward_name: str) -> str: + """Spend points on a configured reward. Returns success/failure and remaining points. + + Args: + reward_name: Name of the reward to redeem (e.g. "1 hour gaming") + """ + data = await api("post", "/points/rewards/redeem", json={"name": reward_name}) + return json.dumps(data, indent=2, default=str) + + +# ── MEAL PLANS (4 tools) ───────────────────────────────────────────────── + +@mcp.tool() +async def load_meal_plan( + name: str, + duration_days: int, + meals: list[dict], + diet_type: str | None = None, + daily_calories: int | None = None, + restrictions: list[str] | None = None, +) -> str: + """Create a complete meal plan with all items. The AI agent generates the plan content; this tool stores it for tracking. + + Args: + name: Plan name (e.g. "7-Day Dairy-Free Keto") + duration_days: How many days the plan covers + meals: List of meal items, each with day_number, meal_type, food_name, calories, protein_g, carbs_g, fat_g + diet_type: Optional diet type (keto, paleo, balanced, custom) + daily_calories: Optional daily calorie target + restrictions: Optional list of dietary restrictions + """ + payload = { + "name": name, + "duration_days": duration_days, + "meals": meals, + } + if diet_type: + payload["diet_type"] = diet_type + if daily_calories: + payload["daily_calories"] = daily_calories + if restrictions: + payload["restrictions"] = restrictions + + data = await api("post", "/meal-plans", json=payload) + return json.dumps(data, indent=2, default=str) + + +@mcp.tool() +async def get_meal_plan(date_str: str | None = None) -> str: + """View the active meal plan's meals for today or a specified date.""" + if date_str: + # TODO: pass date to API when supported + pass + data = await api("get", "/meal-plans/today") + return json.dumps(data, indent=2, default=str) + + +@mcp.tool() +async def update_meal_compliance( + plan_item_id: str, + status: str, + substitution: str | None = None, +) -> str: + """Mark a planned meal as followed, substituted, or skipped. + + Args: + plan_item_id: The meal plan item ID + status: One of 'followed', 'substituted', 'skipped' + substitution: What was eaten instead (required if status is 'substituted') + """ + payload = {"plan_item_id": plan_item_id, "status": status} + if substitution: + payload["substitution"] = substitution + data = await api("post", "/meal-plans/compliance", json=payload) + return json.dumps(data, indent=2, default=str) + + +@mcp.tool() +async def get_plan_progress(plan_id: str | None = None) -> str: + """Get overall compliance stats for a meal plan: percentage followed, days completed, average calories vs target.""" + if plan_id: + data = await api("get", f"/meal-plans/{plan_id}/progress") + else: + # Get active plan progress + plans = await api("get", "/meal-plans") + active = next((p for p in plans if p.get("status") == "active"), None) + if not active: + return json.dumps({"error": "No active meal plan"}) + data = await api("get", f"/meal-plans/{active['id']}/progress") + return json.dumps(data, indent=2, default=str) + + +# ── INTEGRATIONS (2 tools) ─────────────────────────────────────────────── + +@mcp.tool() +async def configure_integration(provider: str, credentials: dict) -> str: + """Store encrypted integration credentials. Write-only — credentials can be set but never read back. + + Args: + provider: Provider name (strava, polar, garmin, fitbit, usda, telegram, groq, etc.) + credentials: Dict of credential fields (e.g. {"client_id": "...", "client_secret": "..."}) + """ + data = await api("post", "/integrations/configure", json={ + "provider": provider, + "credentials": credentials, + }) + return json.dumps(data, indent=2, default=str) + + +@mcp.tool() +async def get_integration_status() -> str: + """Check connection status of all integration providers. Never returns actual credential values.""" + data = await api("get", "/integrations/status") + return json.dumps(data, indent=2, default=str) + + +if __name__ == "__main__": + mcp.run(transport="sse")