No description
  • Go 71.6%
  • PLpgSQL 13.7%
  • Python 12.8%
  • Shell 1.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Satya Benson 404b09f478 Add MIT license
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 15:26:27 -04:00
migrations Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
scripts e2e: refuse to run against the production database or instance 2026-10-01 19:33:22 +00:00
.gitignore Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
body.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
db.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
docs.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
estimator.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
export.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
fit.py Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
fit_test.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
food.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
foods.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
go.mod Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
go.sum Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
health.go health: report build commit and start time 2026-10-05 16:52:22 +00:00
health_test.go health: report build commit and start time 2026-10-05 16:52:22 +00:00
http.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
inputs.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
LICENSE Add MIT license 2026-10-05 15:26:27 -04:00
main.go health: report build commit and start time 2026-10-05 16:52:22 +00:00
meals.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
oura.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
query.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
README.md e2e: refuse to run against the production database or instance 2026-10-01 19:33:22 +00:00
review.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
supplements.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00
training.go Initial commit: Fit service source 2026-10-01 16:33:11 +00:00

fit

Single-user food and fitness tracker. Backend only: the user talks to an AI in natural language, and the AI talks to this API.

Live at https://fit.au.pe. Static bearer token in Authorization: Bearer … (see .env, mode 0600).

Division of labour

The models are good at seeing and interpreting; they are bad at remembering and at arithmetic. So:

The model does — reading plate photos and nutrition labels, parsing free text, deciding what food a description refers to, judging portion sizes, deciding progression and deloads, writing SQL for arbitrary questions.

The server does — storage, provenance, macro arithmetic, day totals, trailing averages, regression, baselines, PR detection, idempotency, and history. Anything that must give the same answer twice.

Invariants

  1. Append-only. No observation row is ever UPDATEd or DELETEd. A correction inserts a new row with supersedes pointing at the old one; deletion inserts a superseding tombstone. supersedes is UNIQUE, so a correction chain cannot fork. "Current" is a view, not a column.
  2. Provenance. Every value carries raw_input_id. Raw text and images are stored permanently, so a better model can re-parse them later and supersede the old reading without touching the original.
  3. Snapshot at write. food_entries store absolute macros, not a join to foods. Correcting a food's macros never rewrites what you ate last March.
  4. Frozen day buckets. eaten_on is computed once from the timezone and cutoff in force at write time, and never recomputed. Changing your timezone does not re-bucket days you have already lived.

Endpoints

Food

Method Path Notes
POST /v1/inputs JSON for text; multipart (file) for images. Deduped by SHA-256.
GET /v1/inputs/{id} · /blob Re-read a stored photo later.
POST /v1/food/log Multiple items per call. Returns interpretation and day totals.
POST /v1/food/entries/{id}/correct Stable entry ID; states only what changed, including optional scale.
DELETE /v1/food/entries/{id} Tombstone; original stays readable.
GET /v1/food/day/{YYYY-MM-DD} Entries plus totals, targets and remaining.
PUT /v1/food/day/{day}/state normal | fasted | partial | untracked.
GET /v1/foods/search?q= Custom foods first, then by how often you eat them.
GET /v1/foods/barcode/{code} Local cache, else Open Food Facts, then cached forever.
POST · PATCH /v1/foods · /v1/foods/{id} Create or append a corrected food revision; past log snapshots stay unchanged.
GET /v1/foods/feedback Per-food estimated-vs-weighed portion calibration after more than three logs.
POST /v1/foods/{id}/portions Add or update a named portion.
GET · POST /v1/meals Saved meals; ad-hoc items retain their macro estimate and missing_info.
POST /v1/meals/from-entries Turn reviewed log entries into a reusable meal without re-estimating.

Supplements

Method Path Notes
GET /v1/supplements · /today Daily state and explicit pending list; accepts ?day=YYYY-MM-DD.
POST /v1/supplements Add a daily supplement with a human-readable dose.
POST /v1/supplements/{id}/taken Idempotently mark taken and return what remains.
POST /v1/supplements/intakes/{id}/correct Correct time, timezone, dose or note without losing history.
DELETE /v1/supplements/{id} Remove it from the active list without deleting history.
DELETE /v1/supplements/{id}/taken/{day} Undo a taken mark with an append-only tombstone.

Training

Method Path Notes
GET /v1/training/next Plan + constraints + last performance + PRs + recovery. Context, not a decision.
POST · GET /v1/training/sessions Sets, cardio, substitutions, RPE, prescription vs achieved.
POST /v1/training/sessions/{id}/correct Correct metadata while preserving sets and the stable session ID.
POST /v1/training/sessions/{id}/sets Append to a session already logged.
POST · DELETE /v1/training/sets/{id}/correct Append-only set corrections.
GET /v1/training/exercises/{id}/history Includes sets logged as substitutions for it.
GET /v1/training/prs Max weight, best estimated 1RM, best single-set volume.

Body, recovery, estimator

