chore: тесты, SETUP.md, фикс parser DTC (mode=43, не 03)
This commit is contained in:
+1
-1
@@ -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
@@ -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
|
||||
@@ -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("ВСЁ ОК!")
|
||||
Reference in New Issue
Block a user