Skip to content

feat(routines): take unit names on slot entries, not just wger's ids - #18

Open
wromansky wants to merge 5 commits into
wger-project:masterfrom
wromansky:feat/repetition-unit-by-name
Open

feat(routines): take unit names on slot entries, not just wger's ids#18
wromansky wants to merge 5 commits into
wger-project:masterfrom
wromansky:feat/repetition-unit-by-name

Conversation

@wromansky

@wromansky wromansky commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

The problem

repetition_unit and weight_unit on attach_exercise_to_slot and update_slot_entry are the only unit fields in the server that take a bare integer with no mapping:

repetition_unit: int | None = None,
weight_unit: int | None = None,

log_set has always taken names ('kg', 'lb', 'seconds') and converted them through as_weight_unit / as_repetition_unit. So a caller logging a plank passes reps_unit="seconds", while a caller planning the same movement has to know that seconds is 3 — a number that appears nowhere in the tool surface.

A wrong id is silent and unrecoverable. wger stores "30 until failure" where 30 seconds was meant: a plausible-looking row that nothing marks as wrong, and no later reading of the plan can tell it was meant to be time. Same failure shape as a pound number stored as kilograms — the value is valid, the meaning is not.

And the docstring pointed at the wrong number

This is not hypothetical, and the tool signature was only half the cause. An LLM client driving this server had to source an id from somewhere, and the nearest document was log_set's own docstring:

"reps_unit says what reps counts: repetitions (wger's default), seconds, minutes, meters, kilometers, miles, until_failure or max_reps."

It reasoned, reasonably: "the enum almost certainly starts at 1 → seconds=2". But that list matches nothing except its first item:

listed position implies actual id
repetitions 1 1 ✓
seconds 2 3
minutes 3 4
meters 4 8
kilometers 5 6
miles 6 5
until_failure 7 2
max_reps 8 7

So the planning path demanded an id, stated none, and the nearest document implied a wrong one.

The change

Both fields accept a name or an id.

  • as_repetition_unit and as_weight_unit widen to int | str | None and share one resolver, so neither can drift from the other. Whether a digit string counts as an id is a flag on that resolver, set per call site from the parameter's own annotation.
  • An unknown name is refused before the request, reusing the existing ToolInputError path, so a typo fails loudly instead of writing a wrong unit.
  • Names are matched after strip().lower() and space-to-underscore, so wger's own display names ('Seconds', 'Until Failure') resolve rather than being refused.

Numeric ids still pass through unchanged, as a number or as a string — on these two tools only. Widening to int | str alone would have taken a working case away: under the old int | None, pydantic's lax coercion turned "3" into 3; under int | str the smart union prefers the exact str match and leaves it "3", which then missed the name lookup and 400'd. Every other id on these tools is typed str (slot_id, exercise_id, slot_entry_id), so a client that has learned ids are strings here is exactly the one that sends "3". A digit string is now read as the id it plainly is.

That coercion is deliberately not applied anywhere else. log_set, update_workout_log, set_slot_entry_config and add_exercise_with_sets type their unit parameters str, and this branch does not change those annotations — a number was already a 400 there, so there is no regression to repair. Coercing one would be a new loosening, and on log_set it would recreate the original incident exactly: a caller counting positions off a list arrives at 2 for seconds, and "2" would silently store until failure. The shared resolver takes an allow_id flag, default off, which only the four int | str call sites turn on. tests/test_workout_log_fields.py::test_a_numeric_reps_unit_is_refused pins that boundary so it cannot be re-widened quietly.

Loose name matching is applied everywhere, since it can only ever turn a refusal into the right unit.

The misleading lists are fixed, in the docstrings and in the README.

  • log_set's docstring lists the units in id order, says to pass the name, and says explicitly not to infer a number from the list's order. REPETITION_UNITS carries the same warning, since that is where anyone checking will end up.
  • attach_exercise_to_slot and update_slot_entry do accept ids, so a list of names in any order is itself an inference hazard there. Their docstrings state every id outright — repetitions=1, until_failure=2, seconds=3, minutes=4, miles=5, kilometers=6, max_reps=7, meters=8, kg=1, lb=2 — alongside the same "do not infer an id from the order of any list" warning, which closes the channel rather than reordering it.
  • README.md carried the original bad order verbatim on log_set, and documented the two slot-entry tools without mentioning that names are now accepted. No test reads the README, so CI stayed green while the hazard survived there. Both are corrected.

No new dependency, no new endpoint; the mapping tables already existed.

Tests

tests/test_slot_entry_units.py, 6 cases:

  • attach_exercise_to_slot with repetition_unit="seconds" and weight_unit="lb" writes ids 3 and 2
  • update_slot_entry with "seconds" writes 3
  • numeric ids still pass through unchanged
  • a numeric id sent as a string ("3") still resolves to 3
  • wger's display names ("Until Failure", " KG ") resolve
  • an unknown name ("secs") is refused and nothing is written

Plus, in tests/test_workout_log_fields.py, one case pinning the other side of the boundary: log_set with reps_unit="2" is refused and nothing is written.

Full suite: 254 passed. ruff check clean; ruff format --check clean on the touched files (9 files elsewhere in the repo are unformatted on master and are left alone).

Note

Fourth of four open drafts touching this area, all independent: #16 (weight unit follows the trainee's profile), #17 (max_reps for rep ranges), #19 (which also edits log_set's docstring), and this one.

Only this PR changes the signature of as_weight_unit / as_repetition_unit; #16 adds a separate resolver and changes callers rather than touching those functions, and common.py auto-merges between the two. The overlap is in the docs. git merge-tree against this branch's head:

pair result
#18 × #19 clean
#18 × #17 conflict in CHANGELOG.md
#18 × #16 conflict in CHANGELOG.md and README.md

Whichever lands second needs a docs rebase, no code rebase. The README.md overlap with #16 is new as of this revision — fixing the misleading unit list there is part of this PR's point, and #16 edits the same table rows.

repetition_unit and weight_unit on attach_exercise_to_slot and
update_slot_entry were the only unit fields in the server that took a
bare integer with no mapping. log_set has always taken 'kg' / 'lb' and
'seconds' and converted them, so a caller writing a plan had to know that
seconds is 3 while a caller logging the same movement did not.

A wrong id is silent and unrecoverable. Writing a 30-second hold with id
2 stores "30 until failure": a plausible-looking row that nothing in the
record marks as wrong, and no later reading can tell it was meant to be
time. That is the same failure shape as a pound number stored as
kilograms.

Both fields now accept a name or an id. as_repetition_unit and
as_weight_unit widen to pass an int through untouched, so existing
callers holding wger's own values are unaffected, and an unknown name is
refused before the request rather than reaching wger.
log_set's docstring listed the repetition units as "repetitions, seconds,
minutes, meters, kilometers, miles, until_failure, max_reps". The ids run
repetitions 1, until_failure 2, seconds 3, minutes 4, miles 5, kilometers
6, max_reps 7, meters 8 — so only the first item matched, and a reader
counting positions arrived at seconds=2, which is until_failure.

That is not hypothetical. An LLM client driving this server reasoned "the
enum almost certainly starts at 1 -> seconds=2" from this exact sentence
and wrote a 30-second hold as "30 until failure".

The list is now in id order, says to pass the name, and says not to infer
a number from it. The mapping table carries the same warning, since that
is where anyone checking will end up.
Widening these parameters to int | str took a working case away. Under
the old int | None, pydantic's lax coercion turned "3" into 3; under
int | str the smart union prefers the exact str match, leaves it "3", and
the name lookup then fails. Every other id on these tools is typed str —
slot_id, exercise_id, slot_entry_id — so a client that has learned ids
are strings here is exactly the one that sends "3" and now gets a 400.

A digit string is therefore read as the id it plainly is. Names are also
matched after strip/lower/underscore, so 'Seconds' and 'Until Failure' —
wger's own display names, and the strings a caller is likeliest to have
seen — resolve instead of being refused.

Both resolvers now share one function, so neither can drift from the
other. An unknown name is still refused before the write, with the error
now saying that a numeric id is accepted too.
attach_exercise_to_slot's new paragraph listed the unit names in an
arbitrary order — seconds, minutes, meters, until_failure, repetitions —
while inviting a numeric id in the same sentence. Counting positions off
that list gives seconds=1, which is repetitions: the same silent wrong
write the log_set docstring caused, reintroduced one file over. The list
was also incomplete, so a caller planning a 5 km row found no name at
all, and it referred to a `reps` parameter this tool does not have.

Listing names in a better order would only move the hazard. Since these
two tools genuinely accept ids, the docstring now states every id
outright and carries the same warning log_set got, which closes the
inference channel instead of reordering it. update_slot_entry points at
that list and says to pass a name.

README.md still carried the original misleading order verbatim, in the
surface most often pasted to a model as context, and documented the two
slot-entry tools without mentioning that they now take names. No test
reads the README, so CI stayed green while the hazard survived there.
…cepted

Coercing digit strings in the shared resolver went one tool too far.
Blocker 3 was a regression: repetition_unit and weight_unit on the two
slot-entry tools changed from int | None to int | str | None, and the
smart union stopped applying the lax int coercion that used to turn "3"
into 3. Restoring that is a repair.

log_set has no such case to restore. reps_unit is str | None and this
branch never changed it, so "2" was a 400 before and coercing it now is a
new loosening — on the exact tool where the original incident happened.
A caller who counts positions off a list arrives at 2 for seconds, and
would silently store until_failure: the failure this PR exists to remove,
reproduced by the fix for it.

_unit_id takes allow_id, default off. Only the four call sites whose
parameter is typed int | str turn it on. Loose name matching stays
everywhere, since it can only turn a refusal into the right unit, and the
error mentions a numeric id only where one is accepted.

test_a_numeric_reps_unit_is_refused pins the boundary, so re-widening it
fails a test rather than passing quietly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant