nicholasg1997
Fix HF Spaces build: add src to sys.path, list explicit requirements
035391d
"""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"""
<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>
"""
# ----------------------------------------------------------------------------- #
# 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)