Skip to content

Latest commit

 

History

History
201 lines (158 loc) · 10.5 KB

File metadata and controls

201 lines (158 loc) · 10.5 KB

Architecture

Goals

  1. Single source of truth for all training data, regardless of which device captured it.
  2. Grounded reasoning — the AI coach should cite specific runs, never invent paces.
  3. Idempotent ingestion — re-running the sync should never duplicate or corrupt data.
  4. Extensible — adding a new source (Apple Watch, Whoop, manual entry) is a localized change.
  5. Resume-quality engineering — clean separation of concerns, typed contracts, real RAG.

System diagram

                 ┌─────────────────────────────────────────┐
                 │              DATA SOURCES               │
                 ├─────────────────────┬───────────────────┤
                 │  Strava REST API    │  Garmin FIT files │
                 │  (OAuth 2.0)        │  (local export)   │
                 └──────────┬──────────┴─────────┬─────────┘
                            │                    │
              ┌─────────────▼─────────┐  ┌───────▼──────────┐
              │  strava_client.py     │  │ garmin_client.py │
              │  — auth + paginated   │  │ — fitparse over  │
              │    fetch              │  │   .fit binary    │
              │  — auto token refresh │  │ — cadence/HR/    │
              │                       │  │   stride extract │
              └──────────┬────────────┘  └──────┬───────────┘
                         │                      │
                         ▼                      ▼
                  ┌──────────────────────────────────┐
                  │       pipeline.py (ETL)          │
                  │                                  │
                  │  1. Fetch raw activities         │
                  │  2. Normalize → Activity model   │
                  │  3. Dedupe (Strava↔Garmin pair)  │
                  │  4. Merge richer fields (HR,     │
                  │     cadence wins from Garmin)    │
                  │  5. Persist to activities.json   │
                  │  6. Trigger reindex              │
                  └──────────────┬───────────────────┘
                                 │
              ┌──────────────────┴──────────────────┐
              ▼                                     ▼
       ┌─────────────────┐                   ┌─────────────────┐
       │ analytics.py    │                   │     rag.py      │
       │                 │                   │                 │
       │ - weekly aggreg │                   │ - chunk corpus  │
       │ - pace trends   │                   │ - embed via     │
       │ - HR zone time  │                   │   sentence-     │
       │ - ACWR / load   │                   │   transformers  │
       │ - PR detection  │                   │ - upsert to     │
       └────────┬────────┘                   │   ChromaDB      │
                │                            └────────┬────────┘
                └─────────────┬──────────────────────┘
                              ▼
                  ┌─────────────────────────┐
                  │       query.py          │
                  │                         │
                  │ Hybrid retrieval:       │
                  │   1. Parse intent       │
                  │   2. Apply structured   │
                  │      filters            │
                  │   3. Semantic search    │
                  │      on remainder       │
                  │   4. Rerank + return    │
                  │      top-K with metrics │
                  └────────────┬────────────┘
                               │
                               ▼
                  ┌─────────────────────────┐
                  │     generation layer    │
                  │                         │
                  │ chat.py CLI, or the     │
                  │ Claude Code subagent,   │
                  │ turns the query.py      │
                  │ bundle into an answer   │
                  │ via your chosen model   │
                  │ (local / Claude / GPT)  │
                  └─────────────────────────┘

Data model

Every activity normalizes to the same shape, whether it came from Strava or Garmin:

class Activity(BaseModel):
    # Identity
    id: str                           # canonical: f"{source}:{source_id}"
    source: Literal["strava", "garmin", "manual"]
    source_id: str

    # When + what
    start_time: datetime              # UTC
    local_date: date                  # local date of run
    name: str
    sport: str                        # "Run", "Walk", "Bike", etc.

    # Distance + duration
    distance_mi: float
    moving_time_sec: int
    elapsed_time_sec: int

    # Pace
    pace_per_mile_sec: float

    # Terrain
    elevation_gain_ft: int

    # Physiological (may be None if device didn't record)
    avg_heartrate: Optional[float]
    max_heartrate: Optional[float]
    avg_cadence: Optional[float]      # Garmin only
    avg_stride_length_m: Optional[float]  # Garmin only

    # Effort
    suffer_score: Optional[float]

    # Free-form
    notes: Optional[str]

    # Provenance
    raw: dict                         # original payload (for debugging)

