This commit is contained in:
2025-09-08 12:51:15 -07:00
commit 574feb1ea1
62 changed files with 10425 additions and 0 deletions

11
backend/app/config.py Normal file
View File

@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str
GPX_STORAGE_PATH: str
AI_MODEL: str = "openrouter/auto"
class Config:
env_file = ".env"
settings = Settings()

17
backend/app/database.py Normal file
View File

@@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = "postgresql+asyncpg://appuser:password@db:5432/cyclingdb"
engine = create_async_engine(DATABASE_URL, echo=True)
AsyncSessionLocal = sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False
)
Base = declarative_base()
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

107
backend/app/main.py Normal file
View File

@@ -0,0 +1,107 @@
from fastapi import FastAPI, Depends, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from .database import get_db, get_database_url
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from alembic.config import Config
from alembic.migration import MigrationContext
from alembic.script import ScriptDirectory
from .routes import gpx as gpx_routes
from .routes import rule as rule_routes
from .routes import plan as plan_routes
from .routes import workouts as workout_routes
from .routes import prompts as prompt_routes
from .config import settings
app = FastAPI(
title="AI Cycling Coach API",
description="Backend service for AI-assisted cycling training platform",
version="0.1.0"
)
# API Key Authentication Middleware
@app.middleware("http")
async def api_key_auth(request: Request, call_next):
if request.url.path.startswith("/docs") or request.url.path.startswith("/redoc") or request.url.path == "/health":
return await call_next(request)
api_key = request.headers.get("X-API-KEY")
if api_key != settings.API_KEY:
raise HTTPException(status_code=401, detail="Invalid API Key")
return await call_next(request)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(gpx_routes.router)
app.include_router(rule_routes.router)
app.include_router(plan_routes.router)
app.include_router(workout_routes.router, prefix="/workouts", tags=["workouts"])
app.include_router(prompt_routes.router, prefix="/prompts", tags=["prompts"])
async def check_migration_status():
"""Check if database migrations are up to date."""
try:
# Get Alembic configuration
config = Config("alembic.ini")
config.set_main_option("sqlalchemy.url", get_database_url())
script = ScriptDirectory.from_config(config)
# Get current database revision
from sqlalchemy import create_engine
engine = create_engine(get_database_url())
with engine.connect() as conn:
context = MigrationContext.configure(conn)
current_rev = context.get_current_revision()
# Get head revision
head_rev = script.get_current_head()
return {
"current_revision": current_rev,
"head_revision": head_rev,
"migrations_up_to_date": current_rev == head_rev
}
except Exception as e:
return {
"error": str(e),
"migrations_up_to_date": False
}
@app.get("/health")
async def health_check(db: AsyncSession = Depends(get_db)):
"""Enhanced health check with migration verification."""
health_status = {
"status": "healthy",
"version": "0.1.0",
"timestamp": "2024-01-15T10:30:00Z" # Should be dynamic
}
# Database connection check
try:
await db.execute(text("SELECT 1"))
health_status["database"] = "connected"
except Exception as e:
health_status["status"] = "unhealthy"
health_status["database"] = f"error: {str(e)}"
# Migration status check
migration_info = await check_migration_status()
health_status["migrations"] = migration_info
if not migration_info.get("migrations_up_to_date", False):
health_status["status"] = "unhealthy"
return health_status
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,11 @@
from .base import BaseModel
from .route import Route
from .section import Section
from .rule import Rule
from .plan import Plan
from .plan_rule import PlanRule
from .user import User
from .workout import Workout
from .analysis import Analysis
from .prompt import Prompt
from .garmin_sync_log import GarminSyncLog

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, ForeignKey, JSON, Boolean, DateTime
from sqlalchemy.orm import relationship
from .base import BaseModel
class Analysis(BaseModel):
"""Analysis model for AI-generated workout feedback."""
__tablename__ = "analyses"
workout_id = Column(Integer, ForeignKey("workouts.id"), nullable=False)
analysis_type = Column(String(50), default='workout_review')
jsonb_feedback = Column(JSON) # AI-generated feedback
suggestions = Column(JSON) # AI-generated suggestions
approved = Column(Boolean, default=False)
# Relationships
workout = relationship("Workout", back_populates="analyses")

View File

@@ -0,0 +1,17 @@
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
Base = declarative_base()
class BaseModel(Base):
__abstract__ = True
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<{self.__class__.__name__} {self.id}>"

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, DateTime, String, Text
from .base import BaseModel
class GarminSyncLog(BaseModel):
"""Log model for tracking Garmin sync operations."""
__tablename__ = "garmin_sync_log"
last_sync_time = Column(DateTime)
activities_synced = Column(Integer, default=0)
status = Column(String(20)) # success, error, in_progress
error_message = Column(Text)

