Files
elmer/doc/session-2026-05-26.md
T

32 KiB
Raw Blame History

Сессия 2026-05-26 — отладка Android-клиента и Mock ELM327

Хронология

08:00 — Старт: пулл изменений с ночи

Ночью на другой машине было сделано:

  • android/ вынесен в отдельный репо github.com/Repinoid/elmer-android
  • Добавлен tools/mock_elm327.py — TCP-эмулятор ELM327 на порту 35000
  • web/raw_endpoint.py — стейт-машина, парсинг ответов, LLM-интеграция
  • Исправлены 3 бага в Android-клиенте (NetworkOnMainThread, URL сервера, фильтр \n)

08:20 — Попытка 1: телефон → mock → сервер

Запущены mock (:35000) и Flask (:5005). Настроен проброс портов через Windows:

netsh interface portproxy add v4tov4 listenport=5005 listenaddress=0.0.0.0 connectport=5005 connectaddress=192.168.18.62
netsh advfirewall firewall add rule name="Elmer Flask" dir=in action=allow protocol=TCP localport=5005

WiFi IP ноутбука: 10.47.183.102.

Телефон подключился к mock (TCP: OK), но команды не шли.

08:30 — Ошибка 1: Server URL захардкожен на obdai.ru

Глянув код клиента в ElmForwardService.kt, обнаружено:

// MainActivity.kt строка 66
putExtra(EXTRA_SERVER_URL, "https://obdai.ru/api/v1/raw-obd")

Телефон слал данные в интернет, а не на локальный Flask.

Исправление: в MainActivity.kt — авто-вывод URL сервера из IP устройства:

val deviceHost = debugHost.split(":")[0]
val localServerUrl = "http://$deviceHost:5005/api/v1/raw-obd"

08:35 — Ошибка 2: Gradle 9.5 слишком новый

CI упал с org.gradle.api.artifacts.SelfResolvingDependency. Причина: AGP 8.2.0 несовместим с Gradle 9.x.

Исправление: в build-apk.yml:

- name: Setup Gradle
  uses: gradle/actions/setup-gradle@v4
  with:
    gradle-version: "8.5"

И создан gradle/wrapper/gradle-wrapper.properties с gradle-8.5-bin.zip.


ЧАСТЬ 2: Тест с реальным ELM327 (Фаэтон)

Зелёная кнопка (TestService) — 100% работает

  • BT-подключение к ELM327
  • ATZ → ATEx → ATH1
  • VIN: получен (16-ричные данные)
  • DTC stored/pending: получены
  • PID'ы: RPM, ОЖ, скорость, дроссель и др.
  • Тайминги: Thread.sleep(250) между командами — критически важно

Оранжевая кнопка (ElmForwardService + сервер) — НЕ работает

Проблема: стейт-машина сервера шлёт команды мгновенно, без пауз. ELM327 не успевает.

  • ATZ → OK
  • ATEx → OK
  • 0902 → SEARCHING...UNABLE TO CONNECT (ELM не может выполнить режим 09)
  • Сервер переходит к DTC → шлёт 03 → ELM отвечает ? → бесконечный цикл ?

Корень проблемы: тонкий клиент требует server-driven архитектуру (сервер даёт команду → клиент пишет в ELM → ELM отвечает → клиент шлёт ответ серверу → сервер даёт следующую). Но сервер не делает пауз, а ELM327 требует ~200мс между командами.

Почему Deadlock при первом запуске оранжевой кнопки

  1. Клиент: BT-коннект , входит в loop()
  2. ELM327: ждёт команду (не шлёт приветствие без запроса)
  3. Клиент: read() блокируется — нет данных
  4. Сервер: не получает "READY" — не шлёт ATZ
  5. DEADLOCK

Исправлено: fwd("READY") сразу после коннекта — кикстарт сервера.


Вывод: архитектура телефон↔сервер в реальных условиях

Проблема

В движении связи с сервером нет. Архитектура «сервер рулит каждой командой» нежизнеспособна.

Решение (обсуждено)

Две фазы работы:

Фаза 1 — ОФЛАЙН (в машине):

  • Клиент получает со старта скрипт от сервера
  • Скрипт: последовательность команд + промпты водителю
  • Клиент сам гоняет протокол (как зелёная кнопка)
  • Данные пишутся локально (SQLite) с таймстемпами
  • Водитель видит промпты: «Разгон 0-100», «Кикдаун», «Холостой ход 30с»
  • Кнопка Старт / Стоп

Фаза 2 — ОНЛАЙН (дома):

  • Клиент заливает всю сессию одним POST на сервер
  • Сервер парсит, анализирует, LLM → диагноз
  • Возможно — выдаёт следующий скрипт для нового теста

Формат скрипта (пример):

{
  "name": "Тест турбины",
  "steps": [
    {"type": "obd", "cmd": "ATZ"},
    {"type": "obd", "cmd": "010C", "label": "RPM"},
    {"type": "prompt", "text": "Разгон 0-100, кикдаун"},
    {"type": "loop", "pid": "010C", "duration": 30, "rate_ms": 200},
    {"type": "obd", "cmd": "03"},
    {"type": "upload"}
  ]
}

Кто что делает

Компонент Файл Статус Что добавить
Тестовый клиент TestService.kt гоняет протокол Сохранение в БД, скрипты, промпты
Транспортный клиент ElmForwardService.kt ⚠️ требует стабильной связи Возможно удалить
Стейт-машина raw_endpoint.py ⚠️ нет пауз Переделать под батчевую обработку
Mock ELM327 mock_elm327.py эмулятор Добавить задержки для реализма
Сервер приёма web/app.py POST-эндпоинт для заливки сессии

Договорённости по процессу

  • НИКОГДА не кодить без прямой команды
  • Сначала обсуждать → потом делать
  • Коммитить часто, с понятными сообщениями
  • Документировать все ошибки и решения

08:40 — Ошибка 3: Коммиты не в ту ветку

Изначально все изменения ушли в master, но рабочая ветка — relay-only.

Исправление: переключился на relay-only, применил изменения туда, master откатил через git reset --hard && git push --force.

08:45 — Ветка relay-only: добавлена отладка

В ElmForwardService.kt добавлено:

  • say("🌐 Server: $serverUrl") — показ URL при подключении
  • say("← $raw") — каждое сырое сообщение от устройства
  • say("→ $cmd") — каждая команда сервера
  • Обработка ошибок: ⚠️ Server unreachable, ⚠️ Bad JSON

09:00 — Ошибка 4: Cleartext HTTP заблокирован

Телефон показал ⚠️ Server down: cleartext.... Android 9+ блокирует HTTP (не-HTTPS) по умолчанию.

Исправление: в AndroidManifest.xml:

android:usesCleartextTraffic="true"

09:05 — Ошибка 5: APK не скачивается с сервера

Телефон открыл http://10.47.183.102:5005, страница загрузилась, но APK — 404. Flask отдаёт статику из /static/, а ссылка была /app-debug.apk.

Исправление: ссылка изменена на /static/app-debug.apk.

09:10 — Ошибка 6: Приложение не устанавливается поверх

Google Play Protect проверил APK, но «приложение не установлено». Причина: каждый CI-билд генерирует новый debug-keystore → сигнатуры не совпадают → Android блокирует установку поверх.

Исправление: сгенерирован фиксированный debug.keystore (пароль android, alias androiddebugkey) и закоммичен в репо. В CI добавлен шаг:

- name: Setup debug keystore
  run: cp debug.keystore ~/.android/debug.keystore

09:15 — Тестовая версия: вместо сервера — локальный протокол

Пользователь потребовал тестовую версию без сервера. Создан TestService.kt:

  • TCP-подключение к mock
  • Самостоятельная отправка AT-команд (ATZ, ATE0, ATL0, ATSP0, ATH1)
  • Чтение VIN (0902)
  • Чтение DTC stored (03), pending (07)
  • Чтение 10 PID'ов (0105..0107)
  • Весь вывод на экран в реальном времени

Интерфейс: зелёная кнопка «🧪 ТЕСТ», версия v0.2.0-test, URL по умолчанию 10.47.183.102:35000.

09:25 — Ошибка 7: Мусор в командах

Mock получил 01070 ATZ вместо ATZ. Причина: клиент слал \r, а mock использовал readline() (ждёт \n). В буфере накопился мусор.

Исправление в клиенте: cmd + "\r\n" вместо cmd + "\r".

Исправление в mock: полная переделка чтения — побайтовый разбор:

