From 0a2a6888a0b346e60e3ea7f4a491f8e0d12bab0b Mon Sep 17 00:00:00 2001 From: Repinoid Date: Tue, 26 May 2026 10:43:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(fat-client):=20=D1=8D=D0=BD=D0=B4=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D0=BD=D1=82=D1=8B=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=B1=D0=B0=D1=82=D1=87=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/script — выдаёт JSON-скрипт диагностики - POST /api/v1/session/upload — принимает батч ответов, парсит, отправляет в LLM (DeepSeek), возвращает диагноз - Без API-ключа — возвращает распарсенные сырые данные --- web/app.py | 2 + web/script_endpoint.py | 196 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 web/script_endpoint.py diff --git a/web/app.py b/web/app.py index 207c499..cb8ef3f 100644 --- a/web/app.py +++ b/web/app.py @@ -20,12 +20,14 @@ from elmer.diagnose import Diagnoser from elmer.elm import ELM327 from elmer.prompts import SYSTEM_PROMPT, build_user_prompt from web.raw_endpoint import register as register_raw_endpoint +from web.script_endpoint import register as register_script_endpoint logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") app = Flask(__name__) config = load() register_raw_endpoint(app) +register_script_endpoint(app) @app.route("/") diff --git a/web/script_endpoint.py b/web/script_endpoint.py new file mode 100644 index 0000000..6021b87 --- /dev/null +++ b/web/script_endpoint.py @@ -0,0 +1,196 @@ +"""Эндпоинты для толстого клиента: скрипты и батчевая загрузка. + +GET /api/v1/script — выдача скрипта диагностики +POST /api/v1/session/upload — приём батча, LLM-анализ, возврат диагноза +""" + +import json +import logging +from datetime import datetime, timezone + +logger = logging.getLogger("elmer.script") + + +def build_default_script() -> dict: + """Стандартный скрипт диагностики (без серверных подсказок).""" + return { + "version": 1, + "title": "Базовая диагностика", + "steps": [ + {"id": "init_atz", "cmd": "ATZ", "desc": "Сброс ELM327"}, + {"id": "init_ate0", "cmd": "ATE0", "desc": "Эхо выкл"}, + {"id": "init_atl0", "cmd": "ATL0", "desc": "Перевод строки выкл"}, + {"id": "init_atsp0", "cmd": "ATSP0", "desc": "Авто-протокол"}, + {"id": "init_ath1", "cmd": "ATH1", "desc": "Заголовки вкл"}, + {"id": "prompt_engine", "prompt": "Заведите двигатель (холостой ход) и нажмите «Далее»", + "wait_for_user": True}, + {"id": "vin", "cmd": "0902", "desc": "VIN"}, + {"id": "dtc_stored", "cmd": "03", "desc": "Ошибки (сохранённые)"}, + {"id": "dtc_pending", "cmd": "07", "desc": "Ошибки (ожидающие)"}, + {"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"}, + {"id": "pid_0C", "cmd": "010C", "desc": "Обороты"}, + {"id": "pid_0D", "cmd": "010D", "desc": "Скорость"}, + {"id": "pid_11", "cmd": "0111", "desc": "Дроссель"}, + {"id": "pid_0B", "cmd": "010B", "desc": "MAP"}, + {"id": "pid_0F", "cmd": "010F", "desc": "IAT"}, + {"id": "pid_1F", "cmd": "011F", "desc": "Время работы"}, + {"id": "pid_04", "cmd": "0104", "desc": "Нагрузка"}, + {"id": "pid_06", "cmd": "0106", "desc": "STFT"}, + {"id": "pid_07", "cmd": "0107", "desc": "LTFT"}, + ], + } + + +def _parse_batch(responses: list[dict]) -> dict: + """Парсит батч ответов в структуру для LLM.""" + result = { + "vin": None, + "dtc_stored": [], + "dtc_pending": [], + "parameters": [], + "raw_log": [], + } + + for r in responses: + cmd = (r.get("cmd") or "").strip() + raw = (r.get("raw") or "").strip() + decoded = (r.get("decoded") or "").strip() + + result["raw_log"].append(f"→ {cmd}\n← {raw}") + + # VIN + if decoded.startswith("VIN:"): + vin = decoded.replace("VIN:", "").strip() + if len(vin) == 17: + result["vin"] = vin + + # DTC + if decoded.startswith("DTC stored:"): + codes = decoded.replace("DTC stored:", "").strip() + if codes != "none": + result["dtc_stored"] = [c.strip() for c in codes.split()] + + if decoded.startswith("DTC pending:"): + codes = decoded.replace("DTC pending:", "").strip() + if codes != "none": + result["dtc_pending"] = [c.strip() for c in codes.split()] + + # PID (формат: "Имя: значение") + if ":" in decoded and not decoded.startswith(("VIN", "DTC", "ELM", "Protocol")): + parts = decoded.split(":", 1) + if len(parts) == 2: + result["parameters"].append({ + "name": parts[0].strip(), + "value": parts[1].strip(), + }) + + return result + + +def _build_diagnosis_prompt(data: dict) -> str: + """Строит промпт для LLM из распарсенных данных.""" + parts = ["## Данные диагностики\n"] + + if data["vin"]: + parts.append(f"**VIN:** {data['vin']}") + + if data["dtc_stored"]: + parts.append(f"\n**Сохранённые ошибки:** {', '.join(data['dtc_stored'])}") + if data["dtc_pending"]: + parts.append(f"**Ожидающие ошибки:** {', '.join(data['dtc_pending'])}") + + if data["parameters"]: + parts.append("\n**Параметры:**") + for p in data["parameters"]: + parts.append(f"- {p['name']}: {p['value']}") + + if not data["vin"] and not data["dtc_stored"] and not data["parameters"]: + parts.append("\n(данные не распознаны)") + + parts.append(f"\n**Сырые ответы:**") + for line in data["raw_log"]: + parts.append(line) + + return "\n".join(parts) + + +def register(app): + """Регистрирует эндпоинты скриптов на Flask-приложении.""" + + @app.route("/api/v1/script", methods=["GET"]) + def get_script(): + """Возвращает скрипт диагностики для клиента.""" + from flask import jsonify + + # В будущем — выбирать скрипт в зависимости от VIN / истории + script = build_default_script() + return jsonify(script) + + @app.route("/api/v1/session/upload", methods=["POST"]) + def upload_session(): + """Принимает батч сессии, анализирует через LLM, возвращает диагноз.""" + from flask import request, jsonify + from elmer.config import load + from elmer.diagnose import Diagnoser + from elmer.prompts import SYSTEM_PROMPT + + data = request.get_json(silent=True) + if not data or "responses" not in data: + return jsonify({"error": "missing 'responses'"}), 400 + + responses = data["responses"] + logger.info(f"Upload: {len(responses)} responses") + + parsed = _parse_batch(responses) + + # LLM + cfg = load() + api_key = cfg["deepseek"]["api_key"] + + if not api_key: + # Без LLM — возвращаем распарсенные данные + return jsonify({ + "diagnosis": _format_no_llm(parsed), + "parsed": { + "vin": parsed["vin"], + "dtc_stored": parsed["dtc_stored"], + "dtc_pending": parsed["dtc_pending"], + "parameters": parsed["parameters"], + }, + }) + + diagnoser = Diagnoser( + api_key=api_key, + model=cfg["deepseek"].get("model", "deepseek-chat"), + ) + user_prompt = _build_diagnosis_prompt(parsed) + answer = diagnoser.diagnose(SYSTEM_PROMPT, user_prompt) + + return jsonify({ + "diagnosis": answer, + "parsed": { + "vin": parsed["vin"], + "dtc_stored": parsed["dtc_stored"], + "dtc_pending": parsed["dtc_pending"], + "parameters": parsed["parameters"], + }, + }) + + +def _format_no_llm(parsed: dict) -> str: + """Форматирует ответ без LLM.""" + lines = [] + if parsed["vin"]: + lines.append(f"VIN: {parsed['vin']}") + if parsed["dtc_stored"]: + lines.append(f"Ошибки: {', '.join(parsed['dtc_stored'])}") + if parsed["dtc_pending"]: + lines.append(f"Pending: {', '.join(parsed['dtc_pending'])}") + if parsed["parameters"]: + lines.append("Параметры:") + for p in parsed["parameters"]: + lines.append(f" {p['name']}: {p['value']}") + if not lines: + lines.append("Данные не распознаны.") + lines.append("\n(LLM не настроен — только сырые данные)") + return "\n".join(lines)