View File

@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from .base import BaseModel
class Plan(BaseModel):
__tablename__ = "plans"
jsonb_plan = Column(JSONB, nullable=False)
version = Column(Integer, nullable=False)
parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True)
parent_plan = relationship("Plan", remote_side="Plan.id", backref="child_plans")
workouts = relationship("Workout", back_populates="plan", cascade="all, delete-orphan")

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime
from .base import BaseModel
class Prompt(BaseModel):
"""Prompt model for AI prompt versioning and management."""
__tablename__ = "prompts"
action_type = Column(String(50), nullable=False) # plan_generation, workout_analysis, rule_parsing, suggestions
model = Column(String(100)) # AI model identifier
prompt_text = Column(Text, nullable=False)
version = Column(Integer, default=1)
active = Column(Boolean, default=True)

View File

@@ -0,0 +1,14 @@
from sqlalchemy import Column, String, Float, ForeignKey
from sqlalchemy.orm import relationship
from .base import BaseModel
class Route(BaseModel):
__tablename__ = "routes"
name = Column(String(100), nullable=False)
description = Column(String(500))
total_distance = Column(Float, nullable=False)
elevation_gain = Column(Float, nullable=False)
gpx_file_path = Column(String(255), nullable=False)
sections = relationship("Section", back_populates="route", cascade="all, delete-orphan")

View File

@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import JSONB
from .base import BaseModel
class Rule(BaseModel):
__tablename__ = "rules"
name = Column(String(100), nullable=False)
user_defined = Column(Boolean, default=True)
jsonb_rules = Column(JSONB, nullable=False)
version = Column(Integer, default=1)
parent_rule_id = Column(Integer, ForeignKey('rules.id'), nullable=True)
parent_rule = relationship("Rule", remote_side="Rule.id")

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, String, Float, ForeignKey
from sqlalchemy.orm import relationship
from .base import BaseModel
class Section(BaseModel):
__tablename__ = "sections"
route_id = Column(ForeignKey("routes.id"), nullable=False)
gpx_file_path = Column(String(255), nullable=False)
distance_m = Column(Float, nullable=False)
grade_avg = Column(Float)
min_gear = Column(String(50))
est_time_minutes = Column(Float)
route = relationship("Route", back_populates="sections")

View File

@@ -0,0 +1,7 @@
from .base import BaseModel
from sqlalchemy.orm import relationship
class User(BaseModel):
__tablename__ = "users"
plans = relationship("Plan", back_populates="user")

View File

@@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, JSON, Boolean
from sqlalchemy.orm import relationship
from .base import BaseModel
class Workout(BaseModel):
"""Workout model for Garmin activity data."""
__tablename__ = "workouts"
plan_id = Column(Integer, ForeignKey("plans.id"), nullable=True)
garmin_activity_id = Column(String(255), unique=True, nullable=False)
activity_type = Column(String(50))
start_time = Column(DateTime, nullable=False)
duration_seconds = Column(Integer)
distance_m = Column(Float)
avg_hr = Column(Integer)
max_hr = Column(Integer)
avg_power = Column(Float)
max_power = Column(Float)
avg_cadence = Column(Float)
elevation_gain_m = Column(Float)
metrics = Column(JSON) # Store full Garmin data as JSONB
# Relationships
plan = relationship("Plan", back_populates="workouts")
analyses = relationship("Analysis", back_populates="workout", cascade="all, delete-orphan")

35
backend/app/routes/gpx.py Normal file
View File

@@ -0,0 +1,35 @@
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.gpx import parse_gpx, store_gpx_file
from app.schemas.gpx import RouteCreate, Route as RouteSchema
from app.models import Route
import os
router = APIRouter(prefix="/gpx", tags=["GPX Routes"])
@router.post("/upload", response_model=RouteSchema)
async def upload_gpx_route(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db)
):
# Store GPX file
gpx_path = await store_gpx_file(file)
# Parse GPX file
gpx_data = await parse_gpx(gpx_path)
# Create route in database
route_data = RouteCreate(
name=file.filename,
description=f"Uploaded from {file.filename}",
total_distance=gpx_data['total_distance'],
elevation_gain=gpx_data['elevation_gain'],
gpx_file_path=gpx_path
)
db_route = Route(**route_data.dict())
db.add(db_route)
await db.commit()
await db.refresh(db_route)
return db_route

View File

