237 lines
24 KiB
Markdown
237 lines
24 KiB
Markdown
# Ревью проекта 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.
|
||
- Стейт-машина ограничена по ретраям (нет бесконечных циклов).
|