diff --git a/doc/opus-fix-plan.md b/doc/opus-fix-plan.md new file mode 100644 index 0000000..c9f48c8 --- /dev/null +++ b/doc/opus-fix-plan.md @@ -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). diff --git a/doc/opus-questions.md b/doc/opus-questions.md new file mode 100644 index 0000000..793cd64 --- /dev/null +++ b/doc/opus-questions.md @@ -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`. + +По каждому вопросу: +- 🔴 Критическая проблема (если есть) +- 🟡 Потенциальная проблема / улучшение +- 🟢 Всё ок +- Конкретные рекомендации с примерами кода где уместно diff --git a/doc/opus-review-android.md b/doc/opus-review-android.md new file mode 100644 index 0000000..72bb4cc --- /dev/null +++ b/doc/opus-review-android.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`), `').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 ...`. Но: +- 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). diff --git a/doc/opus-review.md b/doc/opus-review.md new file mode 100644 index 0000000..286e237 --- /dev/null +++ b/doc/opus-review.md @@ -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//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. +- Стейт-машина ограничена по ретраям (нет бесконечных циклов). diff --git a/elmer/__init__.py b/elmer/__init__.py deleted file mode 100644 index 5ba83b1..0000000 --- a/elmer/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Elmer — анализ ошибок электроники автомобиля через ELM327 + LLM.""" - -__version__ = "0.1.0" diff --git a/elmer/androbd.py b/elmer/androbd.py deleted file mode 100644 index 4d48b83..0000000 --- a/elmer/androbd.py +++ /dev/null @@ -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() diff --git a/elmer/config.py b/elmer/config.py deleted file mode 100644 index fa27765..0000000 --- a/elmer/config.py +++ /dev/null @@ -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) diff --git a/elmer/db.py b/elmer/db.py deleted file mode 100644 index de1e4cd..0000000 --- a/elmer/db.py +++ /dev/null @@ -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] diff --git a/elmer/diagnose.py b/elmer/diagnose.py deleted file mode 100644 index d2460e3..0000000 --- a/elmer/diagnose.py +++ /dev/null @@ -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) diff --git a/elmer/prompts.py b/elmer/prompts.py deleted file mode 100644 index a8fb0a1..0000000 --- a/elmer/prompts.py +++ /dev/null @@ -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) diff --git a/logo.jpg b/logo.jpg new file mode 100755 index 0000000..ae181c5 Binary files /dev/null and b/logo.jpg differ diff --git a/logo.png b/logo.png new file mode 100755 index 0000000..c87b9a4 Binary files /dev/null and b/logo.png differ diff --git a/web/templates/index.html b/web/templates/index.html index 531b2df..3a8497c 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -12,14 +12,14 @@

🔧 elmAI

Диагностика авто через ELM327 + ИИ

-

v0.28.0-dev — 29 мая 2026

+

v0.35.0-dev — 31 мая 2026

📱 Скачай приложение на телефон:

⬇️ Скачать elmAI APK -

v0.28.0-dev • нажмите чтобы скачать

+

v0.35.0-dev • нажмите чтобы скачать