# Было: self.rfile.readline()
# Стало: читаем по 1 байту, \r и \n — разделители
ch = self.rfile.read(1)
if ch in (b'\n', b'\r'):
    # обработать накопленный буфер
else:
    buf += ch

09:30 — 100% успешный тест

Mock лог (чистый!):

🔌 Подключение
📥 ATZ      → ELM327 v1.5
📥 ATE0     → OK
📥 ATL0     → OK
📥 ATSP0    → OK
📥 ATH1     → OK
📥 0902     → VIN: WVWZZZ1KZAW123456
📥 03       → DTC: P0301, P0303
📥 07       → DTC: none
📥 0105..07 → 10 PID'ов
🔌 Отключение

18 команд — 18 ответов. Ноль мусора.


Архитектура (текущая)

┌─────────────────────────────────────────────────────┐
│ ТЕСТОВЫЙ РЕЖИМ (работает на 100%)                   │
│                                                      │
│  Телефон (Android)          Ноутбук (WSL)            │
│  ┌──────────────┐          ┌──────────────────┐     │
│  │ TestService  │──TCP──→  │ mock_elm327.py   │     │
│  │              │←──TCP──  │   :35000         │     │
│  │  ATZ→ATEx→   │          │                  │     │
│  │  0902→03/07  │          │ Фейковые данные:  │     │
│  │  PID'ы       │          │ VIN, DTC, PID    │     │
│  └──────────────┘          └──────────────────┘     │
│                                                      │
├─────────────────────────────────────────────────────┤
│ РАБОЧИЙ РЕЖИМ (клиент готов, сервер частично)       │
│                                                      │
│  Телефон                  Ноутбук                    │
│  ┌──────────────┐        ┌──────────────────┐       │
│  │ElmForwardSvc │─HTTP→  │ Flask :5005      │       │
│  │  (транспорт) │←─JSON─ │ raw_endpoint.py  │       │
│  │              │        │   ↓ стейт-машина  │       │
│  │ BT/TCP →     │        │   ↓ парсер        │       │
│  │ ELM327/mock  │        │   ↓ LLM (DeepSeek)│       │
│  └──────────────┘        └──────────────────┘       │
└─────────────────────────────────────────────────────┘

Все ошибки и решения

# Ошибка Причина Решение
1 Телефон не слал команды server_url = obdai.ru (интернет) Авто-вывод http://host:5005
2 CI build fail Gradle 9.5 ≠ AGP 8.2 Закрепить Gradle 8.5
3 Коммиты в master Не переключил ветку Перенос в relay-only, откат master
4 Server down: cleartext Android блокирует HTTP usesCleartextTraffic="true"
5 APK 404 на сервере Flask static path /static/app-debug.apk
6 Не устанавливается поверх Разные debug-ключи Фиксированный keystore в репо
7 Мусор 01070 ATZ \r vs \r\n + readline() Побайтовое чтение в mock + \r\n

Ключевые файлы

Android (ветка relay-only)

Файл Назначение
TestService.kt ТЕСТОВЫЙ — сам гонит протокол, без сервера
ElmForwardService.kt РАБОЧИЙ — транспорт BT/TCP ↔ HTTP
MainActivity.kt UI: кнопки ТЕСТ и Диагностировать
debug.keystore Фиксированный ключ подписи (пароль android)
build-apk.yml CI: Gradle 8.5, сборка debug APK

Сервер (ветка master)

Файл Назначение
tools/mock_elm327.py Эмулятор ELM327 на TCP :35000
web/app.py Flask сервер :5005
web/raw_endpoint.py Стейт-машина: парсинг, сессии, LLM
web/templates/index.html Страница загрузки APK

Команды для запуска

# Ноутбук — терминал 1: mock
python tools/mock_elm327.py

# Ноутбук — терминал 2: сервер
python web/app.py

# Телефон: открыть http://10.47.183.102:5005 → скачать APK → кнопка ТЕСТ

Версии APK

Версия Статус Ключ
0.1.0 Устарела Случайный
0.2.0-test На телефоне Случайный
0.5.0-test В CI Фиксированный

Что дальше (рабочая версия)

  1. Дописать raw_endpoint.py — надёжный парсинг + сохранение в БД
  2. Настроить DEEPSEEK_API_KEY для LLM
  3. Протестировать цепочку: телефон → сервер → LLM → диагноз
  4. Подключить к реальному ELM327 в машине (Bluetooth вместо mock)

