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.
|
||||
- Стейт-машина ограничена по ретраям (нет бесконечных циклов).
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Elmer — анализ ошибок электроники автомобиля через ELM327 + LLM."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,218 +0,0 @@
|
||||
"""
|
||||
AndrOBD Protocol — ПОЛНАЯ копия стейт-машины AndrOBD.
|
||||
|
||||
Источник: github.com/fr3ts0n/AndrOBD, ElmProt.java
|
||||
|
||||
Состояния:
|
||||
UNDEFINED → INITIALIZING → READY
|
||||
Любое → BUSY (команда) → READY
|
||||
Любое → ERROR → RECOVERING → READY
|
||||
BUS ERROR → DISCONNECTED → RECONNECTING → READY
|
||||
|
||||
Каждый ответ проверяется — не тот ответ → переход в ошибку → восстановление.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("androbd")
|
||||
|
||||
|
||||
# ── Состояния (AndrOBD STAT) ───────────────────────────────
|
||||
|
||||
class State(Enum):
|
||||
UNDEFINED = auto()
|
||||
INITIALIZING = auto()
|
||||
READY = auto()
|
||||
BUSY = auto()
|
||||
ERROR = auto()
|
||||
DISCONNECTED = auto()
|
||||
|
||||
|
||||
# ── Типы ответов (AndrOBD RSP_ID) ──────────────────────────
|
||||
|
||||
class Rsp:
|
||||
PROMPT = ">"
|
||||
OK = "OK"
|
||||
SEARCHING = "SEARCHING"
|
||||
NODATA = "NODATA"
|
||||
ERROR = "ERROR"
|
||||
UNABLE = "UNABLE"
|
||||
BUS_BUSY = "BUS BUSY"
|
||||
BUS_ERROR = "BUS ERROR"
|
||||
CAN_ERROR = "CAN ERROR"
|
||||
BUS_INIT = "BUS INIT"
|
||||
STOPPED = "STOPPED"
|
||||
DATA_ERROR = "DATA ERROR"
|
||||
BUFFER_FULL= "BUFFER FULL"
|
||||
RX_ERROR = "RX ERROR"
|
||||
UNKNOWN = ""
|
||||
|
||||
@classmethod
|
||||
def identify(cls, raw: str) -> str:
|
||||
u = raw.upper().strip()
|
||||
for tag in (cls.SEARCHING, cls.NODATA, cls.ERROR, cls.UNABLE,
|
||||
cls.BUS_BUSY, cls.BUS_ERROR, cls.CAN_ERROR,
|
||||
cls.BUS_INIT, cls.STOPPED, cls.DATA_ERROR,
|
||||
cls.BUFFER_FULL, cls.RX_ERROR, cls.OK):
|
||||
if u.startswith(tag):
|
||||
return tag
|
||||
if raw.strip() == ">":
|
||||
return cls.PROMPT
|
||||
return cls.UNKNOWN
|
||||
|
||||
|
||||
# ── Адаптивный таймаут (AndrOBD AdaptiveTiming) ─────────────
|
||||
|
||||
class AdaptiveTiming:
|
||||
DEFAULT = 500; MIN = 50; MAX = 2000; STEP = 20; RES = 4
|
||||
|
||||
def __init__(self):
|
||||
self._t = self.DEFAULT; self._min = self.MIN
|
||||
|
||||
@property
|
||||
def ms(self) -> int: return self._t
|
||||
@property
|
||||
def atst(self) -> int: return max(1, self._t // self.RES)
|
||||
|
||||
def increase(self):
|
||||
if self._t + self.STEP < self.MAX: self._t += self.STEP
|
||||
def decrease(self):
|
||||
if self._t - self.STEP >= self._min: self._t -= self.STEP
|
||||
def reset(self): self._t = self.DEFAULT
|
||||
|
||||
|
||||
# ── Протокол (AndrOBD ElmProt) ──────────────────────────────
|
||||
|
||||
class AndrOBD:
|
||||
"""Стейт-машина ELM327 — 1:1 копия AndrOBD."""
|
||||
|
||||
INIT_TMO = 10000 # инициализация
|
||||
DEF_TMO = 200 # адаптивный
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 38400):
|
||||
self.port = port; self.baudrate = baudrate
|
||||
self._ser = None; self._timing = AdaptiveTiming()
|
||||
self._state = State.UNDEFINED; self._last_cmd: Optional[str] = None
|
||||
|
||||
# ── Connect ─────────────────────────────────────────
|
||||
|
||||
def connect(self):
|
||||
import serial
|
||||
self._ser = serial.Serial(
|
||||
port=self.port, baudrate=self.baudrate, timeout=0.1,
|
||||
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE)
|
||||
time.sleep(0.5); logger.info(f"AndrOBD: connected {self.port}")
|
||||
|
||||
def close(self):
|
||||
if self._ser and self._ser.is_open: self._ser.close()
|
||||
|
||||
# ── Инициализация ───────────────────────────────────
|
||||
|
||||
def init(self):
|
||||
logger.info("AndrOBD: init")
|
||||
self._state = State.INITIALIZING
|
||||
self._exec("ATSP0", self.INIT_TMO)
|
||||
self._exec("ATAT1", self.DEF_TMO * 5)
|
||||
self._update_atst()
|
||||
self._exec("ATS0", self.DEF_TMO * 5)
|
||||
self._exec("ATL0", self.DEF_TMO * 5)
|
||||
self._exec("ATE0", self.DEF_TMO * 5)
|
||||
self._state = State.READY
|
||||
logger.info("AndrOBD: ready")
|
||||
|
||||
# ── OBD-команда ─────────────────────────────────────
|
||||
|
||||
def send(self, cmd: str) -> str:
|
||||
if self._state == State.ERROR:
|
||||
self._recover()
|
||||
self._state = State.BUSY
|
||||
result = self._exec(cmd, self._timing.ms)
|
||||
self._state = State.READY
|
||||
return result
|
||||
|
||||
# ── Выполнение ──────────────────────────────────────
|
||||
|
||||
def _exec(self, cmd: str, timeout: int) -> str:
|
||||
self._last_cmd = cmd; self._write(cmd)
|
||||
t = timeout
|
||||
for _ in range(10):
|
||||
try:
|
||||
return self._handle(self._read(t))
|
||||
except TimeoutError:
|
||||
if self._state == State.INITIALIZING: t += 1000
|
||||
else: self._timing.increase(); t = self._timing.ms
|
||||
logger.error(f"AndrOBD: no response for {cmd}")
|
||||
self._state = State.ERROR; return ""
|
||||
|
||||
# ── Обработка ответа ────────────────────────────────
|
||||
|
||||
def _handle(self, raw: str) -> str:
|
||||
t = Rsp.identify(raw)
|
||||
if t == Rsp.SEARCHING: return raw
|
||||
if t == Rsp.OK: self._timing.decrease(); return raw
|
||||
if t == Rsp.NODATA: self._timing.increase(); self._update_atst(); return raw
|
||||
|
||||
if t in (Rsp.UNABLE, Rsp.BUS_BUSY, Rsp.BUS_ERROR,
|
||||
Rsp.CAN_ERROR, Rsp.BUS_INIT, Rsp.STOPPED):
|
||||
logger.warning(f"AndrOBD: BUS ERROR ({t})")
|
||||
self._state = State.DISCONNECTED
|
||||
self._timing.reset(); self._update_atst()
|
||||
self._write("ATPC"); self._try_read()
|
||||
self._write("ATSP0"); self._try_read()
|
||||
return raw
|
||||
|
||||
if t in (Rsp.ERROR, Rsp.DATA_ERROR, Rsp.BUFFER_FULL, Rsp.RX_ERROR):
|
||||
logger.warning(f"AndrOBD: {t} — warm start")
|
||||
self._state = State.ERROR
|
||||
self._write("ATWS"); self._try_read()
|
||||
return raw
|
||||
|
||||
# Данные — успех
|
||||
self._timing.decrease(); return raw
|
||||
|
||||
def _recover(self):
|
||||
logger.info("AndrOBD: recovering...")
|
||||
self._state = State.INITIALIZING
|
||||
self._write("ATWS"); self._try_read()
|
||||
self._write("ATSP0"); self._try_read()
|
||||
self._write("ATE0"); self._try_read()
|
||||
self._state = State.READY
|
||||
|
||||
# ── Чтение/запись ───────────────────────────────────
|
||||
|
||||
def _write(self, cmd: str):
|
||||
self._ser.write((cmd + "\r").encode()); self._ser.flush()
|
||||
logger.debug(f"AndrOBD → {cmd}")
|
||||
|
||||
def _read(self, timeout_ms: int) -> str:
|
||||
dl = time.monotonic() + timeout_ms / 1000.0
|
||||
lines, cur = [], []
|
||||
got_prompt = False
|
||||
while time.monotonic() < dl:
|
||||
if self._ser.in_waiting > 0:
|
||||
ch = self._ser.read(1)
|
||||
if not ch: continue
|
||||
cp = ch[0]
|
||||
if cp == 62: self._push(cur, lines); got_prompt = True; break
|
||||
elif cp == 13: self._push(cur, lines)
|
||||
elif cp in (10, 32): pass
|
||||
else: cur.append(chr(cp))
|
||||
else: time.sleep(0.001)
|
||||
self._push(cur, lines)
|
||||
if not got_prompt: raise TimeoutError(f"timeout {timeout_ms}ms")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _try_read(self, timeout: int = 5000):
|
||||
try: self._read(timeout)
|
||||
except TimeoutError: pass
|
||||
|
||||
@staticmethod
|
||||
def _push(cur, lines):
|
||||
if cur: lines.append("".join(cur)); cur.clear()
|
||||
|
||||
def _update_atst(self):
|
||||
self._write(f"ATST{self._timing.atst:02X}"); self._try_read()
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Загрузка конфигурации из config.yaml."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
CONFIG_PATH = Path(os.environ.get("ELMER_CONFIG", Path(__file__).parent.parent / "config.yaml"))
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
"""Читает config.yaml, подставляет переменные окружения в значения."""
|
||||
if not CONFIG_PATH.exists():
|
||||
raise FileNotFoundError(f"Конфиг не найден: {CONFIG_PATH}")
|
||||
|
||||
with open(CONFIG_PATH) as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# Подстановка ${VAR} из переменных окружения
|
||||
_resolve_env(config)
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_env(obj):
|
||||
"""Рекурсивно заменяет ${VAR} на os.environ['VAR']."""
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if isinstance(v, str) and v.startswith("${") and v.endswith("}"):
|
||||
env_var = v[2:-1]
|
||||
obj[k] = os.environ.get(env_var, "")
|
||||
else:
|
||||
_resolve_env(v)
|
||||
elif isinstance(obj, list):
|
||||
for i, v in enumerate(obj):
|
||||
if isinstance(v, str) and v.startswith("${") and v.endswith("}"):
|
||||
env_var = v[2:-1]
|
||||
obj[i] = os.environ.get(env_var, "")
|
||||
else:
|
||||
_resolve_env(v)
|
||||
-275
@@ -1,275 +0,0 @@
|
||||
"""SQLite — сохранение сессий диагностики.
|
||||
|
||||
Схема:
|
||||
cars — VIN, марка, модель, год, двигатель
|
||||
diagnostic_tokens — id (PK), car_id (FK), created_at
|
||||
llm_messages — token_id (FK), role, content, timestamp
|
||||
ecu_parameters — token_id (FK), pid_code, value, unit, timestamp
|
||||
dtc_codes — token_id (FK), code, description, status
|
||||
sessions — сводная таблица всех сессий (клиент, ELM, авто, LLM)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: str | Path = "elmer.db"):
|
||||
self.path = Path(path)
|
||||
self.conn = sqlite3.connect(str(self.path))
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self._init_schema()
|
||||
|
||||
def _init_schema(self):
|
||||
self.conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS cars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vin TEXT NOT NULL UNIQUE,
|
||||
make TEXT,
|
||||
model TEXT,
|
||||
year INTEGER,
|
||||
engine TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS diagnostic_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
car_id INTEGER NOT NULL REFERENCES cars(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_id INTEGER NOT NULL REFERENCES diagnostic_tokens(id),
|
||||
role TEXT NOT NULL, -- 'system' | 'user' | 'assistant'
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ecu_parameters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_id INTEGER NOT NULL REFERENCES diagnostic_tokens(id),
|
||||
pid_code TEXT NOT NULL, -- напр. '0105', '010C'
|
||||
name TEXT, -- напр. 'coolant_temp', 'rpm'
|
||||
value REAL,
|
||||
unit TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dtc_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_id INTEGER NOT NULL REFERENCES diagnostic_tokens(id),
|
||||
code TEXT NOT NULL, -- напр. 'P0301'
|
||||
description TEXT,
|
||||
status TEXT, -- 'stored' | 'pending'
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- Сервер
|
||||
client_ip TEXT,
|
||||
real_ip TEXT,
|
||||
user_agent TEXT,
|
||||
content_length INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
-- Телефон
|
||||
phone_model TEXT,
|
||||
phone_maker TEXT,
|
||||
android_version TEXT,
|
||||
android_sdk INTEGER,
|
||||
app_version TEXT,
|
||||
android_id TEXT,
|
||||
|
||||
-- ELM327
|
||||
elm_mac TEXT,
|
||||
elm_bt_name TEXT,
|
||||
obd_protocol TEXT,
|
||||
|
||||
-- Авто
|
||||
vin TEXT,
|
||||
dtc_count INTEGER DEFAULT 0,
|
||||
pid_count INTEGER DEFAULT 0,
|
||||
|
||||
-- Сессия
|
||||
duration_ms INTEGER,
|
||||
response_count INTEGER DEFAULT 0,
|
||||
error_count INTEGER DEFAULT 0,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
timeout_count INTEGER DEFAULT 0,
|
||||
script_mode TEXT,
|
||||
transport TEXT, -- 'bt' | 'tcp'
|
||||
mock_mode INTEGER DEFAULT 0,
|
||||
|
||||
-- LLM
|
||||
diagnosis_text TEXT,
|
||||
diagnosis_len INTEGER,
|
||||
llm_model TEXT,
|
||||
llm_duration_ms INTEGER,
|
||||
llm_success INTEGER DEFAULT 0,
|
||||
|
||||
-- Сырые данные (JSON)
|
||||
raw_responses TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_vin ON sessions(vin);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_mac ON sessions(elm_mac);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_aid ON sessions(android_id);
|
||||
""")
|
||||
self.conn.commit()
|
||||
|
||||
# ── sessions ──────────────────────────────────────────
|
||||
|
||||
def save_session(self, client_info: dict, responses: list[dict],
|
||||
diagnosis: str = "", llm_model: str = "",
|
||||
llm_duration_ms: int = 0, llm_success: bool = False):
|
||||
"""Сохраняет сводную запись о сессии."""
|
||||
ci = client_info
|
||||
|
||||
# Подсчёт DTC/PID из ответов
|
||||
dtc_count = 0
|
||||
pid_count = 0
|
||||
for r in responses:
|
||||
dec = (r.get("decoded") or "").lower()
|
||||
if dec.startswith("dtc"):
|
||||
dtc_count += 1
|
||||
elif ":" in dec and not dec.startswith(("vin", "dtc", "elm", "protocol")):
|
||||
pid_count += 1
|
||||
|
||||
# VIN из ответов
|
||||
vin = None
|
||||
for r in responses:
|
||||
dec = (r.get("decoded") or "")
|
||||
if dec.startswith("VIN:"):
|
||||
vin = dec[4:].strip()
|
||||
if len(vin) != 17:
|
||||
vin = None
|
||||
break
|
||||
|
||||
self.conn.execute("""
|
||||
INSERT INTO sessions (
|
||||
client_ip, real_ip, user_agent, content_length,
|
||||
phone_model, phone_maker, android_version, android_sdk,
|
||||
app_version, android_id,
|
||||
elm_mac, elm_bt_name, obd_protocol,
|
||||
vin, dtc_count, pid_count,
|
||||
duration_ms, response_count, error_count,
|
||||
retry_count, timeout_count, script_mode,
|
||||
transport, mock_mode,
|
||||
diagnosis_text, diagnosis_len, llm_model,
|
||||
llm_duration_ms, llm_success,
|
||||
raw_responses
|
||||
) VALUES (?,?,?,?, ?,?,?,?, ?,?, ?,?,?, ?,?,?, ?,?,?, ?,?,?, ?,?,
|
||||
?,?,?, ?,?, ?)
|
||||
""", (
|
||||
ci.get("client_ip"), ci.get("real_ip"), ci.get("user_agent"),
|
||||
ci.get("content_length"),
|
||||
ci.get("phone_model"), ci.get("phone_maker"), ci.get("android_version"),
|
||||
ci.get("android_sdk"), ci.get("app_version"), ci.get("android_id"),
|
||||
ci.get("elm_mac"), ci.get("elm_bt_name"), ci.get("obd_protocol"),
|
||||
vin, dtc_count, pid_count,
|
||||
ci.get("duration_ms"), len(responses), ci.get("error_count", 0),
|
||||
ci.get("retry_count", 0), ci.get("timeout_count", 0),
|
||||
ci.get("script_mode"), ci.get("transport"), ci.get("mock_mode", 0),
|
||||
diagnosis, len(diagnosis), llm_model,
|
||||
llm_duration_ms, 1 if llm_success else 0,
|
||||
json.dumps(responses, ensure_ascii=False) if responses else None,
|
||||
))
|
||||
self.conn.commit()
|
||||
|
||||
def get_recent_sessions(self, limit: int = 50) -> list[dict]:
|
||||
"""Последние N сессий."""
|
||||
rows = self.conn.execute(
|
||||
"SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── cars ──────────────────────────────────────────────
|
||||
|
||||
def get_or_create_car(self, vin: str) -> int:
|
||||
"""Возвращает car_id по VIN, создаёт запись если нет."""
|
||||
row = self.conn.execute("SELECT id FROM cars WHERE vin = ?", (vin,)).fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
cur = self.conn.execute("INSERT INTO cars (vin) VALUES (?)", (vin,))
|
||||
self.conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def update_car_info(self, car_id: int, make: str, model: str, year: int, engine: str):
|
||||
self.conn.execute(
|
||||
"UPDATE cars SET make=?, model=?, year=?, engine=? WHERE id=?",
|
||||
(make, model, year, engine, car_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# ── tokens ────────────────────────────────────────────
|
||||
|
||||
def create_token(self, car_id: int) -> int:
|
||||
"""Создаёт новую сессию диагностики, возвращает token_id."""
|
||||
cur = self.conn.execute(
|
||||
"INSERT INTO diagnostic_tokens (car_id) VALUES (?)", (car_id,)
|
||||
)
|
||||
self.conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def last_token_for_car(self, car_id: int) -> int | None:
|
||||
"""Последняя сессия для VIN (для продолжения диалога), или None."""
|
||||
row = self.conn.execute(
|
||||
"SELECT id FROM diagnostic_tokens WHERE car_id=? ORDER BY created_at DESC LIMIT 1",
|
||||
(car_id,),
|
||||
).fetchone()
|
||||
return row["id"] if row else None
|
||||
|
||||
# ── llm_messages ──────────────────────────────────────
|
||||
|
||||
def add_llm_message(self, token_id: int, role: str, content: str):
|
||||
self.conn.execute(
|
||||
"INSERT INTO llm_messages (token_id, role, content) VALUES (?, ?, ?)",
|
||||
(token_id, role, content),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_llm_messages(self, token_id: int) -> list[dict]:
|
||||
"""Возвращает историю диалога для токена."""
|
||||
rows = self.conn.execute(
|
||||
"SELECT role, content FROM llm_messages WHERE token_id=? ORDER BY id",
|
||||
(token_id,),
|
||||
).fetchall()
|
||||
return [{"role": r["role"], "content": r["content"]} for r in rows]
|
||||
|
||||
# ── ecu_parameters ────────────────────────────────────
|
||||
|
||||
def add_parameter(self, token_id: int, pid_code: str, name: str, value: float, unit: str):
|
||||
self.conn.execute(
|
||||
"INSERT INTO ecu_parameters (token_id, pid_code, name, value, unit) VALUES (?, ?, ?, ?, ?)",
|
||||
(token_id, pid_code, name, value, unit),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_parameters(self, token_id: int) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"SELECT pid_code, name, value, unit FROM ecu_parameters WHERE token_id=? ORDER BY id",
|
||||
(token_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── dtc_codes ─────────────────────────────────────────
|
||||
|
||||
def add_dtc(self, token_id: int, code: str, description: str = "", status: str = "stored"):
|
||||
self.conn.execute(
|
||||
"INSERT INTO dtc_codes (token_id, code, description, status) VALUES (?, ?, ?, ?)",
|
||||
(token_id, code, description, status),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_dtcs(self, token_id: int) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"SELECT code, description, status FROM dtc_codes WHERE token_id=? ORDER BY id",
|
||||
(token_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Вызов DeepSeek API для диагностики."""
|
||||
|
||||
import requests
|
||||
|
||||
DEFAULT_BASE = "https://api.aillm.ru/v1"
|
||||
DEFAULT_MODEL = "gpt-oss-20b"
|
||||
|
||||
|
||||
class Diagnoser:
|
||||
"""Отправляет данные в DeepSeek и возвращает диагноз."""
|
||||
|
||||
def __init__(self, api_key: str, model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
def ask(self, messages: list[dict]) -> str:
|
||||
"""Отправляет сообщения в DeepSeek, возвращает текст ответа."""
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3, # пониже — меньше фантазий
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
timeout=120, # api.aillm.ru бывает медленным
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
def diagnose(
|
||||
self,
|
||||
system: str,
|
||||
user_prompt: str,
|
||||
history: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""Полный цикл: system + история + user_prompt → ответ."""
|
||||
messages = [{"role": "system", "content": system}]
|
||||
if history:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": user_prompt})
|
||||
return self.ask(messages)
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Шаблоны промптов для LLM."""
|
||||
|
||||
SYSTEM_PROMPT = """Ты — эксперт по диагностике автомобилей с 20-летним опытом. Ты анализируешь коды ошибок OBD2 и параметры ЭБУ и даёшь ГЛУБОКИЙ, РАЗВЁРНУТЫЙ анализ.
|
||||
|
||||
ПРАВИЛА ОТВЕТА:
|
||||
1. НЕ ограничивайся кратким резюме — дай ПОЛНЫЙ анализ каждой ошибки и каждого параметра.
|
||||
2. Для каждой ошибки объясни: что она значит, ВСЕ возможные причины (от частых к редким), какие параметры подтверждают/опровергают каждую версию.
|
||||
3. Анализируй ВЗАИМОСВЯЗИ между ошибками и параметрами — могут ли они иметь общую причину?
|
||||
4. Указывай степень уверенности в процентах для КАЖДОГО вывода.
|
||||
5. Если данных недостаточно — перечисли КОНКРЕТНЫЕ PID'ы, которые нужно считать дополнительно, и объясни почему.
|
||||
6. Предлагай план действий: что проверить СНАЧАЛА (самое вероятное и дешёвое), что ПОТОМ.
|
||||
7. Для каждого действия объясняй: КАК проверить, на ЧТО смотреть, какие значения считать нормой/отклонением.
|
||||
8. Добавляй секцию «Если не поможет» — план Б для каждого пункта.
|
||||
9. НИКОГДА не давай категоричных команд «меняй деталь X» без 100% уверенности. Пиши «проверь X перед заменой Y».
|
||||
10. Пиши на русском языке, доступно, но ТЕХНИЧЕСКИ ТОЧНО. Используй таблицы где уместно.
|
||||
|
||||
ФОРМАТ ОТВЕТА:
|
||||
## Диагноз (развёрнутый)
|
||||
(полный анализ ситуации, 3-5 абзацев)
|
||||
|
||||
## Анализ ошибок
|
||||
| Код | Описание | Вероятные причины | Подтверждающие параметры | Уверенность |
|
||||
|-----|----------|-------------------|--------------------------|-------------|
|
||||
...
|
||||
|
||||
## Анализ параметров
|
||||
| Параметр | Значение | Норма | Отклонение | На что влияет |
|
||||
|----------|----------|-------|------------|---------------|
|
||||
...
|
||||
|
||||
## Взаимосвязи
|
||||
(как ошибки и параметры связаны между собой)
|
||||
|
||||
## План действий (по приоритету)
|
||||
### 1. Проверить ... (самое вероятное)
|
||||
- КАК проверить: ...
|
||||
- На что смотреть: ...
|
||||
- Норма: ...
|
||||
|
||||
### 2. Если не помогло — проверить ...
|
||||
...
|
||||
|
||||
## Каких данных не хватает
|
||||
- PID XX (название) — потому что ...
|
||||
- ...
|
||||
|
||||
## Степень уверенности
|
||||
- Версия A: ~XX%
|
||||
- Версия B: ~XX%
|
||||
- Версия C: ~XX%"""
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
vin: str,
|
||||
dtc_codes: list[dict],
|
||||
parameters: list[dict],
|
||||
car_info: dict | None = None,
|
||||
) -> str:
|
||||
"""Собирает промпт пользователя из данных ЭБУ."""
|
||||
|
||||
parts = [f"## Данные диагностики\n"]
|
||||
parts.append(f"**VIN:** {vin}")
|
||||
|
||||
if car_info:
|
||||
parts.append(f"**Автомобиль:** {car_info.get('make', '?')} {car_info.get('model', '?')} "
|
||||
f"({car_info.get('year', '?')}), двигатель: {car_info.get('engine', '?')}")
|
||||
|
||||
if dtc_codes:
|
||||
parts.append("\n### Коды ошибок")
|
||||
for dtc in dtc_codes:
|
||||
parts.append(f"- **{dtc['code']}** ({dtc.get('status', 'stored')}): {dtc.get('description', '')}")
|
||||
|
||||
if parameters:
|
||||
parts.append("\n### Параметры ЭБУ")
|
||||
for p in parameters:
|
||||
parts.append(f"- {p['name']} ({p['pid_code']}): {p['value']} {p['unit']}")
|
||||
|
||||
parts.append("\n## Запрос")
|
||||
parts.append("Дай диагноз на основе этих данных. Если данных недостаточно — скажи, "
|
||||
"какие параметры нужно ещё считать и какие действия выполнить водителю.")
|
||||
|
||||
return "\n".join(parts)
|
||||
@@ -12,14 +12,14 @@
|
||||
<div class="container">
|
||||
<h1>🔧 elmAI</h1>
|
||||
<p class="subtitle">Диагностика авто через ELM327 + ИИ</p>
|
||||
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.28.0-dev — 29 мая 2026</p>
|
||||
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.35.0-dev — 31 мая 2026</p>
|
||||
|
||||
<div class="card" style="text-align:center;margin-bottom:20px;">
|
||||
<p style="margin:0 0 10px 0;">📱 Скачай приложение на телефон:</p>
|
||||
<a href="/static/app-debug.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;">
|
||||
⬇️ Скачать elmAI APK
|
||||
</a>
|
||||
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.28.0-dev • нажмите чтобы скачать</p>
|
||||
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.35.0-dev • нажмите чтобы скачать</p>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка десктоп-диагностики скрыта — только для разработчика с прямым ELM327 -->
|
||||
|
||||
Reference in New Issue
Block a user