Method Path Notes
POST · GET /v1/body/weight Accepts notes; returns annotations, trailing averages and a fitted trend.
POST /v1/body/weight/{id}/correct A second weigh-in the same day is a new reading, not a correction.
POST · GET /v1/body/measurements · /v1/body/photos
GET /v1/body/recovery z-scores against your own 60-day baseline.
POST /v1/body/trend-events Creatine, illness, scale change — removed from the trend before fitting.
POST /v1/body/travel Surfaces as an estimator caveat.
POST · GET /v1/estimate/maintenance Adaptive maintenance with interval, caveats, sharpen note.
GET · POST /v1/targets {"from_estimate": true} adopts the recommendation with its rationale.

Review, query, export

Method Path Notes
GET /v1/review/weekly Adherence, protein hit rate, trend, sleep, recovery flags, volume, PRs.
GET /v1/review/correlate?a=&b=&lag_days= On request only. Always returns n and a reliability caveat.
POST /v1/query Model-written read-only SQL.
GET /v1/schema Tables, columns, comments, enums and canonical query_surfaces — read before SQL.
GET /v1/export · /v1/stats Everything, including superseded rows.
GET · PUT /v1/settings Timezone, cutoff, and the body profile the estimator needs.

Logging

Quantity may be grams, a named portion times a count, or prose — whichever is available. Given food_id and a weight the server computes the macros; a third of a 568 g bottle is arithmetic, not a guess.

{"request_id":"msg-4812","raw_text":"third of a bottle of milk","meal_label":"breakfast",
 "items":[{"description":"whole milk","food_id":"…","portion_name":"bottle",
           "portion_count":0.333,"quantity_text":"a third of the bottle",
           "confidence":"estimated","missing_info":["exact_volume"]}]}

confidence is required and describes the act of measuring, not the data source: weighed | labelled | estimated | guessed. missing_info records what would make an estimate cheap to improve. Day responses return up to five actionable needs_info objects, ranked by the calories in the affected entry. Generic requests to weigh a photo-estimated meal after the fact are omitted.

request_id makes writes idempotent: a retry after a timeout returns the original entries rather than logging the meal twice.

Food log, correction, deletion, state and day-read responses use the same flat shape: day, totals, target, remaining, entry_count, entries, needs_info, and supplements_pending.

Corrections

State only what changed. Restating portion_count recomputes the weight and the macros; restating macros directly overrides them.

{"scale":0.7,"edit_reason":"ate 70% of the shared dish"}

Food entry id is stable across corrections; revision_id identifies the immutable history row. Repeated corrections use the same id. Food-library corrections use the same append-only idea. PATCH /v1/foods/{id} carries omitted fields and portions forward, returns a new ID, and rejects a second patch against the stale ID. Existing food entries keep their macro snapshot; saved meals resolve the latest food revision when next logged.

Estimator feedback

Once a food has more than three logs, food search and GET /v1/foods/feedback surface its calibration state. Matched corrections from estimated/guessed to weighed portions are preferred; otherwise the API compares median per-occurrence estimated and weighed grams. The returned correction_factor is advisory. Historical entries are never silently rewritten. Quantity corrections clear quantity-related missing information; other fields can be cleared deliberately with "missing_info":[].

The estimator

maintenance = mean_logged_intake − trend_kg_per_day × 7700

Two things about this are load-bearing:

It is fitted on logged intake, not corrected intake. If you consistently under-log by 15%, the fitted maintenance comes out about 15% low, and the target derived from it is correspondingly low — so the target still produces the intended rate of weight change. The bias cancels. Correcting entries upward first and then fitting would double-count it.

Bias cancellation assumes consistency. It holds only while your logging accuracy is stable. The estimator may use a change in measurement quality to widen its interval, but does not expose a permanently-low pct_solid score or nag the user to change an established photo-logging style.

Under-logging is therefore reported, never applied. It cannot be inferred from intake and weight alone — maintenance is fitted from exactly those two things, so any discrepancy is absorbed by construction. Detecting it needs an external reference, so the fitted number is compared against Mifflin–St Jeor from your body profile. Set height_cm, birth_year, sex and activity_factor via PUT /v1/settings or the comparison is skipped and says so.

Day states drive the fit:

  • fasted → intake 0. Real data, included.
  • partial / untracked → excluded; knowingly incomplete would bias the mean.
  • no row at all → unlogged, excluded, and counted against coverage.

Creatine and similar water-weight steps are recorded as trend_events with an expected_kg_shift and subtracted from the weight series before fitting, so a 1.2 kg jump is not read as an enormous surplus.

The minimum is seven usable intake days and seven distinct weigh-in days in the window. Intake and weight writes automatically refresh one stored estimate for the current day/window once that threshold is met; no separate trigger call is required.

The raw calorie recommendation is anchored to mean logged intake: it asks what intake would have changed the observed trend into the goal trend. That is not always the same as a change from the configured calorie target, especially when intake exceeded the target or the target changed inside the fit window. The recommendation therefore never moves the configured target opposite the rate correction: if loss is too slow it cannot raise the target, and if gain is too slow it cannot lower it. In that case it returns action: "hold", keeps the current target, and exposes the unconstrained calculation as model_kcal.

