132 lines
7.1 KiB
Markdown
132 lines
7.1 KiB
Markdown
# Спецификация 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.
|