# Спецификация 03: VoiceCommandExecutor и WorkoutCore.setLastSetRpe Источники: Ф-1.2–1.5 (применение/отклонение/подтверждение/журнал), Ф-13.3 (RPE последнего подхода), инвариант m09 (единственный писатель 🛡), решение гейта 1.3 №2/№3. Единицы 4 и 8. ## 1. Шлюз к ядру (тестируемость без Room) ```kotlin interface VoiceCoreGateway { suspend fun logSet(workoutId: String, exerciseId: String?, weightKg: Double, reps: Int, source: String) suspend fun logCommand(workoutId: String, text: String, semantics: JsonObject, accepted: Boolean) suspend fun setLastSetRpe(workoutId: String, rpe: Int): Boolean } ``` Реализация шлюза — прямые вызовы `WorkoutCore` (m09): `logSet` (source="voice"), `logCommand` (уже есть), `setLastSetRpe` (§4). `VoiceInput` LocalDb не пишет (🛡 инвариант 3 module_reqs). ## 2. Executor ```kotlin class VoiceCommandExecutor( private val gateway: VoiceCoreGateway, private val feedback: VoiceFeedback, // интерфейс, спец 04 private val effects: VoiceEffects, // колбэки UI, §3 ) fun interface VoiceEffects { /** Возврат в UI: рабочий вес / старт отдыха / сообщение. */ fun onEffect(effect: VoiceEffect) } sealed interface VoiceEffect { data class WorkingWeight(val weightKg: Double) : VoiceEffect data class RestStarted(val seconds: Int) : VoiceEffect data class Message(val text: String) : VoiceEffect } ``` `handle(workoutId, alternatives, currentExerciseId)` — по каждой финальной гипотезе (спец 02): 1. `VoiceCommandParser.parse` → команда; `null` («не было команды») — выход без записи и звука; 2. **Approach(w, r)**: нет выбранного упражнения (`currentExerciseId == null`) → отклонение `no_exercise` (§5); иначе `gateway.logSet(source="voice")` → журнал `accepted=true` → эффект щелчка + TTS «Принято, подход W на R» (W — целое/«с половиной» озвучивается числом); 3. **Weight(w)**: нет упражнения → `no_exercise`; иначе эффект `WorkingWeight(w)` + журнал `accepted=true` + TTS «Принято, вес W»; 4. **Rest(s)**: эффект `RestStarted(s)` (таймер m10) + журнал `accepted=true` + TTS «Принято, отдых S секунд»; 5. **Rpe(n)**: `gateway.setLastSetRpe` → false (подходов нет) → отклонение `no_set`; true → журнал `accepted=true` + TTS «Принято, РПЭ N»; 6. **Rejected(out_of_grammar / out_of_range)**: журнал `accepted=false` (семантика парсера, спец 01 §5) + сигнал ошибки (TTS молчит — Ф-1.3 «короткий сигнал»); состояние тренировки не меняется; 7. отклонения `no_exercise` / `no_set`: журнал `accepted=false` (`{"cmd":"…","reason":"no_exercise"|"no_set"}`) + сигнал ошибки + эффект `Message("Сначала выберите упражнение")` / `Message("Подходов ещё нет")`; 8. после ЛЮБОЙ журналируемой команды (пп. 2–7 — журнал пишется и для отклонённых) — сигнал UI на `SyncWorker.schedule` (уточнение по реализации: записи журнала тоже попадают в sync_pending, будить воркер нужно всегда; расписание — компетенция UI, ядро без Android-контекста). Журнал пишется для КАЖДОЙ распознанной команды — принятой и отклонённой (🛡 Ф-1.5); `recognized_text` — исходная гипотеза (первая при Rejected), `t_offset_s` — ставит `WorkoutCore.logCommand` от начала тренировки. ## 3. Эффекты в UI (обрабатывает WorkoutViewModel, спец 05) - `WorkingWeight` → рабочий вес текущего упражнения (форма + показ); - `RestStarted` → `restLeft = seconds` (переиспользование таймера отдыха m10); - `Message` → строка статуса голоса. ## 4. WorkoutCore.setLastSetRpe — расширение m09 (решение гейта 1.3 №3) ```kotlin /** RPE последнего подхода тренировки (Ф-13.3, «РПЭ N»); false — подходов нет. */ suspend fun setLastSetRpe(workoutId: String, rpe: Int): Boolean { val set = db.workoutDao().lastSet(workoutId) ?: return false val updated = set.copy(rpe = rpe) db.workoutDao().upsertSet(updated) enqueue("workout_set", set.id, PayloadFactory.workoutSet(updated)) return true } ``` - DAO-добавление m09: `lastSet(workoutId)` — строка с max(seq); - повторная отправка той же сущности (`client_guid` = id подхода, op=upsert) — сервер идемпотентен (m02), паттерн переименования упражнения m10; - rpe из голоса 1–10 уже валидирован парсером; правка RPE вручную после голоса не блокируется (последняя запись выигрывает — upsert). ## 5. Таблица отклонений | Причина | Условие | Звук | Журнал | |---|---|---|---| | `out_of_grammar` | шаблон не совпал | сигнал ошибки | accepted=false, семантика парсера | | `out_of_range` | число вне диапазона | сигнал ошибки | accepted=false + значения | | `no_exercise` | Approach/Weight без выбранного упражнения | сигнал ошибки | accepted=false + reason | | `no_set` | Rpe без единого подхода | сигнал ошибки | accepted=false + reason | ## 6. Тесты (unit, pure JVM) 1. Executor на фейках `VoiceCoreGateway`/`VoiceFeedback`/сборщик эффектов: Approach → logSet(source=voice) + журнал accepted=true + эффектов нет; Weight → WorkingWeight + журнал; Rest → RestStarted; Rpe → setLastSetRpe=true → журнал. 2. Отклонения: Rejected → журнал accepted=false + error-сигнал, gateway не вызывался; no_exercise / no_set (фейк setLastSetRpe=false) → Message + журнал accepted=false. 3. null от парсера (не команда) — ничего не вызвалось (ни журнала, ни звука). 4. Последовательность: текст каждой гипотезы (первая распознанная команда из alternatives) попадает в recognized_text.