Translated from the original published on Habr on 2026-09-14 — original
LLM is good at the form of a training plan: it distributes sessions across days, describes intensity, explains exercises. But coherent text does not yet mean a coherent methodology. The model by itself does not store the athlete's current context and does not ensure continuity between the season goal and tomorrow's workout. At 1trAIner we solve this task through a cascade of horizons, explicit context, and constraints on top of generation. Below is how this approach works, its safeguards, and its limits.
Why a single request doesn't work
The request "make me a training plan" is not specific enough. It doesn't make clear what the person did yesterday, how much time they have tomorrow, and how the coming week relates to a longer-term goal. The model can fill these gaps with plausible assumptions. The result will be a neat answer, but the choice of load will be random relative to the athlete's actual situation.
The problem is not solved by the instruction "be an experienced coach." Such an instruction sets a role but does not convey history. Even a detailed profile does not replace recent workouts and recovery metrics. The goal may stay the same, while the basis for tomorrow's session changes.
There is also a difference in scale. At the season level, an overall training concept is needed. At the day level, a specific session compatible with that concept, the current condition, and the schedule. If each horizon is generated independently, there is no mandatory link between them: the weekly plan may look reasonable on its own but not continue the monthly one.
So the question for the architecture is not "how to write the most detailed prompt" but "what decisions must exist before this request." Cascading workout planning with LLM is a way to explicitly set such dependencies. It does not make the model error-free, but it does not leave the order of building the plan to its discretion.
Cascade of horizons
The service uses a chain:
season → month → week → day
The lower horizon is built on top of the upper one. The daily plan should not appear as an independent essay about a suitable workout. Its foundation is the weekly plan, which in turn has monthly and seasonal context.
If the required higher-level horizon is missing, the service builds it first. It is important to distinguish this from the instruction to the model to "take the season into account": you can only take into account what is actually passed in. A missing plan is a task for orchestration, not a suggestion for the model to guess at the content.
There is an additional check: the plan text must contain at least 300 characters. The placeholder "the planner returned no text" does not count as a plan and does not become the basis for the next horizon.
Below is an illustration of choosing the missing support, not a copy of the service's source code. The context here is conventionally represented by an object with plans by horizon:
// Which horizon needs to be built before the current one (null — nothing needed).
// We look exactly one step up: the month is fixed by weekly generation.
const MIN_PLAN_TEXT_LENGTH = 300;
function planUsable(plan) {
if (!plan || !plan.id) return false;
return String(plan.markdown_text || '').trim().length >= MIN_PLAN_TEXT_LENGTH;
}
function missingParentScope(scope, ctx = {}) {
if (scope === 'day') return planUsable(ctx.weekly_plan) ? null : 'week';
if (scope === 'week') return planUsable(ctx.monthly_plan) ? null : 'month';
return null;
}
The function is short, but it closes a whole class of errors: before it, the daily plan could
be built on top of an empty weekly plan and honestly write about it right in the text
of the task. The user received a plan in which, instead of a workout, there was
an explanation of why it was missing.
The daily plan is assembled in advance, at night. This is not an optimization for the sake of beauty: by
the morning delivery, the text must already exist, otherwise the person opens the chat and
waits for generation.
What we put into the context
The second half of the task is what exactly the model sees at the moment of generation. Here it is easy
go to extremes: either give away everything and pay for tokens that have no
effect, or save money and get a plan disconnected from reality.
Context is assembled anew for each request — it costs more than writing
the profile into the system prompt once, but that's the only way the model responds about today, not about
an abstract athlete.
- Recent workouts with dates and a "today / yesterday" marker. Without explicit dates
the model confuses the order and reviews the day-before-yesterday's session as a fresh one.
- Auto-review of recent activities from Garmin data: heart rate zones, splits,
weather. This is what turns "ran 10 km" into "ran 10 km in the second
zone with a positive split at +24 °C".
- Recovery metrics: sleep, resting heart rate, heart rate variability,
readiness, training load. The decision about tomorrow's
intensity.
- Training metrics: threshold heart rate and zones, predictions for distances.
Intensity is tied to them, not to generic tables from the internet.
- Nutrition diary for the week. It serves exactly one purpose: to warn about
underfueling before a high-volume day. The service does not prescribe diets.
- Constraints from the dashboard: blocked dates, day-by-day availability,
minute limit. These are hard constraints, not preferences.
- Athlete profile, goals, and memory — things that rarely change.
How metrics shape tomorrow
The rule is simple and stated explicitly: with low readiness, reduced heart rate
variability, short sleep, or high acute training load, intensity is reduced; with
high readiness, quality work is allowed. There is no rigid template of "three weeks
up, one down” does not exist in the planner — the load increases in steps, and the moment
recovery is determined by training load, readiness dynamics and quality
of completed workouts.
Precision of wording matters here: these are rules on top of the model, not its
own decision. The model phrases the workout in words, but the framework “today
quality is possible / today only easy” is not set by it.
Validators: what we check before a person sees the plan
Every check here is a response to a specific incident, not theoretical
caution. The general rule is this: if a model error can reach a person
silently, a check is needed to make it loud.
Several checks stand between the model’s response and the user.
- Suitability of the reference. That same 300-character threshold: a short response is considered
a generation failure, not a plan.
- Building out the horizon. If there is no weekly plan, it is built down to the daily one.
- Hard constraints. Blocked dates and the minute limit are non-negotiable: on
a day marked as unavailable, no workout is scheduled, even if according to
the methodology one is needed there.
- Medical boundary. Pain, injury, symptoms of illness are a reason to refer to
a doctor, not to pick a load. The service is informational and does not make diagnoses.
// Whether the plan is usable as a foundation for the lower horizon.
const usable = planUsable(ctx.weekly_plan);
if (!usable) {
await generatePlanForScope('week', ctx); // first build out the week
}
await generatePlanForScope('day', ctx); // and only then release the day
Request cost
An architecture with rich context hits money faster than quality:
one coach response is several thousand input tokens. That is why the schedule
of background tasks is dictated by the provider's pricing, not by developer convenience.
Context for each request costs money, so the price should be calculated in advance. As of
10.09.2026 the model rates are as follows: input — $0.15 per million tokens, cached
input — $0.003 per million, output — $0.60 per million. During peak hours they are twice
higher: $0.30, $0.006 and $1.20 respectively.
Peak according to the provider's price list is 01:00–04:00 and 06:00–10:00 UTC on weekdays; weekends are not
counted as peak. Hence the nightly build of daily plans at 03:15–03:59 Moscow time:
in UTC that is off-peak, and the same work costs half as much. Cached input
is fifty times cheaper than regular, so it pays to keep the unchanging part of the context
stable from request to request.
What didn't work the first time
Three stories, each cost a separate fix.
The first — a plan on top of emptiness. The weekly generation was marked as started, no plan
appeared, and the nightly build of the day constructed a task on top of an empty weekly
plan and wrote about it right in the text. It's fixed by checking the foundation on which
the article begins.
The second — placeholders instead of text. The answer "the planner returned no text" formally
counts as a plan: there is an entry, there is a horizon, there is a date. Hence the length threshold.
The third — voice recognition. Voice messages are transcribed by our own
service built on faster-whisper. Without a domain hint, the model confidently turned
"Lycian Way" into "Lekistian", and Body Battery into whatever came out. A list of
terms and proper names in the recognizer's hint helped.
What's next
The immediate work is less about the model and more about the checks around it:
more automated tests for the plan structure and for compliance with the constraints from
the dashboard. We won't name dates: what already works is described on the page
"How the coach makes decisions", and you can try
you can access the service from the home page or from the page
Source: Habr