This commit is contained in:
xds
2026-03-16 14:46:20 +03:00
parent 00db55720c
commit de8c2472e2
45 changed files with 3714 additions and 140 deletions

View File

@@ -5,24 +5,34 @@ from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_current_rider
from backend.app.core.config import settings
from backend.app.core.database import get_session
from backend.app.models.activity import Activity, DataPoint
from backend.app.models.activity import Activity, ActivityMetrics, DataPoint, Interval
from backend.app.models.fitness import PowerCurve
from backend.app.models.rider import Rider
from backend.app.schemas.activity import (
ActivityResponse,
ActivityListResponse,
DataPointResponse,
ZonesResponse,
PowerCurveResponse,
)
from backend.app.models.fitness import DiaryEntry
from backend.app.services.fit_parser import parse_fit_file
from backend.app.services.metrics import calculate_metrics
from backend.app.services.zones import calculate_power_zones, calculate_hr_zones
from backend.app.services.power_curve import calculate_power_curve
from backend.app.services.intervals import detect_intervals
from backend.app.services.ai_summary import generate_summary
router = APIRouter()
@router.post("/upload", response_model=ActivityResponse)
async def upload_activity(
rider_id: uuid.UUID,
file: UploadFile = File(...),
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
if not file.filename or not file.filename.lower().endswith(".fit"):
@@ -37,18 +47,33 @@ async def upload_activity(
content = await file.read()
file_path.write_bytes(content)
activity, data_points = parse_fit_file(content, rider_id, str(file_path))
# 1. Parse FIT
activity, data_points = parse_fit_file(content, rider.id, str(file_path))
session.add(activity)
await session.flush()
# 2. Save data points
for dp in data_points:
dp.activity_id = activity.id
session.add_all(data_points)
metrics = calculate_metrics(data_points, activity, rider_id, session)
# 3. Calculate & save metrics (with FTP if available)
metrics = calculate_metrics(data_points, activity, ftp=rider.ftp)
if metrics:
session.add(metrics)
# 4. Detect & save intervals
intervals = detect_intervals(data_points, ftp=rider.ftp)
for interval in intervals:
interval.activity_id = activity.id
session.add_all(intervals)
# 5. Calculate & save power curve
curve_data = calculate_power_curve(data_points)
if curve_data:
pc = PowerCurve(activity_id=activity.id, curve_data=curve_data)
session.add(pc)
await session.commit()
await session.refresh(activity)
return activity
@@ -56,17 +81,17 @@ async def upload_activity(
@router.get("", response_model=ActivityListResponse)
async def list_activities(
rider_id: uuid.UUID,
limit: int = 20,
offset: int = 0,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
count_query = select(func.count(Activity.id)).where(Activity.rider_id == rider_id)
count_query = select(func.count(Activity.id)).where(Activity.rider_id == rider.id)
total = (await session.execute(count_query)).scalar() or 0
query = (
select(Activity)
.where(Activity.rider_id == rider_id)
.where(Activity.rider_id == rider.id)
.order_by(Activity.date.desc())
.limit(limit)
.offset(offset)
@@ -80,10 +105,11 @@ async def list_activities(
@router.get("/{activity_id}", response_model=ActivityResponse)
async def get_activity(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity:
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
return activity
@@ -91,8 +117,14 @@ async def get_activity(
@router.get("/{activity_id}/stream", response_model=list[DataPointResponse])
async def get_activity_stream(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
# Verify ownership
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
query = (
select(DataPoint)
.where(DataPoint.activity_id == activity_id)
@@ -100,3 +132,173 @@ async def get_activity_stream(
)
result = await session.execute(query)
return result.scalars().all()
@router.get("/{activity_id}/zones", response_model=ZonesResponse)
async def get_activity_zones(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
query = (
select(DataPoint)
.where(DataPoint.activity_id == activity_id)
.order_by(DataPoint.timestamp)
)
result = await session.execute(query)
data_points = list(result.scalars().all())
power_zones = []
hr_zones = []
if rider.ftp:
power_zones = calculate_power_zones(data_points, rider.ftp)
if rider.lthr:
hr_zones = calculate_hr_zones(data_points, rider.lthr)
return ZonesResponse(power_zones=power_zones, hr_zones=hr_zones)
@router.get("/{activity_id}/power-curve", response_model=PowerCurveResponse)
async def get_activity_power_curve(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
# Try cached
query = select(PowerCurve).where(PowerCurve.activity_id == activity_id)
result = await session.execute(query)
pc = result.scalar_one_or_none()
if pc:
return PowerCurveResponse(curve=pc.curve_data)
# Calculate on the fly
dp_query = (
select(DataPoint)
.where(DataPoint.activity_id == activity_id)
.order_by(DataPoint.timestamp)
)
dp_result = await session.execute(dp_query)
data_points = list(dp_result.scalars().all())
curve = calculate_power_curve(data_points)
return PowerCurveResponse(curve=curve)
@router.post("/{activity_id}/ai-summary")
async def generate_ai_summary(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
# Check for existing diary entry with summary
query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id)
result = await session.execute(query)
diary = result.scalar_one_or_none()
if diary and diary.ai_summary:
return {"summary": diary.ai_summary}
# Generate new summary
summary = await generate_summary(activity, rider_ftp=rider.ftp)
if diary:
diary.ai_summary = summary
else:
diary = DiaryEntry(activity_id=activity_id, ai_summary=summary)
session.add(diary)
await session.commit()
return {"summary": summary}
@router.delete("/{activity_id}")
async def delete_activity(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
# Delete related records
for model in [DataPoint, Interval, ActivityMetrics, PowerCurve, DiaryEntry]:
q = select(model).where(model.activity_id == activity_id)
result = await session.execute(q)
for row in result.scalars().all():
await session.delete(row)
await session.delete(activity)
await session.commit()
return {"ok": True}
@router.get("/{activity_id}/diary")
async def get_diary(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id)
result = await session.execute(query)
diary = result.scalar_one_or_none()
if not diary:
return {"rider_notes": None, "mood": None, "rpe": None, "sleep_hours": None}
return {
"rider_notes": diary.rider_notes,
"mood": diary.mood,
"rpe": diary.rpe,
"sleep_hours": diary.sleep_hours,
}
@router.put("/{activity_id}/diary")
async def update_diary(
activity_id: uuid.UUID,
data: dict,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id)
result = await session.execute(query)
diary = result.scalar_one_or_none()
if not diary:
diary = DiaryEntry(activity_id=activity_id)
session.add(diary)
for field in ["rider_notes", "mood", "rpe", "sleep_hours"]:
if field in data:
setattr(diary, field, data[field])
await session.commit()
return {
"rider_notes": diary.rider_notes,
"mood": diary.mood,
"rpe": diary.rpe,
"sleep_hours": diary.sleep_hours,
}

307
backend/app/api/coaching.py Normal file
View File

@@ -0,0 +1,307 @@
"""Coaching API endpoints — onboarding, chat, plan, compliance."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_current_rider
from backend.app.core.database import get_session
from backend.app.models.coaching import CoachingChat
from backend.app.models.rider import Rider
from backend.app.models.training import TrainingPlan
from backend.app.services.coaching import (
process_chat_message,
generate_plan,
get_today_workout,
calculate_compliance,
)
router = APIRouter()
class MessageRequest(BaseModel):
message: str
class ChatResponse(BaseModel):
chat_id: str
chat_type: str
status: str
messages: list[dict]
class PlanResponse(BaseModel):
id: str
goal: str
start_date: str
end_date: str
phase: str | None
description: str | None
status: str
weeks: list[dict]
# --- Onboarding ---
@router.post("/onboarding/start")
async def start_onboarding(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Start or resume onboarding chat."""
# Check for existing active onboarding
query = (
select(CoachingChat)
.where(CoachingChat.rider_id == rider.id)
.where(CoachingChat.chat_type == "onboarding")
.where(CoachingChat.status == "active")
.order_by(CoachingChat.created_at.desc())
.limit(1)
)
result = await session.execute(query)
chat = result.scalar_one_or_none()
if chat:
return {
"chat_id": str(chat.id),
"status": chat.status,
"messages": chat.messages_json or [],
"onboarding_completed": rider.onboarding_completed,
}
# Create new onboarding chat
chat = CoachingChat(
rider_id=rider.id,
chat_type="onboarding",
status="active",
messages_json=[],
)
session.add(chat)
await session.commit()
await session.refresh(chat)
# Send initial greeting by processing an empty-ish message
response = await process_chat_message(rider, chat.id, "Привет! Я хочу начать тренировки.", session)
return {
"chat_id": str(chat.id),
"status": chat.status,
"messages": chat.messages_json or [],
"onboarding_completed": rider.onboarding_completed,
}
@router.get("/onboarding/status")
async def get_onboarding_status(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Check onboarding status."""
return {
"onboarding_completed": rider.onboarding_completed,
"coaching_profile": rider.coaching_profile,
}
# --- Chat ---
@router.post("/chat/new")
async def create_chat(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Create a new general coaching chat."""
chat = CoachingChat(
rider_id=rider.id,
chat_type="general",
status="active",
messages_json=[],
)
session.add(chat)
await session.commit()
await session.refresh(chat)
return {"chat_id": str(chat.id), "status": "active", "messages": []}
@router.get("/chats")
async def list_chats(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""List all coaching chats."""
query = (
select(CoachingChat)
.where(CoachingChat.rider_id == rider.id)
.order_by(CoachingChat.updated_at.desc())
.limit(20)
)
result = await session.execute(query)
chats = result.scalars().all()
return [
{
"id": str(c.id),
"chat_type": c.chat_type,
"status": c.status,
"message_count": len(c.messages_json or []),
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
"last_message": (c.messages_json[-1]["text"][:100] if c.messages_json else None),
}
for c in chats
]
@router.get("/chat/{chat_id}")
async def get_chat(
chat_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Get chat with messages."""
chat = await session.get(CoachingChat, chat_id)
if not chat or chat.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Chat not found")
return {
"chat_id": str(chat.id),
"chat_type": chat.chat_type,
"status": chat.status,
"messages": chat.messages_json or [],
}
@router.post("/chat/{chat_id}/message")
async def send_message(
chat_id: uuid.UUID,
body: MessageRequest,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Send a message to the coaching chat."""
response = await process_chat_message(rider, chat_id, body.message, session)
chat = await session.get(CoachingChat, chat_id)
return {
"response": response,
"chat_id": str(chat_id),
"status": chat.status if chat else "active",
"messages": chat.messages_json if chat else [],
"onboarding_completed": rider.onboarding_completed,
}
# --- Training Plan ---
@router.post("/plan/generate")
async def create_plan(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Generate a new AI training plan."""
plan = await generate_plan(rider, session)
return _plan_to_dict(plan)
@router.get("/plan/active")
async def get_active_plan(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Get the active training plan."""
query = (
select(TrainingPlan)
.where(TrainingPlan.rider_id == rider.id)
.where(TrainingPlan.status == "active")
.order_by(TrainingPlan.created_at.desc())
.limit(1)
)
result = await session.execute(query)
plan = result.scalar_one_or_none()
if not plan:
return None
return _plan_to_dict(plan)
@router.get("/plan/{plan_id}/compliance")
async def get_plan_compliance(
plan_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Get compliance data for a plan."""
plan = await session.get(TrainingPlan, plan_id)
if not plan or plan.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Plan not found")
compliance = await calculate_compliance(plan, session)
return compliance
@router.get("/today")
async def get_today(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Get today's planned workout."""
workout = await get_today_workout(rider, session)
return workout
# --- Adjustment chat ---
@router.post("/plan/adjust")
async def start_plan_adjustment(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Start an adjustment chat for the active plan."""
# Check active plan exists
plan_query = (
select(TrainingPlan)
.where(TrainingPlan.rider_id == rider.id)
.where(TrainingPlan.status == "active")
.limit(1)
)
plan_result = await session.execute(plan_query)
plan = plan_result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=400, detail="No active plan to adjust")
chat = CoachingChat(
rider_id=rider.id,
chat_type="adjustment",
status="active",
messages_json=[],
)
session.add(chat)
await session.commit()
await session.refresh(chat)
# Start with context about what needs adjustment
response = await process_chat_message(
rider, chat.id,
"Мне нужно скорректировать мой текущий план тренировок. Посмотри на мои последние данные и предложи изменения.",
session,
)
return {
"chat_id": str(chat.id),
"status": chat.status,
"messages": chat.messages_json or [],
"response": response,
}
def _plan_to_dict(plan: TrainingPlan) -> dict:
weeks = plan.weeks_json.get("weeks", []) if plan.weeks_json else []
return {
"id": str(plan.id),
"goal": plan.goal,
"start_date": plan.start_date.isoformat(),
"end_date": plan.end_date.isoformat(),
"phase": plan.phase,
"description": plan.description,
"status": plan.status,
"weeks": weeks,
}

View File

@@ -1,52 +1,146 @@
import uuid
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_current_rider
from backend.app.core.database import get_session
from backend.app.models.activity import Activity, ActivityMetrics
from backend.app.models.fitness import PowerCurve
from backend.app.models.rider import Rider
from backend.app.schemas.rider import RiderCreate, RiderUpdate, RiderResponse
from backend.app.schemas.rider import RiderUpdate, RiderResponse, FitnessHistoryResponse
from backend.app.services.fitness import get_fitness_history
router = APIRouter()
@router.post("/profile", response_model=RiderResponse)
async def create_rider(
data: RiderCreate,
session: AsyncSession = Depends(get_session),
):
rider = Rider(**data.model_dump())
session.add(rider)
await session.commit()
await session.refresh(rider)
@router.get("/profile", response_model=RiderResponse)
async def get_rider(rider: Rider = Depends(get_current_rider)):
return rider
@router.get("/profile/{rider_id}", response_model=RiderResponse)
async def get_rider(
rider_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
):
rider = await session.get(Rider, rider_id)
if not rider:
raise HTTPException(status_code=404, detail="Rider not found")
return rider
@router.put("/profile/{rider_id}", response_model=RiderResponse)
@router.put("/profile", response_model=RiderResponse)
async def update_rider(
rider_id: uuid.UUID,
data: RiderUpdate,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
rider = await session.get(Rider, rider_id)
if not rider:
raise HTTPException(status_code=404, detail="Rider not found")
update_data = data.model_dump(exclude_unset=True)
ftp_changed = "ftp" in update_data and update_data["ftp"] != rider.ftp
for key, value in update_data.items():
setattr(rider, key, value)
# Recalculate TSS/IF for all activities when FTP changes
if ftp_changed and rider.ftp:
query = (
select(ActivityMetrics)
.join(Activity, Activity.id == ActivityMetrics.activity_id)
.where(Activity.rider_id == rider.id)
.where(ActivityMetrics.normalized_power.isnot(None))
)
result = await session.execute(query)
for metrics in result.scalars().all():
np_val = metrics.normalized_power
duration_q = await session.execute(
select(Activity.duration).where(Activity.id == metrics.activity_id)
)
duration = duration_q.scalar()
if np_val and duration:
metrics.intensity_factor = round(np_val / rider.ftp, 2)
metrics.tss = round(
(duration * np_val * (np_val / rider.ftp))
/ (rider.ftp * 3600)
* 100,
1,
)
await session.commit()
await session.refresh(rider)
return rider
@router.get("/fitness", response_model=list[FitnessHistoryResponse])
async def get_fitness(
days: int = 90,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
entries = await get_fitness_history(rider.id, session, days=days)
return entries
@router.get("/weekly-stats")
async def get_weekly_stats(
weeks: int = 8,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
today = date.today()
# Start from Monday of current week
start_of_week = today - timedelta(days=today.weekday())
start_date = start_of_week - timedelta(weeks=weeks - 1)
week_col = func.date_trunc('week', Activity.date).label("week")
query = (
select(
week_col,
func.count(Activity.id).label("rides"),
func.sum(Activity.duration).label("duration"),
func.sum(Activity.distance).label("distance"),
func.sum(ActivityMetrics.tss).label("tss"),
)
.select_from(Activity)
.outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
.where(Activity.rider_id == rider.id)
.where(Activity.date >= start_date)
.group_by(week_col)
.order_by(week_col)
)
result = await session.execute(query)
return [
{
"week": row.week.strftime("%Y-%m-%d") if row.week else None,
"rides": row.rides,
"duration": row.duration or 0,
"distance": round(float(row.distance or 0) / 1000, 1),
"tss": round(float(row.tss or 0), 0),
}
for row in result
]
@router.get("/personal-records")
async def get_personal_records(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Best power at each standard duration across all activities."""
query = (
select(PowerCurve.curve_data, Activity.date, Activity.name, Activity.id)
.join(Activity, Activity.id == PowerCurve.activity_id)
.where(Activity.rider_id == rider.id)
)
result = await session.execute(query)
# Merge all curves: keep best power + source activity for each duration
records: dict[int, dict] = {}
for row in result:
curve_data = row.curve_data
for dur_str, power in curve_data.items():
dur = int(dur_str)
if dur not in records or power > records[dur]["power"]:
records[dur] = {
"duration": dur,
"power": power,
"activity_id": str(row.id),
"activity_name": row.name or "Ride",
"date": row.date.isoformat(),
}
# Sort by duration
return sorted(records.values(), key=lambda r: r["duration"])

View File

@@ -3,9 +3,11 @@ from fastapi import APIRouter
from backend.app.api.auth import router as auth_router
from backend.app.api.activities import router as activities_router
from backend.app.api.rider import router as rider_router
from backend.app.api.coaching import router as coaching_router
api_router = APIRouter(prefix="/api")
api_router.include_router(auth_router, prefix="/auth", tags=["auth"])
api_router.include_router(activities_router, prefix="/activities", tags=["activities"])
api_router.include_router(rider_router, prefix="/rider", tags=["rider"])
api_router.include_router(coaching_router, prefix="/coaching", tags=["coaching"])