DATA WITH DILLON

CASE STUDY / GYM TRACKER

A chat boundary around a training database

The Gym Tracker is a workout log, analytics dashboard, and AI coach. The interesting part is the boundary between the coach and the database: every query is inspected, constrained, enriched, and returned with enough context for the model to recover when the first attempt is wrong.

958

LINES

AST validation in sql-policy.ts

SELECT

POLICY

Read-only queries reach Postgres

LIVE

CATALOG

Schema and body-part values join the prompt

R3F

VISUALS

3D body map beside Recharts panels

01 / SQL POLICY

The model gets a boundary, not a database connection.

I do not let the model send arbitrary SQL to PostgreSQL. Its query passes through validateAndRewriteSql, which parses the statement with pgsql-ast-parser, rejects forbidden syntax, checks tables and columns against the allowlist, parameterizes string literals, validates CTE scope, and applies a limit and time window.

Only a single SELECT or WITH ... SELECT is accepted. The policy returns rewritten SQL and parameters for the executor. The boundary is explicit in code, so a prompt cannot turn a coaching question into a write operation.

  • SQL policy AST validator

02 / PROMPT CONTEXT

The prompt knows the current shape of the data.

The chat route builds its system prompt from the catalog, metric definitions, semantic query hints, and the current body-part values. The catalog reads the allowed tables and columns from information_schema, caches the result, and keeps a fallback schema for local or unavailable database connections.

Body parts are loaded separately from body_parts. The model receives the exact keys it can use in filters and gym_lifts_v comparisons. That removes a common source of failure: inventing a label that is close to the display name but not valid in the data.

  • Prompt construction and tool results
  • Live schema and body-part catalog

03 / DATA MODEL

The view resolves anatomy before the chat has to reason about it.

gym_lifts stores the exercise text that was logged. The gym_lifts_v view joins that text to exercises and exercise_aliases, then exposes canonical_name and body_part_key. An entry such as RDL can resolve to the canonical exercise without making every model query repeat alias logic.

The chat keeps planned intent separate from logged work. gym_day_meta.body_parts describes the muscles planned for a training day. gym_lifts_v.body_part_key describes the muscles attached to sets that were actually logged. Comparing the arrays makes gaps visible instead of treating a plan as proof of completed work.

  • Muscle-aware view migration
  • Prompt construction and tool results

04 / FAILURE PATH

A failed query becomes useful context for the next turn.

The executor returns the query id, purpose, rewritten SQL, parameters, row count, preview rows, applied policy, and error. Validation failures are captured in the same shape as database failures. The model can see what failed, explain what is missing, and choose a narrower follow-up instead of receiving a generic server error.

The response also limits preview rows while preserving the total row count. That keeps the conversation small without hiding whether a result is a sample or the full result set.

  • Prompt construction and tool results
  • SQL policy AST validator

05 / VISUAL LAYER

The dashboard turns the same model into a review surface.

The dashboard uses Recharts for time-series volume and body-part comparisons. React Three Fiber renders the interactive body diagram, so the data can be read as a trend, a table, or a map of trained areas. Each view answers a different question without asking the chat layer to carry the whole interface.

The result is a small system with a clear division of work: PostgreSQL stores the log, the policy controls access, the prompt supplies the vocabulary, the model interprets returned rows, and the dashboard makes the pattern visible.

  • Gym dashboard chart composition
  • Recharts volume chart
  • Three.js body diagram

SOURCE INDEX

Read the implementation

The repository is public. These links point to the files behind the decisions described above.