From f83a14a1cef28ab8fe974ebcfa791972afbbc569 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Sat, 6 Jun 2026 06:53:28 +0400 Subject: [PATCH] =?UTF-8?q?chore:=20=D1=82=D0=B5=D1=81=D1=82=D1=8B,=20SETU?= =?UTF-8?q?P.md,=20=D1=84=D0=B8=D0=BA=D1=81=20parser=20DTC=20(mode=3D43,?= =?UTF-8?q?=20=D0=BD=D0=B5=2003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/parser.py | 2 +- doc/SETUP.md | 159 +++++++++++++++++++++++++++++++++++++ tests/test_all.py | 198 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 doc/SETUP.md create mode 100644 tests/test_all.py diff --git a/api/parser.py b/api/parser.py index f92ec09..3179e5c 100644 --- a/api/parser.py +++ b/api/parser.py @@ -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) diff --git a/doc/SETUP.md b/doc/SETUP.md new file mode 100644 index 0000000..1ec0312 --- /dev/null +++ b/doc/SETUP.md @@ -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 diff --git a/tests/test_all.py b/tests/test_all.py new file mode 100644 index 0000000..7fe65e9 --- /dev/null +++ b/tests/test_all.py @@ -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("ВСЁ ОК!")