Skip to content

Two agentic features for Wattlog.pro: an auditor and a debrief coach

How LangGraph state machines turn raw ride data into decisions, not just charts — and how they'd plug into Wattlog.pro's existing FastAPI/WebSocket backend.


Wattlog already computes the numbers serious cyclists care about: TSS per session, CTL/ATL/TSB trend, HRV readiness. What it doesn't do yet is act on them. The dashboard tells you your TSB is -34. It doesn't tell you what to do about it, and it doesn't touch tomorrow's plan.

That's an agent problem, not a dashboard problem — a workflow that reads state, applies domain rules, and takes a conditional action. Two concrete features fit this shape well. Both are LangGraph graphs sitting behind Wattlog's existing analytics layer, not a rewrite of it.


Use case 1: The Automated Training Load Auditor

Instead of an athlete guessing whether they're overtraining, a stateful graph audits the trend and intervenes on the schedule directly.

[Fetch Athlete Data]  [Analyze Fatigue: TSS/CTL/ATL]  [Check Next Scheduled Ride]  [Conditional: Adjust or Approve]

Node 1 — Fetch Athlete Data. Pulls the last 14 days from Wattlog's session store: ride durations, average power, per-session TSS. Wattlog already persists this in SQLite for the PMC chart, so this node is a read, not a new pipeline.

Node 2 — Analyze Fatigue. Runs the standard PMC math (CTL = 42-day exponentially weighted TSS, ATL = 7-day, TSB = CTL − ATL). If TSB drops below a threshold (e.g. -30), the state gets flagged fatigued and the graph takes the conditional edge instead of the default pass-through.

Node 3 — Check Next Scheduled Ride. Reads the upcoming workout from the plan library — intensity, target zones, duration.

Node 4 — Conditional: Adjust or Approve.

  • Fresh (TSB above threshold): approve the scheduled interval session unchanged.
  • Fatigued: intercept the plan, generate a plain-language explanation grounded in the athlete's own numbers ("your TSB is -34, third day in a row below -20 — that's a fatigue trend, not a bad night's sleep"), and rewrite tomorrow's session to 45 minutes of active recovery.
from langgraph.graph import StateGraph, END

def fetch_athlete_data(state):
    state["rides"] = db.get_recent_sessions(state["athlete_id"], days=14)
    return state

def analyze_fatigue(state):
    ctl, atl = compute_pmc(state["rides"])
    state["tsb"] = ctl - atl
    state["status"] = "fatigued" if state["tsb"] < -30 else "fresh"
    return state

def check_next_ride(state):
    state["next_ride"] = db.get_scheduled_workout(state["athlete_id"])
    return state

def route(state):
    return "adjust" if state["status"] == "fatigued" else "approve"

def adjust_plan(state):
    state["revised_ride"] = build_recovery_spin(minutes=45)
    state["message"] = coach_llm.explain_adjustment(state["tsb"], state["rides"])
    return state

def approve_plan(state):
    state["message"] = "TSB in range — interval session approved as planned."
    return state

graph = StateGraph(dict)
graph.add_node("fetch", fetch_athlete_data)
graph.add_node("analyze", analyze_fatigue)
graph.add_node("check_ride", check_next_ride)
graph.add_node("adjust", adjust_plan)
graph.add_node("approve", approve_plan)

graph.set_entry_point("fetch")
graph.add_edge("fetch", "analyze")
graph.add_edge("analyze", "check_ride")
graph.add_conditional_edges("check_ride", route, {"adjust": "adjust", "approve": "approve"})
graph.add_edge("adjust", END)
graph.add_edge("approve", END)

auditor = graph.compile()

Why a graph and not a script. The rule itself (TSB < -30 → recovery) is one line of Python. What justifies LangGraph is everything around it: durable state across nodes, a conditional edge instead of nested ifs buried in a scheduler job, and a checkpointer (PostgresSaver) so a crashed run resumes at "check next ride" instead of re-fetching and re-analyzing. It also gives a clean seam to add nodes later — a fifth node checking HRV readiness alongside TSB, without touching the first four.

Integration with Wattlog. This runs as a nightly job against the existing FastAPI backend — same SQLite session store, same PMC calculation code the Fitness Trend chart already uses. Output lands as a push notification and a card on the dashboard next to the PMC chart, not a separate surface. No new data pipeline; the graph is a consumer of data Wattlog already collects.


Use case 2: The Post-Ride Debrief Coach

The Auditor looks forward — should tomorrow change? The Debrief Coach looks backward, immediately after a session ends, and answers the question every athlete has after a hard ride: how did that actually go, in the context of everything before it?

[Session Ends]  [Pull Session + Historical Context]  [Compare Against Baseline/Goal]  [Generate Debrief]  [Update Athlete Memory]

Node 1 — Pull Session + Historical Context. Normalized power, intensity factor, TSS, HR zones, and RR trace for the session just completed, plus the athlete's rolling HRV baseline and last 4 weeks of PMC — all data Wattlog's session analytics already produce.

Node 2 — Compare Against Baseline/Goal. Checks the session against what was planned (target zones, duration) and against the athlete's trend (is this a PR? a decoupling red flag? a session that matches a stated goal like "build FTP by August"?).

Node 3 — Generate Debrief. An LLM call, grounded strictly in the numbers pulled in nodes 1–2 (no free-floating advice), produces a short natural-language summary — closer to a training partner's text message than a generated report. This is where the tone matters: the Cycling Trainer Hub roadmap already commits to "speaks like a knowledgeable friend, not a performance judge," and that constraint belongs in the prompt, not just in copy review.

Node 4 — Update Athlete Memory. Writes durable facts back to a per-athlete store — semantic memory (goal: "sub-20-minute 5K bike leg", current FTP estimate) rather than episodic chat history. This is what makes debrief #40 reference debrief #12 without replaying the whole conversation log into context. It's also what lets the Auditor graph above and the Debrief Coach graph share athlete state instead of each re-deriving it.

Integration with Wattlog. Triggered by the same WebSocket event that already fires when a session is finalized and written to the analytics DB — the debrief graph is a subscriber on that event, not a new trigger path. The output slots into the existing session detail view as a coach's note above the charts. Memory lives in a small dedicated table (athlete_id, fact, updated_at), separate from session data, so it survives independently of any single ride.


The shared pattern

Both graphs read from the same source of truth (Wattlog's session store and PMC calc) and both end by writing one thing back: a decision or a note, not a wall of numbers. That's the actual value of putting LangGraph in front of an analytics platform — the athlete stops interpreting a chart and starts getting a call. The dashboard remains the ground truth; the graphs are a thin, auditable layer that turns "here's your TSB" into "here's what to do because of your TSB," with the reasoning traceable at every node.

Next up on this: wiring the Auditor's nightly run to Wattlog's existing notification service, and giving the Debrief Coach's memory table a UI so athletes can see — and correct — what the coach has "remembered" about their goals.