VEU.WathBody/1-docs/4-modules/m11-voice-input/api_specs/03-voice-command-executor.md

132 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Спецификация 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.