Progression

The training plan is prose in documents, read and rewritten by the model. exercise_sets records both what was prescribed (target_reps, target_weight_kg) and what was achieved. The server does no progression arithmetic and has no exercise-equivalence model — a machine press standing in for a barbell is a sentence in the plan plus a substitution_for annotation, so the thread survives a different gym. Substituted sets still appear in the substituted-for exercise's history.

raw_text is provenance, never a set parser. Requests containing sets or cardio require "performed_confirmed":true; plan-derived prescriptions stay in the caller's draft until the user reports what was actually performed.

Targets can explicitly ignore a field, for example {"kcal":2200,"protein_g":160,"ignore":["fat_g"]}. Ignored fields are omitted from both target and remaining.

PUT /v1/documents/{kind}/{slug} accepts expected_id for optimistic concurrency, so a rewrite based on a stale read is refused rather than silently discarding an intervening edit.

Testing

go test ./...        # pure logic: regression, corrections, SQL guard
./scripts/e2e.py     # full API checks against a SCRATCH instance — DESTRUCTIVE

e2e.py wipes the transactional tables, seeds five weeks of data, and asserts every invariant above, including that the estimator recovers a known planted maintenance figure to within 120 kcal.

Never run it against production. It targets a scratch database fitdb_e2e served by a second instance on 127.0.0.1:5104 (override with --db and --url), and refuses to start if the database is fitdb (or whatever FIT_DSN in .env names), the port is 5004 (or FIT_ADDR's), or the host is not loopback. Before wiping anything it also checks, read-only, that both psql and the instance's /v1/query report current_database() = the scratch name — a fit binary started without FIT_DSN silently falls back to fitdb. Setup:

sudo -u postgres createdb -O fit fitdb_e2e
sudo -u postgres pg_dump fitdb | sudo -u postgres psql -q -d fitdb_e2e
(set -a; . ./.env; set +a
 FIT_DSN='postgres:///fitdb_e2e?host=/var/run/postgresql&user=fit' \
 FIT_DSN_RO='postgres:///fitdb_e2e?host=/var/run/postgresql&user=fit_ro' \
 FIT_ADDR=127.0.0.1:5104 FIT_BLOB_DIR=/tmp/fit-e2e-blobs exec ./fit) &
./scripts/e2e.py

The dump brings the USDA reference data the search checks need; OURA_TOKEN from .env lets the scratch instance backfill recovery data for those checks.

Notes for whoever extends this

  • Views freeze SELECT *. Postgres resolves the column list at creation time. Migration 002 added tz columns and the current_* views silently kept serving the old shape until 003 rebuilt them with explicit column lists. Any migration that adds a column to a versioned table must also update the view.
  • Resolve foods through current_foods. The base foods table is revision history and deliberately retains superseded misidentifications.
  • day is a keyword in bare-alias position. SELECT measured_on day is a syntax error; SELECT measured_on AS "day" is not.
  • Substring search fails on USDA naming. "chicken breast" appears nowhere in "Chicken, broilers or fryers, breast, meat and skin, raw", so plain cuts were unfindable while breaded tenders ranked top. Migration 005 adds a generated search_tsv; plainto_tsquery ANDs the terms and ignores what sits between.
  • Open Food Facts is not importable here. 25 GB free on a 2-core box. It is fetched by barcode and cached permanently on first hit.
  • Foundation Foods could not be located. Its bulk-download URL 404s at every date pattern tried. scripts/import_usda.sh takes any FDC CSV directory, so it can be added later without code changes.
  • Query UUIDs are JSON strings. /v1/query converts UUID values to canonical hyphenated strings; bytea remains binary/hex only when that is the real type.

Operations

  • Service: fit.service (systemd), binary /home/satya/fit/fit, port 5004, nginx → fit.au.pe, certbot-managed TLS.
  • Build: go build -buildvcs=false -o fit . (home is inside a git repo it doesn't own).
  • Database: fitdb on PostgreSQL 18, owner role fit; fit_ro is SELECT-only with default_transaction_read_only and a 10 s statement timeout, used solely by /v1/query.
  • Oura: polls every 3 hours, re-fetching a trailing 7 days because Oura finalises and re-scores nights after the fact; backfills 60 days on start. Needs OURA_TOKEN.
  • Blobs: /home/satya/fit/blobs, sharded by hash prefix. This is what will grow — the database itself stays small enough to pg_dump trivially.
  • Migrations: applied in order, by hand. psql -d fitdb -f migrations/NNN_*.sql.
  • Client: ./fit.py METHOD /v1/path [JSON] reads .env and retries transient 502/503/504 and network failures with exponential backoff and jitter.
  • Reference data: ./scripts/import_usda.sh <dir with food.csv>. Idempotent.