| """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 |
|
|
| |
| |
| |
| sys.path.insert(0, str(Path(__file__).resolve().parent / "src")) |
|
|
| import gradio as gr |
| from dotenv import load_dotenv |
|
|
| from rate_my_run.analytics import analyze |
| from rate_my_run.charts import weekly_distance_chart |
| from rate_my_run.client import StravaClient |
| from rate_my_run.coach import PERSONALITIES, continue_conversation, start_conversation |
| from rate_my_run.render import format_pace, summary_to_text |
| from rate_my_run.samples import demo_activities, DEMO_GROUPS |
|
|
| 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." |
|
|
|
|
| |
| |
| |
| 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 = 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""" |
| <div id="brand"> |
| <div class="logo"> |
| <svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"> |
| <rect width="44" height="44" rx="12" fill="{ACCENT}"/> |
| <polyline points="8,29 16,20 23,24 30,11 37,17" fill="none" |
| stroke="white" stroke-width="2.8" |
| stroke-linecap="round" stroke-linejoin="round"/> |
| </svg> |
| </div> |
| <div class="brand-text"> |
| <div class="name">{APP_NAME}<span>.</span></div> |
| <div class="tag">{TAGLINE}</div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| |
| |
| |
| 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): |
| |
| 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") |
|
|
| |
| 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_out = gr.Plot(value=weekly_distance_chart([]), show_label=False, elem_id="chart-card") |
|
|
| |
| 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) |
|
|
| |
| 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]) |
|
|
| |
| with gr.Tab("Connect Strava"): |
| gr.Markdown(STRAVA_GUIDE) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch(theme=theme, css=CSS) |
|
|