chore: тесты, SETUP.md, фикс parser DTC (mode=43, не 03)

This commit is contained in:
Repinoid
2026-06-06 06:53:28 +04:00
parent beecf2fb9e
commit f83a14a1ce
3 changed files with 358 additions and 1 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ def parse_batch(responses: list[dict]) -> dict:
result["raw_log"].append(f"{cmd}\n{raw}")
_parse_vin(result, raw, decoded)
_parse_dtc(result, raw, decoded, mode="03", key="dtc_stored", prefix="DTC stored:")
_parse_dtc(result, raw, decoded, mode="43", key="dtc_stored", prefix="DTC stored:")
_parse_dtc(result, raw, decoded, mode="47", key="dtc_pending", prefix="DTC pending:")
_parse_pid(result, decoded, cmd)
+159
View File
@@ -0,0 +1,159 @@
# elmAI — полная настройка для нового разработчика
> v0.40.0-dev, 6 июня 2026
---
## 1. Что где лежит
```
elmer/ ← сервер (gitea.services.ngcloud.ru/Nail/elmer)
├── api/ Python/Flask эндпоинты, БД, парсер, скрипты
├── brain/ LLM-клиент (Diagnoser), промпты
├── obd/ ELM327 стейт-машина (AndrOBD)
├── web/ Flask, шаблоны, статика (APK, лого)
├── android/ ← Android (github.com/Repinoid/elmer-android)
│ └── app/... Kotlin-код, ресурсы
├── doc/ Документация, история изменений, DTC-коды
├── config.yaml LLM API ключ, модель, порты
├── deploy.sh Скрипт развёртывания
└── run.py Локальный запуск
```
## 2. Git
**Сервер:** `https://gitea.services.ngcloud.ru/Nail/elmer.git`
**Android:** `https://github.com/Repinoid/elmer-android.git`
```bash
git clone https://Nail:b1fe1db54be4cfcc19c38f7fee713cde6f351524@gitea.services.ngcloud.ru/Nail/elmer.git
cd elmer
git clone https://github.com/Repinoid/elmer-android.git android
# или если уже есть:
git pull origin master
cd android && git pull origin master
```
## 3. Зависимости сервера
```bash
cd elmer
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt gunicorn
```
## 4. Конфигурация
`config.yaml`:
```yaml
llm:
api_key: "sk-78ec529c1eba4ba69995091046c9fa33"
model: "deepseek-v4-flash"
base_url: "https://api.deepseek.com/v1"
elm327:
port: /dev/rfcomm0
baudrate: 38400
```
## 5. Запуск локально
```bash
cd elmer
source venv/bin/activate
python run.py
# → http://localhost:5005
```
## 6. Боевой сервер (obdai.ru, 5.172.178.213)
**SSH ключ:** `~/.ssh/naeel_vm_id_ed25519`
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213
```
**Деплой сервера:**
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
"cd /opt/elmer && git pull origin master && sudo systemctl restart elmer"
```
**Сборка APK (на сервере):**
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
"export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
export ANDROID_HOME=\$ANDROID_SDK_ROOT && \
cd /opt/elmer && rm -rf android && \
cd /opt/elmer && \
export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
export ANDROID_HOME=\$ANDROID_SDK_ROOT && \
cd android && gradle wrapper --gradle-version 8.7 && \
./gradlew clean assembleDebug && \
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/"
```
**Nginx:**
```nginx
location = /elmer.apk { alias /opt/elmer/web/static/app-debug.apk; }
location /static/ { alias /opt/elmer/web/static/; }
location / {
proxy_pass http://127.0.0.1:8000;
client_body_timeout 120s;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
**Systemd сервис:** `/etc/systemd/system/elmer.service`
```
[Unit]
Description=Elmer Flask API
After=network.target
[Service]
User=naeel
WorkingDirectory=/opt/elmer
ExecStart=/opt/elmer/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 --timeout 180 web.app:app
Restart=always
[Install]
WantedBy=multi-user.target
```
## 7. Структура БД SQLite
Файл: `elmer.db` (создаётся автоматически)
Таблица `sessions` (35+ колонок):
- client_ip, real_ip, user_agent, content_length, created_at
- phone_model, phone_maker, android_version, android_sdk, app_version
- android_id, device_uuid, phone_lang, phone_tz, phone_display
- 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, request_id, response_json
## 8. API эндпоинты
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/v1/ping` | Проверка сервера |
| GET | `/api/v1/ping-llm` | Проверка LLM (кэш 60с) |
| GET | `/api/v1/script?mode=full/test` | Скрипт диагностики |
| POST | `/api/v1/session/upload` | Загрузка батча + LLM |
| POST | `/api/v1/chat` | Вопрос к LLM |
| POST | `/api/v1/dtc/decode` | Расшифровка DTC |
| POST | `/api/v1/dtc/upload` | Сохранение DTC |
## 9. Правила работы с Copilot
См. `.instructions.md` в корне. Главное:
- **Вопрос → только ответ. Никаких действий.**
- Всегда читать `doc/architecture.md` перед действиями
- Изменения документировать в `doc/history/YYYY-MM-DD.md`
- APK отдавать через nginx, не через Flask
+198
View File
@@ -0,0 +1,198 @@
"""
Сквозные тесты elmAI — без LLM.
Проверяет: эндпоинты, БД, идемпотентность, DTC, chat, ping, script.
"""
import json
import os
import sqlite3
import sys
import tempfile
from pathlib import Path
# Добавляем корень проекта в путь
sys.path.insert(0, str(Path(__file__).parent))
# Временная БД для тестов
TEST_DB = Path(tempfile.gettempdir()) / f"elmer_test_{os.getpid()}.db"
os.environ["ELMER_CONFIG"] = str(Path(__file__).parent / "config.yaml")
from api.db import Database
from api.parser import parse_batch, format_no_llm
from api.routes import _load_dtc_dict, _build_diagnosis_prompt
from api.scripts import build_default_script, build_full_script
passed = 0
failed = 0
def test(name: str, ok: bool, detail: str = ""):
global passed, failed
if ok:
passed += 1
print(f"{name}")
else:
failed += 1
print(f"{name}: {detail}")
# ── 1. Скрипты ──────────────────────────────────────────────────
print("\n═══ 1. Сборка скриптов ═══")
s = build_default_script()
test("default_script — есть steps", "steps" in s)
test("default_script — 1+ шагов", len(s.get("steps", [])) >= 1)
s = build_full_script()
test("full_script — есть steps", "steps" in s)
test("full_script — 2+ шагов", len(s.get("steps", [])) >= 2)
# ── 2. Парсер ELM-ответов ──────────────────────────────────────
print("\n═══ 2. Парсер ответов ═══")
# VIN из decoded
r = parse_batch([{"cmd": "0902", "raw": "", "decoded": "VIN: WVWZZZ1KZAW123456"}])
test("VIN из decoded", r["vin"] == "WVWZZZ1KZAW123456")
# VIN из raw HEX (полный 17-символьный)
r = parse_batch([{"cmd": "0902", "raw": "490201 57 56 57 5A 5A 5A 31 4B 5A 41 57 31 32 33 34 35 36", "decoded": ""}])
test("VIN из raw HEX", r["vin"] == "WVWZZZ1KZAW123456")
# DTC stored
r = parse_batch([{"cmd": "03", "raw": "", "decoded": "DTC stored: P0301 P0302"}])
test("DTC stored", r["dtc_stored"] == ["P0301", "P0302"])
# DTC pending
r = parse_batch([{"cmd": "07", "raw": "", "decoded": "DTC pending: P0302"}])
test("DTC pending", r["dtc_pending"] == ["P0302"])
# PID
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83 °C"}])
test("PID разобран", len(r["parameters"]) == 1 and r["parameters"][0]["name"] == "ОЖ")
# Пустой батч — нет данных
r = parse_batch([])
test("пустой батч", r["vin"] is None and r["dtc_stored"] == [] and r["dtc_pending"] == [])
# DTC из raw HEX (mode 03, ответ начинается с 43)
r = parse_batch([{"cmd": "03", "raw": "43 02 01 00 02 00", "decoded": ""}])
test("DTC из raw HEX", len(r["dtc_stored"]) > 0)
# DTC из raw HEX mode 07 (byte count 01, DTC P0100)
r = parse_batch([{"cmd": "07", "raw": "47 01 01 00", "decoded": ""}])
test("DTC pending из raw HEX", len(r["dtc_pending"]) > 0)
# Mixed: данные + команда без decoded
r = parse_batch([
{"cmd": "010C", "raw": "410C 0C A8", "decoded": "RPM: 948.0 RPM"},
{"cmd": "0902", "raw": "", "decoded": "VIN: WVWZZZ1KZAW123456"},
])
test("mixed: VIN + PID", r["vin"] == "WVWZZZ1KZAW123456" and len(r["parameters"]) >= 1)
# ── 3. Промпт билдер ────────────────────────────────────────────
print("\n═══ 3. Промпт билдер ═══")
data = {
"vin": "WLL0333272859",
"dtc_stored": ["P0301"],
"dtc_pending": [],
"parameters": [{"name": "RPM", "value": "948"}],
"raw_log": ["→ 010C\n← RPM: 948"],
}
p = _build_diagnosis_prompt(data)
test("промпт содержит VIN", "WLL0333272859" in p)
test("промпт содержит ошибку", "P0301" in p)
test("промпт содержит параметр", "RPM" in p)
test("промпт содержит запрос на анализ", "краткий" in p or "диагноз" in p)
p2 = _build_diagnosis_prompt(data, car_info="Volkswagen Passat 1.8T 2005")
test("car_info в промпте", "Volkswagen" in p2)
# Без данных
p3 = _build_diagnosis_prompt({"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []})
test("промпт без данных содержит 'не распознаны'", "не распознаны" in p3)
# ── 4. DTC справочник ───────────────────────────────────────────
print("\n═══ 4. DTC декодер ═══")
dtc = _load_dtc_dict()
test("DTC словарь загружен", len(dtc) > 50)
test("P0301 есть", "P0301" in dtc)
test("P0301 описание", "цилиндр" in dtc.get("P0301", "").lower())
test("P0420 есть", "P0420" in dtc)
# ── 5. База данных ─────────────────────────────────────────────
print("\n═══ 5. База данных ═══")
db_path = TEST_DB
db = Database(db_path)
test("БД создана", db_path.exists())
# Сохраняем сессию
db.save_session(
client_info={"phone_model": "Pixel", "android_id": "test123", "device_uuid": "uuid-1", "elm_mac": "00:11:22:33:44:55"},
responses=[{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83"}],
diagnosis="Тестовый диагноз",
llm_model="deepseek-v4-flash",
llm_success=True,
request_id="req-001",
response_json={"diagnosis": "Тестовый диагноз"},
)
sessions = db.get_recent_sessions(1)
test("сессия сохранена", len(sessions) == 1)
test("diagnosis_text сохранён", sessions[0]["diagnosis_text"] == "Тестовый диагноз")
test("llm_success", sessions[0]["llm_success"] == 1)
test("request_id сохранён", sessions[0]["request_id"] == "req-001")
test("device_uuid сохранён", sessions[0]["device_uuid"] == "uuid-1")
test("elm_mac сохранён", sessions[0]["elm_mac"] == "00:11:22:33:44:55")
# Идемпотентность — повторный request_id
cached = db.get_cached_response("req-001")
test("кэш по request_id работает", cached is not None and cached.get("diagnosis") == "Тестовый диагноз")
# Несуществующий request_id
cached = db.get_cached_response("nonexistent")
test("несуществующий request_id → None", cached is None)
# Сессия без request_id
db.save_session(
client_info={"phone_model": "Pixel", "android_id": "test456", "device_uuid": "uuid-2"},
responses=[{"cmd": "010C", "raw": "", "decoded": "RPM: 948"}],
)
sessions = db.get_recent_sessions(5)
test("сессия без request_id", len(sessions) >= 2)
# DTC scan
db.save_dtc_scan(
client_info={"phone_model": "Pixel", "device_uuid": "uuid-3", "elm_mac": "00:11:22:33:44:66"},
dtc_codes=["P0301", "P0302"],
)
test("DTC scan сохранён в sessions", db.get_recent_sessions(10)[0]["response_count"] == 0)
# Валидация колонок
col = [r[1] for r in db.conn.execute("PRAGMA table_info(sessions)").fetchall()]
for c in ["device_uuid", "phone_lang", "phone_tz", "phone_display", "request_id", "response_json"]:
test(f"колонка {c} существует", c in col)
db.close()
# Чистим
if db_path.exists():
db_path.unlink()
# ── 6. Format no LLM ────────────────────────────────────────────
print("\n═══ 6. Format no LLM ═══")
f = format_no_llm({"vin": "VIN123", "dtc_stored": ["P0301"], "dtc_pending": [], "parameters": [], "raw_log": []})
test("format_no_llm содержит VIN", "VIN123" in f)
f = format_no_llm({"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []})
test("format_no_llm без данных", len(f) > 0)
# ── 7. Скрипты через API (без HTTP) ─────────────────────────────
print("\n═══ 7. Скрипты ═══")
s = build_default_script()
test("default в JSON", isinstance(s, dict))
test("version", s.get("version") == 1)
s2 = build_full_script()
test("full", s2.get("version") == 1)
# ── Итог ─────────────────────────────────────────────────────────
print(f"\n═══ РЕЗУЛЬТАТ: {passed} пройдено, {failed} упало ═══")
if failed:
print("ЕСТЬ ОШИБКИ!")
sys.exit(1)
else:
print("ВСЁ ОК!")