feat(fat-client): эндпоинты скриптов и батчевой загрузки
- GET /api/v1/script — выдаёт JSON-скрипт диагностики - POST /api/v1/session/upload — принимает батч ответов, парсит, отправляет в LLM (DeepSeek), возвращает диагноз - Без API-ключа — возвращает распарсенные сырые данные
This commit is contained in:
@@ -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("/")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user