refactor: script_endpoint.py разбит на script_builder + script_parser + endpoint

This commit is contained in:
Repinoid
2026-05-27 10:55:37 +03:00
parent e7b580ff70
commit 75d690b4ea
3 changed files with 183 additions and 191 deletions
+26
View File
@@ -0,0 +1,26 @@
"""Сборка диагностических скриптов."""
def build_default_script() -> dict:
"""Стандартный скрипт диагностики."""
return {
"version": 1,
"title": "Базовая диагностика",
"steps": [
{"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"},
],
}
+27 -191
View File
@@ -4,148 +4,13 @@ GET /api/v1/script — выдача скрипта диагностик
POST /api/v1/session/upload — приём батча, LLM-анализ, возврат диагноза POST /api/v1/session/upload — приём батча, LLM-анализ, возврат диагноза
""" """
import json
import logging import logging
from datetime import datetime, timezone from web.script_builder import build_default_script
from web.script_parser import parse_batch, format_no_llm
logger = logging.getLogger("elmer.script") 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_ath0", "cmd": "ATH0", "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."""
import re
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
elif "49" in raw and ("02" in raw or "4902" in raw.replace(" ", "")):
# fallback: парсим VIN из raw
# формат: "014 0:49 02 01 57 56 57..." или "490201575657..."
clean = raw.replace(":", "").replace(" ", "").upper()
if "490201" in clean:
hex_str = clean.split("490201")[-1][:34]
vin = ""
for i in range(0, len(hex_str) - 1, 2):
try:
vin += chr(int(hex_str[i:i+2], 16))
except (ValueError, OverflowError):
pass
if len(vin) == 17:
result["vin"] = vin
decoded = f"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()]
elif raw.replace(" ", "").startswith("43"):
# fallback: парсим DTC из raw 43XX...
clean = raw.replace(" ", "").upper()
if clean.startswith("43") and len(clean) >= 4:
codes = []
i = 2 # skip byte count
while i + 3 < len(clean):
try:
a = int(clean[i:i+2], 16)
b = int(clean[i+2:i+4], 16)
p = {0: "P", 1: "C", 2: "B", 3: "U"}.get(a >> 6, "?")
code = f"{p}{(a>>4)&3}{a&15}{b>>4:X}{b&15:X}"
if code != "P0000":
codes.append(code)
except Exception:
pass
i += 4
if codes:
result["dtc_stored"] = codes
decoded = f"DTC stored: {' '.join(codes)}"
if decoded.startswith("DTC pending:"):
codes = decoded.replace("DTC pending:", "").strip()
if codes != "none":
result["dtc_pending"] = [c.strip() for c in codes.split()]
elif raw.replace(" ", "").startswith("47"):
# fallback: pending DTC
clean = raw.replace(" ", "").upper()
if clean.startswith("47") and len(clean) >= 4:
codes = []
i = 2
while i + 3 < len(clean):
try:
a = int(clean[i:i+2], 16)
b = int(clean[i+2:i+4], 16)
p = {0: "P", 1: "C", 2: "B", 3: "U"}.get(a >> 6, "?")
code = f"{p}{(a>>4)&3}{a&15}{b>>4:X}{b&15:X}"
if code != "P0000":
codes.append(code)
except Exception:
pass
i += 4
if codes:
result["dtc_pending"] = codes
decoded = f"DTC pending: {' '.join(codes)}"
# ── 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(),
})
logger.info(f"[{cmd}] decoded={decoded[:60]}")
return result
def _build_diagnosis_prompt(data: dict) -> str: def _build_diagnosis_prompt(data: dict) -> str:
"""Строит промпт для LLM из распарсенных данных.""" """Строит промпт для LLM из распарсенных данных."""
parts = ["## Данные диагностики\n"] parts = ["## Данные диагностики\n"]
@@ -166,21 +31,21 @@ def _build_diagnosis_prompt(data: dict) -> str:
if not data["vin"] and not data["dtc_stored"] and not data["parameters"]: if not data["vin"] and not data["dtc_stored"] and not data["parameters"]:
parts.append("\n(данные не распознаны)") parts.append("\n(данные не распознаны)")
parts.append(f"\n**Сырые ответы ЭБУ:**") parts.append("\n**Сырые ответы ЭБУ:**")
for line in data["raw_log"]: parts.extend(data["raw_log"])
parts.append(line)
parts.append("\n---") parts.append("\n---")
parts.append("## Запрос на анализ") parts.append("## Запрос на анализ")
parts.append("Дай ГЛУБОКИЙ, РАЗВЁРНУТЫЙ анализ на основе этих данных. " parts.append(
"Не ограничивайся кратким резюме — мне нужен полный технический разбор.\n" "Дай ГЛУБОКИЙ, РАЗВЁРНУТЫЙ анализ на основе этих данных. "
"1. Разбери КАЖДУЮ ошибку: все возможные причины, от частых к редким.\n" "Не ограничивайся кратким резюме — мне нужен полный технический разбор.\n"
"2. Проанализируй КАЖДЫЙ параметр: норма/отклонение, на что влияет.\n" "1. Разбери КАЖДУЮ ошибку: все возможные причины, от частых к редким.\n"
"3. Найди ВЗАИМОСВЯЗИ между ошибками и параметрами.\n" "2. Проанализируй КАЖДЫЙ параметр: норма/отклонение, на что влияет.\n"
"4. Предложи КОНКРЕТНЫЙ план проверок: что сначала, что потом, как проверять.\n" "3. Найди ВЗАИМОСВЯЗИ между ошибками и параметрами.\n"
"5. Если данных мало — скажи какие PID'ы досчитать и зачем.\n" "4. Предложи КОНКРЕТНЫЙ план проверок: что сначала, что потом, как проверять.\n"
"6. Дай оценки уверенности в ПРОЦЕНТАХ для каждой версии.") "5. Если данных мало — скажи какие PID'ы досчитать и зачем.\n"
"6. Дай оценки уверенности в ПРОЦЕНТАХ для каждой версии."
)
return "\n".join(parts) return "\n".join(parts)
@@ -189,16 +54,11 @@ def register(app):
@app.route("/api/v1/script", methods=["GET"]) @app.route("/api/v1/script", methods=["GET"])
def get_script(): def get_script():
"""Возвращает скрипт диагностики для клиента."""
from flask import jsonify from flask import jsonify
return jsonify(build_default_script())
# В будущем — выбирать скрипт в зависимости от VIN / истории
script = build_default_script()
return jsonify(script)
@app.route("/api/v1/session/upload", methods=["POST"]) @app.route("/api/v1/session/upload", methods=["POST"])
def upload_session(): def upload_session():
"""Принимает батч сессии, анализирует через LLM, возвращает диагноз."""
from flask import request, jsonify from flask import request, jsonify
from elmer.config import load from elmer.config import load
from elmer.diagnose import Diagnoser from elmer.diagnose import Diagnoser
@@ -210,23 +70,15 @@ def register(app):
responses = data["responses"] responses = data["responses"]
logger.info(f"Upload: {len(responses)} responses") logger.info(f"Upload: {len(responses)} responses")
parsed = parse_batch(responses)
parsed = _parse_batch(responses)
# LLM
cfg = load() cfg = load()
api_key = cfg["llm"]["api_key"] api_key = cfg["llm"]["api_key"]
if not api_key: if not api_key:
# Без LLM — возвращаем распарсенные данные
return jsonify({ return jsonify({
"diagnosis": _format_no_llm(parsed), "diagnosis": format_no_llm(parsed),
"parsed": { "parsed": _summary(parsed),
"vin": parsed["vin"],
"dtc_stored": parsed["dtc_stored"],
"dtc_pending": parsed["dtc_pending"],
"parameters": parsed["parameters"],
},
}) })
diagnoser = Diagnoser( diagnoser = Diagnoser(
@@ -234,34 +86,18 @@ def register(app):
model=cfg["llm"].get("model", "gpt-oss-120b"), model=cfg["llm"].get("model", "gpt-oss-120b"),
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"), base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
) )
user_prompt = _build_diagnosis_prompt(parsed) answer = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
answer = diagnoser.diagnose(SYSTEM_PROMPT, user_prompt)
return jsonify({ return jsonify({
"diagnosis": answer, "diagnosis": answer,
"parsed": { "parsed": _summary(parsed),
"vin": parsed["vin"],
"dtc_stored": parsed["dtc_stored"],
"dtc_pending": parsed["dtc_pending"],
"parameters": parsed["parameters"],
},
}) })
def _format_no_llm(parsed: dict) -> str: def _summary(p: dict) -> dict:
"""Форматирует ответ без LLM.""" return {
lines = [] "vin": p["vin"],
if parsed["vin"]: "dtc_stored": p["dtc_stored"],
lines.append(f"VIN: {parsed['vin']}") "dtc_pending": p["dtc_pending"],
if parsed["dtc_stored"]: "parameters": p["parameters"],
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)
+130
View File
@@ -0,0 +1,130 @@
"""Парсинг батча ответов ELM327 в структуру для LLM.
Поддерживает:
- VIN (mode 09 PID 02) — из decoded и fallback из raw HEX
- DTC stored/pending (mode 03/07) — из decoded и fallback из raw HEX
- PID параметры (mode 01) — из decoded
"""
import logging
logger = logging.getLogger("elmer.parser")
def parse_batch(responses: list[dict]) -> dict:
"""Парсит батч ответов в структуру для LLM.
Returns:
{"vin": str|None, "dtc_stored": [str], "dtc_pending": [str],
"parameters": [{"name": str, "value": str}], "raw_log": [str]}
"""
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}")
_parse_vin(result, raw, decoded)
_parse_dtc(result, raw, decoded, mode="03", key="dtc_stored", prefix="DTC stored:")
_parse_dtc(result, raw, decoded, mode="47", key="dtc_pending", prefix="DTC pending:")
_parse_pid(result, decoded, cmd)
logger.info(f"[{cmd}] decoded={decoded[:60]}")
return result
def _parse_vin(result: dict, raw: str, decoded: str):
if decoded.startswith("VIN:"):
vin = decoded.replace("VIN:", "").strip()
if len(vin) == 17:
result["vin"] = vin
return
# Fallback: парсим VIN из raw HEX
if "49" in raw and ("02" in raw or "4902" in raw.replace(" ", "")):
clean = raw.replace(":", "").replace(" ", "").upper()
if "490201" in clean:
hex_str = clean.split("490201")[-1][:34]
vin = ""
for i in range(0, len(hex_str) - 1, 2):
try:
vin += chr(int(hex_str[i:i+2], 16))
except (ValueError, OverflowError):
pass
if len(vin) == 17:
result["vin"] = vin
def _parse_dtc(result: dict, raw: str, decoded: str, *, mode: str, key: str, prefix: str):
if decoded.startswith(prefix):
codes = decoded.replace(prefix, "").strip()
if codes != "none":
result[key] = [c.strip() for c in codes.split()]
return
# Fallback: парсим DTC из raw HEX (43XX... или 47XX...)
clean = raw.replace(" ", "").upper()
if clean.startswith(mode) and len(clean) >= 4:
codes = _decode_dtc_bytes(clean[2:])
if codes:
result[key] = codes
def _decode_dtc_bytes(hex_str: str) -> list[str]:
"""Декодирует HEX-строку DTC (после 43/47) в коды."""
codes = []
i = 2 # skip byte count
while i + 3 < len(hex_str):
try:
a = int(hex_str[i:i+2], 16)
b = int(hex_str[i+2:i+4], 16)
p = {0: "P", 1: "C", 2: "B", 3: "U"}.get(a >> 6, "?")
code = f"{p}{(a>>4)&3}{a&15}{b>>4:X}{b&15:X}"
if code != "P0000":
codes.append(code)
except Exception:
pass
i += 4
return codes
def _parse_pid(result: dict, decoded: str, cmd: str):
if ":" not in decoded:
return
if decoded.startswith(("VIN", "DTC", "ELM", "Protocol")):
return
parts = decoded.split(":", 1)
if len(parts) == 2:
result["parameters"].append({
"name": parts[0].strip(),
"value": parts[1].strip(),
})
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)