mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2026-03-01 02:25:32 +00:00
sync
This commit is contained in:
@@ -15,7 +15,7 @@ RUN apt-get update && \
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies
|
||||
COPY backend/requirements.txt .
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Runtime stage
|
||||
@@ -39,7 +39,7 @@ COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/pytho
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
|
||||
# Copy application code
|
||||
COPY backend/ .
|
||||
COPY . .
|
||||
|
||||
# Create entrypoint script for migration handling
|
||||
RUN echo '#!/bin/bash\n\
|
||||
|
||||
11
backend/app/dependencies.py
Normal file
11
backend/app/dependencies.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import HTTPException, Header, status
|
||||
import os
|
||||
|
||||
async def verify_api_key(api_key: str = Header(..., alias="X-API-Key")):
|
||||
"""Dependency to verify API key header"""
|
||||
expected_key = os.getenv("API_KEY")
|
||||
if not expected_key or api_key != expected_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing API Key"
|
||||
)
|
||||
@@ -9,9 +9,12 @@ class Analysis(BaseModel):
|
||||
|
||||
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
|
||||
jsonb_feedback = Column(JSON, nullable=False)
|
||||
suggestions = Column(JSON)
|
||||
approved = Column(Boolean, default=False)
|
||||
|
||||
created_plan_id = Column(Integer, ForeignKey('plans.id'))
|
||||
approved_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
workout = relationship("Workout", back_populates="analyses")
|
||||
workout = relationship("Workout", back_populates="analyses")
|
||||
plan = relationship("Plan", back_populates="analyses")
|
||||
@@ -11,4 +11,5 @@ class Plan(BaseModel):
|
||||
parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True)
|
||||
|
||||
parent_plan = relationship("Plan", remote_side="Plan.id", backref="child_plans")
|
||||
analyses = relationship("Analysis", back_populates="plan")
|
||||
workouts = relationship("Workout", back_populates="plan", cascade="all, delete-orphan")
|
||||
23
backend/app/routes/garmin.py
Normal file
23
backend/app/routes/garmin.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.dependencies import verify_api_key
|
||||
from app.services.workout_sync import WorkoutSyncService
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter(dependencies=[Depends(verify_api_key)])
|
||||
|
||||
@router.post("/sync")
|
||||
async def trigger_garmin_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Trigger background sync of 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")
|
||||
async def get_sync_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Get latest sync status"""
|
||||
sync_service = WorkoutSyncService(db)
|
||||
return await sync_service.get_latest_sync_status()
|
||||
@@ -32,6 +32,21 @@ async def read_workout(workout_id: int, db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
return workout
|
||||
|
||||
@router.get("/{workout_id}/metrics", response_model=list[schemas.WorkoutMetric])
|
||||
async def get_workout_metrics(
|
||||
workout_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get time-series metrics for a workout"""
|
||||
workout = await db.get(Workout, workout_id)
|
||||
if not workout:
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
|
||||
if not workout.metrics:
|
||||
return []
|
||||
|
||||
return workout.metrics
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def trigger_garmin_sync(
|
||||
@@ -135,4 +150,17 @@ async def approve_analysis(
|
||||
return {"message": "Analysis approved", "new_plan_id": new_plan.id if new_plan else None}
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Analysis approved"}
|
||||
return {"message": "Analysis approved"}
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}/evolution", response_model=List[schemas.Plan])
|
||||
async def get_plan_evolution(
|
||||
plan_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get full evolution history for a plan."""
|
||||
evolution_service = PlanEvolutionService(db)
|
||||
plans = await evolution_service.get_plan_evolution_history(plan_id)
|
||||
if not plans:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
return plans
|
||||
@@ -4,16 +4,18 @@ from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
class PlanBase(BaseModel):
|
||||
user_id: UUID
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
goal: str
|
||||
jsonb_plan: dict = Field(..., description="Training plan data in JSONB format")
|
||||
version: int = Field(..., gt=0, description="Plan version number")
|
||||
parent_plan_id: Optional[int] = Field(None, description="Parent plan ID for evolution tracking")
|
||||
|
||||
class PlanCreate(PlanBase):
|
||||
rule_ids: List[UUID]
|
||||
pass
|
||||
|
||||
class Plan(PlanBase):
|
||||
id: UUID
|
||||
id: int
|
||||
created_at: datetime
|
||||
analyses: List["Analysis"] = Field([], description="Analyses that created this plan version")
|
||||
child_plans: List["Plan"] = Field([], description="Evolved versions of this plan")
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -3,6 +3,12 @@ from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class WorkoutMetric(BaseModel):
|
||||
timestamp: datetime
|
||||
heart_rate: Optional[int] = None
|
||||
power: Optional[float] = None
|
||||
cadence: Optional[float] = None
|
||||
|
||||
class WorkoutBase(BaseModel):
|
||||
garmin_activity_id: str
|
||||
activity_type: Optional[str] = None
|
||||
|
||||
@@ -65,6 +65,17 @@ class AIService:
|
||||
response = await self._make_ai_request(prompt)
|
||||
return self._parse_rules_response(response)
|
||||
|
||||
async def evolve_plan(self, evolution_context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Evolve a training plan using AI based on workout analysis."""
|
||||
prompt_template = await self.prompt_manager.get_active_prompt("plan_evolution")
|
||||
|
||||
if not prompt_template:
|
||||
raise ValueError("No active plan evolution prompt found")
|
||||
|
||||
prompt = prompt_template.format(**evolution_context)
|
||||
response = await self._make_ai_request(prompt)
|
||||
return self._parse_plan_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:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, desc
|
||||
from app.services.garmin import GarminService, GarminAPIError, GarminAuthError
|
||||
from app.models.workout import Workout
|
||||
from app.models.garmin_sync_log import GarminSyncLog
|
||||
from app.models.garmin_sync_log import GarminSyncLog
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,11 +36,27 @@ class WorkoutSyncService:
|
||||
|
||||
synced_count = 0
|
||||
for activity in activities:
|
||||
if await self.activity_exists(activity['activityId']):
|
||||
activity_id = activity['activityId']
|
||||
if await self.activity_exists(activity_id):
|
||||
continue
|
||||
|
||||
# Get full activity details with retry logic
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
details = await self.garmin_service.get_activity_details(activity_id)
|
||||
break
|
||||
except (GarminAPIError, GarminAuthError) as e:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
logger.warning(f"Retrying activity details fetch for {activity_id}, attempt {attempt + 1}")
|
||||
|
||||
# Merge basic activity data with detailed metrics
|
||||
full_activity = {**activity, **details}
|
||||
|
||||
# Parse and create workout
|
||||
workout_data = await self.parse_activity_data(activity)
|
||||
workout_data = await self.parse_activity_data(full_activity)
|
||||
workout = Workout(**workout_data)
|
||||
self.db.add(workout)
|
||||
synced_count += 1
|
||||
@@ -52,8 +70,14 @@ class WorkoutSyncService:
|
||||
logger.info(f"Successfully synced {synced_count} activities")
|
||||
return synced_count
|
||||
|
||||
except GarminAuthError as e:
|
||||
sync_log.status = "auth_error"
|
||||
sync_log.error_message = str(e)
|
||||
await self.db.commit()
|
||||
logger.error(f"Garmin authentication failed: {str(e)}")
|
||||
raise
|
||||
except GarminAPIError as e:
|
||||
sync_log.status = "error"
|
||||
sync_log.status = "api_error"
|
||||
sync_log.error_message = str(e)
|
||||
await self.db.commit()
|
||||
logger.error(f"Garmin API error during sync: {str(e)}")
|
||||
@@ -65,6 +89,15 @@ class WorkoutSyncService:
|
||||
logger.error(f"Unexpected error during sync: {str(e)}")
|
||||
raise
|
||||
|
||||
async def get_latest_sync_status(self):
|
||||
"""Get the most recent sync log entry"""
|
||||
result = await self.db.execute(
|
||||
select(GarminSyncLog)
|
||||
.order_by(desc(GarminSyncLog.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def activity_exists(self, garmin_activity_id: str) -> bool:
|
||||
"""Check if activity already exists in database."""
|
||||
result = await self.db.execute(
|
||||
|
||||
Reference in New Issue
Block a user