chore: удалён мёртвый elmer/, версия 0.35.0-dev, документы Опуса

This commit is contained in:
Repinoid
2026-05-31 17:28:53 +03:00
parent 6893ae33f5
commit dda72ccfc6
13 changed files with 808 additions and 667 deletions
+144
View File
@@ -0,0 +1,144 @@
# План правок по отчётам Опуса
> 31 мая 2026 · ветка `opus-fixes` · порядок: по критичности + зависимостям
---
## Этап 1. Сервер (`elmer/`) — 4 правки
### 1.1 🔴 `api/db.py` — WAL + закрытие соединений + request_id
**Файл**: `api/db.py`
**Строки**: класс `Database`, методы `_init_schema()`, `save_session()`
- [x] Добавить `PRAGMA journal_mode=WAL` и `busy_timeout=30000`
- [x] Добавить колонку `request_id TEXT UNIQUE` в `sessions`
- [x] Метод `close()` и контекстный менеджер (`__enter__`/`__exit__`)
- [x] `save_session()` — проверять `request_id` на дубликат, возвращать кэшированный диагноз
- [x] Индекс `idx_sessions_request_id`
### 1.2 🔴 `api/routes.py` — идемпотентность upload + /ping-llm без LLM
**Файл**: `api/routes.py`
**Строки**: `upload_session()`, `ping_llm()`
- [x] `upload_session()` — принимать `request_id` из JSON, возвращать кэш при дубликате
- [x] `upload_session()` — закрывать `db` через контекстный менеджер
- [x] `upload_session()` — не отдавать `str(e)` наружу, логировать, клиенту — обобщённый текст
- [x] `/ping-llm` — кэшировать результат на 60с, не вызывать LLM на каждый GET
### 1.3 🔴 `obd/protocol.py` — сброс буфера + не затирать ERROR
**Файл**: `obd/protocol.py`
**Строки**: `_write()`, `send()`
- [x] `_write()` — `self._ser.reset_input_buffer()` перед записью
- [x] `send()` — `if self._state == State.BUSY: self._state = State.READY` (не безусловно)
### 1.4 🟡 `brain/client.py` — таймаут из конфига + модель
**Файл**: `brain/client.py`
**Строки**: `Diagnoser.__init__()`, `Diagnoser.ask()`
- [x] `DEFAULT_MODEL` → `"gpt-oss-120b"`
- [x] `timeout` — параметр конструктора (по умолчанию 180)
- [x] Комментарии: убрать «DeepSeek»
---
## Этап 2. Сервер (`elmer/`) — улучшения (без 🔴 но важные)
### 2.1 🟡 `api/routes.py` — импорты наверх + кэш конфига
**Файл**: `api/routes.py`, `api/config.py`
- [x] Поднять импорты (`from brain.client import Diagnoser` и др.) на уровень модуля
- [x] `api/config.py` — `@lru_cache(maxsize=1)` на `load()`
### 2.2 🟡 `api/routes.py` — история /chat через роли
**Файл**: `api/routes.py`
**Строки**: `chat()`
- [x] Передавать историю как массив `messages` с ролями, а не строкой «Водитель:/Автоэксперт:»
### 2.3 🟡 `brain/client.py` — обработка ошибок LLM
**Файл**: `brain/client.py`
- [x] Различать `Timeout`, `HTTPError(429)`, `HTTPError(5xx)`, `HTTPError(4xx)`
- [x] Не отдавать детали исключения наружу
---
## Этап 3. Android (`elmer-android/`) — 6 правок
### 3.1 🔴 `ServerClient.kt` — request_id + идемпотентность
**Файл**: `app/src/main/java/ru/elmer/client/server/ServerClient.kt`
**Строки**: `uploadSession()`
- [ ] Генерировать `UUID` один раз до цикла ретраев
- [ ] Добавить `"request_id"` в JSON-тело
- [ ] Добавить заголовок `Idempotency-Key`
### 3.2 🔴 `ServerClient.kt` + `build.gradle.kts` — X-Api-Key
**Файлы**: `ServerClient.kt`, `app/build.gradle.kts`
- [ ] `build.gradle.kts` — `buildConfigField("String", "API_KEY", ...)`
- [ ] `ServerClient` — добавлять `X-Api-Key` во все запросы
- [ ] `MainActivity.sendToLlm()` и `startTest()` — тоже `X-Api-Key`
### 3.3 🔴 `ElmProtocol.kt` — не затирать ERROR + дренаж буфера
**Файл**: `app/src/main/java/ru/elmer/client/elm/ElmProtocol.kt`
**Строки**: `sendCommand()`, `write()`
- [ ] `sendCommand()` — `if (state == State.BUSY) state = State.READY`
- [ ] `write()` — `while (input.available() > 0) input.read()` перед записью
### 3.4 🔴 `SessionDb.kt` — безопасная миграция + индекс
**Файл**: `app/src/main/java/ru/elmer/client/db/SessionDb.kt`
**Строки**: `onUpgrade()`, `onCreate()`
- [ ] `onUpgrade()` — `ALTER TABLE` вместо `DROP TABLE`
- [ ] Индекс `idx_resp_session ON responses(session_id)`
### 3.5 🔴 `MainActivity.kt` — /ping-llm без LLM + двойной receiver + chatHistory
**Файл**: `app/src/main/java/ru/elmer/client/ui/MainActivity.kt`
**Строки**: `startTest()`, `sendToLlm()`, receiver-регистрация
- [ ] `startTest()` — троттлить `/ping-llm` (не чаще раза в 60с), предупреждать
- [ ] Убрать дублирующий receiver `statusReceiver` (оставить `scriptStatusReceiver`)
- [ ] `chatHistory` сохранять в `onSaveInstanceState` (JSON)
### 3.6 🔴 `ScriptRunnerService.kt` — null intent + мёртвый paused + try/finally
**Файл**: `app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt`
**Строки**: `onStartCommand()`, `executeScript()`
- [ ] `onStartCommand()` — `if (intent == null) { stopSelf(); return START_NOT_STICKY }`
- [ ] Убрать мёртвый флаг `paused` и `ACTION_RESUME` (или доделать паузу)
- [ ] `executeScript()` — `try/finally` вокруг `progress.stop()`
---
## Этап 4. Android (`elmer-android/`) — улучшения
### 4.1 🟡 `ServerClient.kt` — exponential backoff
**Файл**: `ServerClient.kt`
- [ ] `(1 shl (attempt-1)) * 1000 + Random.nextLong(0, 500)` вместо фиксированных 2000
### 4.2 🟡 `MainActivity.kt` — единый HTTP-клиент
**Файл**: `MainActivity.kt`
- [ ] `sendToLlm()` и `startTest()` перевести на OkHttp (через `ServerClient`)
### 4.3 🟡 `ScriptRunnerService.kt` — вынести хост в константу
**Файл**: `ScriptRunnerService.kt`, `MainActivity.kt`
- [ ] `obdai.ru` → `BuildConfig.SERVER_HOST` или константа
---
## Порядок выполнения
```
Этап 1 (сервер 🔴) → коммит
Этап 2 (сервер 🟡) → коммит
Этап 3 (Android 🔴) → коммит
Этап 4 (Android 🟡) → коммит
```
После каждого этапа — проверка: `python run.py` (сервер), сборка APK (Android).
+146
View File
@@ -0,0 +1,146 @@
# Вопросы к Opus 4.8 по проекту elmAI
> v0.35.0-dev, 31 мая 2026
> Сервер: Ubuntu 24, Python/Flask, gunicorn + nginx
> Android: Kotlin, minSdk 24, OkHttp
> LLM: api.aillm.ru, модель gpt-oss-120b
---
## Какие файлы смотреть (и только их)
### Сервер (elmer/)
- `obd/protocol.py` — ELM327 стейт-машина AndrOBD (State, Rsp, AdaptiveTiming)
- `brain/client.py` — Diagnoser (HTTP к LLM API)
- `brain/prompts.py` — SYSTEM_PROMPT для диагностики
- `api/routes.py` — все 5 эндпоинтов (script, upload, chat, ping, ping-llm)
- `api/db.py` — SQLite: таблица sessions (30+ полей)
- `api/scripts.py` — сборка диагностических скриптов
- `api/parser.py` — парсинг ответов ELM327
- `web/app.py` — точка входа Flask
- `doc/architecture.md` — описание архитектуры
### Android (elmer-android/)
- `script/ScriptRunnerService.kt` — сервис фоновой диагностики
- `script/ScriptEngine.kt` — движок выполнения скриптов
- `script/UploadProgress.kt` — таймер прогресса загрузки
- `server/ServerClient.kt` — HTTP-клиент (OkHttp, retry 3x)
- `elm/ElmProtocol.kt` — ELM327 стейт-машина (Kotlin)
- `elm/ObdDecoder.kt` — декодер PID/DTC/VIN
- `ui/MainActivity.kt` — главный экран
- `db/SessionDb.kt` — локальная SQLite
---
## Вопрос 1. Стейт-машина ELM327: баги и крайние случаи
**Файлы**: `obd/protocol.py`, `elm/ElmProtocol.kt`
Стейт-машина — 1:1 копия AndrOBD (ElmProt.java). Ключевые моменты:
- Байт-за-байтом чтение с 1мс поллингом
- `>` как разделитель ответов
- Адаптивный таймаут (200мс ± 4мс)
- Восстановление после BUS ERROR (ATPC → ATSP0)
Вопросы:
1. Есть ли race conditions или deadlocks в переходах состояний?
2. Что если `>` приходит НЕ после полного ответа (мусор в буфере)?
3. Корректна ли логика восстановления после BUS ERROR? Не теряем ли мы ответы при ATPC→ATSP0?
4. Достаточен ли 1мс поллинг или на некоторых ELM нужен меньше?
5. Есть ли риск бесконечного цикла в `_exec()` (10 ретраев)?
---
## Вопрос 2. HTTP 499 при upload с мобильной сети
**Файлы**: `script/ScriptRunnerService.kt`, `server/ServerClient.kt`, `api/routes.py`
**Симптом**: сервер получает POST, но клиент обрывает соединение (nginx: 499).
- Connect timeout: 30с, read: 180с, write: 60с
- 3 ретрая с задержкой 2с
- nginx: client_body_timeout 120s, proxy_read_timeout 300s
- gunicorn: timeout 180s
Вопросы:
1. Какие ещё причины HTTP 499 на мобильной сети кроме таймаутов?
2. Достаточна ли стратегия ретраев? Может, нужен exponential backoff?
3. Может ли проблема быть в отправке тела запроса (write timeout) на медленной сети?
4. Стоит ли разбивать upload на чанки или сжать JSON?
5. Корректно ли мы обрабатываем случай, когда сервер получил запрос но клиент упал — данные могут дублироваться?
---
## Вопрос 3. Архитектура: три модуля + Android пакеты
**Файлы**: `doc/architecture.md`, `web/app.py`, `api/routes.py`
Сервер разбит на `obd/`, `brain/`, `api/`. Android — на `elm/`, `server/`, `script/`, `db/`, `ui/`.
Вопросы:
1. Чистые ли границы между модулями? Нет ли неявных зависимостей?
2. `api/routes.py` делает `from brain.client import Diagnoser` внутри функций — это нормально или лучше на уровне модуля?
3. Стоит ли вынести `config.yaml` из `api/` на уровень выше?
4. `web/app.py` зависит от `api/routes.py` — это правильное направление?
5. Какие модули можно было бы легко заменить (например, `brain/` на локальный LLM)?
---
## Вопрос 4. SQL схема: таблица sessions
**Файлы**: `api/db.py`
Таблица `sessions` — 30+ колонок (IP, телефон, ELM, авто, сессия, LLM). VIN — nullable.
Также старые таблицы: `cars`, `diagnostic_tokens`, `llm_messages`, `ecu_parameters`, `dtc_codes`.
Вопросы:
1. 30+ колонок в одной таблице — это нормально для SQLite или лучше разбить?
2. raw_responses хранится как JSON TEXT — ок ли для SQLite?
3. Индексы: по `created_at`, `vin`, `elm_mac`, `android_id` — достаточны?
4. Старые таблицы (cars, dtc_codes) всё ещё создаются в `_init_schema()` но не используются. Удалять или оставить для совместимости?
5. Нет ли проблем с конкурентным доступом к SQLite из gunicorn (4 воркера)?
---
## Вопрос 5. LLM-интеграция: промпты и таймауты
**Файлы**: `brain/client.py`, `brain/prompts.py`, `api/routes.py`
- Diagnoser использует `requests.post` без streaming
- SYSTEM_PROMPT — 10 правил ответа
- Для /chat — лимит 20 строк, история диалога (последние 10 сообщений)
Вопросы:
1. Достаточен ли промпт для качественной диагностики? Чего не хватает?
2. `requests.post` без streaming при таймауте 180с — ок или лучше streaming + heartbeat?
3. Для /chat: правильно ли форматируется история диалога? Не переполнит ли контекст?
4. Модель gpt-oss-120b — адекватный выбор? Какие альтернативы для авто-диагностики?
5. Как правильно обрабатывать ошибки LLM API (rate limit, timeout, bad response)?
---
## Вопрос 6. Безопасность API
**Файлы**: `api/routes.py`, `web/app.py`
- API без аутентификации, только HTTPS через nginx
- API ключ LLM на сервере, не в APK
- `usesCleartextTraffic` убран из манифеста
Вопросы:
1. Достаточен ли HTTPS без API-ключей для MVP? Какие риски?
2. Какие минимальные меры добавить: rate limiting, API key в APK, CORS?
3. `raw_responses` пишутся в БД — есть ли риск инъекции через ответы ELM327?
4. `/api/v1/chat` без аутентификации — можно ли его абузить (спамить токенами)?
5. Нужно ли скрывать API-ключ LLM за прокси или текущая схема ок?
---
## Формат ответа
Пожалуйста, запиши ответ в файл `/home/naeel/elmer/doc/opus-review.md`.
По каждому вопросу:
- 🔴 Критическая проблема (если есть)
- 🟡 Потенциальная проблема / улучшение
- 🟢 Всё ок
- Конкретные рекомендации с примерами кода где уместно
+280
View File
@@ -0,0 +1,280 @@
# Ответ Opus 4.8 — ревью elmer-android
> v0.35.0-dev, 31 мая 2026
> Проверены файлы: ElmProtocol.kt, ObdDecoder.kt, ScriptRunnerService.kt, ScriptEngine.kt,
> ServerClient.kt, SessionDb.kt, MainActivity.kt, UploadProgress.kt, AndroidManifest.xml
---
## Вопрос 1. Стейт-машина ElmProtocol.kt
### 1.1 `startsWith("ERROR") && !startsWith("DATA ERROR")`
🟡 **Потенциальная проблема.** `handle()` работает с уже распарсенными строками-ответами, а не с PID-именами, поэтому коллизии с «ERROR_xxx» в данных нет — декодирование имён происходит позже в `ObdDecoder`. НО: реальные ELM-ошибки не всегда начинаются с `ERROR`. Например `?` (неизвестная команда), `UNABLE TO CONNECT` (ловится в `isBusError`), `<RX ERROR` (с префиксом `<`). Строка `<DATA ERROR` из-за лидирующего `<` **не** сматчится `startsWith("DATA ERROR")`. ELM327 при ошибке кадра иногда шлёт `<` перед сообщением.
Рекомендация — нормализовать перед классификацией:
```kotlin
val u = raw.uppercase().trim().trimStart('<', '>').trim()
```
### 1.2 `sendCommand()` безусловно ставит READY после exec()
🔴 **Критично — маскирование ошибки.**
```kotlin
fun sendCommand(cmd: String): String {
if (state == State.ERROR) recover()
state = State.BUSY
val result = exec(cmd, timeoutMs) // exec может выставить State.ERROR/DISCONNECTED
state = State.READY // ← затирает ошибку
return result
}
```
`exec()` при исчерпании ретраев ставит `state = State.ERROR`, а `handle()` — `ERROR`/`DISCONNECTED`. Следующая строка безусловно перетирает это на `READY`. Ошибка «теряется» до следующего вызова. В AndrOBD состояние не сбрасывается слепо.
Рекомендация:
```kotlin
val result = exec(cmd, timeoutMs)
if (state == State.BUSY) state = State.READY // только если не было ошибки
return result
```
### 1.3 `init()` не проверяет результат AT-команд
🟡 **Поведение AndrOBD, но рискованное.** AndrOBD действительно прогоняет init-цепочку «оптимистично», полагаясь на то, что первые реальные OBD-команды отловят BUS ERROR. Для MVP допустимо, но `ATSP0` (выбор протокола) стоит проверять — если адаптер вернул `?`, дальнейшие команды бессмысленны. Минимум — логировать ответ и считать в `errorCount`.
### 1.4 Нет сброса input-буфера перед write()
🟡 **Риск десинхронизации есть.** В `read()` чтение идёт до `>` (prompt), но если предыдущая команда оставила хвост в буфере (например после таймаута пришёл запоздалый ответ), он прилипнет к следующему чтению. Рекомендация — дренировать буфер перед записью:
```kotlin
private fun write(cmd: String) {
while (input.available() > 0) input.read() // drain stale bytes
output.write((cmd + "\r").toByteArray())
output.flush()
}
```
### 1.5 `BUFFER FULL` → warm start
🟡 **Спорно.** В AndrOBD `BUFFER FULL` — это переполнение буфера ELM при большом ответе, лечится **повторным запросом**, а не полным `ATWS` (warm start сбрасывает протокол и теряет адаптацию таймингов). Здесь `BUFFER FULL` попадает в `isDataError` → `ATWS`, что излишне тяжело. Лучше выделить:
```kotlin
u.startsWith("BUFFER FULL") -> { increaseTimeout() /* retry */ }
```
---
## Вопрос 2. ScriptRunnerService — жизненный цикл
### 2.1 `START_STICKY` + null intent
🔴 **Падение при пересоздании.** При рестарте системой `onStartCommand` получает `intent == null`. Сейчас `when (intent?.action)` отрабатывает в `else`-ветку (ничего не делает) и возвращает `START_STICKY` — краша нет, но сервис висит в foreground без работы и без уведомления о реальной задаче. Лучше:
```kotlin
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) { stopSelf(); return START_NOT_STICKY }
...
}
```
Для разовой диагностики вообще логичнее `START_NOT_STICKY` — нет смысла воскрешать прерванную сессию.
### 2.2 Демон-поток `ScriptRunner`
🟡 Демон-поток живёт пока жив процесс. Если Activity убита, а сервис foreground — процесс жив, поток работает. Но при нехватке памяти система может убить весь процесс (вместе с потоком) несмотря на foreground. Это нормально для разовой задачи. Замечание: исключения внутри потока никуда не пробрасываются — добавьте `try/catch` обёртку с `errorDone()`.
### 2.3 / 2.4 `btSocket` и `onDestroy()`
🟢 **Уже закрывается.** `onDestroy()` вызывает `disconnect()`, который закрывает `btSocket` и снимает foreground. Утечки сокета нет. ✅
### 2.5 Флаг `paused`
🔴 **Мёртвый код / недоделанная фича.** `paused` выставляется в `false` по `ACTION_RESUME`, но **нигде не проверяется** — ни в `ScriptEngine.run()`, ни в `executeScript()`. Механизм паузы «водитель ответил» (broadcast `BROADCAST_PROMPT`, `scriptPromptReceiver` в UI) фактически не реализован на стороне движка. Либо удалить флаг и UI-приёмник промптов, либо доделать: `ScriptEngine` должен уметь блокироваться на шаге до сброса `paused`.
---
## Вопрос 3. ServerClient — ретраи и идемпотентность
### 3.1 Повторное использование тела запроса на ретраях
🟢 **Работает корректно.** Тело создано через `String.toRequestBody(...)` — это `RequestBody` поверх неизменяемой строки. В OkHttp 4.x такой `RequestBody` **stateless**: `writeTo()` вызывается заново на каждой попытке и пишет ту же строку. Пустого тела на 2-3 ретрае **не будет**. (Проблема была бы только с одноразовым стримом, например `InputStream.source()`.)
### 3.2 Exponential backoff
🟡 Для мобильной сети фиксированные 2с приемлемы, но джиттер + рост лучше против «retry storm»:
```kotlin
if (attempt < 3) Thread.sleep(1000L * (1 shl (attempt - 1)) + Random.nextLong(0, 500))
```
### 3.3 `downloadScript()` без ретраев
🟢 Это сознательный и правильный выбор: есть качественный `DEFAULT_SCRIPT` fallback, поэтому мгновенный переход к нему при оффлайне — корректное поведение. 1 ретрай можно добавить, но не критично.
### 3.4 gzip на upload
🟡 При `count * 200` байт типичный батч < 5 KB — выигрыш от gzip минимален, а overhead на сжатие/совместимость с nginx добавляет риск. Не нужно для MVP.
### 3.5 Порядок `.string()` / `.close()`
🟢 **Корректно.** `val body = resp.body?.string()` сначала читает (и закрывает поток тела), затем `resp.close()`. Порядок верный, двойного закрытия нет. ✅
### 3.6 Идемпотентность / `request_id`
🔴 **Критично (подтверждаю отчёт Q2).** При 499/таймауте и ретрае сервер создаёт дубликат сессии и повторно тратит LLM-токен. Клиент должен генерировать UUID **один раз до цикла ретраев** и слать его в теле:
```kotlin
fun uploadSession(...): JSONObject? {
val requestId = java.util.UUID.randomUUID().toString() // один на все 3 попытки
val json = JSONObject().apply {
put("request_id", requestId)
put("session_id", sessionId)
...
}
val req = Request.Builder()
.url("$serverUrl/api/v1/session/upload")
.header("Idempotency-Key", requestId)
.post(json.toString().toRequestBody("application/json".toMediaType()))
.build()
...
}
```
На сервере (`save_session()`): UNIQUE-индекс по `request_id`, при повторе — вернуть **сохранённый** результат (включая готовый диагноз), не вызывая LLM повторно:
```python
existing = db.execute("SELECT diagnosis FROM sessions WHERE request_id=?", [rid]).fetchone()
if existing:
return jsonify(diagnosis=existing["diagnosis"], llm_success=True, cached=True)
```
---
## Вопрос 4. ObdDecoder — корректность декодирования
### 4.1 VIN с пробелами
🟢 **Корректно.** `replace(" ", "")` снимает пробелы до проверки `"490201" in clean`, плюс убраны `:` (ISO-TP индикаторы кадров `0:`, `1:`...). Работает и для multi-frame. ✅
### 4.2 `decodeDtc()` начинает с `i = 2`
🟡 **Не всегда верно.** `hex = clean.substring(2)` снимает байт режима (`43`), затем `i = 2` снимает **байт count** (число DTC). Это корректно для классического формата `43 NN <dtc>...`. Но:
- Multi-frame CAN ISO-TP: ответ может содержать байты длины PCI (`007`, `10 0E`...), которые здесь **не вычищены** (убраны только пробелы и `:`). Тогда `i=2` указывает не на тот байт.
- Некоторые адаптеры на mode 03 не шлют байт count вовсе.
Для надёжности стоит парсить DTC по парам байт от конца режима и отбрасывать `0000`, что код уже делает (фильтр `P0000`). Главный риск — невычищенные PCI-заголовки multi-frame. Для коротких ответов (1-2 DTC, single frame) работает.
### 4.3 PID `0100` (4 байта supported)
🟡 `decodePid()` читает только `b0, b1`. PID `00/20/40...` (битовые маски supported PIDs, 4 байта) не входят в `pidValue()` → вернётся `"PID 00: raw"`. Поскольку скрипт их не запрашивает — не баг сейчас, но при расширении скрипта декодер их не покажет.
### 4.4 Только 10 PID
🟢 **Ок для MVP.** Скрипт `DEFAULT_SCRIPT` запрашивает ровно эти PID. Для неподдерживаемых — `"PID $pid: raw"`, сырьё всё равно уходит на сервер и в LLM. Расширять по мере надобности.
### 4.5 STFT/LTFT формула
🟢 Формула `(A - 128) * 100 / 128` верна по SAE J1979. Для PID 06/07 это однобайтовые значения (банк 1), `b1` игнорируется правильно. ✅ (Замечание: PID 06/07 — это банк 1 short/long; банки 2 — это 08/09, в скрипте их нет.)
---
## Вопрос 5. SessionDb — схема и доступ
### 5.1 `onUpgrade()` DROP TABLE
🔴 **Потеря данных при апдейте.** Любое повышение `DB_VERSION` сотрёт всю историю пользователя. Для продакшена недопустимо. Минимальная безопасная миграция:
```kotlin
override fun onUpgrade(db: SQLiteDatabase, oldV: Int, newV: Int) {
if (oldV < 2) db.execSQL("ALTER TABLE sessions ADD COLUMN server_url TEXT")
// будущие версии — ALTER, не DROP
}
```
### 5.2 Без явного закрытия соединений
🟢 **Ок.** `SQLiteOpenHelper` кэширует одно соединение на хелпер; курсоры закрываются (`cursor.close()`). Не закрывать сам `db` — правильно. ⚠️ Замечание: `SessionDb` создаётся и в сервисе, и в `MainActivity.showHistory()` — два хелпера на одну БД. Лучше один экземпляр (синглтон), иначе при одновременном write возможен `SQLiteDatabaseLockedException`.
### 5.3 `getPendingSessions()` — мёртвый код
🟡 Метод нигде не вызывается. Это задел под «дослать неотправленные сессии при следующем запуске», но фича не реализована. Либо удалить, либо доделать ретрай-аплоад оффлайн-сессий в `onCreate` сервиса.
### 5.4 `created_at` INTEGER vs сервер TEXT
🟡 Несогласованность форматов. На клиенте unix-секунды, на сервере ISO 8601. При синхронизации сервер должен конвертировать. Лучше слать с клиента ISO-8601 (или явно `unix_ts` с понятным именем) в `client_info`/`responses`, чтобы не было путаницы с часовыми поясами. Сейчас `timestamp` ответов уходит как строка unix-секунд — сервер должен это знать.
### 5.5 Индексы
🟡 `WHERE session_id = ?` в `getResponses()` без индекса — full scan. При сотнях ответов на сессию заметно. Добавьте:
```kotlin
db.execSQL("CREATE INDEX idx_resp_session ON responses(session_id)")
```
---
## Вопрос 6. MainActivity — чат и UI
### 6.1 `chatHistory` теряется при повороте
🔴 `onSaveInstanceState` сохраняет только `status_text`, `chatHistory` живёт в поле Activity → при повороте/пересоздании теряется, и LLM теряет контекст диалога. Варианты: сохранить в Bundle (сериализовать в JSON), либо вынести в `ViewModel` (`SavedStateHandle`). Минимум:
```kotlin
outState.putString("chat", JSONArray(chatHistory.map { ... }).toString())
```
### 6.2 Чат на `HttpURLConnection` вместо OkHttp
🟡 Дублирование HTTP-логики и таймаутов. `ServerClient` уже инкапсулирует OkHttp — `sendToLlm()` и `startTest()` стоит перевести на него (общие таймауты, ретраи, будущий `X-Api-Key`). Сейчас три места шлют HTTP по-разному.
### 6.3 `startTest()` дёргает `/ping-llm` (платный токен)
🔴 **Расход денег на каждом «Тест».** `/ping-llm` делает реальный LLM-запрос. Кнопку «Тест» пользователь может жать многократно. Варианты: на сервере сделать `/ping-llm` дешёвой проверкой доступности (HEAD к API провайдера / кэш на 60с), либо на клиенте троттлить (не чаще раза в N минут) и предупреждать.
### 6.4 Двойная регистрация receiver
🟡 `scriptStatusReceiver`/`scriptStageReceiver` защищены флагом `scriptRegistered` — двойной регистрации этих двух нет. НО: `statusReceiver` (отдельный, для `BROADCAST_STATUS`) регистрируется в `onCreate` **и** `scriptStatusReceiver` тоже слушает `BROADCAST_STATUS` — два приёмника на один экшен → **каждое сообщение `log()` обработается дважды** (дублирование строк в UI). Также `scriptPromptReceiver` регистрируется... — на самом деле **нигде не регистрируется**, только разрегистрируется в `onDestroy`. Промпты не приходят (связано с мёртвым `paused`, Q2.5).
Рекомендация: оставить один приёмник на `BROADCAST_STATUS`.
### 6.5 `btnClose` не чистит `chatHistory` и не стопит сервис
🟡 Кнопка ✕ только прячет UI и пишет «Готов». Если сервис ещё работает — он продолжит и пришлёт новые статусы поверх. Для «закрыть» логично слать `ACTION_STOP` в сервис. `chatHistory` чистить не обязательно (диалог отдельный от диагностики), но сервис стоит остановить.
---
## Вопрос 7. UploadProgress — таймер и батарея
### 7.1 Broadcast каждую секунду до 180с
🟡 Незначительно для батареи (≤180 broadcast на сессию), но это локальный `sendBroadcast` с `setPackage` — дёшево. Не проблема.
### 7.2 Поток висит при исключении
🟡 **Реальный риск.** В `executeScript()` `progress.start()` → `uploadSession()` → `progress.stop()`. Если `uploadSession()` бросит непойманное исключение, `stop()` не вызовется и `UploadTimer` останется крутиться (демон, до смерти процесса). Оберните в `try/finally`:
```kotlin
val progress = UploadProgress(...); progress.start()
val resp = try { client.uploadSession(...) } finally { progress.stop() }
```
### 7.3 `Handler.postDelayed` вместо потока
🟢 Можно, но текущий вариант с `AtomicBoolean` + демон-поток корректен и проще. Не критично. Главное — гарантировать `stop()` (см. 7.2).
---
## Вопрос 8. Общая архитектура
### 8.1 MainActivity знает про Service и SessionDb
🟡 Нарушение SRP есть, но для MVP с одним экраном терпимо. При росте — вынести историю в `Repository`, а UI-логику в `ViewModel`.
### 8.2 ElmProtocol замокать для тестов
🟡 `ScriptEngine` отлично тестируется (lambdas) — это сильная сторона. `ElmProtocol` жёстко завязан на `InputStream/OutputStream`, но это **тестируемо**: подайте `ByteArrayInputStream`/`ByteArrayOutputStream` с заскриптованными ответами ELM. Интерфейс выделять не нужно, потоки — уже абстракция. Рекомендую написать unit-тест на `handle()`-классификацию и таймаут-адаптацию.
### 8.3 Нет ViewModel/DI/Navigation
🟢 Для MVP с одной кнопкой — ок. ViewModel стоит ввести первым (решает 6.1, 6.4). DI/Navigation — преждевременно.
### 8.4 minSdk 24 + BluetoothAdapter.getDefaultAdapter
🟡 `getDefaultAdapter()` deprecated с API 31, но работает на 24+. `createRfcommSocketToServiceRecord` + reflection-fallback `createRfcommSocket(1)` — стандартный надёжный приём для китайских ELM327, покрывает большинство устройств. Замечание: на Android 12+ (API 31) для `connect()` нужен рантайм-`BLUETOOTH_CONNECT` — в манифесте он есть, проверьте что он реально запрашивается в рантайме (в показанном коде `MainActivity` запрос пермишенов есть в константах, но самого `requestPermissions` в прочитанном фрагменте не видно — убедитесь, что вызывается).
### 8.5 `usesCleartextTraffic` не объявлен
🟢 По умолчанию `false` на API 28+, все запросы на `https://obdai.ru` — ок. ✅ Замечание: жёстко зашитый хост `obdai.ru` в нескольких местах (Service, MainActivity) — вынесите в `BuildConfig`/константу.
### 8.6 Эндпоинты без аутентификации (X-Api-Key)
🔴 **Критично (подтверждаю отчёт Q6).** `/chat`, `/upload`, `/ping-llm` открыты → любой может тратить ваши LLM-токены.
Статический ключ в APK **извлекаем** (reverse engineering), поэтому он защищает только от случайных/ленивых злоупотреблений, не от целевой атаки. Для MVP это разумный первый рубеж:
```kotlin
// BuildConfig.API_KEY из gradle (не в git, через local.properties / CI secret)
val req = Request.Builder()
.url(...)
.header("X-Api-Key", BuildConfig.API_KEY)
.post(...)
.build()
```
build.gradle.kts:
```kotlin
buildConfigField("String", "API_KEY", "\"${project.findProperty("ELMER_API_KEY") ?: ""}\"")
```
Сервер — отклонять без верного `X-Api-Key` (401) + **rate-limit по IP/ключу** + квота на LLM. Для серьёзной защиты позже: подпись запроса (HMAC от тела + nonce + timestamp), либо Play Integrity API / device attestation. Но для MVP: `X-Api-Key` + rate-limit + серверная квота на LLM — достаточный минимум, при этом главную защиту денег даёт именно **серверный лимит**, а не ключ.
---
## Сводка приоритетов
🔴 **Чинить сейчас:**
1. `request_id`/идемпотентность upload (Q3.6) — дубли сессий и двойной расход LLM.
2. `X-Api-Key` + серверный rate-limit/квота (Q8.6) — открытые платные эндпоинты.
3. `sendCommand()` маскирует ERROR-состояние (Q1.2).
4. `onUpgrade()` DROP TABLE — потеря истории (Q5.1).
5. `/ping-llm` тратит токен на каждом «Тест» (Q6.3).
6. Двойной приёмник `BROADCAST_STATUS` → дублирование строк (Q6.4).
🟡 **Желательно:**
- `paused` — мёртвый код / недоделанная пауза (Q2.5, Q6.4-prompt).
- `try/finally` вокруг `UploadProgress` (Q7.2).
- Дренаж BT-буфера перед write (Q1.4).
- `chatHistory` в onSaveInstanceState/ViewModel (Q6.1).
- Индекс `responses(session_id)` (Q5.5).
- Единый HTTP-клиент (OkHttp) для чата/теста (Q6.2).
- null-intent guard в onStartCommand (Q2.1).
🟢 **Хорошо как есть:** закрытие сокета (2.3), повторное тело OkHttp (3.1), порядок string/close (3.5), VIN-декод (4.1), STFT/LTFT (4.5), cleartext off (8.5), fallback-скрипт без ретраев (3.3).
+236
View File
@@ -0,0 +1,236 @@
# Ревью проекта elmAI (ответы на opus-questions.md)
> Ревьювер: Opus 4.8 · 31 мая 2026 · v0.35.0-dev
> Разбор по коду: `obd/protocol.py`, `brain/client.py`, `brain/prompts.py`, `api/routes.py`, `api/db.py`, `api/parser.py`, `web/app.py`.
> Android-модуль (`elmer-android/`) в workspace отсутствует — по нему выводы на основе описаний в вопросах.
---
## Вопрос 1. Стейт-машина ELM327: баги и крайние случаи
### 🔴 Стартовый буфер не сбрасывается перед командой → десинхронизация
В `_exec()` сразу идёт `_write(cmd)` без очистки входного буфера. Если предыдущая команда отвалилась по таймауту, в ОС-буфере остаются «хвосты» (часть ответа, поздний `>`). Следующий `_read()` прочитает этот мусор как ответ на новую команду и классифицирует его неверно — классическая рассинхронизация ELM327.
```python
def _write(self, cmd: str):
self._ser.reset_input_buffer() # сбросить хвосты предыдущего ответа
self._ser.write((cmd + "\r").encode())
self._ser.flush()
logger.debug(f"AndrOBD → {cmd}")
```
### 🔴 Состояние `DISCONNECTED`/`ERROR` затирается в `send()`
`send()` проверяет только `State.ERROR` перед `_recover()`. Но BUS ERROR в `_handle()` ставит `DISCONNECTED`, а в конце `send()` безусловно пишет `self._state = State.READY`. То есть после фатальной ошибки шины машина всё равно объявляется READY, и накопленный сбой маскируется.
```python
def send(self, cmd: str) -> str:
if self._state in (State.ERROR, State.DISCONNECTED):
self._recover()
self._state = State.BUSY
result = self._exec(cmd, self._timing.ms)
# НЕ ставить READY безусловно — _exec мог уйти в ERROR
if self._state == State.BUSY:
self._state = State.READY
return result
```
### 🟡 `>` посреди мусора (вопрос 1.2)
`_read()` возвращает всё накопленное до первого `>`. Если ELM прислал `SEARCHING...` затем данные затем `>`, всё склеится в одну строку через `\n`, а `Rsp.identify()` смотрит только на начало (`startswith`) — реальные данные после `SEARCHING` будут потеряны/неверно классифицированы. AndrOBD обрабатывает каждую строку отдельно. Рекомендация: классифицировать построчно, а не всю склейку.
### 🟢 Бесконечный цикл в `_exec()` (вопрос 1.5)
Цикл жёстко ограничен `range(10)`, по выходу — `State.ERROR` и `return ""`. Бесконечного цикла нет. Но обратите внимание: при инициализации шаг `t += 1000` за 10 итераций даёт суммарно до ~55с ожидания на одну команду — для `INIT_TMO=10000` это может неприятно затянуть `init()`.
### 🟡 Восстановление после BUS ERROR (вопрос 1.3)
Логика `ATPC → ATSP0` корректна по сути, но ответы на них читаются `_try_read()` и **молча выбрасываются**. Если `ATSP0` не подтвердился (ELM завис), машина об этом не узнает и пойдёт слать команды в неинициализированный протокол. Желательно проверять, что на `ATSP0` пришёл `OK`/`>`, иначе — полный reset (`ATZ`).
### 🟡 Поллинг 1мс (вопрос 1.4)
1мс `time.sleep` в Python реально даёт ~1–15мс из-за гранулярности планировщика — на практике это не вредит (ELM медленнее), но и «честных» 1мс там нет. На быстрых ELM327 v1.5/v2.1 это не узкое место; узкое место — таймаут адаптива, а не поллинг. Менять не нужно.
### Race conditions
В Python-версии всё однопоточное — гонок нет, **пока** один экземпляр `AndrOBD` не шарится между потоками. Если планируется параллельный доступ — добавьте `threading.Lock` вокруг `send()`.
---
## Вопрос 2. HTTP 499 при upload с мобильной сети
### 🔴 Нет идемпотентности → дубликаты при ретрае (вопрос 2.5)
Это главная проблема. Сценарий 499: сервер **уже принял и обработал** запрос (LLM-анализ 30–120с), но клиент отвалился по read timeout и шлёт ретрай. Результат — вторая полная LLM-сессия и **вторая запись в `sessions`**. `upload_session()` не имеет ключа идемпотентности.
Решение — клиент генерирует `request_id` (UUID), сервер кэширует результат:
```python
data = request.get_json(silent=True)
req_id = data.get("request_id")
if req_id:
cached = db.get_session_by_request_id(req_id) # + колонка request_id UNIQUE
if cached:
return jsonify(cached["response_json"]), 200
```
### 🟡 Стратегия ретраев — нужен backoff и идемпотентность
3 ретрая с фиксированной задержкой 2с на мобильной сети мало помогают: если причина — долгий LLM-ответ (>read timeout 180с), то все 3 попытки упрутся в тот же таймаут и каждая запустит новый LLM-прогон. Рекомендация: exponential backoff (2/4/8с + jitter) **и** обязательно идемпотентность (см. выше), иначе ретраи только множат нагрузку.
### 🟡 Корень 499 — рассинхрон таймаутов клиент/сервер (вопрос 2.1)
Клиентский read 180с ≈ gunicorn timeout 180с. При длинном ответе LLM (`Diagnoser.timeout=120`, но сам upload может суммарно дольше) клиент рвёт соединение ровно в момент, когда сервер ещё пишет ответ. Прочие частые причины 499 на мобильной: смена сети Wi-Fi↔LTE (новый IP, старый сокет мёртв), NAT-таймаут оператора (часто 30–60с тишины), Doze/засыпание приложения. Рекомендация: клиентский read timeout должен быть **строго больше** серверного (например, 240с против gunicorn 180с), а сервер — отвечать быстрее (streaming, см. ниже).
### 🟡 Write timeout на медленной сети (вопрос 2.3)
Да, при толстом батче (`raw_responses` целиком) и слабом upload на LTE write timeout 60с реально достижим. Тело JSON со всеми сырыми ответами может быть десятки–сотни КБ.
### 🟡 Чанки/сжатие (вопрос 2.4)
Чанкинг избыточен для типичного объёма, а вот **gzip тела** даст быстрый выигрыш (JSON сжимается в 5–10 раз) и снимет риск write timeout:
```kotlin
// OkHttp: добавить gzip-обёртку RequestBody + заголовок
.header("Content-Encoding", "gzip")
```
Сервер: nginx сам разожмёт при наличии `gunzip`/decompression, либо Flask с `request.get_data()` + `gzip.decompress`. Это дешевле, чем переписывать на чанки.
### Главная архитектурная рекомендация
Разделите «приём данных» и «LLM-анализ». Эндпоинт должен **быстро** (1–2с) принять батч, сохранить, вернуть `session_id`, а диагноз отдавать отдельным polling-эндпоинтом (`GET /api/v1/session/<id>/result`) или через streaming. Тогда 499 из-за долгого LLM исчезнет как класс.
---
## Вопрос 3. Архитектура: три модуля + Android пакеты
### 🟢 Границы модулей в целом чистые
`obd/` ничего не знает про `brain/` и `api/`; `brain/` — изолированный LLM-клиент; `api/` оркестрирует. Направление зависимостей `web → api → brain/obd` корректное (вопрос 3.4 — да, правильное).
### 🟡 Импорты внутри функций (вопрос 3.2)
В `routes.py` все `from brain.client import Diagnoser`, `from api.db import Database`, `from api.config import load` сделаны внутри обработчиков. Это не «нормально», а компромисс — обычно так лечат циклические импорты или ускоряют старт. Минусы: `load()` читает конфиг с диска **на каждый запрос**, импорт-резолвинг повторяется. Рекомендация: поднять импорты на уровень модуля, а конфиг закэшировать:
```python
# api/config.py
from functools import lru_cache
@lru_cache(maxsize=1)
def load(): ...
```
Если поднятие импортов ломает цикл — это сигнал, что цикл надо разорвать явно, а не прятать.
### 🟡 `config.yaml` (вопрос 3.3)
Конфиг сейчас грузится через `api/config.py`. Держать `config.yaml` в корне проекта (рядом с `web/app.py`) логичнее — он общий для `api/`, `brain/`, `obd/`, а не принадлежит только `api/`. Вынесите на верхний уровень, путь резолвьте от корня.
### 🟢 Заменяемость модулей (вопрос 3.5)
`brain/` заменяется на локальный LLM тривиально — он зависит только от OpenAI-совместимого HTTP (`/chat/completions`). Достаточно сменить `base_url`/`model` в конфиге; код менять не нужно. `obd/` тоже изолирован. Это хороший знак для дизайна.
---
## Вопрос 4. SQL-схема: таблица sessions
### 🔴 Утечка соединений + конкурентный доступ (вопрос 4.5)
`Database()` создаётся в каждом запросе, открывает `sqlite3.connect(...)` и **никогда не закрывается** — connection leak. При 4 gunicorn-воркерах одновременные записи в один файл дают `database is locked` (SQLite по умолчанию: 1 писатель, нет ожидания). Минимум:
```python
self.conn = sqlite3.connect(str(self.path), timeout=30, check_same_thread=False)
self.conn.execute("PRAGMA journal_mode=WAL") # параллельные читатели + 1 писатель
self.conn.execute("PRAGMA busy_timeout=30000")
```
И закрывать соединение (контекстный менеджер / `try/finally` / `db.close()`), либо держать один пул на воркер. WAL критичен для multi-worker.
### 🟡 30+ колонок в одной таблице (вопрос 4.1)
Для SQLite это **нормально** (лимит 2000 колонок), денормализация под аналитику оправдана. Но смешаны три логических домена: телефон, ELM, LLM. Это не баг, а запах. Пока таблица аналитическая (одна запись = одна сессия) — оставьте; если начнёте часто менять набор полей телефона/ELM — выносите в отдельные таблицы или JSON-колонку.
### 🟢 raw_responses как JSON TEXT (вопрос 4.2)
Ок для SQLite. При необходимости запросов внутрь — используйте `json_extract()` (есть в SQLite ≥3.38). Менять не нужно.
### 🟡 Индексы (вопрос 4.3)
`created_at`, `vin`, `elm_mac`, `android_id` — разумный набор. Но `vin` nullable и часто NULL — индекс будет «разреженным», это норм. Добавьте составной `(android_id, created_at)` если будете строить историю по устройству — иначе текущих достаточно.
### 🟡 Мёртвые таблицы (вопрос 4.4)
`cars`, `diagnostic_tokens`, `llm_messages`, `ecu_parameters`, `dtc_codes` создаются в `_init_schema()`, имеют методы-обёртки в `db.py`, но в текущем пути `upload`/`chat` **не используются**. Это «второй контур», который вводит в заблуждение (например, история диалога в `/chat` идёт из клиента, а не из `llm_messages`). Решение: либо подключите их (тогда `/chat` сможет хранить историю на сервере по VIN), либо удалите вместе с методами. Сейчас они — технический долг и риск рассинхрона схемы.
### 🟢 Инъекции
Все запросы параметризованы (`?`), SQL-инъекций нет.
---
## Вопрос 5. LLM-интеграция: промпты и таймауты
### 🔴 Рассинхрон модели и таймаута в коде
- `brain/client.py`: `DEFAULT_MODEL = "gpt-oss-20b"`, а конфиг/доки — `gpt-oss-120b`. Дефолт-fallback тихо подменит модель, если конфиг недокинул `model`.
- `Diagnoser.ask(... timeout=120)`, но в вопросе и nginx/gunicorn заявлено 180с. Таймаут захардкожен и не берётся из конфига.
- Докстринги и комментарии говорят «DeepSeek», хотя API — `api.aillm.ru` / gpt-oss. Чисто косметика, но путает.
```python
def __init__(self, api_key, model="gpt-oss-120b", base_url=DEFAULT_BASE, timeout=180):
...
self.timeout = timeout
def ask(self, messages):
resp = requests.post(..., timeout=self.timeout)
```
### 🟡 Нет streaming + heartbeat (вопрос 5.2)
`requests.post` без `stream=True` на 120–180с — это «чёрный ящик»: клиент не видит прогресса и рвёт по таймауту (см. Вопрос 2). Для длинной генерации лучше streaming (SSE) с проксированием токенов клиенту — тогда соединение «живое», NAT не закрывает, 499 пропадает. Минимум — heartbeat-байты каждые N секунд.
### 🟡 История диалога в /chat (вопрос 5.3)
История склеивается в **один user-prompt** строкой («Водитель: …/Автоэксперт: …»), а не передаётся как полноценный массив `messages` с ролями. Модель хуже держит контекст, и при длинной истории (даже срезанной до 10) промпт может раздуться. Лучше передавать историю настоящими `role: user/assistant` сообщениями (метод `diagnose` это уже умеет через `history`!) и считать токены, а не сообщения:
```python
hist_msgs = [{"role": m["role"], "content": m["content"]} for m in history[-10:]]
answer = diagnoser.diagnose(SYSTEM_CHAT, question, history=hist_msgs)
```
Переполнения контекста сейчас никто не контролирует — добавьте бюджет по токенам.
### 🟡 Обработка ошибок LLM (вопрос 5.5)
Сейчас один общий `except Exception` → строка «LLM недоступен: {e}». Нет различия rate limit (429, нужен retry-after), timeout (нужен ретрай), 5xx (ретрай) vs 4xx (не ретраить). И текст исключения уходит **прямо в ответ пользователю** — может протечь URL/детали. Разделите коды:
```python
try:
...
except requests.Timeout: # ретрай
except requests.HTTPError as e:
if e.response.status_code == 429: ... # backoff по Retry-After
```
### 🟢 Промпт для диагностики (вопрос 5.1)
`SYSTEM_PROMPT` сильный: 10 правил, явный формат с таблицами, проценты уверенности, «проверь перед заменой», секция «если не поможет». Это хорошо. Чего не хватает: (1) данных об авто (make/model/year/engine почти всегда отсутствуют — VIN есть, но не расшифровывается), (2) пробег/условия, (3) явного запрета галлюцинировать значения PID, которых нет в данных. Добавьте расшифровку VIN→марка/год (хотя бы WMI) перед отправкой — резко поднимет качество.
### 🟡 Выбор gpt-oss-120b (вопрос 5.4)
Для авто-диагностики ключевое — знание DTC и инженерная логика. 120b разумен как баланс цена/качество. Альтернативы под задачу: Qwen2.5-72B/Qwen3 (хорош в технике, но у вас отмечен CoT-leak баг на fp8-варианте), DeepSeek-V3 (сильная техничка), либо рассуждающая модель (o-серия/R1) для сложных взаимосвязей — но они дороже и медленнее, что усугубит проблему таймаутов из Вопроса 2. Вывод: 120b ок, менять стоит только если качество разбора DTC не устраивает.
---
## Вопрос 6. Безопасность API
### 🔴 Любой эндпоинт без аутентификации → бесплатный прокси к платному LLM (вопросы 6.1, 6.4)
`/api/v1/chat`, `/api/v1/session/upload`, `/api/v1/ping-llm` дёргают платный LLM **без какой-либо аутентификации и без rate limit**. Любой, кто узнал домен, может в цикле слать `/chat` и жечь ваш токен `api.aillm.ru`, а `/ping-llm` вообще тратит LLM-вызов на каждый GET. Для MVP HTTPS защищает только канал, но не от абуза. Минимум:
- статический API-ключ приложения в заголовке (да, его можно вытащить из APK, но он отсекает массовый скан-абуз);
- rate limiting на nginx (`limit_req_zone`) и/или Flask-Limiter по IP/`android_id`;
- `/ping-llm` не должен реально вызывать LLM на каждый пинг — кэшируйте результат на 1–5 мин.
```nginx
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
location /api/v1/chat { limit_req zone=api burst=5 nodelay; ... }
```
### 🟡 XSS через diagnosis/raw (вопрос 6.3 — не инъекция, а отображение)
SQL-инъекции через `raw_responses` нет (запросы параметризованы). **Но**: ответ ELM327 и текст диагноза от LLM (markdown с таблицами) где-то рендерятся в вебе (`web/templates/index.html`, дашборд сессий). Если markdown/HTML вставляется без экранирования — это stored XSS: вредонос в `raw` ELM или в ответе LLM выполнится в браузере админа. Проверьте, что вывод экранируется (Jinja autoescape по умолчанию вкл — не отключайте `|safe` на этих полях; markdown рендерьте через санитайзер).
### 🟡 Утечка деталей в ответах
`except ... return f"LLM недоступен: {e}"` и `error: str(e)[:100]` отдают внутренние сообщения наружу. Логируйте полностью, клиенту — обобщённый текст.
### 🟡 API-ключ LLM (вопрос 6.5)
Текущая схема (ключ только на сервере, не в APK) — **правильная**, это лучшее в безопасности проекта. Дополнительный прокси не нужен; достаточно закрыть абуз (rate limit + ключ приложения), чтобы вашим серверным ключом не пользовались чужие.
### Сводка по безопасности
| Мера | Приоритет | Статус |
|------|-----------|--------|
| Rate limiting (nginx/Flask-Limiter) | 🔴 высокий | нет |
| Ключ приложения в заголовке | 🟡 средний | нет |
| `/ping-llm` без реального LLM-вызова | 🟡 средний | вызывает LLM |
| Экранирование diagnosis/raw в вебе | 🟡 средний | проверить |
| Не отдавать текст исключений клиенту | 🟡 средний | отдаёт |
| Ключ LLM только на сервере | 🟢 | сделано |
---
## Итоговый топ проблем (по убыванию важности)
1. 🔴 **Нет идемпотентности upload** → дубликаты сессий и двойной расход LLM при 499/ретраях (Q2).
2. 🔴 **Открытые LLM-эндпоинты без auth/rate-limit** → абуз платного токена (Q6).
3. 🔴 **SQLite: утечка соединений + нет WAL/busy_timeout** при 4 воркерах → `database is locked` (Q4).
4. 🔴 **Долгий синхронный LLM в запросе** — корень 499; разделить приём данных и анализ, добавить streaming (Q2, Q5).
5. 🔴 **`reset_input_buffer` перед командой** в стейт-машине — иначе десинхрон ELM327 (Q1).
6. 🟡 Рассинхрон модели/таймаута в `client.py` (20b vs 120b, 120с vs 180с) (Q5).
7. 🟡 История диалога `/chat` строкой вместо ролей `messages` (Q5).
8. 🟡 Мёртвые таблицы в схеме — подключить или удалить (Q4).
## Что уже хорошо 🟢
- Чистые границы модулей, заменяемый `brain/`.
- Сильный диагностический системный промпт.
- Параметризованный SQL (нет инъекций).
- Ключ LLM не в APK.
- Стейт-машина ограничена по ретраям (нет бесконечных циклов).