"""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"""
{APP_NAME}.
{TAGLINE}
""" # ----------------------------------------------------------------------------- # # Layout # ----------------------------------------------------------------------------- # with gr.Blocks(title=f"{APP_NAME} — AI Running Coach") as demo: history = gr.State([]) gr.HTML(LOGO_SVG) with gr.Tab("Coach"): with gr.Row(equal_height=False): # --- controls -------------------------------------------------------- # with gr.Column(scale=1, elem_classes="panel"): gr.Markdown("#### Your run") name = gr.Textbox(label="Name", placeholder="e.g. Nick") goal = gr.Textbox( label="Goal", value="Build consistency: run 3 times per week", ) personality = gr.Dropdown( choices=list(PERSONALITIES.keys()), value="Sports Scientist", label="Coach personality", ) with gr.Group(visible=True) as demo_group: demo_choice = gr.Dropdown(choices=list(DEMO_GROUPS), value='excelling', label="Demo group") with gr.Group(visible=False) as strava_group: client_id = gr.Textbox(label="Strava client ID", placeholder="123456789") client_secret = gr.Textbox(label="Strava client secret", type="password") refresh_token = gr.Textbox(label="Strava refresh token", type="password") use_demo = gr.Checkbox(label="Use demo data (no Strava needed)", value=True) generate_btn = gr.Button("Generate report", variant="primary", size="lg") # --- conversation ---------------------------------------------------- # with gr.Column(scale=2): chatbot = gr.Chatbot( label="Coach", height=460, show_label=False, avatar_images=(None, str(Path(__file__).parent / "assets" / "coach_avatar.svg")), placeholder="Your coaching report will appear here.\nGenerate one to start the conversation.", ) with gr.Row(): msg = gr.Textbox( placeholder="Ask a follow-up… e.g. how should I structure next week?", scale=5, show_label=False, container=False, ) send_btn = gr.Button("Send", scale=1, min_width=80) # --- chart (full width, stock-ticker vibe) ------------------------------- # chart_out = gr.Plot(value=weekly_distance_chart([]), show_label=False, elem_id="chart-card") # --- raw data, tucked away ----------------------------------------------- # with gr.Accordion("Training data & model input", open=False): table_out = gr.Dataframe( headers=["Week", "# Runs", "Distance (km)", "Longest (km)", "Avg pace"], interactive=False, ) summary_out = gr.Textbox(label="What the model sees", lines=14) # --- wiring -------------------------------------------------------------- # generate_btn.click( on_generate, inputs=[use_demo, goal, personality, demo_choice, name, client_id, client_secret, refresh_token], outputs=[history, chatbot, summary_out, table_out, chart_out], ) send_btn.click(on_send, inputs=[msg, history], outputs=[history, chatbot, msg]) msg.submit(on_send, inputs=[msg, history], outputs=[history, chatbot, msg]) use_demo.change(toggle_demo, inputs=use_demo, outputs=[demo_group, strava_group]) # --- Connect Strava Instructions --------------------------------------------- # with gr.Tab("Connect Strava"): gr.Markdown(STRAVA_GUIDE) if __name__ == "__main__": demo.launch(theme=theme, css=CSS)