mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-02-15 03:42:02 +00:00
sync
This commit is contained in:
11
backend/app/config.py
Normal file
11
backend/app/config.py
Normal 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
17
backend/app/database.py
Normal 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
107
backend/app/main.py
Normal 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)
|
||||
11
backend/app/models/__init__.py
Normal file
11
backend/app/models/__init__.py
Normal 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
|
||||
17
backend/app/models/analysis.py
Normal file
17
backend/app/models/analysis.py
Normal 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")
|
||||
17
backend/app/models/base.py
Normal file
17
backend/app/models/base.py
Normal 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}>"
|
||||
12
backend/app/models/garmin_sync_log.py
Normal file
12
backend/app/models/garmin_sync_log.py
Normal 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)
|
||||
14
backend/app/models/plan.py
Normal file
14
backend/app/models/plan.py
Normal 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")
|
||||
13
backend/app/models/prompt.py
Normal file
13
backend/app/models/prompt.py
Normal 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)
|
||||
14
backend/app/models/route.py
Normal file
14
backend/app/models/route.py
Normal 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")
|
||||
14
backend/app/models/rule.py
Normal file
14
backend/app/models/rule.py
Normal 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")
|
||||
15
backend/app/models/section.py
Normal file
15
backend/app/models/section.py
Normal 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")
|
||||
7
backend/app/models/user.py
Normal file
7
backend/app/models/user.py
Normal 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")
|
||||
26
backend/app/models/workout.py
Normal file
26
backend/app/models/workout.py
Normal 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
35
backend/app/routes/gpx.py
Normal 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
|
||||
89
backend/app/routes/plan.py
Normal file
89
backend/app/routes/plan.py
Normal 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"}
|
||||
79
backend/app/routes/prompts.py
Normal file
79
backend/app/routes/prompts.py
Normal 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"}
|
||||
66
backend/app/routes/rule.py
Normal file
66
backend/app/routes/rule.py
Normal 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"}
|
||||
138
backend/app/routes/workouts.py
Normal file
138
backend/app/routes/workouts.py
Normal 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"}
|
||||
25
backend/app/schemas/analysis.py
Normal file
25
backend/app/schemas/analysis.py
Normal 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
|
||||
25
backend/app/schemas/gpx.py
Normal file
25
backend/app/schemas/gpx.py
Normal 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
|
||||
19
backend/app/schemas/plan.py
Normal file
19
backend/app/schemas/plan.py
Normal 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
|
||||
30
backend/app/schemas/prompt.py
Normal file
30
backend/app/schemas/prompt.py
Normal 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
|
||||
17
backend/app/schemas/rule.py
Normal file
17
backend/app/schemas/rule.py
Normal 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
|
||||
40
backend/app/schemas/workout.py
Normal file
40
backend/app/schemas/workout.py
Normal 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
|
||||
130
backend/app/services/ai_service.py
Normal file
130
backend/app/services/ai_service.py
Normal 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
|
||||
84
backend/app/services/garmin.py
Normal file
84
backend/app/services/garmin.py
Normal 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
|
||||
62
backend/app/services/gpx.py
Normal file
62
backend/app/services/gpx.py
Normal 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")
|
||||
74
backend/app/services/plan_evolution.py
Normal file
74
backend/app/services/plan_evolution.py
Normal 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()
|
||||
92
backend/app/services/prompt_manager.py
Normal file
92
backend/app/services/prompt_manager.py
Normal 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
|
||||
90
backend/app/services/workout_sync.py
Normal file
90
backend/app/services/workout_sync.py
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user