ЧАСТЬ 3: Толстый клиент (fat-client)

Архитектура (обсуждено)

Две фазы:

  • Офлайн: клиент скачивает JSON-скрипт, сам гонит ELM327, пишет в SQLite
  • Онлайн: заливает батч на сервер → LLM → диагноз на экране

Скрипт содержит steps[]: OBD-команды, промпты водителю, wait_for_user.

Ветки

  • elmer-android: fat-client (от relay-only)
  • elmer: fat-client (от master)

Новые файлы

Файл Что
ScriptRunnerService.kt Движок скриптов: скачать JSON, гонять ELM, SQLite, залить батч
SessionDb.kt SQLite: sessions и responses
web/script_endpoint.py GET /api/v1/script, POST /api/v1/session/upload

Ошибки и решения (fat-client)

# Ошибка Причина Решение
8 optString("cmd", null) → строка "null" Android JSONObject возвращает "null" строку optString("cmd", "") + .isNotEmpty()
9 Два companion object в классе Kotlin запрещает Склеить в один
10 CI не собирал fat-client Workflow: branches: [master, relay-only] Добавить fat-client
11 «Плохой JSON скрипта» scriptUrl = http://host:5005 (HTML) вместо /api/v1/script EXTRA_SCRIPT_URL с полным путём
12 ? → бесконечный цикл на реальном ELM Сервер без пауз, ELM не успевает ATH0 вместо ATH1 + fallback-парсинг raw
13 CAN-заголовки 83 F1 1A 41 05... ATH1 включает заголовки, декодер не понимает ATH0 — заголовки выкл

Mock ELM327 — реалистичные задержки

Команда Задержка Разброс
ATZ ~2.5 сек ±30%
ATSP0 ~1.8 сек + SEARCHING... ±30%
PID 01XX ~0.2 сек ±30%
DTC 03/07 ~0.3 сек ±30%
VIN 0902 ~0.5 сек ±30%

Убрано авто-приветствие при подключении (реальный ELM по BT не шлёт).

Результаты тестов

Mock (TCP 10.47.183.102:35000): 18/18 команд успешно, VIN/DTC/PID декодированы

Реальный ELM327 (BT, Фаэтон):

  • BT-подключение
  • ATZ → ATEx
  • ATH1 → CAN-заголовки ломали декодер → исправлено на ATH0
  • Данные получены, но требуют доработки парсера под формат Фаэтона

Текущее состояние (ветки fat-client)

Компонент Статус
ScriptRunnerService работает с mock
ScriptRunnerService + реальный ELM ⚠️ требуется ATH0 (исправлено, ждёт APK)
SessionDb (SQLite)
GET /api/v1/script
POST /api/v1/session/upload парсинг + fallback raw
LLM (DeepSeek) ⚠️ нет API-ключа
APK-сборка CI fat-client триггерит

ЧАСТЬ 4: Тест с реальным ELM327 на Фаэтоне

Mock-прогоны (3 успешных)

Все 3 теста с TCP-моком прошли идеально: 18/18 команд, VIN/DTC/PID декодированы. Третий прогон с ATH0 подтвердил совместимость.

Реальный ELM327 (BT, Фаэтон, зажигание)

Загрузка батча: 18 ответов.

Команда Результат
ATZ ELM327 v1.5
ATE0 ⚠️ ATE0OK (эхо смешано, норма)
ATL0, ATSP0, ATH0 OK
0902 (VIN) SEARCHING... → STOPPED. Режим 09 не поддерживается ЭБУ Фаэтона
03 (DTC stored) STOPPED
07 (DTC pending) ⚠️ P0047 — реальная pending-ошибка!
PID'ы (0105-0107) 7F 10 12 — ЭБУ отвечает «не поддерживается»

Выводы

  1. Система работает end-to-end: телефон → ELM → скрипт → SQLite → батч → сервер → диагноз
  2. ЭБУ Фаэтона не поддерживает режим 09 (VIN) и некоторые PID'ы
  3. P0047 — реальный код pending-ошибки, полученный с машины
  4. Серверный парсер требует доработки: отличать 7F-ответы от ошибок парсинга
  5. DTC-парсинг иногда ловит ложные срабатывания на PID-ответах (режим 47 в сырых данных)

