traininglogs
An app that turns workout notes into a queryable, chartable training history.
Why this exists
I lift weights. I write down what I do, and later I want to answer questions: am I getting stronger? am I hammering the same muscle group three days in a row? what was my squat doing six months ago? did the deload actually do anything?
Spreadsheets work until they don't. Off-the-shelf apps assume a workout shape that isn't mine — they don't model myo-reps, partial reps, or the difference between a warmup set and a working set the way I think about them. The shape I want is: write the session the way I'd write it in a notebook, and have a system parse it into something strict enough to query and chart.
That's traininglogs.
Vision
traininglogs today is a post-workout pipeline: write a markdown file, parse it, store it. The direction it is heading is a live, in-workout agent that captures sets as they happen, on mobile, by voice or tap, with the same data fidelity.
The shape of the system
Four steps. capture() saves the raw text. extract() runs the LLM and saves the result as pending. The card is shown; the user confirms or corrects it. confirm() writes the normalized session to Postgres. Each step saves before returning, so a crash or a rejected extraction never loses what was captured.
These three functions live in ingest/. cli/log.py calls them directly, and the API will too.
Postgres is the source of truth. The dashboard and the API both read from it; neither writes back. A rule-based parser is also available via --parser rules.
Pydantic Data Model
A session is one workout on one day. A session has three ordered groups of movements: an optional session-level warmup, the main work (exercises), and an optional session-level cooldown. Session warmup/cooldown movements are lightweight (name, reps or duration, notes) and use their own models — they are not exercises. An exercise has sets and, separately, its own optional warmup sets (e.g. ramp-up sets before a lift's working weight) — a different thing from the session-level warmup despite the shared name. A working set may carry a failure technique (myo-reps, lengthened-partials, static hold, drop set) when it's taken to RPE 10.
TrainingSession
├── data_model_version, data_model_type, session_id, user_id, user_name
│ system fields — injected by the processor, never user-supplied
├── date YYYY-MM-DD
├── program = None real program name; stays unset for ad-hoc sessions (inputs/sessions/)
├── program_author = None
├── program_length_weeks = None
│ set for real programs — used to validate `week` below
├── phase = None
├── week = None must be 1..program_length_weeks when both are set
├── is_deload_week = None
├── focus = None
├── session_duration_minutes = None
├── weight_unit "kg" (default) or "lbs" — lbs converted to kg at parse time
├── warmup = None [SessionWarmup] — session-level warmup movements
├── exercises [Exercise] — the main work, always at least one
├── cooldown = None [SessionCooldown] — session-level cooldown movements
└── notes = None remarks that don't belong to any specific exercise or set
SessionWarmup / SessionCooldown (identical shape, two separate models — not Exercise)
├── number, name
├── reps = None
├── duration_seconds = None
└── notes = None
Exercise
├── number, name
├── tags = None free-text classification: ["strength"], ["cardio"],
│ ["mobility"], ["skill", "jiu-jitsu"], etc.
├── modality = None free-text equipment/style: "barbell", "bodyweight", "gi", "pool", etc.
├── movement_pattern = None list, supports compound patterns: squat, hip_hinge, push,
│ pull, lunge, carry, rotation
├── target_muscle_groups = None
├── rep_tempo = None
├── current_goal = None Goal — target weight/sets/rep range/rest for this exercise
├── notes = None
├── warmup_notes = None
├── form_cues = None
├── warmup_sets = None [WarmupSet] — this exercise's own ramp-up sets, lower-resolution
└── sets = None [WorkingSet] — the sets that count
WarmupSet
├── number, weight_kg
├── rep_count = None
└── notes = None
WorkingSet one flat model — every measurement field optional; the fields
│ present on a given set describe what kind of set it was
├── number
├── rpe = None 1–10, whole or half steps
├── rest = None {minutes, seconds} — never both set at once
├── notes = None
├── weight_kg = None
├── rep_count = None {full, partial}
├── unilateral_rep_count = None {left: {full, partial}, right: {full, partial}}
├── rep_quality_assessment = None good | bad | perfect | learning
├── duration_seconds = None
├── distance_meters = None
├── heart_rate_bpm = None
└── failure_technique = None only valid when rpe == 10; discriminated union on technique_type
├── MyoReps → mini_sets[] {number, rep_count}
├── LLP → partial_rep_count
├── Static → hold_duration_seconds
└── DropSet → drop_sets[] {number, weight_kg, rep_count}
How it works
PostgreSQL is the source of truth: nine tables. Here is what happens to one submission, table by table.
What happens when a session is submitted
The person writes a session, as markdown or free text. capture() saves it
before anything else happens — raw_inputs (table 1) gets one row:
content (the raw text, untouched), source_kind (markdown, photo, or
speech), source_file, checksum, captured_at.
extract() reads that row and calls the model. The work is split into several
calls — segmenting the exercises, reading the session's shell (date, focus, program),
then one call per exercise. Each call writes one row to llm_calls (table 2):
step (which call this was), model, attempts,
input_tokens, output_tokens, cost_usd, ms,
failed, raw_payload (the last thing the model actually returned, kept
even if it failed validation). Once every call returns, the assembled reading is saved as one
row in extractions (table 3): raw_input_id, model,
prompt_version, the whole reading as extract (JSONB),
uncertain_fields, warnings, status set to
pending.
The confirmation card is built from that extraction and shown to the person. Typing
y moves on. Describing a correction makes another model call — another
llm_calls (table 2) row — and the edit it produces is
appended to extractions.corrections (table 3), a JSONB list.
extractions.extract is never rewritten; only corrections grows. This
repeats until the person confirms.
confirm() then writes the final reading — the original extract plus every
correction — as a normalized session. sessions (table 4) gets one row
(date, program, phase, week,
focus, weight_unit, source_file,
extraction_id linking back to the reading that produced it). Session-level
warmup/cooldown movements go to warmups (table 5)/cooldowns
(table 6). Each exercise goes to exercises (table 7); each exercise's sets go to
working_sets (table 8) and warmup_sets (table 9).
extractions.status flips to confirmed, confirmed_at is
set.
After the DB insert succeeds, the same session is written to
output_training_logs_json/ as JSON — a cheap, diffable copy for checking the
parser without touching the database.
The rule-based parser skips all of this: markdown goes straight to sessions
(table 4) with no raw_inputs (table 1), extractions (table 3), or
llm_calls (table 2) rows at all.
Table reference
Inside the AI agent
The default parsing path. The user writes freely — during or after the workout — and the pipeline maps the draft to a TrainingSession. Nothing is written to the database until the person confirms the reading.
The flow
TrainingSession.model_validate() always runs on the final result. The model produces input to Pydantic, never a bypass around it.
ConfirmationCard architecture
The card is a tree of dataclasses defined in agent/validation_card_data.py, built from a TrainingLogLLMExtract by agent/validation_card_builder.py. It is the stable contract between the LLM output and any renderer — terminal today, JSON/frontend later.
UserValidationCard
SessionHeader date · focus · duration · program / phase / week
NotePreview = None session-level note, shown right after the header
SessionMovementSection[] warmup / cooldown, each a MovementRow[]
ExerciseCard[]
ExerciseHeader number · name · goal (plain English)
WarmupRow[] weight · reps
WorkingSetRow[] weight · reps · RPE · quality · failure (plain English)
NotePreview = None first ~40 chars of note + "…" if truncated
NotePreview = None same, for the exercise's own warmup notes
Fields the LLM was uncertain about are flagged in uncertain_fields: list[str] (dot-path format, e.g. "exercises[1].sets[0].weight_kg"). The renderer marks those positions with ?. The user's eyes go directly to uncertain fields; everything else can be scanned in seconds.
Form cues are not shown on the card — they are stored but low-stakes if wrong, and omitting them keeps the card skimmable. Notes (session-level and exercise-level) are shown as a truncated preview. Goals and warmup sets are shown in full because they are stored data the user should verify.
Correction loop
After the card is printed, the person sees one prompt: confirm, or say what's wrong. Anything other than y/yes is a correction. The model is sent the current extract and the correction, and returns a list of edits — {path, value} pairs — not a rewritten extract. The edits are applied in Python; a field the correction doesn't name cannot change, because Python only touches the paths it's given. Measured on a real ten-exercise session: the old whole-document shape needed roughly 5,410 output tokens against a 4,096 ceiling, so it could not have returned a correction for a session that size at all.
Each correction is appended to extractions.corrections. extractions.extract itself is never rewritten. The card is rebuilt from the edited extract and shown again. This repeats until the person confirms.
LLM call design
| Call | Tool | Input | Output |
|---|---|---|---|
| Segment | split_exercises | raw session text | exercise positions and anchors |
| Session shell | extract_session_shell | raw session text | date, focus, program, phase, week |
| Exercise worker | extract_exercise | one exercise's isolated text, or the full text as a fallback | one exercise, with its sets |
| Correction | edit_extraction | current extract + the correction | a list of edits |
All four use claude-haiku-4-5, structured tool-call output, and a schema derived from the Pydantic models — no separate schema to keep in sync. A worker gets an isolated chunk when the segmenter's anchor for that exercise can be found verbatim in the text; when it can't, the worker falls back to the full text with a warning noting lower reliability, not a failure. A worker that raises becomes a flagged placeholder exercise plus a warning — never a crash.
CLI interface
# AI parser (default) — free-text input, confirmation card
traininglogs log draft.md
# Rule-based parser — structured markdown, no confirmation card
traininglogs log session.md --parser rules
All log flags (--no-commit, --pr) apply to both parser modes. The --parser flag only controls the parse stage; everything downstream is identical. Use traininglogs validate to test parsing with no DB or git side effects.
The API
FastAPI. Two halves: read endpoints answer questions about existing sessions; ingest
endpoints are capture → extract → confirm/correct, the same functions
cli/log.py already uses, reachable over HTTP.
Read
| Endpoint | Answers |
|---|---|
GET /sessions | What did I do, and when? (filterable by phase, week, date range) |
GET /sessions/{id} | Full detail of one session, exercises and sets included. |
GET /exercises/{name}/history | How has this lift moved over time? Every working set, every session. |
Ingest
| Endpoint | Does |
|---|---|
POST /inputs | capture() then extract() — {raw_input_id, extraction_id}. A failed extraction still returns raw_input_id; the text isn't lost. |
GET /extractions/{id} | The confirmation card, as JSON — the same ValidationCardBuilder the CLI renders to a terminal. |
POST /extractions/{id}/confirm | ingest.confirm() — writes the normalized session. 409 on a session_id collision. |
POST /extractions/{id}/correct | One correction. Fully stateless — the client round-trips extract between calls; the server holds nothing. |
Every request requires an X-Api-Key header. The app fails at startup if API_KEY is unset. Single key, no users table, no OAuth — it's mine and the dashboard's.
The dashboard
The dashboard is a single static HTML file rebuilt from the database by scripts/build_dashboard.py and written to docs/index.html. Clean white background, Inter for body text, JetBrains Mono for labels and numbers. Six sections:
| Section | Purpose |
|---|---|
| Overview | Total sessions · current plan · current phase · last session date |
| Session Timeline | Sessions grouped by ISO week; color-coded by focus; links to source files on GitHub |
| Strength Progression | Top weight per day per exercise (actual kg, not e1RM); goal weight as a faded dashed red line; set-level note shown in side panel |
| Weekly Load | Total kg moved per phase/week; deload weeks in red |
| Personal Bests | Heaviest set per highlight exercise — weight, reps, date |
| Program | Auto-discovered from inputs/programs/*/program.md; alias read from YAML frontmatter |
The data flow at build time:
The HTML embeds all data as inline JSON and renders charts with Chart.js. No server is required to view it. It is committed to docs/index.html as part of the normal traininglogs log workflow, which publishes it two ways: GitHub Pages serves this repo's own docs/ folder directly at apoorvasharma007.github.io/traininglogs; separately, the website's deploy workflow checks out this repo at build time and copies docs/index.html into the site at apoorvasharma7.com/dashboard. Neither requires an explicit push step from this repo beyond the normal commit — the website redeploy (automatic on its own pushes, or manual via workflow_dispatch) is what picks up the latest committed dashboard.
Versioning
Four things are versioned, each a different way.
- App version.
pyproject.toml'sversionfield (currently3.0.0), semver, bumped by hand when cutting a release. CI syncs it into this document's eyebrow on merge tomain— that span is never hand-edited. - Data model version.
data_model_versionon everyTrainingSession, written into the JSON snapshot only, not a DB column. A hand-set constant inprocessor.py, bumped when the Pydantic model's shape changes in a way worth marking. - Prompt version.
extractions.prompt_version, a hash of the three live extraction prompts, recomputed automatically on import. Never hand-bumped — a hand-maintained constant is only correct while someone remembers to update it, and a hash can't go stale. - Schema version. No version number.
db/schema.sqlis the authoritative current shape; every change so far has been additive (CREATE TABLE IF NOT EXISTS,ADD COLUMN IF NOT EXISTS) and recorded inCHANGELOG.md.db/db.py:apply_schema()applies it explicitly, run by migration/import scripts — never automatically on app startup. A breaking migration, when one lands, will be documented here.
What's next
- Confirm UI. A minimal web surface — textarea, rendered card, confirm button — so a session can be logged from a browser, not just a terminal.
- Deploy. The API hosted on Fly, against the Supabase database already live.
- Mobile capture. Offline draft capture on a phone, synced on reconnect, feeding the same pipeline. Further out — photo and speech input follow the same pattern once this lands.
This document is meant to be the one place that explains the system as it is today, not as it was or as it might be.