Conflict resolution

When the same run appears in both Strava and Garmin (same start_time within ±60s, similar distance):

Field Winner Reason
distance_mi, pace_per_mile_sec Garmin Better GPS
avg_heartrate, max_heartrate Garmin Direct sensor data
avg_cadence, stride_length Garmin only Strava doesn't have these
name Strava User-edited names live in Strava
elevation_gain_ft Garmin Barometric altimeter beats GPS

RAG corpus design

The corpus mixes document types, so every retrievable unit carries metadata for filtering:

Document type Content Metadata
Activity One-paragraph description of the run date, distance, pace, hr, type, elevation
Weekly summary "Week of YYYY-MM-DD: X miles across N runs, longest Y mi..." week_start, miles, runs
Progress note The full markdown coaching note date, tag
Plan section Each phase of the current plan, as a chunk phase, weeks, target_pace

Why hybrid (structured + semantic)?

Pure vector search fails on questions like "my longest run last month" — embeddings don't know about dates and aggregates. Pure SQL fails on "runs where I struggled" — there's no struggled column.

The query flow:

  1. Intent extraction — parse the question for structured constraints (dates, distance ranges, run types).
  2. Filter — narrow the candidate pool by metadata.
  3. Semantic rank — embed the question, rank candidates by cosine similarity.
  4. Return — top-K documents + raw metrics for grounding.

Generation layer

query.py produces a model-agnostic bundle: the deterministic snapshot plus the retrieved docs, as JSON. Nothing about it assumes which model reads it. Two front-ends consume that same bundle:

  1. chat.py, a provider-agnostic CLI. It folds the bundle into a prompt and sends it to whatever model you configure — a local model (Ollama, LM Studio, vLLM, Hermes) through the OpenAI-compatible API, or a hosted service (Claude, OpenAI). llm.py holds the two backends: one native Anthropic client, one OpenAI-compatible client that covers hosted OpenAI and every local server.
  2. The Claude Code subagent, which calls query.py itself and lets Claude Code supply the model.

Splitting retrieval (query.py) from generation (chat.py / the subagent) is what keeps the coach portable. Swapping models never touches the pipeline.

Where RAG earns its keep

Inside Claude Code, semantic retrieval is close to redundant: the subagent has file tools and a large context window, so it could read activities.json directly. The vector store looks like overhead.

The moment you point the coach at a small-context local model, that inverts. Hermes can't swallow every run, note, and plan section — you have to choose what goes in the prompt. That choice is exactly what the snapshot + top-K retrieved bundle does: it fits the answer-relevant slice into a small window while keeping the coach grounded. Deterministic stats cover the arithmetic a model can't be trusted with; retrieval covers the "which runs matter" question. That is what makes running the coach on a modest local model possible at all.


Why keep the Claude Code subagent too?

The CLI is the portable front-end, but the Claude Code subagent earns its place beside it. It gets its own system prompt, its own tool list, and an explicit invocation (@running-coach):

  • The coach has a fixed persona (knowledgeable, honest about limitations, evidence-grounded).
  • The coach has scoped tools — read access to the data, ability to run query.py, but no destructive write access by default.
  • The coach is discoverable — the main agent can delegate running-related questions to it.

Future enhancements

  • MCP server wrapper around query.py so other Claude Code projects can hit the coach as a tool
  • Apple Health import for sleep/HRV context
  • Predicted race time using the Riegel formula on the longest recent run
  • Auto-adjusting plan — if last week was missed, regenerate this week
  • Web dashboard (FastAPI + minimal frontend) to visualize trends