Add AI trainer chat with persistent goals and conversation history

Adds ChatMessage and Goal DB models, five new API endpoints
(/api/chat/history, /api/chat, /api/goals CRUD), and a Trainer panel
in the SPA with streaming chat, goal management, and ride-aware context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 17:32:39 -04:00
parent 40b685e6d0
commit 96d2d9e0cc
3 changed files with 493 additions and 3 deletions
+174 -2
View File
@@ -5,7 +5,7 @@ import math
import os
import threading
import uuid
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from xml.etree import ElementTree as ET
@@ -19,7 +19,7 @@ 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 db import ChatMessage, Goal, Insight, Ride, User, create_tables, engine
from storage import download_gpx, upload_gpx
logging.basicConfig(
@@ -454,6 +454,178 @@ def generate_all_insights(user: User = Depends(require_user), rhr: int = Query(6
return StreamingResponse(sse_stream(), media_type="text/event-stream")
# ---------------------------------------------------------------------------
# AI Trainer Chat
# ---------------------------------------------------------------------------
def _build_trainer_context(user_id: str, new_message: str) -> list[dict]:
rides_map = get_user_rides_cached(user_id)
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
recent_rides = []
for r in rides_map.values():
if r.get("start_time") and r["has_gps"] and r["distance_km"] > 0:
try:
rt = datetime.fromisoformat(r["start_time"])
if rt.tzinfo is None:
rt = rt.replace(tzinfo=timezone.utc)
if rt >= cutoff:
recent_rides.append(r)
except (ValueError, TypeError):
pass
if recent_rides:
total_km = sum(r["distance_km"] for r in recent_rides)
total_hours = sum(r["duration_s"] for r in recent_rides) / 3600
speed_list = [r["avg_speed_kmh"] for r in recent_rides if r["avg_speed_kmh"]]
avg_speed = sum(speed_list) / len(speed_list) if speed_list else 0
hr_list = [r["avg_hr"] for r in recent_rides if r["avg_hr"]]
avg_hr = round(sum(hr_list) / len(hr_list)) if hr_list else None
max_hr = max((r["max_hr"] or 0 for r in recent_rides), default=0) or None
rides_block = (
f"Past 30 days: {len(recent_rides)} ride{'s' if len(recent_rides) != 1 else ''}, "
f"{total_km:.1f} km total, {total_hours:.1f} hours. "
f"Avg speed {avg_speed:.1f} km/h."
+ (f" HR: avg {avg_hr} bpm, max {max_hr} bpm." if avg_hr else "")
)
else:
rides_block = "No rides recorded in the past 30 days."
with Session(engine) as s:
active_goals = (
s.query(Goal)
.filter_by(user_id=user_id, is_active=True)
.order_by(Goal.created_at.asc())
.all()
)
goals_block = (
"\n".join(
f"- {g.title}" + (f": {g.description}" if g.description else "")
for g in active_goals
)
if active_goals
else "No goals set yet. Ask the athlete what they're training for."
)
system_prompt = f"""You are a personal cycling coach having an ongoing conversation with your athlete. Be supportive, specific, and data-driven. Keep responses concise (2–4 sentences unless the athlete asks for detail). Reference their actual numbers when relevant. Do not use headers or bullet points unless explicitly asked.
## Athlete's Current Goals
{goals_block}
## Recent Ride Activity (last 30 days)
{rides_block}"""
with Session(engine) as s:
history_rows = (
s.query(ChatMessage)
.filter_by(user_id=user_id)
.order_by(ChatMessage.created_at.desc())
.limit(21)
.all()
)
# Drop index 0 (the just-saved user message), reverse to chronological order
history_rows = list(reversed(history_rows[1:]))
messages: list[dict] = [{"role": "system", "content": system_prompt}]
for msg in history_rows:
messages.append({"role": msg.role, "content": msg.content})
messages.append({"role": "user", "content": new_message})
return messages
@app.get("/api/chat/history")
def get_chat_history(user: User = Depends(require_user)):
with Session(engine) as s:
rows = (
s.query(ChatMessage)
.filter_by(user_id=user.id)
.order_by(ChatMessage.created_at.desc())
.limit(50)
.all()
)
return [r.to_dict() for r in reversed(rows)]
@app.post("/api/chat")
async def post_chat(request: Request, user: User = Depends(require_user)):
body = await request.json()
message = (body.get("message") or "").strip()
if not message:
raise HTTPException(status_code=400, detail="message required")
with Session(engine) as s:
s.add(ChatMessage(user_id=user.id, role="user", content=message))
s.commit()
prompt_messages = _build_trainer_context(user.id, message)
client = _llm_client()
log.info("POST /chat user=%s", user.id)
def stream_response():
accumulated = []
try:
stream = client.chat.completions.create(
model=MODEL_NAME,
messages=prompt_messages,
stream=True,
max_tokens=800,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
for chunk in stream:
d = chunk.choices[0].delta.model_dump()
delta = d.get("content") or d.get("reasoning_content")
if delta:
accumulated.append(delta)
yield delta
with Session(engine) as s:
s.add(ChatMessage(user_id=user.id, role="assistant", content="".join(accumulated)))
s.commit()
except Exception as exc:
log.exception("Error streaming chat: %s", exc)
yield f"\n\n[Error: {exc}]"
return StreamingResponse(stream_response(), media_type="text/plain")
@app.get("/api/goals")
def get_goals(user: User = Depends(require_user)):
with Session(engine) as s:
rows = (
s.query(Goal)
.filter_by(user_id=user.id, is_active=True)
.order_by(Goal.created_at.desc())
.all()
)
return [r.to_dict() for r in rows]
@app.post("/api/goals")
async def create_goal(request: Request, user: User = Depends(require_user)):
body = await request.json()
title = (body.get("title") or "").strip()
if not title:
raise HTTPException(status_code=400, detail="title required")
description = (body.get("description") or "").strip() or None
with Session(engine) as s:
goal = Goal(user_id=user.id, title=title, description=description)
s.add(goal)
s.commit()
s.refresh(goal)
result = goal.to_dict()
return result
@app.delete("/api/goals/{goal_id}")
def delete_goal(goal_id: str, user: User = Depends(require_user)):
with Session(engine) as s:
goal = s.query(Goal).filter_by(id=goal_id, user_id=user.id).first()
if not goal:
raise HTTPException(status_code=404, detail="Goal not found")
goal.is_active = False
s.commit()
return {"ok": True}
# ---------------------------------------------------------------------------
# Garmin Connect sync
# ---------------------------------------------------------------------------
+44
View File
@@ -39,6 +39,8 @@ class User(Base):
rides = relationship("Ride", back_populates="user", cascade="all, delete-orphan")
insights = relationship("Insight", back_populates="user", cascade="all, delete-orphan")
chat_messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan")
goals = relationship("Goal", back_populates="user", cascade="all, delete-orphan")
class Ride(Base):
@@ -101,6 +103,48 @@ class Insight(Base):
user = relationship("User", back_populates="insights")
class ChatMessage(Base):
__tablename__ = "chat_messages"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
role = Column(String, nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
user = relationship("User", back_populates="chat_messages")
def to_dict(self) -> dict:
return {
"id": self.id,
"role": self.role,
"content": self.content,
"created_at": self.created_at.isoformat(),
}
class Goal(Base):
__tablename__ = "goals"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
user = relationship("User", back_populates="goals")
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"description": self.description,
"is_active": self.is_active,
"created_at": self.created_at.isoformat(),
}
def create_tables() -> None:
Base.metadata.create_all(engine)
+275 -1
View File
@@ -144,10 +144,57 @@
@keyframes blink { 50% { opacity: 0; } }
.no-ride-msg { color: var(--muted); font-size: 12px; text-align: center; padding: 24px 16px; }
/* Trainer panel */
#trainer-panel { display: none; flex: 1; flex-direction: column; background: var(--surface); overflow: hidden; }
#trainer-panel.trainer-active { display: flex; }
#goals-section { border-bottom: 1px solid var(--border); padding: 12px 16px; flex-shrink: 0; max-height: 220px; overflow-y: auto; }
#goals-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
#goals-header .section-title { margin-bottom: 0; }
#add-goal-btn { font-size: 20px; background: none; border: none; color: var(--accent); cursor: pointer; line-height: 1; padding: 0 2px; }
.goal-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.goal-item:last-child { border-bottom: none; }
.goal-text { flex: 1; }
.goal-title { font-size: 12px; font-weight: 500; color: var(--text); }
.goal-desc { font-size: 11px; color: var(--muted); margin-top: 2px; }
.goal-delete { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 16px; flex-shrink: 0; padding: 0; transition: color 0.15s; }
.goal-delete:hover { color: var(--red); }
#no-goals-msg { font-size: 12px; color: var(--muted); font-style: italic; }
#goal-add-form { display: none; flex-direction: column; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
#goal-add-form.open { display: flex; }
#goal-title-input, #goal-desc-input { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 12px; padding: 6px 8px; width: 100%; font-family: inherit; }
#goal-title-input:focus, #goal-desc-input:focus { outline: none; border-color: var(--accent); }
#goal-desc-input { resize: none; height: 48px; }
.goal-form-actions { display: flex; gap: 6px; }
.goal-form-actions button { flex: 1; padding: 6px; border-radius: 6px; font-size: 11px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); transition: all 0.15s; }
#goal-save-btn { background: var(--accent); color: #fff; border-color: var(--accent); }
#goal-save-btn:hover { opacity: 0.85; }
#goal-cancel-btn { background: var(--surface2); color: var(--muted); }
#goal-cancel-btn:hover { color: var(--text); }
#chat-messages { flex: 1; overflow-y: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; }
#chat-messages::-webkit-scrollbar { width: 4px; }
#chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.chat-msg { max-width: 85%; }
.chat-msg.user { align-self: flex-end; }
.chat-msg.assistant { align-self: flex-start; }
.chat-bubble { padding: 8px 12px; border-radius: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
.chat-msg.user .chat-bubble { background: var(--accent); color: #fff; border-bottom-right-radius: 3px; }
.chat-msg.assistant .chat-bubble { background: var(--surface2); color: var(--text); border-bottom-left-radius: 3px; }
.chat-time { font-size: 10px; color: var(--muted); margin-top: 3px; }
.chat-msg.user .chat-time { text-align: right; }
#chat-input-bar { border-top: 1px solid var(--border); padding: 10px 12px; display: flex; gap: 8px; align-items: flex-end; background: var(--surface); flex-shrink: 0; }
#chat-input { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: 18px; color: var(--text); font-size: 13px; padding: 8px 14px; resize: none; font-family: inherit; line-height: 1.4; max-height: 100px; overflow-y: auto; }
#chat-input:focus { outline: none; border-color: var(--accent); }
#chat-send-btn { background: var(--accent); border: none; border-radius: 50%; width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; transition: opacity 0.15s; }
#chat-send-btn:hover { opacity: 0.85; }
#chat-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#chat-send-btn svg { width: 16px; height: 16px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
#trainer-sidebar-btn { margin-top: 4px; width: 100%; padding: 6px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); font-size: 11px; cursor: pointer; transition: all 0.15s; }
#trainer-sidebar-btn:hover { border-color: var(--accent); color: var(--text); }
/* Mobile */
#mobile-nav { display: none; }
@media (max-width: 768px) {
#sidebar, #main, #details {
#sidebar, #main, #details, #trainer-panel {
position: fixed;
top: 0; left: 0; right: 0;
bottom: calc(48px + env(safe-area-inset-bottom));
@@ -155,6 +202,7 @@
display: none;
}
#sidebar.mobile-active, #main.mobile-active, #details.mobile-active { display: flex; }
#trainer-panel.mobile-active { display: flex; }
#sidebar { border-right: none; }
#details { border-left: none; }
#mobile-nav {
@@ -212,6 +260,7 @@
<div id="gen-all-label"></div>
</div>
<button id="fetch-btn">Fetch new rides from Garmin</button>
<button id="trainer-sidebar-btn">Open Trainer</button>
</div>
<div id="fetch-modal-overlay">
@@ -295,6 +344,33 @@
</div>
</div>
<div id="trainer-panel">
<div id="goals-section">
<div id="goals-header">
<div class="section-title">Goals</div>
<button id="add-goal-btn" title="Add goal">+</button>
</div>
<div id="goals-list">
<div id="no-goals-msg">No goals set. Add one to give your trainer context.</div>
</div>
<div id="goal-add-form">
<input id="goal-title-input" type="text" placeholder="Goal (e.g. Complete a 100km ride)">
<textarea id="goal-desc-input" placeholder="Details (optional)"></textarea>
<div class="goal-form-actions">
<button id="goal-cancel-btn">Cancel</button>
<button id="goal-save-btn">Save Goal</button>
</div>
</div>
</div>
<div id="chat-messages"></div>
<div id="chat-input-bar">
<textarea id="chat-input" rows="1" placeholder="Ask your trainer…"></textarea>
<button id="chat-send-btn" title="Send">
<svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22,2 15,22 11,13 2,9"/></svg>
</button>
</div>
</div>
<nav id="mobile-nav">
<button class="mobile-nav-btn active" data-tab="list" onclick="setMobileTab('list')">
<svg viewBox="0 0 24 24"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
@@ -308,6 +384,10 @@
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Details
</button>
<button class="mobile-nav-btn" data-tab="trainer" onclick="setMobileTab('trainer')">
<svg viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Trainer
</button>
</nav>
<script>
@@ -877,11 +957,205 @@ function setMobileTab(tab) {
document.getElementById('sidebar').classList.toggle('mobile-active', tab === 'list');
document.getElementById('main').classList.toggle('mobile-active', tab === 'map');
document.getElementById('details').classList.toggle('mobile-active', tab === 'details');
document.getElementById('trainer-panel').classList.toggle('mobile-active', tab === 'trainer');
document.querySelectorAll('.mobile-nav-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
if (tab === 'map') setTimeout(() => map.invalidateSize(), 50);
if (tab === 'trainer') openTrainerPanel();
}
setMobileTab('list');
// ---------------------------------------------------------------------------
// Trainer
// ---------------------------------------------------------------------------
let goals = [];
let goalsLoaded = false;
let chatLoaded = false;
let trainerActive = false;
function escHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function openTrainerPanel() {
if (!goalsLoaded) loadGoals();
if (!chatLoaded) loadChatHistory();
}
function setTrainerMode(active) {
if (window.matchMedia('(max-width: 768px)').matches) {
if (active) setMobileTab('trainer');
return;
}
trainerActive = active;
document.getElementById('main').style.display = active ? 'none' : '';
document.getElementById('details').style.display = active ? 'none' : '';
document.getElementById('trainer-panel').classList.toggle('trainer-active', active);
document.getElementById('trainer-sidebar-btn').textContent = active ? 'Back to Rides' : 'Open Trainer';
if (active) openTrainerPanel();
}
document.getElementById('trainer-sidebar-btn').addEventListener('click', () => setTrainerMode(!trainerActive));
// Goals
async function loadGoals() {
goalsLoaded = true;
const res = await fetch('/api/goals');
goals = await res.json();
renderGoals();
}
function renderGoals() {
const list = document.getElementById('goals-list');
if (!goals.length) {
list.innerHTML = '<div id="no-goals-msg">No goals set. Add one to give your trainer context.</div>';
return;
}
list.innerHTML = goals.map(g => `
<div class="goal-item">
<div class="goal-text">
<div class="goal-title">${escHtml(g.title)}</div>
${g.description ? `<div class="goal-desc">${escHtml(g.description)}</div>` : ''}
</div>
<button class="goal-delete" data-id="${g.id}" title="Remove goal">×</button>
</div>
`).join('');
list.querySelectorAll('.goal-delete').forEach(btn => {
btn.addEventListener('click', () => deleteGoal(btn.dataset.id));
});
}
async function deleteGoal(id) {
await fetch(`/api/goals/${encodeURIComponent(id)}`, { method: 'DELETE' });
goals = goals.filter(g => g.id !== id);
renderGoals();
}
document.getElementById('add-goal-btn').addEventListener('click', () => {
document.getElementById('goal-add-form').classList.add('open');
document.getElementById('goal-title-input').focus();
});
document.getElementById('goal-cancel-btn').addEventListener('click', () => {
document.getElementById('goal-add-form').classList.remove('open');
document.getElementById('goal-title-input').value = '';
document.getElementById('goal-desc-input').value = '';
});
document.getElementById('goal-save-btn').addEventListener('click', async () => {
const title = document.getElementById('goal-title-input').value.trim();
if (!title) return;
const desc = document.getElementById('goal-desc-input').value.trim();
const res = await fetch('/api/goals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: desc }),
});
const newGoal = await res.json();
goals.push(newGoal);
renderGoals();
document.getElementById('goal-cancel-btn').click();
});
// Chat
function scrollChatBottom() {
const c = document.getElementById('chat-messages');
c.scrollTop = c.scrollHeight;
}
function appendChatBubble(role, content, isoTime) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = `chat-msg ${role}`;
const timeStr = isoTime
? new Date(isoTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
: '';
const bubble = document.createElement('div');
bubble.className = 'chat-bubble';
bubble.textContent = content;
div.appendChild(bubble);
if (timeStr) {
const t = document.createElement('div');
t.className = 'chat-time';
t.textContent = timeStr;
div.appendChild(t);
}
container.appendChild(div);
return bubble;
}
async function loadChatHistory() {
chatLoaded = true;
const res = await fetch('/api/chat/history');
const history = await res.json();
const container = document.getElementById('chat-messages');
container.innerHTML = '';
for (const msg of history) appendChatBubble(msg.role, msg.content, msg.created_at);
scrollChatBottom();
}
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('chat-send-btn');
const message = input.value.trim();
if (!message || sendBtn.disabled) return;
input.value = '';
input.style.height = '';
sendBtn.disabled = true;
appendChatBubble('user', message, new Date().toISOString());
scrollChatBottom();
const container = document.getElementById('chat-messages');
const assistantDiv = document.createElement('div');
assistantDiv.className = 'chat-msg assistant';
const bubble = document.createElement('div');
bubble.className = 'chat-bubble';
bubble.innerHTML = '<span class="blinking"></span>';
assistantDiv.appendChild(bubble);
container.appendChild(assistantDiv);
scrollChatBottom();
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let text = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
bubble.textContent = text;
scrollChatBottom();
}
bubble.textContent = text;
const timeDiv = document.createElement('div');
timeDiv.className = 'chat-time';
timeDiv.textContent = new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
assistantDiv.appendChild(timeDiv);
} catch (e) {
bubble.textContent = `Error: ${e.message}`;
} finally {
sendBtn.disabled = false;
input.focus();
}
}
document.getElementById('chat-send-btn').addEventListener('click', sendChatMessage);
document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
});
document.getElementById('chat-input').addEventListener('input', function () {
this.style.height = '';
this.style.height = Math.min(this.scrollHeight, 100) + 'px';
});
async function initAuth() {
const res = await fetch('/api/me');
if (res.status === 401) {