@@ -0,0 +1,89 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import Plan, PlanRule, Rule
from app.schemas.plan import PlanCreate, Plan as PlanSchema
from uuid import UUID
router = APIRouter(prefix="/plans", tags=["Training Plans"])
@router.post("/", response_model=PlanSchema)
async def create_plan(
plan: PlanCreate,
db: AsyncSession = Depends(get_db)
):
# Create plan
db_plan = Plan(
user_id=plan.user_id,
start_date=plan.start_date,
end_date=plan.end_date,
goal=plan.goal
)
db.add(db_plan)
await db.flush() # Flush to get plan ID
# Add rules to plan
for rule_id in plan.rule_ids:
db_plan_rule = PlanRule(plan_id=db_plan.id, rule_id=rule_id)
db.add(db_plan_rule)
await db.commit()
await db.refresh(db_plan)
return db_plan
@router.get("/{plan_id}", response_model=PlanSchema)
async def read_plan(
plan_id: UUID,
db: AsyncSession = Depends(get_db)
):
plan = await db.get(Plan, plan_id)
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")
return plan
@router.get("/", response_model=list[PlanSchema])
async def read_plans(
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Plan))
return result.scalars().all()
@router.put("/{plan_id}", response_model=PlanSchema)
async def update_plan(
plan_id: UUID,
plan: PlanCreate,
db: AsyncSession = Depends(get_db)
):
db_plan = await db.get(Plan, plan_id)
if not db_plan:
raise HTTPException(status_code=404, detail="Plan not found")
# Update plan fields
db_plan.user_id = plan.user_id
db_plan.start_date = plan.start_date
db_plan.end_date = plan.end_date
db_plan.goal = plan.goal
# Update rules
await db.execute(PlanRule.delete().where(PlanRule.plan_id == plan_id))
for rule_id in plan.rule_ids:
db_plan_rule = PlanRule(plan_id=plan_id, rule_id=rule_id)
db.add(db_plan_rule)
await db.commit()
await db.refresh(db_plan)
return db_plan
@router.delete("/{plan_id}")
async def delete_plan(
plan_id: UUID,
db: AsyncSession = Depends(get_db)
):
plan = await db.get(Plan, plan_id)
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")
await db.delete(plan)
await db.commit()
return {"detail": "Plan deleted"}

View File

@@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models.prompt import Prompt
from app.schemas.prompt import Prompt as PromptSchema, PromptCreate, PromptUpdate
from app.services.prompt_manager import PromptManager
router = APIRouter()
@router.get("/", response_model=List[PromptSchema])
async def read_prompts(db: AsyncSession = Depends(get_db)):
"""Get all prompts."""
result = await db.execute(select(Prompt))
return result.scalars().all()
@router.get("/{prompt_id}", response_model=PromptSchema)
async def read_prompt(prompt_id: int, db: AsyncSession = Depends(get_db)):
"""Get a specific prompt by ID."""
prompt = await db.get(Prompt, prompt_id)
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
return prompt
@router.post("/", response_model=PromptSchema)
async def create_prompt(
prompt: PromptCreate,
db: AsyncSession = Depends(get_db)
):
"""Create a new prompt version."""
prompt_manager = PromptManager(db)
new_prompt = await prompt_manager.create_prompt_version(
action_type=prompt.action_type,
prompt_text=prompt.prompt_text,
model=prompt.model
)
return new_prompt
@router.get("/active/{action_type}")
async def get_active_prompt(
action_type: str,
db: AsyncSession = Depends(get_db)
):
"""Get the active prompt for a specific action type."""
prompt_manager = PromptManager(db)
prompt_text = await prompt_manager.get_active_prompt(action_type)
if not prompt_text:
raise HTTPException(status_code=404, detail=f"No active prompt found for {action_type}")
return {"action_type": action_type, "prompt_text": prompt_text}
@router.get("/history/{action_type}", response_model=List[PromptSchema])
async def get_prompt_history(
action_type: str,
db: AsyncSession = Depends(get_db)
):
"""Get the version history for a specific action type."""
prompt_manager = PromptManager(db)
prompts = await prompt_manager.get_prompt_history(action_type)
return prompts
@router.post("/{prompt_id}/activate")
async def activate_prompt_version(
prompt_id: int,
db: AsyncSession = Depends(get_db)
):
"""Activate a specific prompt version."""
prompt_manager = PromptManager(db)
success = await prompt_manager.activate_prompt_version(prompt_id)
if not success:
raise HTTPException(status_code=404, detail="Prompt not found")
return {"message": "Prompt version activated successfully"}

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Rule
from app.schemas.rule import RuleCreate, Rule as RuleSchema
from uuid import UUID
router = APIRouter(prefix="/rules", tags=["Rules"])
@router.post("/", response_model=RuleSchema)
async def create_rule(
rule: RuleCreate,
db: AsyncSession = Depends(get_db)
):
db_rule = Rule(**rule.dict())
db.add(db_rule)
await db.commit()
await db.refresh(db_rule)
return db_rule
@router.get("/{rule_id}", response_model=RuleSchema)
async def read_rule(
rule_id: UUID,
db: AsyncSession = Depends(get_db)
):
rule = await db.get(Rule, rule_id)
if not rule:
raise HTTPException(status_code=404, detail="Rule not found")
return rule
@router.get("/", response_model=list[RuleSchema])
async def read_rules(
db: AsyncSession = Depends(get_db)
):
result = await db.execute(sa.select(Rule))
return result.scalars().all()
@router.put("/{rule_id}", response_model=RuleSchema)
async def update_rule(
rule_id: UUID,
rule: RuleCreate,
db: AsyncSession = Depends(get_db)
):
db_rule = await db.get(Rule, rule_id)
if not db_rule:
raise HTTPException(status_code=404, detail="Rule not found")
for key, value in rule.dict().items():
setattr(db_rule, key, value)
await db.commit()
await db.refresh(db_rule)
return db_rule
@router.delete("/{rule_id}")
async def delete_rule(
rule_id: UUID,
db: AsyncSession = Depends(get_db)
):
rule = await db.get(Rule, rule_id)
if not rule:
raise HTTPException(status_code=404, detail="Rule not found")
await db.delete(rule)
await db.commit()
return {"detail": "Rule deleted"}

