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:
parent
cb892564d9
commit
6fb5d759ef
13 changed files with 958 additions and 2 deletions
99
AGENT_GUIDE.md
Normal file
99
AGENT_GUIDE.md
Normal 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
21
LICENSE
Normal 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.
|
||||
|
|
@ -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"}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
25
backend/app/models/integration_config.py
Normal file
25
backend/app/models/integration_config.py
Normal 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"),
|
||||
)
|
||||
65
backend/app/models/meal_plan.py
Normal file
65
backend/app/models/meal_plan.py
Normal 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))
|
||||
295
backend/app/routers/meal_plans.py
Normal file
295
backend/app/routers/meal_plans.py
Normal 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,
|
||||
}
|
||||
89
backend/app/services/provider_registry.py
Normal file
89
backend/app/services/provider_registry.py
Normal 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",
|
||||
},
|
||||
}
|
||||
|
|
@ -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
31
frontend/public/llms.txt
Normal 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
8
mcp-connector/Dockerfile
Normal 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"]
|
||||
3
mcp-connector/requirements.txt
Normal file
3
mcp-connector/requirements.txt
Normal 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
288
mcp-connector/server.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue