feat(routines): take unit names on slot entries, not just wger's ids - #18
Open
wromansky wants to merge 5 commits into
Open
feat(routines): take unit names on slot entries, not just wger's ids#18wromansky wants to merge 5 commits into
wromansky wants to merge 5 commits into
Conversation
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.
This was referenced Aug 31, 2026
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.
wromansky
marked this pull request as ready for review
September 1, 2026 19:57
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
repetition_unitandweight_unitonattach_exercise_to_slotandupdate_slot_entryare the only unit fields in the server that take a bare integer with no mapping:log_sethas always taken names ('kg','lb','seconds') and converted them throughas_weight_unit/as_repetition_unit. So a caller logging a plank passesreps_unit="seconds", while a caller planning the same movement has to know that seconds is3— 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:It reasoned, reasonably: "the enum almost certainly starts at 1 → seconds=2". But that list matches nothing except its first item:
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_unitandas_weight_unitwiden toint | str | Noneand 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.ToolInputErrorpath, so a typo fails loudly instead of writing a wrong unit.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 | stralone would have taken a working case away: under the oldint | None, pydantic's lax coercion turned"3"into3; underint | strthe smart union prefers the exactstrmatch and leaves it"3", which then missed the name lookup and 400'd. Every other id on these tools is typedstr(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_configandadd_exercise_with_setstype their unit parametersstr, 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 onlog_setit would recreate the original incident exactly: a caller counting positions off a list arrives at2for seconds, and"2"would silently store until failure. The shared resolver takes anallow_idflag, default off, which only the fourint | strcall sites turn on.tests/test_workout_log_fields.py::test_a_numeric_reps_unit_is_refusedpins 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_UNITScarries the same warning, since that is where anyone checking will end up.attach_exercise_to_slotandupdate_slot_entrydo 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.mdcarried the original bad order verbatim onlog_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_slotwithrepetition_unit="seconds"andweight_unit="lb"writes ids 3 and 2update_slot_entrywith"seconds"writes 3"3") still resolves to 3"Until Failure"," KG ") resolve"secs") is refused and nothing is writtenPlus, in
tests/test_workout_log_fields.py, one case pinning the other side of the boundary:log_setwithreps_unit="2"is refused and nothing is written.Full suite: 254 passed.
ruff checkclean;ruff format --checkclean 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_repsfor rep ranges), #19 (which also editslog_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, andcommon.pyauto-merges between the two. The overlap is in the docs.git merge-treeagainst this branch's head:CHANGELOG.mdCHANGELOG.mdandREADME.mdWhichever lands second needs a docs rebase, no code rebase. The
README.mdoverlap 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.