Deliver Steps 3-8: integration config, meal plans, provider registry, MCP connector (14 tools), B2A files, nginx proxy, AGENT_GUIDE, LICENSE

This commit is contained in:
Claude 2026-06-15 14:34:09 +00:00
parent cb892564d9
commit 6fb5d759ef
13 changed files with 958 additions and 2 deletions

99
AGENT_GUIDE.md Normal file
View file

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

21
LICENSE Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

31
frontend/public/llms.txt Normal file
View file

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

8
mcp-connector/Dockerfile Normal file
View file

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

View file

@ -0,0 +1,3 @@
mcp[cli]>=1.0.0
httpx>=0.28.0
uvicorn>=0.34.0

288
mcp-connector/server.py Normal file
View file

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