This commit is contained in:
xds
2026-03-17 10:00:55 +03:00
parent 1d76f29244
commit 2f4a30ccaf
9 changed files with 1404 additions and 87 deletions

View File

@@ -26,6 +26,7 @@ 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
from backend.app.services.coaching import link_activity_to_plan
from backend.app.models.training import TrainingPlan
router = APIRouter()
@@ -205,6 +206,7 @@ async def get_activity_power_curve(
@router.post("/{activity_id}/ai-summary")
async def generate_ai_summary(
activity_id: uuid.UUID,
body: dict | None = None,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
@@ -212,16 +214,31 @@ async def generate_ai_summary(
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
force = (body or {}).get("force", False)
# 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:
if diary and diary.ai_summary and not force:
return {"summary": diary.ai_summary}
# If linked to a plan, find the planned workout for comparison
planned_workout = None
if activity.training_plan_id and activity.plan_week and activity.plan_day:
plan = await session.get(TrainingPlan, activity.training_plan_id)
if plan and plan.weeks_json:
for week in plan.weeks_json.get("weeks", []):
if week.get("week_number") == activity.plan_week:
for day in week.get("days", []):
if day.get("day") == activity.plan_day:
planned_workout = day
break
break
# Generate new summary
summary = await generate_summary(activity, rider_ftp=rider.ftp)
summary = await generate_summary(activity, rider_ftp=rider.ftp, planned_workout=planned_workout)
if diary:
diary.ai_summary = summary

View File

@@ -1,7 +1,7 @@
from datetime import date, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy import select, func, extract, cast, Date
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_current_rider
@@ -144,3 +144,402 @@ async def get_personal_records(
# Sort by duration
return sorted(records.values(), key=lambda r: r["duration"])
@router.get("/detect-ftp")
async def detect_ftp(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Auto-detect FTP from best 20min power × 0.95 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)
best_20min = 0
best_activity_id = None
best_activity_name = None
best_date = None
for row in result:
power_20 = row.curve_data.get("1200") # 20 min = 1200s
if power_20 and power_20 > best_20min:
best_20min = power_20
best_activity_id = str(row.id)
best_activity_name = row.name or "Ride"
best_date = row.date.isoformat()
if best_20min == 0:
return {"detected_ftp": None, "best_20min_power": None, "message": "No 20-minute power data found"}
detected_ftp = round(best_20min * 0.95)
return {
"detected_ftp": detected_ftp,
"best_20min_power": best_20min,
"activity_id": best_activity_id,
"activity_name": best_activity_name,
"date": best_date,
"current_ftp": rider.ftp,
}
@router.get("/activity-calendar")
async def get_activity_calendar(
months: int = 12,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Activity heatmap data: date → TSS for the last N months."""
start_date = date.today() - timedelta(days=months * 30)
day_col = cast(Activity.date, Date).label("day")
query = (
select(
day_col,
func.count(Activity.id).label("count"),
func.sum(Activity.duration).label("duration"),
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(day_col)
.order_by(day_col)
)
result = await session.execute(query)
return [
{
"date": row.day.isoformat(),
"count": row.count,
"duration": row.duration or 0,
"tss": round(float(row.tss or 0), 0),
}
for row in result
]
@router.get("/monthly-trends")
async def get_monthly_trends(
months: int = 12,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Monthly trends: avg power, avg HR, volume, W/kg."""
start_date = date.today() - timedelta(days=months * 30)
month_col = func.date_trunc("month", Activity.date).label("month")
query = (
select(
month_col,
func.count(Activity.id).label("rides"),
func.sum(Activity.duration).label("duration"),
func.sum(Activity.distance).label("distance"),
func.avg(ActivityMetrics.avg_power).label("avg_power"),
func.avg(ActivityMetrics.normalized_power).label("avg_np"),
func.avg(ActivityMetrics.avg_hr).label("avg_hr"),
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(month_col)
.order_by(month_col)
)
result = await session.execute(query)
weight = rider.weight
return [
{
"month": row.month.strftime("%Y-%m") if row.month else None,
"rides": row.rides,
"hours": round((row.duration or 0) / 3600, 1),
"distance_km": round(float(row.distance or 0) / 1000, 0),
"avg_power": round(float(row.avg_power), 0) if row.avg_power else None,
"avg_np": round(float(row.avg_np), 0) if row.avg_np else None,
"avg_hr": round(float(row.avg_hr), 0) if row.avg_hr else None,
"tss": round(float(row.tss or 0), 0),
"w_per_kg": round(float(row.avg_power) / weight, 2) if row.avg_power and weight else None,
}
for row in result
]
@router.get("/progress-summary")
async def get_progress_summary(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Compare this week vs last week, this month vs last month. Current fitness state."""
today = date.today()
start_of_week = today - timedelta(days=today.weekday()) # Monday
last_week_start = start_of_week - timedelta(weeks=1)
start_of_month = today.replace(day=1)
last_month_start = (start_of_month - timedelta(days=1)).replace(day=1)
async def _period_stats(start: date, end: date):
query = (
select(
func.count(Activity.id).label("rides"),
func.sum(Activity.duration).label("duration"),
func.sum(Activity.distance).label("distance"),
func.sum(ActivityMetrics.tss).label("tss"),
func.avg(ActivityMetrics.avg_power).label("avg_power"),
func.avg(ActivityMetrics.normalized_power).label("avg_np"),
func.avg(ActivityMetrics.avg_hr).label("avg_hr"),
)
.select_from(Activity)
.outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
.where(Activity.rider_id == rider.id)
.where(Activity.date >= start)
.where(Activity.date < end)
)
row = (await session.execute(query)).first()
if not row or row.rides == 0:
return {"rides": 0, "hours": 0, "distance_km": 0, "tss": 0,
"avg_power": None, "avg_np": None, "avg_hr": None}
return {
"rides": row.rides,
"hours": round((row.duration or 0) / 3600, 1),
"distance_km": round(float(row.distance or 0) / 1000, 1),
"tss": round(float(row.tss or 0), 0),
"avg_power": round(float(row.avg_power), 0) if row.avg_power else None,
"avg_np": round(float(row.avg_np), 0) if row.avg_np else None,
"avg_hr": round(float(row.avg_hr), 0) if row.avg_hr else None,
}
this_week = await _period_stats(start_of_week, today + timedelta(days=1))
last_week = await _period_stats(last_week_start, start_of_week)
this_month = await _period_stats(start_of_month, today + timedelta(days=1))
last_month = await _period_stats(last_month_start, start_of_month)
# Current fitness (latest CTL/ATL/TSB)
from backend.app.models.fitness import FitnessHistory
fh_query = (
select(FitnessHistory)
.where(FitnessHistory.rider_id == rider.id)
.order_by(FitnessHistory.date.desc())
.limit(1)
)
fh = (await session.execute(fh_query)).scalar_one_or_none()
# Fitness 7 days ago for trend
fh_prev_query = (
select(FitnessHistory)
.where(FitnessHistory.rider_id == rider.id)
.where(FitnessHistory.date <= today - timedelta(days=7))
.order_by(FitnessHistory.date.desc())
.limit(1)
)
fh_prev = (await session.execute(fh_prev_query)).scalar_one_or_none()
fitness_now = {
"ctl": round(fh.ctl, 1) if fh else None,
"atl": round(fh.atl, 1) if fh else None,
"tsb": round(fh.tsb, 1) if fh else None,
}
fitness_prev = {
"ctl": round(fh_prev.ctl, 1) if fh_prev else None,
"atl": round(fh_prev.atl, 1) if fh_prev else None,
"tsb": round(fh_prev.tsb, 1) if fh_prev else None,
}
# Total activity count
total_count = (await session.execute(
select(func.count(Activity.id)).where(Activity.rider_id == rider.id)
)).scalar() or 0
# Days since last activity
last_act = (await session.execute(
select(Activity.date)
.where(Activity.rider_id == rider.id)
.order_by(Activity.date.desc())
.limit(1)
)).scalar_one_or_none()
if last_act is not None:
from datetime import datetime as dt_type
last_date = last_act.date() if isinstance(last_act, dt_type) else last_act
days_since_last = (today - last_date).days
else:
days_since_last = None
return {
"this_week": this_week,
"last_week": last_week,
"this_month": this_month,
"last_month": last_month,
"fitness": fitness_now,
"fitness_7d_ago": fitness_prev,
"total_activities": total_count,
"days_since_last_activity": days_since_last,
"ftp": rider.ftp,
"weight": rider.weight,
}
@router.get("/ai-progress")
async def get_ai_progress(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""AI analysis of long-term progress with actionable recommendations."""
from backend.app.services.coaching import build_rider_context
from backend.app.services.gemini_client import chat_async
from backend.app.models.fitness import FitnessHistory
today = date.today()
rider_context = await build_rider_context(rider, session)
# Collect monthly stats for the last 6 months
lines = [rider_context, "\n--- Monthly breakdown (last 6 months) ---"]
for i in range(6):
m_start = (today.replace(day=1) - timedelta(days=30 * i)).replace(day=1)
m_end = (m_start + timedelta(days=32)).replace(day=1)
query = (
select(
func.count(Activity.id).label("rides"),
func.sum(Activity.duration).label("duration"),
func.sum(Activity.distance).label("distance"),
func.sum(ActivityMetrics.tss).label("tss"),
func.avg(ActivityMetrics.avg_power).label("avg_power"),
func.avg(ActivityMetrics.avg_hr).label("avg_hr"),
)
.select_from(Activity)
.outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
.where(Activity.rider_id == rider.id)
.where(Activity.date >= m_start)
.where(Activity.date < m_end)
)
row = (await session.execute(query)).first()
if row and row.rides:
lines.append(
f"{m_start.strftime('%Y-%m')}: {row.rides} rides, "
f"{(row.duration or 0) / 3600:.1f}h, "
f"TSS={float(row.tss or 0):.0f}, "
f"AP={float(row.avg_power):.0f}W" if row.avg_power else f"{m_start.strftime('%Y-%m')}: {row.rides} rides"
)
# Fitness trend: sample every 7 days for last 90 days
fh_query = (
select(FitnessHistory.date, FitnessHistory.ctl, FitnessHistory.atl, FitnessHistory.tsb)
.where(FitnessHistory.rider_id == rider.id)
.where(FitnessHistory.date >= today - timedelta(days=90))
.order_by(FitnessHistory.date)
)
fh_rows = (await session.execute(fh_query)).all()
if fh_rows:
lines.append("\n--- Fitness trend (sampled weekly, last 90d) ---")
for idx, fh in enumerate(fh_rows):
if idx % 7 == 0 or idx == len(fh_rows) - 1:
lines.append(f"{fh.date}: CTL={fh.ctl:.0f} ATL={fh.atl:.0f} TSB={fh.tsb:.0f}")
system = """You are VeloBrain AI Coach performing a comprehensive progress analysis.
Analyze the rider's data and produce a structured report with these sections:
1. **Текущее состояние** — Current fitness level, form, fatigue. Is the rider fresh, building, overreaching?
2. **Динамика прогресса** — How fitness, power, and volume have changed over the last months. Specific numbers and trends.
3. **Сильные стороны** — What the rider is doing well (consistency, intensity, volume, specific power durations).
4. **Зоны роста** — Specific weaknesses or areas for improvement. Be constructive.
5. **Рекомендации** — 3-5 specific, actionable recommendations for the next 4 weeks. Include target numbers where possible.
6. **Риски** — Any warning signs: overtraining, stagnation, imbalance. Only include if relevant.
Be specific with numbers. Compare periods where possible.
Keep each section 2-4 sentences.
Respond in Russian. Use HTML formatting."""
analysis = await chat_async(
[{"role": "user", "text": "\n".join(lines)}],
system_instruction=system,
temperature=0.5,
)
return {"analysis": analysis}
@router.get("/weekly-digest")
async def get_weekly_digest(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Get data for AI weekly digest generation."""
from backend.app.services.coaching import build_rider_context
from backend.app.services.gemini_client import chat_async
today = date.today()
week_start = today - timedelta(days=today.weekday() + 7) # Last Monday
week_end = week_start + timedelta(days=7)
# Activities from last week
act_query = (
select(Activity, ActivityMetrics)
.outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
.where(Activity.rider_id == rider.id)
.where(Activity.date >= week_start)
.where(Activity.date < week_end)
.order_by(Activity.date)
)
result = await session.execute(act_query)
activities = list(result.all())
if not activities:
return {"digest": None, "message": "No activities last week"}
# Build prompt
lines = [f"Weekly digest for {week_start.isoformat()}{week_end.isoformat()}"]
total_duration = 0
total_tss = 0
total_distance = 0
for act, metrics in activities:
total_duration += act.duration
total_distance += act.distance or 0
tss = float(metrics.tss or 0) if metrics else 0
total_tss += tss
lines.append(
f"- {act.date.strftime('%a')}: {act.name or 'Workout'}, "
f"{act.duration // 60}min, "
f"{f'{act.distance / 1000:.1f}km, ' if act.distance else ''}"
f"{f'AP={metrics.avg_power:.0f}W, NP={metrics.normalized_power:.0f}W, ' if metrics and metrics.avg_power else ''}"
f"TSS={tss:.0f}"
)
lines.append(f"\nTotals: {len(activities)} workouts, {total_duration / 3600:.1f}h, "
f"{total_distance / 1000:.1f}km, TSS={total_tss:.0f}")
rider_context = await build_rider_context(rider, session)
lines.append(f"\n--- Rider ---\n{rider_context}")
system = """You are VeloBrain AI Coach generating a weekly training digest.
Provide a concise summary with these sections:
1. **Итоги недели** — what was done, key numbers
2. **Что получилось хорошо** — highlights, PRs, consistency
3. **На что обратить внимание** — areas for improvement, recovery needs
4. **Рекомендации на следующую неделю** — specific actionable advice
Keep it 8-12 sentences total. Be specific with numbers.
Respond in Russian. Use HTML formatting."""
digest = await chat_async(
[{"role": "user", "text": "\n".join(lines)}],
system_instruction=system,
temperature=0.5,
)
return {
"digest": digest,
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"total_activities": len(activities),
"total_hours": round(total_duration / 3600, 1),
"total_tss": round(total_tss, 0),
"total_distance_km": round(total_distance / 1000, 1),
}