7.1 KiB
7.1 KiB
Спецификация 03: VoiceCommandExecutor и WorkoutCore.setLastSetRpe
Источники: Ф-1.2–1.5 (применение/отклонение/подтверждение/журнал), Ф-13.3 (RPE последнего подхода), инвариант m09 (единственный писатель 🛡), решение гейта 1.3 №2/№3. Единицы 4 и 8.
1. Шлюз к ядру (тестируемость без Room)
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
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):
VoiceCommandParser.parse→ команда;null(«не было команды») — выход без записи и звука;- Approach(w, r): нет выбранного упражнения (
currentExerciseId == null) → отклонениеno_exercise(§5); иначеgateway.logSet(source="voice")→ журналaccepted=true→ эффект щелчка + TTS «Принято, подход W на R» (W — целое/«с половиной» озвучивается числом); - Weight(w): нет упражнения →
no_exercise; иначе эффектWorkingWeight(w)+ журналaccepted=true+ TTS «Принято, вес W»; - Rest(s): эффект
RestStarted(s)(таймер m10) + журналaccepted=true+ TTS «Принято, отдых S секунд»; - Rpe(n):
gateway.setLastSetRpe→ false (подходов нет) → отклонениеno_set; true → журналaccepted=true+ TTS «Принято, РПЭ N»; - Rejected(out_of_grammar / out_of_range): журнал
accepted=false(семантика парсера, спец 01 §5) + сигнал ошибки (TTS молчит — Ф-1.3 «короткий сигнал»); состояние тренировки не меняется; - отклонения
no_exercise/no_set: журналaccepted=false({"cmd":"…","reason":"no_exercise"|"no_set"}) + сигнал ошибки + эффектMessage("Сначала выберите упражнение")/Message("Подходов ещё нет"); - после ЛЮБОЙ журналируемой команды (пп. 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)
/** 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)
- Executor на фейках
VoiceCoreGateway/VoiceFeedback/сборщик эффектов: Approach → logSet(source=voice) + журнал accepted=true- эффектов нет; Weight → WorkingWeight + журнал; Rest → RestStarted; Rpe → setLastSetRpe=true → журнал.
- Отклонения: Rejected → журнал accepted=false + error-сигнал, gateway не вызывался; no_exercise / no_set (фейк setLastSetRpe=false) → Message + журнал accepted=false.
- null от парсера (не команда) — ничего не вызвалось (ни журнала, ни звука).
- Последовательность: текст каждой гипотезы (первая распознанная команда из alternatives) попадает в recognized_text.