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

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

After

Width:  |  Height:  |  Size: 586 KiB

Executable
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+2 -2
View File
@@ -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 -->