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

7.1 KiB
Raw Blame History

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

  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)

/** 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.