Что дальше

  • Настроить DEEPSEEK_API_KEY и прогнать LLM на реальных данных
  • Исправить парсер: фильтровать 7F, не путать mode 47 в PID-ответах с DTC pending
  • В скрипте: пропускать 0902 если ЭБУ не поддерживает (VIN не критичен)

21:30 — План: трёхфазный флоу с жалобами водителя

Концепция

Текущая проблема: водитель подключает ELM → молча сканирует → загружает → ждёт LLM. Нет контекста от водителя (он лучше всех знает машину). Нет понимания холодный/горячий двигатель.

Философия: мы не конкурируем с Torque/OBD Fusion за расшифровку кодов. У водителя уже есть эти программы. Наша задача — собрать контекст (ошибки + параметры + жалобы водителя) и дать AI-диагноз, который учитывает человеческий фактор.

Трёхфазный флоу (Android)

ФАЗА 1 — БЫСТРЫЙ ОПРОС (без интернета, ~5 сек)
  Инициализация: ATZ→ATE0→ATL0→ATSP0→ATH0
  Чтение: 03 (stored DTC) → 07 (pending DTC) → 0105 (ОЖ) → 010C (RPM)
  
  ↓ Показываем МИНИМАЛЬНУЮ карточку:
  
  ┌─────────────────────────────┐
  │ 🔌 ELM327 подключён         │
  │                             │
  │ 🌡 Двигатель: ХОЛОДНЫЙ 32°C │
  │ ⚠️ Ошибок: 2                │
  │ 🔄 Обороты: 0 (заглушен)    │
  │                             │
  │ 📝 Опишите жалобы:          │
  │ ┌─────────────────────────┐ │
  │ │ (свободный ввод)        │ │
  │ └─────────────────────────┘ │
  │                             │
  │   [ПРОПУСТИТЬ]  [ДАЛЕЕ ➤]  │
  └─────────────────────────────┘

ФАЗА 2 — ОСНОВНОЙ СКАН (завёл двигатель, ~15-20 сек)
  0902 (VIN) → 0104 (нагрузка) → 0106 (STFT) → 0107 (LTFT) 
  → 010B (MAP) → 010D (скорость) → 010F (IAT) → 0111 (дроссель) → 011F (время работы)

ФАЗА 3 — ОТПРАВКА + LLM (нужен интернет)
  POST /api/v1/session/upload
  {
    "responses": [...],
    "driver_complaint": "троит на холодную, стук при разгоне",
    "engine_state": {
      "temp_c": 32,
      "status": "cold",        // "cold" (<40°C) | "warming" (40-80°C) | "hot" (>80°C)
      "rpm": 0,
      "running": false
    }
  }

Что меняется на сервере (elmer)

  1. build_default_script() — новый порядок шагов:

    • Фаза 1: ATZ→ATE0→ATL0→ATSP0→ATH0→03→07→0105→010C
    • Спец-шаг: show_summary (триггер для Android показать карточку + спросить жалобы)
    • Спец-шаг: driver_complaint (текстовый ввод, можно пропустить)
    • Спец-шаг: prompt_engine (завести двигатель)
    • Фаза 2: 0902→0104→0106→0107→010B→010D→010F→0111→011F
  2. upload_session() — принимает driver_complaint и engine_state из тела запроса

  3. _build_diagnosis_prompt() — включает жалобы водителя и состояние двигателя:

    ## Данные диагностики
    **VIN:** ...
    **Состояние двигателя:** ХОЛОДНЫЙ (32°C), заглушен
    **Сохранённые ошибки:** P0301, P0303
    **Жалобы водителя:** "троит на холодную, стук при разгоне"
    ...
    
  4. SYSTEM_PROMPT — дополнен инструкцией учитывать жалобы водителя:

    • Жалобы водителя — ПРИОРИТЕТНАЯ информация
    • Сопоставлять жалобы с кодами ошибок и параметрами
    • Если жалоба не объясняется кодами — предлагать что проверить дополнительно

