chore: удалён мёртвый elmer/, версия 0.35.0-dev, документы Опуса
This commit is contained in:
@@ -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).
|
||||
@@ -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`.
|
||||
|
||||
По каждому вопросу:
|
||||
- 🔴 Критическая проблема (если есть)
|
||||
- 🟡 Потенциальная проблема / улучшение
|
||||
- 🟢 Всё ок
|
||||
- Конкретные рекомендации с примерами кода где уместно
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
- Стейт-машина ограничена по ретраям (нет бесконечных циклов).
|
||||
Reference in New Issue
Block a user