Files
bikeslop/app.py
T
ryan c6d9a3b8b6 Add multi-user support: Authelia OIDC, SQLite, Garage S3
- Auth via Authelia OIDC (public client + PKCE) with signed session cookies
- SQLite DB for users, rides, and insights (SQLAlchemy)
- Garage S3 for GPX file storage (boto3)
- All API endpoints scoped to authenticated user
- Dockerfile + docker-compose.yml with Garage service
- Frontend auth check: redirects to /auth/login on 401, shows user badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 22:12:23 -04:00

592 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import json
import logging
import math
import os
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from xml.etree import ElementTree as ET
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
from garminconnect import Garmin, GarminConnectAuthenticationError
from openai import OpenAI
from sqlalchemy import and_, select
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from auth import require_user, router as auth_router
from db import Insight, Ride, User, create_tables, engine
from storage import download_gpx, upload_gpx
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
for _noisy in ("httpx", "httpcore", "openai"):
logging.getLogger(_noisy).setLevel(logging.WARNING)
log = logging.getLogger("bikeslop")
LLAMACPP_BASE_URL = os.getenv("LLAMACPP_BASE_URL", "http://localhost:8080/v1")
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
MODEL_NAME = "local-model"
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
app.include_router(auth_router)
_mfa_sessions: dict[str, dict] = {}
_user_rides_cache: dict[str, dict[str, dict]] = {}
_points_cache: dict[str, list[dict]] = {}
_POINTS_CACHE_MAX = 50
# ---------------------------------------------------------------------------
# GPX parsing helpers
# ---------------------------------------------------------------------------
NS = {
"gpx": "http://www.topografix.com/GPX/1/1",
"ns3": "http://www.garmin.com/xmlschemas/TrackPointExtension/v1",
}
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def parse_gpx_bytes(data: bytes, filename: str) -> dict:
"""Parse GPX bytes into a ride dict including 'points'. No 'id' field."""
root = ET.fromstring(data)
name_el = root.find(".//gpx:trk/gpx:name", NS)
stem = Path(filename).stem
name = name_el.text if name_el is not None else stem
type_el = root.find(".//gpx:trk/gpx:type", NS)
activity_type = type_el.text if type_el is not None else "cycling"
points = []
for pt in root.findall(".//gpx:trkpt", NS):
lat = float(pt.get("lat"))
lon = float(pt.get("lon"))
ele_el = pt.find("gpx:ele", NS)
ele = float(ele_el.text) if ele_el is not None else 0.0
time_el = pt.find("gpx:time", NS)
ts = None
if time_el is not None:
ts = datetime.fromisoformat(time_el.text.replace("Z", "+00:00"))
hr_el = pt.find(".//ns3:hr", NS)
hr = int(hr_el.text) if hr_el is not None else None
points.append({"lat": lat, "lon": lon, "ele": ele, "time": ts, "hr": hr})
distance_m = 0.0
ele_gain = 0.0
ele_loss = 0.0
for i in range(1, len(points)):
p0, p1 = points[i - 1], points[i]
distance_m += haversine(p0["lat"], p0["lon"], p1["lat"], p1["lon"])
de = p1["ele"] - p0["ele"]
if de > 0:
ele_gain += de
else:
ele_loss += abs(de)
duration_s = 0
start_time = None
end_time = None
if points and points[0]["time"] and points[-1]["time"]:
start_time = points[0]["time"]
end_time = points[-1]["time"]
duration_s = int((end_time - start_time).total_seconds())
hrs = [p["hr"] for p in points if p["hr"] is not None]
avg_hr = round(sum(hrs) / len(hrs)) if hrs else None
max_hr = max(hrs) if hrs else None
avg_speed_kmh = (distance_m / 1000) / (duration_s / 3600) if duration_s > 0 else 0
zone_counts = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
for h in hrs:
if h < 120:
zone_counts["z1"] += 1
elif h < 140:
zone_counts["z2"] += 1
elif h < 160:
zone_counts["z3"] += 1
elif h < 180:
zone_counts["z4"] += 1
else:
zone_counts["z5"] += 1
total_hr_pts = len(hrs)
hr_zones = {
k: round(v / total_hr_pts * 100, 1) if total_hr_pts else 0
for k, v in zone_counts.items()
}
return {
"name": name,
"activity_type": activity_type,
"filename": filename,
"start_time": start_time.isoformat() if start_time else None,
"end_time": end_time.isoformat() if end_time else None,
"duration_s": duration_s,
"distance_m": round(distance_m),
"distance_km": round(distance_m / 1000, 2),
"avg_speed_kmh": round(avg_speed_kmh, 1),
"elevation_gain_m": round(ele_gain),
"elevation_loss_m": round(ele_loss),
"avg_hr": avg_hr,
"max_hr": max_hr,
"hr_zones": hr_zones,
"has_gps": len(points) > 0 and points[0].get("lat") is not None,
"points": [
{
"lat": p["lat"],
"lon": p["lon"],
"ele": round(p["ele"], 1),
"time": p["time"].isoformat() if p["time"] else None,
"hr": p["hr"],
}
for p in points
],
}
# ---------------------------------------------------------------------------
# Per-user ride + points cache
# ---------------------------------------------------------------------------
def _load_user_rides(user_id: str) -> dict[str, dict]:
with Session(engine) as s:
rows = s.query(Ride).filter_by(user_id=user_id).all()
return {r.id: r.to_dict() for r in rows}
def get_user_rides_cached(user_id: str) -> dict[str, dict]:
if user_id not in _user_rides_cache:
_user_rides_cache[user_id] = _load_user_rides(user_id)
return _user_rides_cache[user_id]
def invalidate_user_cache(user_id: str) -> None:
_user_rides_cache.pop(user_id, None)
def _fetch_ride_points(ride_id: str, s3_key: str) -> list[dict]:
if ride_id not in _points_cache:
if len(_points_cache) >= _POINTS_CACHE_MAX:
del _points_cache[next(iter(_points_cache))]
gpx_data = download_gpx(s3_key)
parsed = parse_gpx_bytes(gpx_data, s3_key.split("/")[-1])
_points_cache[ride_id] = parsed["points"]
return _points_cache[ride_id]
def _get_ride_insight(ride_id: str) -> str | None:
with Session(engine) as s:
row = s.query(Insight).filter_by(ride_id=ride_id).first()
return row.text if row else None
def _save_ride_insight(ride_id: str, user_id: str, text: str) -> None:
with Session(engine) as s:
row = s.query(Insight).filter_by(ride_id=ride_id).first()
if row:
row.text = text
else:
row = Insight(ride_id=ride_id, user_id=user_id, text=text)
s.add(row)
s.commit()
# ---------------------------------------------------------------------------
# Startup
# ---------------------------------------------------------------------------
@app.on_event("startup")
def startup():
global MODEL_NAME
create_tables()
log.info("Database tables ready.")
try:
client = OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed")
models = client.models.list()
first = next(iter(models), None)
if first:
MODEL_NAME = first.id
log.info("Discovered model: %s", MODEL_NAME)
else:
log.warning("No models from %s — using fallback '%s'", LLAMACPP_BASE_URL, MODEL_NAME)
except Exception as exc:
log.warning("Could not reach llamacpp at %s (%s) — using fallback '%s'", LLAMACPP_BASE_URL, exc, MODEL_NAME)
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
@app.get("/api/me")
def get_me(user: User = Depends(require_user)):
return {"display_name": user.display_name, "email": user.email}
@app.get("/api/rides")
def list_rides(user: User = Depends(require_user)):
summary_keys = [
"id", "name", "activity_type", "start_time", "duration_s",
"distance_km", "avg_speed_kmh", "elevation_gain_m",
"avg_hr", "max_hr", "has_gps",
]
rides_map = get_user_rides_cached(user.id)
rides = [{k: r[k] for k in summary_keys} for r in rides_map.values()]
rides.sort(key=lambda x: x["start_time"] or "", reverse=True)
return rides
@app.get("/api/rides/{ride_id}")
def get_ride(ride_id: str, user: User = Depends(require_user)):
rides_map = get_user_rides_cached(user.id)
if ride_id not in rides_map:
raise HTTPException(status_code=404, detail="Ride not found")
r = {k: v for k, v in rides_map[ride_id].items() if k != "points"}
r["cached_insight"] = _get_ride_insight(ride_id)
return r
@app.get("/api/rides/{ride_id}/points")
def get_ride_points(ride_id: str, user: User = Depends(require_user)):
rides_map = get_user_rides_cached(user.id)
if ride_id not in rides_map:
raise HTTPException(status_code=404, detail="Ride not found")
return _fetch_ride_points(ride_id, rides_map[ride_id]["s3_key"])
def _karvonen_zones(rhr: int, max_hr: int, points: list[dict]) -> dict:
hrr = max_hr - rhr
bounds = [rhr + p * hrr for p in (0.50, 0.60, 0.70, 0.80)]
labels = [
f"Z1 <{round(bounds[0])}bpm",
f"Z2 {round(bounds[0])}{round(bounds[1])}bpm",
f"Z3 {round(bounds[1])}{round(bounds[2])}bpm",
f"Z4 {round(bounds[2])}{round(bounds[3])}bpm",
f"Z5 >{round(bounds[3])}bpm",
]
hrs = [p["hr"] for p in points if p.get("hr") is not None]
if not hrs:
return {label: 0 for label in labels}
counts = [0] * 5
for h in hrs:
if h < bounds[0]: counts[0] += 1
elif h < bounds[1]: counts[1] += 1
elif h < bounds[2]: counts[2] += 1
elif h < bounds[3]: counts[3] += 1
else: counts[4] += 1
return {labels[i]: round(counts[i] / len(hrs) * 100, 1) for i in range(5)}
def _llm_client() -> OpenAI:
return OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed")
def _get_previous_insight_for_user(ride: dict, user_id: str) -> str | None:
ride_time = ride.get("start_time")
if not ride_time:
return None
with Session(engine) as s:
row = s.execute(
select(Insight.text)
.join(Ride, Insight.ride_id == Ride.id)
.where(and_(
Ride.user_id == user_id,
Ride.start_time < ride_time,
Ride.has_gps == True,
Ride.distance_km > 0,
))
.order_by(Ride.start_time.desc())
.limit(1)
).first()
return row[0] if row else None
def _build_prompt(ride: dict, user_id: str, rhr: int, global_max_hr: int, points: list[dict]) -> str:
ride_time = ride["start_time"]
rides_map = get_user_rides_cached(user_id)
gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
past_rides = [r for r in gps_rides if r["start_time"] < ride_time]
zones = _karvonen_zones(rhr, global_max_hr, points)
zone_str = ", ".join(f"{k}={v}%" for k, v in zones.items())
duration_min = ride["duration_s"] // 60
ride_date = ride_time[:10] if ride_time else "unknown"
if past_rides:
avg_dist = sum(r["distance_km"] for r in past_rides) / len(past_rides)
hr_list = [r["avg_hr"] for r in past_rides if r["avg_hr"]]
avg_hr_val = sum(hr_list) / len(hr_list) if hr_list else 0
history_str = f"Before today they've completed {len(past_rides)} rides, averaging {avg_dist:.1f} km and {avg_hr_val:.0f} bpm."
else:
history_str = "This is one of their earliest recorded rides — no prior history to compare against."
previous_insight = _get_previous_insight_for_user(ride, user_id)
if previous_insight:
prior_section = f"""
Your feedback from their last ride:
---
{previous_insight}
---
Based on that advice, briefly note (in one sentence woven naturally into your response) whether today's data suggests they followed it or not. Do not lecture about it — just one honest observation."""
else:
prior_section = ""
return f"""You are a friendly cycling coach giving post-ride feedback. You are writing this on {ride_date}, the day of the ride. Do not plan, outline, or show any reasoning — just write the response directly.
Today's ride: {duration_min} min, {ride["distance_km"]} km, {ride["avg_speed_kmh"]} km/h, {ride["elevation_gain_m"]} m elevation. Avg HR {ride["avg_hr"]}, max {ride["max_hr"]}, resting HR {rhr} bpm (lifetime max {global_max_hr}). Time in zones: {zone_str}.
Athlete history (as of {ride_date}): {history_str}{prior_section}
Your response must begin with exactly one line in this format:
TITLE: <a funny, punchy title for this specific ride in 48 words>
Then one blank line, then write exactly 34 short paragraphs in a warm, direct "you/your" voice: effort level, what the zones reveal about the session type, how it compares to their history so far, one concrete suggestion. No bullet points, no headers, no preamble. Begin writing now."""
@app.post("/api/rides/{ride_id}/insights")
def post_insights(ride_id: str, user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)):
rides_map = get_user_rides_cached(user.id)
if ride_id not in rides_map:
raise HTTPException(status_code=404, detail="Ride not found")
ride = rides_map[ride_id]
gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0]
global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200)
points = _fetch_ride_points(ride_id, ride["s3_key"])
prompt = _build_prompt(ride, user.id, rhr, global_max_hr, points)
client = _llm_client()
log.info("POST /insights ride_id=%s rhr=%d user=%s", ride_id, rhr, user.id)
log.debug("Prompt:\n%s", prompt)
def stream_response():
accumulated = []
try:
log.info("Opening stream...")
stream = client.chat.completions.create(
model=MODEL_NAME,
messages=[{"role": "user", "content": prompt}],
stream=True,
max_tokens=600,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
chunks_received = 0
for chunk in stream:
chunks_received += 1
if chunks_received <= 3:
log.debug("Chunk #%d raw: %s", chunks_received, chunk.model_dump())
d = chunk.choices[0].delta.model_dump()
delta = d.get("content") or d.get("reasoning_content")
if delta:
accumulated.append(delta)
yield delta
log.info("Stream complete — %d chunks, %d chars", chunks_received, sum(len(c) for c in accumulated))
_save_ride_insight(ride_id, user.id, "".join(accumulated))
log.info("Saved insight for %s", ride_id)
except Exception as exc:
log.exception("Error streaming insights: %s", exc)
yield f"\n\n[Error: {exc}]"
return StreamingResponse(stream_response(), media_type="text/plain")
@app.post("/api/insights/generate-all")
def generate_all_insights(user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)):
rides_map = get_user_rides_cached(user.id)
gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200)
ordered = sorted(gps_rides, key=lambda r: r["start_time"])
with Session(engine) as s:
insighted_ids = {
row[0] for row in s.execute(
select(Insight.ride_id).where(Insight.user_id == user.id)
)
}
to_generate = [r for r in ordered if r["id"] not in insighted_ids]
total = len(to_generate)
log.info("generate-all: %d rides to generate (rhr=%d user=%s)", total, rhr, user.id)
def sse_stream():
llm = _llm_client()
for idx, ride in enumerate(to_generate, 1):
ride_id = ride["id"]
yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'generating'})}\n\n"
log.info("Generating insight %d/%d: %s", idx, total, ride_id)
try:
points = _fetch_ride_points(ride_id, ride["s3_key"])
prompt = _build_prompt(ride, user.id, rhr, global_max_hr, points)
response = llm.chat.completions.create(
model=MODEL_NAME,
messages=[{"role": "user", "content": prompt}],
stream=False,
max_tokens=600,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
msg = response.choices[0].message.model_dump()
text = msg.get("content") or msg.get("reasoning_content") or ""
_save_ride_insight(ride_id, user.id, text)
yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'done'})}\n\n"
except Exception as exc:
log.exception("Failed insight for %s: %s", ride_id, exc)
yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'error', 'error': str(exc)})}\n\n"
yield f"data: {json.dumps({'status': 'complete', 'total': total})}\n\n"
return StreamingResponse(sse_stream(), media_type="text/event-stream")
# ---------------------------------------------------------------------------
# Garmin Connect sync
# ---------------------------------------------------------------------------
@app.post("/api/fetch-rides")
async def fetch_rides_from_garmin(request: Request, user: User = Depends(require_user)):
body = await request.json()
email = body.get("email", "")
password = body.get("password", "")
user_id = user.id
session_id = uuid.uuid4().hex
queue: asyncio.Queue = asyncio.Queue()
loop = asyncio.get_running_loop()
mfa_event = threading.Event()
_mfa_sessions[session_id] = {"event": mfa_event, "code": None}
def emit(evt):
loop.call_soon_threadsafe(queue.put_nowait, evt)
def prompt_mfa():
emit({"status": "mfa_required", "session_id": session_id})
mfa_event.wait(timeout=120)
return _mfa_sessions.get(session_id, {}).get("code") or ""
def garmin_worker():
try:
emit({"status": "auth"})
garmin = Garmin(email=email, password=password, prompt_mfa=prompt_mfa)
garmin.login()
emit({"status": "fetching"})
activities = garmin.get_activities(0, 50)
cycling = [
a for a in activities
if "cycling" in a.get("activityType", {}).get("typeKey", "")
]
with Session(engine) as s:
existing_filenames = {
row[0] for row in s.execute(
select(Ride.filename).where(Ride.user_id == user_id)
)
}
to_download = []
for act in cycling:
act_id = act["activityId"]
start = act.get("startTimeLocal", "")
name = act.get("activityName", "activity")
safe_time = start.replace(":", "-").replace(" ", "_")
safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-")).strip()
filename = f"{safe_time}_{safe_name}_{act_id}.gpx"
if filename not in existing_filenames:
to_download.append((act_id, name, filename))
skipped = len(cycling) - len(to_download)
added = 0
for idx, (act_id, name, filename) in enumerate(to_download, 1):
emit({"status": "downloading", "index": idx, "total": len(to_download), "name": name})
try:
gpx_data = garmin.download_activity(act_id, dl_fmt=garmin.ActivityDownloadFormat.GPX)
s3_key = upload_gpx(user_id, filename, gpx_data)
parsed = parse_gpx_bytes(gpx_data, filename)
ride_id = uuid.uuid4().hex
with Session(engine) as s:
s.add(Ride(
id=ride_id,
user_id=user_id,
s3_key=s3_key,
filename=filename,
name=parsed["name"],
activity_type=parsed["activity_type"],
start_time=parsed["start_time"],
end_time=parsed["end_time"],
duration_s=parsed["duration_s"],
distance_m=parsed["distance_m"],
distance_km=parsed["distance_km"],
avg_speed_kmh=parsed["avg_speed_kmh"],
elevation_gain_m=parsed["elevation_gain_m"],
elevation_loss_m=parsed["elevation_loss_m"],
avg_hr=parsed["avg_hr"],
max_hr=parsed["max_hr"],
hr_zones_json=json.dumps(parsed["hr_zones"]),
has_gps=parsed["has_gps"],
))
s.commit()
_points_cache[ride_id] = parsed["points"]
invalidate_user_cache(user_id)
added += 1
except Exception as exc:
log.error("Failed to download activity %s: %s", act_id, exc)
emit({"status": "complete", "added": added, "skipped": skipped})
except GarminConnectAuthenticationError:
emit({"status": "error", "message": "Login failed. Check your email and password."})
except Exception as exc:
log.exception("fetch-rides error: %s", exc)
emit({"status": "error", "message": str(exc)})
finally:
_mfa_sessions.pop(session_id, None)
emit(None)
async def generate():
loop.run_in_executor(None, garmin_worker)
while True:
evt = await queue.get()
if evt is None:
break
yield f"data: {json.dumps(evt)}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/api/mfa")
async def submit_mfa(request: Request):
body = await request.json()
session_id = body.get("session_id", "")
code = body.get("code", "")
session = _mfa_sessions.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found or expired")
session["code"] = code
session["event"].set()
return {"ok": True}
# ---------------------------------------------------------------------------
# Serve frontend
# ---------------------------------------------------------------------------
static_dir = Path(__file__).parent / "static"
static_dir.mkdir(exist_ok=True)
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")