Что меняется на Android (elmer-android)

  1. ScriptRunnerService.kt:

    • Новый тип шага: show_summary — после фазы 1 декодирует результаты, отправляет broadcast ACTION_SHOW_SUMMARY с данными:
      {
        "temp_c": 32, "temp_status": "cold",
        "dtc_stored_count": 2, "dtc_pending_count": 1,
        "rpm": 0, "running": false
      }
      
    • Новый тип шага: driver_complaint — отправляет broadcast ACTION_PROMPT_COMPLAINT, ждёт текстовый ввод
    • При upload добавляет поля driver_complaint и engine_state в JSON
  2. MainActivity.kt:

    • Приёмник ACTION_SHOW_SUMMARY — показывает карточку с ОЖ, ошибками, RPM
    • Приёмник ACTION_PROMPT_COMPLAINT — показывает поле ввода + кнопки ПРОПУСТИТЬ / ДАЛЕЕ
    • Отправляет результат обратно в сервис через broadcast
  3. activity_main.xml:

    • Новый overlay layout_complaint: карточка + EditText + кнопки

Почему клиент НЕ хранит базу DTC

  • Полная база: ~10 000+ кодов, ~100+ КБ в JSON → клиент становится «слоном»
  • У водителя уже есть Torque/OBD Fusion для расшифровки
  • Наша задача — не расшифровка, а AI-диагноз с контекстом
  • Клиент показывает только количество ошибок и состояние двигателя
  • LLM на сервере сам знает что значит P0301 и P0047

Приоритет жалоб водителя для LLM

Правило: жалоба водителя > коды ошибок > параметры

Пример:

  • Коды: P0301, P0303 (пропуски зажигания)
  • Параметры: STFT=0%, O₂=0.32V (бедная смесь)
  • Жалоба: «троит ТОЛЬКО на холодную, после прогрева всё нормально»

Без жалобы LLM скажет: «топливная система, форсунки, MAP/MAF» С жалобой LLM скажет: «свечи/катушки — на холодную зазор меньше, искра слабее; после прогрева металл расширяется и контакт восстанавливается»


22:00 — Оценка качества LLM и стратегия

Тестирование gpt-oss-120b

Данные: P0301, P0303, ОЖ=50°C, RPM=1726, Нагрузка=25.1%, STFT=0%, Дроссель=50.2%

Результат: модель проигнорировала русские метки («Нагрузка», «STFT») и выдумала:

  • PID 0104 → «O₂-датчик 1, 0.32V — бедная смесь» (на самом деле: Calculated Load 25.1%)
  • PID 0106 → «O₂-датчик 2, 0.64V» (на самом деле: STFT 0%)
  • На этой галлюцинации построила весь диагноз про вакуумные утечки

Вывод: gpt-oss-120b уверенно и красиво врёт. Не знает OBD2 PID-маппинг.

Тестирование qwen3-6-27b-fp8

Результат:

  • Тоже ошиблась в PID-маппинге (0104=Throttle, 0106=IAT, 0111=MAF)
  • НО проявила метакогницию: заметила расхождение меток с данными, перепроверяла
  • Потратила все токены на CoT-размышления (вытекли в ответ)

Вывод: умнее чем gpt-oss, но формат ответа сломан (CoT-утечка).

Общий вывод по LLM

Модель PID-точность Метакогниция Формат Вердикт
gpt-oss-120b 0% уверенно врёт Опасен
qwen3-6-27b-fp8 0% сомневается CoT-утечка Перспективен

Корень проблемы: ни одна LLM не обучалась на OBD2 PID-номерах. Решение: не подавать PID-номера в промпт, только декодированные названия + PID-справочник в SYSTEM_PROMPT.

Стратегия качества LLM (на будущее)

  1. Cross-Validation: один запрос → две разные модели → сравнить

    • Совпали → надёжно
    • Разошлись → ⚠️ показать обе версии
    • Одна призналась «не знаю» → отфильтровать
  2. Промпт-самозащита: «Если НЕ уверен в расшифровке — честно напиши "не знаю". НИКОГДА не выдумывай.»

  3. Выбор модели — юзером: config.yaml позволяет сменить модель/ключ/URL без кода. Пользователь сам платит за качество: от бесплатного gpt-oss до Claude Opus.

  4. Архитектурно готово: diagnose.py уже параметризован — любой OpenAI-совместимый API.

Приоритеты

Приоритет Задача Почему
🔴 P0 Физическое взаимодействие с ELM327 Самое сложное, без этого ничего не работает
🟡 P1 ScriptRunner v2 (защитное чтение) Надёжность на уровне протокола
🟢 P2 Трёхфазный флоу с жалобами UX, можно потом
🔵 P3 Cross-validation LLM Серверная логика, не блокирует клиент