"""Local AI Running Coach — Gradio UI (Hugging Face Spaces entry point). Run locally: uv run python app.py Then open the printed http://127.0.0.1:7860 URL. """ import sys from datetime import datetime, timedelta from pathlib import Path # src-layout: make `rate_my_run` importable on Hugging Face Spaces, which copies # the repo to /app but does NOT pip-install it. (Locally it's already installed # via uv, so this insert is just a harmless no-op there.) sys.path.insert(0, str(Path(__file__).resolve().parent / "src")) import gradio as gr # noqa: E402 from dotenv import load_dotenv # noqa: E402 from rate_my_run.analytics import analyze # noqa: E402 from rate_my_run.charts import weekly_distance_chart # noqa: E402 from rate_my_run.client import StravaClient # noqa: E402 from rate_my_run.coach import PERSONALITIES, continue_conversation, start_conversation # noqa: E402 from rate_my_run.render import format_pace, summary_to_text # noqa: E402 from rate_my_run.samples import demo_activities, DEMO_GROUPS # noqa: E402 load_dotenv() APP_NAME = "STRIDE" TAGLINE = "LOCAL AI RUNNING COACH" ACCENT = "#FF4B1F" ACCENT_DARK = "#E63E12" doc_path = Path(__file__).parent / "docs" / "sports_science.md" SPORTS_DOC = doc_path.read_text() if doc_path.exists() else None if not SPORTS_DOC: print(f"WARNING: Could not load {doc_path}") strava_doc_path = Path(__file__).parent / "docs" / "connect_strava.md" STRAVA_GUIDE = strava_doc_path.read_text() if strava_doc_path.exists() else "Instructions coming soon." # ----------------------------------------------------------------------------- # # Logic handlers # ----------------------------------------------------------------------------- # def visible(messages): """Chatbot shows only user/assistant turns, never the system grounding.""" return [m for m in messages if m["role"] != "system"] def _error_chat(text: str): return [{"role": "assistant", "content": f"⚠️ {text}"}] def run_coach(use_demo, goal, demo, name=None, client_id=None, client_secret=None, refresh_token=None): """Pure data: fetch + analyze + render. NO model call.""" if use_demo: activities = demo_activities(demo=demo) else: if client_id and client_secret and refresh_token: client = StravaClient(client_id, client_secret, refresh_token) else: client = StravaClient._from_env() after = int((datetime.now() - timedelta(weeks=8)).timestamp()) activities = client.get_activities(after=after, per_page=200) summary = analyze(activities) summary_text = summary_to_text(summary, goal=goal or None, name=name or None) rows = [ [ w.week_start.isoformat(), w.num_runs, round(w.distance_km, 1), round(w.longest_run_km, 1), format_pace(w.avg_pace_min_per_km), ] for w in summary.weeks ] return summary_text, rows def on_generate(use_demo, goal, personality, demo, name=None, client_id=None, client_secret=None, refresh_token=None): """Generate the first report and seed the conversation.""" try: summary_text, rows = run_coach(use_demo, goal, demo, name, client_id, client_secret, refresh_token) except Exception as e: return [], _error_chat(f"Could not load your runs: {e}"), "", [], weekly_distance_chart([]) chart = weekly_distance_chart(rows) try: hist = start_conversation(summary_text, personality, sports_doc=SPORTS_DOC) except Exception as e: return [], _error_chat(f"Could not reach the coach model: {e}"), summary_text, rows, chart return hist, visible(hist), summary_text, rows, chart def on_send(user_message, hist): """Append a follow-up question and the coach's reply.""" if not hist or not user_message.strip(): return hist, visible(hist), "" try: hist = continue_conversation(hist, user_message) except Exception as e: hist = hist + [ {"role": "user", "content": user_message}, {"role": "assistant", "content": f"⚠️ Could not reach the coach model: {e}"}, ] return hist, visible(hist), "" def toggle_demo(use_demo): return gr.update(visible=use_demo), gr.update(visible=not use_demo) # ----------------------------------------------------------------------------- # # Theme + styling # ----------------------------------------------------------------------------- # theme = gr.themes.Soft( primary_hue=gr.themes.colors.orange, neutral_hue=gr.themes.colors.gray, font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], ).set( button_primary_background_fill=ACCENT, button_primary_background_fill_hover=ACCENT_DARK, button_primary_text_color="white", color_accent=ACCENT, color_accent_soft="#FFEAE3", body_background_fill="#FAFAFA", ) CSS = """ .gradio-container { max-width: 1080px !important; margin: 0 auto !important; } #brand { display:flex; align-items:center; gap:14px; padding:6px 2px 14px; } #brand .logo { flex:0 0 auto; line-height:0; } #brand .name { font-size:28px; font-weight:800; letter-spacing:1.5px; color:#161616; line-height:1; } #brand .name span { color:#FF4B1F; } #brand .tag { font-size:11px; letter-spacing:3px; text-transform:uppercase; color:#8A8A8A; margin-top:5px; } /* card-like panels */ .panel { background:#FFFFFF; border:1px solid #EDEDED; border-radius:16px; padding:16px !important; } /* tighten the chart container so the dark plot reads as one clean block */ #chart-card { background:#0E0E0E; border:none; border-radius:16px; padding:6px; } #chart-card .label-wrap, #chart-card label { display:none; } """ LOGO_SVG = f"""