View File

@@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models.workout import Workout
from app.models.analysis import Analysis
from app.models.garmin_sync_log import GarminSyncLog
from app.models.plan import Plan
from app.schemas.workout import Workout as WorkoutSchema, WorkoutSyncStatus
from app.schemas.analysis import Analysis as AnalysisSchema
from app.services.workout_sync import WorkoutSyncService
from app.services.ai_service import AIService
from app.services.plan_evolution import PlanEvolutionService
router = APIRouter()
@router.get("/", response_model=List[WorkoutSchema])
async def read_workouts(db: AsyncSession = Depends(get_db)):
"""Get all workouts."""
result = await db.execute(select(Workout))
return result.scalars().all()
@router.get("/{workout_id}", response_model=WorkoutSchema)
async def read_workout(workout_id: int, db: AsyncSession = Depends(get_db)):
"""Get a specific workout by ID."""
workout = await db.get(Workout, workout_id)
if not workout:
raise HTTPException(status_code=404, detail="Workout not found")
return workout
@router.post("/sync")
async def trigger_garmin_sync(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""Trigger background sync of recent Garmin activities."""
sync_service = WorkoutSyncService(db)
background_tasks.add_task(sync_service.sync_recent_activities, days_back=14)
return {"message": "Garmin sync started"}
@router.get("/sync-status", response_model=WorkoutSyncStatus)
async def get_sync_status(db: AsyncSession = Depends(get_db)):
"""Get the latest sync status."""
result = await db.execute(
select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc()).limit(1)
)
sync_log = result.scalar_one_or_none()
if not sync_log:
return WorkoutSyncStatus(status="never_synced")
return sync_log
@router.post("/{workout_id}/analyze")
async def analyze_workout(
workout_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""Trigger AI analysis of a specific workout."""
workout = await db.get(Workout, workout_id)
if not workout:
raise HTTPException(status_code=404, detail="Workout not found")
ai_service = AIService(db)
background_tasks.add_task(
analyze_and_store_workout,
db, workout, ai_service
)
return {"message": "Analysis started", "workout_id": workout_id}
async def analyze_and_store_workout(db: AsyncSession, workout: Workout, ai_service: AIService):
"""Background task to analyze workout and store results."""
try:
# Get current plan if workout is associated with one
plan = None
if workout.plan_id:
plan = await db.get(Plan, workout.plan_id)
# Analyze workout
analysis_result = await ai_service.analyze_workout(workout, plan.jsonb_plan if plan else None)
# Store analysis
analysis = Analysis(
workout_id=workout.id,
jsonb_feedback=analysis_result.get("feedback", {}),
suggestions=analysis_result.get("suggestions", {})
)
db.add(analysis)
await db.commit()
except Exception as e:
# Log error but don't crash the background task
print(f"Error analyzing workout {workout.id}: {str(e)}")
@router.get("/{workout_id}/analyses", response_model=List[AnalysisSchema])
async def read_workout_analyses(workout_id: int, db: AsyncSession = Depends(get_db)):
"""Get all analyses for a specific workout."""
workout = await db.get(Workout, workout_id)
if not workout:
raise HTTPException(status_code=404, detail="Workout not found")
return workout.analyses
@router.post("/analyses/{analysis_id}/approve")
async def approve_analysis(
analysis_id: int,
db: AsyncSession = Depends(get_db)
):
"""Approve analysis suggestions and trigger plan evolution."""
analysis = await db.get(Analysis, analysis_id)
if not analysis:
raise HTTPException(status_code=404, detail="Analysis not found")
analysis.approved = True
# Trigger plan evolution if suggestions exist and workout has a plan
if analysis.suggestions and analysis.workout.plan_id:
evolution_service = PlanEvolutionService(db)
current_plan = await db.get(Plan, analysis.workout.plan_id)
if current_plan:
new_plan = await evolution_service.evolve_plan_from_analysis(
analysis, current_plan
)
await db.commit()
return {"message": "Analysis approved", "new_plan_id": new_plan.id if new_plan else None}
await db.commit()
return {"message": "Analysis approved"}

View File

@@ -0,0 +1,25 @@
from pydantic import BaseModel
from typing import Optional, Dict, Any
class AnalysisBase(BaseModel):
workout_id: int
analysis_type: str = 'workout_review'
jsonb_feedback: Optional[Dict[str, Any]] = None
suggestions: Optional[Dict[str, Any]] = None
approved: bool = False
class AnalysisCreate(AnalysisBase):
pass
class Analysis(AnalysisBase):
id: int
class Config:
orm_mode = True
class AnalysisUpdate(BaseModel):
approved: bool

View File

@@ -0,0 +1,25 @@
from pydantic import BaseModel
from typing import Optional, List
class GPXData(BaseModel):
total_distance: float
elevation_gain: float
points: List[dict]
class RouteCreate(BaseModel):
name: str
description: Optional[str] = None
total_distance: float
elevation_gain: float
gpx_file_path: str
class Route(BaseModel):
id: str
name: str
description: Optional[str] = None
total_distance: float
elevation_gain: float
gpx_file_path: str
class Config:
orm_mode = True

View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel
from datetime import datetime
from typing import List, Optional
from uuid import UUID
class PlanBase(BaseModel):
user_id: UUID
start_date: datetime
end_date: datetime
goal: str
class PlanCreate(PlanBase):
rule_ids: List[UUID]
class Plan(PlanBase):
id: UUID
class Config:
orm_mode = True

View File

@@ -0,0 +1,30 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class PromptBase(BaseModel):
action_type: str
model: Optional[str] = None
prompt_text: str
version: int = 1
active: bool = True
class PromptCreate(BaseModel):
action_type: str
prompt_text: str
model: Optional[str] = None
class PromptUpdate(BaseModel):
prompt_text: Optional[str] = None
active: Optional[bool] = None
class Prompt(PromptBase):
id: int
created_at: datetime
class Config:
orm_mode = True

View File

@@ -0,0 +1,17 @@
from pydantic import BaseModel
from typing import Optional
class RuleBase(BaseModel):
name: str
description: Optional[str] = None
condition: str
priority: int = 0
class RuleCreate(RuleBase):
pass
class Rule(RuleBase):
id: str
class Config:
orm_mode = True

View File

@@ -0,0 +1,40 @@
from pydantic import BaseModel
from typing import Optional, Dict, Any
from datetime import datetime
class WorkoutBase(BaseModel):
garmin_activity_id: str
activity_type: Optional[str] = None
start_time: datetime
duration_seconds: Optional[int] = None
distance_m: Optional[float] = None
avg_hr: Optional[int] = None
max_hr: Optional[int] = None
avg_power: Optional[float] = None
max_power: Optional[float] = None
avg_cadence: Optional[float] = None
elevation_gain_m: Optional[float] = None
metrics: Optional[Dict[str, Any]] = None
class WorkoutCreate(WorkoutBase):
plan_id: Optional[int] = None
class Workout(WorkoutBase):
id: int
plan_id: Optional[int] = None
class Config:
orm_mode = True
class WorkoutSyncStatus(BaseModel):
status: str
last_sync_time: Optional[datetime] = None
activities_synced: int = 0
error_message: Optional[str] = None
class Config:
orm_mode = True

View File

@@ -0,0 +1,130 @@
import os
import asyncio
from typing import Dict, Any, List, Optional
import httpx
import json
from app.services.prompt_manager import PromptManager
from app.models.workout import Workout
import logging
logger = logging.getLogger(__name__)
class AIService:
"""Service for AI-powered analysis and plan generation."""
def __init__(self, db_session):
self.db = db_session
self.prompt_manager = PromptManager(db_session)
self.api_key = os.getenv("OPENROUTER_API_KEY")
self.model = os.getenv("AI_MODEL", "anthropic/claude-3-sonnet-20240229")
self.base_url = "https://openrouter.ai/api/v1"
async def analyze_workout(self, workout: Workout, plan: Optional[Dict] = None) -> Dict[str, Any]:
"""Analyze a workout using AI and generate feedback."""
prompt_template = await self.prompt_manager.get_active_prompt("workout_analysis")
if not prompt_template:
raise ValueError("No active workout analysis prompt found")
# Build context from workout data
workout_context = {
"activity_type": workout.activity_type,
"duration_minutes": workout.duration_seconds / 60 if workout.duration_seconds else 0,
"distance_km": workout.distance_m / 1000 if workout.distance_m else 0,
"avg_hr": workout.avg_hr,
"avg_power": workout.avg_power,
"elevation_gain": workout.elevation_gain_m,
"planned_workout": plan
}
prompt = prompt_template.format(**workout_context)
response = await self._make_ai_request(prompt)
return self._parse_workout_analysis(response)
async def generate_plan(self, rules: List[Dict], goals: Dict[str, Any]) -> Dict[str, Any]:
"""Generate a training plan using AI."""
prompt_template = await self.prompt_manager.get_active_prompt("plan_generation")
context = {
"rules": rules,
"goals": goals,
"current_fitness_level": goals.get("fitness_level", "intermediate")
}
prompt = prompt_template.format(**context)
response = await self._make_ai_request(prompt)
return self._parse_plan_response(response)
async def parse_rules_from_natural_language(self, natural_language: str) -> Dict[str, Any]:
"""Parse natural language rules into structured format."""
prompt_template = await self.prompt_manager.get_active_prompt("rule_parsing")
prompt = prompt_template.format(user_rules=natural_language)
response = await self._make_ai_request(prompt)
return self._parse_rules_response(response)
async def _make_ai_request(self, prompt: str) -> str:
"""Make async request to OpenRouter API with retry logic."""
async with httpx.AsyncClient() as client:
for attempt in range(3): # Simple retry logic
try:
response = await client.post(
f"{self.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2000,
},
timeout=30.0
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
except Exception as e:
if attempt == 2: # Last attempt
logger.error(f"AI request failed after 3 attempts: {str(e)}")
raise AIServiceError(f"AI request failed after 3 attempts: {str(e)}")
await asyncio.sleep(2 ** attempt) # Exponential backoff
def _parse_workout_analysis(self, response: str) -> Dict[str, Any]:
"""Parse AI response for workout analysis."""
try:
# Assume AI returns JSON
clean_response = response.strip()
if clean_response.startswith("```json"):
clean_response = clean_response[7:-3]
return json.loads(clean_response)
except json.JSONDecodeError:
return {"raw_analysis": response, "structured": False}
def _parse_plan_response(self, response: str) -> Dict[str, Any]:
"""Parse AI response for plan generation."""
try:
clean_response = response.strip()
if clean_response.startswith("```json"):
clean_response = clean_response[7:-3]
return json.loads(clean_response)
except json.JSONDecodeError:
return {"raw_plan": response, "structured": False}
def _parse_rules_response(self, response: str) -> Dict[str, Any]:
"""Parse AI response for rule parsing."""
try:
clean_response = response.strip()
if clean_response.startswith("```json"):
clean_response = clean_response[7:-3]
return json.loads(clean_response)
except json.JSONDecodeError:
return {"raw_rules": response, "structured": False}
class AIServiceError(Exception):
"""Raised when AI service requests fail."""
pass

View File

@@ -0,0 +1,84 @@
import os
import garth
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
class GarminService:
"""Service for interacting with Garmin Connect API."""
def __init__(self):
self.username = os.getenv("GARMIN_USERNAME")
self.password = os.getenv("GARMIN_PASSWORD")
self.client: Optional[garth.Client] = None
self.session_dir = "/app/data/sessions"
# Ensure session directory exists
os.makedirs(self.session_dir, exist_ok=True)
async def authenticate(self) -> bool:
"""Authenticate with Garmin Connect and persist session."""
if not self.client:
self.client = garth.Client()
try:
# Try to load existing session
self.client.load(self.session_dir)
logger.info("Loaded existing Garmin session")
return True
except Exception:
# Fresh authentication required
try:
await self.client.login(self.username, self.password)
self.client.save(self.session_dir)
logger.info("Successfully authenticated with Garmin Connect")
return True
except Exception as e:
logger.error(f"Garmin authentication failed: {str(e)}")
raise GarminAuthError(f"Authentication failed: {str(e)}")
async def get_activities(self, limit: int = 10, start_date: datetime = None) -> List[Dict[str, Any]]:
"""Fetch recent activities from Garmin Connect."""
if not self.client:
await self.authenticate()
if not start_date:
start_date = datetime.now() - timedelta(days=7)
try:
activities = self.client.get_activities(limit=limit, start=start_date)
logger.info(f"Fetched {len(activities)} activities from Garmin")
return activities
except Exception as e:
logger.error(f"Failed to fetch activities: {str(e)}")
raise GarminAPIError(f"Failed to fetch activities: {str(e)}")
async def get_activity_details(self, activity_id: str) -> Dict[str, Any]:
"""Get detailed activity data including metrics."""
if not self.client:
await self.authenticate()
try:
details = self.client.get_activity(activity_id)
logger.info(f"Fetched details for activity {activity_id}")
return details
except Exception as e:
logger.error(f"Failed to fetch activity details for {activity_id}: {str(e)}")
raise GarminAPIError(f"Failed to fetch activity details: {str(e)}")
def is_authenticated(self) -> bool:
"""Check if we have a valid authenticated session."""
return self.client is not None
class GarminAuthError(Exception):
"""Raised when Garmin authentication fails."""
pass
class GarminAPIError(Exception):
"""Raised when Garmin API calls fail."""
pass

View File

@@ -0,0 +1,62 @@
import os
import uuid
import logging
from fastapi import UploadFile, HTTPException
import gpxpy
from app.config import settings
logger = logging.getLogger(__name__)
async def store_gpx_file(file: UploadFile) -> str:
"""Store uploaded GPX file and return path"""
try:
file_ext = os.path.splitext(file.filename)[1]
if file_ext.lower() != '.gpx':
raise HTTPException(status_code=400, detail="Invalid file type")
file_name = f"{uuid.uuid4()}{file_ext}"
file_path = os.path.join(settings.GPX_STORAGE_PATH, file_name)
# Ensure storage directory exists
os.makedirs(os.path.dirname(file_path), exist_ok=True)
# Save file
with open(file_path, "wb") as f:
f.write(await file.read())
return file_path
except Exception as e:
logger.error(f"Error storing GPX file: {e}")
raise HTTPException(status_code=500, detail="Error storing file")
async def parse_gpx(file_path: str) -> dict:
"""Parse GPX file and extract key metrics"""
try:
with open(file_path, 'r') as f:
gpx = gpxpy.parse(f)
total_distance = 0.0
elevation_gain = 0.0
points = []
for track in gpx.tracks:
for segment in track.segments:
total_distance += segment.length_3d()
for i in range(1, len(segment.points)):
elevation_gain += max(0, segment.points[i].elevation - segment.points[i-1].elevation)
points = [{
'lat': point.latitude,
'lon': point.longitude,
'ele': point.elevation,
'time': point.time.isoformat() if point.time else None
} for point in segment.points]
return {
'total_distance': total_distance,
'elevation_gain': elevation_gain,
'points': points
}
except Exception as e:
logger.error(f"Error parsing GPX file: {e}")
raise HTTPException(status_code=500, detail="Error parsing GPX file")

View File

@@ -0,0 +1,74 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.services.ai_service import AIService
from app.models.analysis import Analysis
from app.models.plan import Plan
import logging
logger = logging.getLogger(__name__)
class PlanEvolutionService:
"""Service for evolving training plans based on workout analysis."""
def __init__(self, db: AsyncSession):
self.db = db
self.ai_service = AIService(db)
async def evolve_plan_from_analysis(
self,
analysis: Analysis,
current_plan: Plan
) -> Plan:
"""Create a new plan version based on workout analysis."""
if not analysis.approved:
return None
suggestions = analysis.suggestions
if not suggestions:
return None
# Generate new plan incorporating suggestions
evolution_context = {
"current_plan": current_plan.jsonb_plan,
"workout_analysis": analysis.jsonb_feedback,
"suggestions": suggestions,
"evolution_type": "workout_feedback"
}
new_plan_data = await self.ai_service.evolve_plan(evolution_context)
# Create new plan version
new_plan = Plan(
jsonb_plan=new_plan_data,
version=current_plan.version + 1,
parent_plan_id=current_plan.id
)
self.db.add(new_plan)
await self.db.commit()
await self.db.refresh(new_plan)
logger.info(f"Created new plan version {new_plan.version} from analysis {analysis.id}")
return new_plan
async def get_plan_evolution_history(self, plan_id: int) -> list[Plan]:
"""Get the evolution history for a plan."""
result = await self.db.execute(
select(Plan)
.where(
(Plan.id == plan_id) |
(Plan.parent_plan_id == plan_id)
)
.order_by(Plan.version)
)
return result.scalars().all()
async def get_current_active_plan(self) -> Plan:
"""Get the most recent active plan."""
result = await self.db.execute(
select(Plan)
.order_by(Plan.version.desc())
.limit(1)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,92 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func
from app.models.prompt import Prompt
import logging
logger = logging.getLogger(__name__)
class PromptManager:
"""Service for managing AI prompts with versioning."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_active_prompt(self, action_type: str, model: str = None) -> str:
"""Get the active prompt for a specific action type."""
query = select(Prompt).where(
Prompt.action_type == action_type,
Prompt.active == True
)
if model:
query = query.where(Prompt.model == model)
result = await self.db.execute(query.order_by(Prompt.version.desc()))
prompt = result.scalar_one_or_none()
return prompt.prompt_text if prompt else None
async def create_prompt_version(
self,
action_type: str,
prompt_text: str,
model: str = None
) -> Prompt:
"""Create a new version of a prompt."""
# Deactivate previous versions
await self.db.execute(
update(Prompt)
.where(Prompt.action_type == action_type)
.values(active=False)
)
# Get next version number
result = await self.db.execute(
select(func.max(Prompt.version))
.where(Prompt.action_type == action_type)
)
max_version = result.scalar() or 0
# Create new prompt
new_prompt = Prompt(
action_type=action_type,
model=model,
prompt_text=prompt_text,
version=max_version + 1,
active=True
)
self.db.add(new_prompt)
await self.db.commit()
await self.db.refresh(new_prompt)
logger.info(f"Created new prompt version {new_prompt.version} for {action_type}")
return new_prompt
async def get_prompt_history(self, action_type: str) -> list[Prompt]:
"""Get all versions of prompts for an action type."""
result = await self.db.execute(
select(Prompt)
.where(Prompt.action_type == action_type)
.order_by(Prompt.version.desc())
)
return result.scalars().all()
async def activate_prompt_version(self, prompt_id: int) -> bool:
"""Activate a specific prompt version."""
# First deactivate all prompts for this action type
prompt = await self.db.get(Prompt, prompt_id)
if not prompt:
return False
await self.db.execute(
update(Prompt)
.where(Prompt.action_type == prompt.action_type)
.values(active=False)
)
# Activate the specific version
prompt.active = True
await self.db.commit()
logger.info(f"Activated prompt version {prompt.version} for {prompt.action_type}")
return True

View File

@@ -0,0 +1,90 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.services.garmin import GarminService, GarminAPIError
from app.models.workout import Workout
from app.models.garmin_sync_log import GarminSyncLog
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
class WorkoutSyncService:
"""Service for syncing Garmin activities to database."""
def __init__(self, db: AsyncSession):
self.db = db
self.garmin_service = GarminService()
async def sync_recent_activities(self, days_back: int = 7) -> int:
"""Sync recent Garmin activities to database."""
try:
# Create sync log entry
sync_log = GarminSyncLog(status="in_progress")
self.db.add(sync_log)
await self.db.commit()
# Calculate start date
start_date = datetime.now() - timedelta(days=days_back)
# Fetch activities from Garmin
activities = await self.garmin_service.get_activities(
limit=50, start_date=start_date
)
synced_count = 0
for activity in activities:
if await self.activity_exists(activity['activityId']):
continue
# Parse and create workout
workout_data = await self.parse_activity_data(activity)
workout = Workout(**workout_data)
self.db.add(workout)
synced_count += 1
# Update sync log
sync_log.status = "success"
sync_log.activities_synced = synced_count
sync_log.last_sync_time = datetime.now()
await self.db.commit()
logger.info(f"Successfully synced {synced_count} activities")
return synced_count
except GarminAPIError as e:
sync_log.status = "error"
sync_log.error_message = str(e)
await self.db.commit()
logger.error(f"Garmin API error during sync: {str(e)}")
raise
except Exception as e:
sync_log.status = "error"
sync_log.error_message = str(e)
await self.db.commit()
logger.error(f"Unexpected error during sync: {str(e)}")
raise
async def activity_exists(self, garmin_activity_id: str) -> bool:
"""Check if activity already exists in database."""
result = await self.db.execute(
select(Workout).where(Workout.garmin_activity_id == garmin_activity_id)
)
return result.scalar_one_or_none() is not None
async def parse_activity_data(self, activity: Dict[str, Any]) -> Dict[str, Any]:
"""Parse Garmin activity data into workout model format."""
return {
"garmin_activity_id": activity['activityId'],
"activity_type": activity.get('activityType', {}).get('typeKey'),
"start_time": datetime.fromisoformat(activity['startTimeLocal'].replace('Z', '+00:00')),
"duration_seconds": activity.get('duration'),
"distance_m": activity.get('distance'),
"avg_hr": activity.get('averageHR'),
"max_hr": activity.get('maxHR'),
"avg_power": activity.get('avgPower'),
"max_power": activity.get('maxPower'),
"avg_cadence": activity.get('averageBikingCadenceInRevPerMinute'),
"elevation_gain_m": activity.get('elevationGain'),
"metrics": activity # Store full Garmin data as JSONB
}