fix(server): идемпотентность upload, WAL, буфер ELM, таймаут LLM
- api/db.py: WAL mode, busy_timeout, request_id UNIQUE, close(), контекстный менеджер - api/routes.py: проверка request_id при upload, /ping-llm кэш 60с, /chat через roles - api/config.py: lru_cache на load() - obd/protocol.py: reset_input_buffer перед _write(), условный READY в send() - brain/client.py: модель 120b, таймаут из параметра, LLMError класс, обработка 429/5xx
This commit is contained in:
+76
-67
@@ -6,11 +6,21 @@ POST /api/v1/session/upload — приём батча, LLM-анализ, воз
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from api.config import load
|
||||
from api.db import Database
|
||||
from api.parser import format_no_llm, parse_batch
|
||||
from api.scripts import build_default_script, build_full_script
|
||||
from api.parser import parse_batch, format_no_llm
|
||||
from brain.client import Diagnoser, LLMError
|
||||
from brain.prompts import SYSTEM_PROMPT
|
||||
|
||||
logger = logging.getLogger("elmer.script")
|
||||
|
||||
# Кэш для /ping-llm (60 секунд)
|
||||
_ping_llm_cache: dict = {}
|
||||
|
||||
|
||||
def _build_diagnosis_prompt(data: dict) -> str:
|
||||
"""Строит промпт для LLM из распарсенных данных."""
|
||||
@@ -55,19 +65,12 @@ def register(app):
|
||||
|
||||
@app.route("/api/v1/script", methods=["GET"])
|
||||
def get_script():
|
||||
from flask import jsonify, request
|
||||
mode = request.args.get("mode", "full")
|
||||
script = build_full_script() if mode == "full" else build_default_script()
|
||||
return jsonify(script)
|
||||
|
||||
@app.route("/api/v1/session/upload", methods=["POST"])
|
||||
def upload_session():
|
||||
from flask import request, jsonify
|
||||
from api.config import load
|
||||
from api.db import Database
|
||||
from brain.client import Diagnoser
|
||||
from brain.prompts import SYSTEM_PROMPT
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "responses" not in data:
|
||||
return jsonify({"error": "missing 'responses'"}), 400
|
||||
@@ -75,6 +78,15 @@ def register(app):
|
||||
responses = data["responses"]
|
||||
logger.info(f"Upload: {len(responses)} responses")
|
||||
|
||||
# ── Идемпотентность: проверяем request_id ─────
|
||||
request_id = (data.get("request_id") or "").strip()
|
||||
if request_id:
|
||||
with Database() as db:
|
||||
cached = db.get_cached_response(request_id)
|
||||
if cached is not None:
|
||||
logger.info(f"Upload: cached response for {request_id}")
|
||||
return jsonify(cached), 200
|
||||
|
||||
# ── Информация о клиенте ──────────────────────
|
||||
client_info = data.get("client_info", {})
|
||||
client_info["client_ip"] = request.remote_addr
|
||||
@@ -104,40 +116,40 @@ def register(app):
|
||||
try:
|
||||
diagnosis = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
|
||||
llm_success = True
|
||||
except Exception as e:
|
||||
except LLMError as e:
|
||||
logger.warning(f"LLM failed: {e}")
|
||||
diagnosis = format_no_llm(parsed) + f"\n\n(LLM недоступен: {e})"
|
||||
diagnosis = format_no_llm(parsed) + f"\n\n({e})"
|
||||
|
||||
llm_duration_ms = int((time.time() - llm_start) * 1000)
|
||||
|
||||
# ── Сохранение в БД ───────────────────────────
|
||||
try:
|
||||
db = Database()
|
||||
db.save_session(
|
||||
client_info=client_info,
|
||||
responses=responses,
|
||||
diagnosis=diagnosis,
|
||||
llm_model=model,
|
||||
llm_duration_ms=llm_duration_ms,
|
||||
llm_success=llm_success,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB save failed: {e}")
|
||||
|
||||
return jsonify({
|
||||
response = {
|
||||
"diagnosis": diagnosis,
|
||||
"parsed": _summary(parsed),
|
||||
"llm_available": llm_available,
|
||||
"llm_success": llm_success,
|
||||
})
|
||||
}
|
||||
|
||||
# ── Сохранение в БД ───────────────────────────
|
||||
try:
|
||||
with Database() as db:
|
||||
db.save_session(
|
||||
client_info=client_info,
|
||||
responses=responses,
|
||||
diagnosis=diagnosis,
|
||||
llm_model=model,
|
||||
llm_duration_ms=llm_duration_ms,
|
||||
llm_success=llm_success,
|
||||
request_id=request_id,
|
||||
response_json=response if request_id else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB save failed: {e}")
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
@app.route("/api/v1/chat", methods=["POST"])
|
||||
def chat():
|
||||
"""Свободный вопрос к LLM (без ELM)."""
|
||||
from flask import request, jsonify
|
||||
from api.config import load
|
||||
from brain.client import Diagnoser
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "question" not in data:
|
||||
return jsonify({"error": "missing 'question'"}), 400
|
||||
@@ -146,27 +158,18 @@ def register(app):
|
||||
if not question:
|
||||
return jsonify({"answer": "Пустой вопрос."})
|
||||
|
||||
# История диалога
|
||||
history = data.get("history", [])
|
||||
history_text = ""
|
||||
if history:
|
||||
history_text = "## История диалога\n"
|
||||
for m in history[-10:]: # последние 10 сообщений
|
||||
role = "Водитель" if m.get("role") == "user" else "Автоэксперт"
|
||||
history_text += f"{role}: {m.get('content', '')}\n"
|
||||
history_text += "\n"
|
||||
|
||||
cfg = load()
|
||||
api_key = cfg["llm"]["api_key"]
|
||||
if not api_key:
|
||||
return jsonify({"answer": "LLM не настроен."})
|
||||
|
||||
prompt = (
|
||||
f"{history_text}"
|
||||
f"Ты — автоэксперт. Помни контекст диалога выше. "
|
||||
f"Отвечай КРАТКО, не более 20 строк. Без воды, только по делу.\n\n"
|
||||
f"Вопрос: {question}"
|
||||
)
|
||||
# История диалога — передаём как массив messages с ролями
|
||||
history_raw = data.get("history", [])
|
||||
history_msgs = [
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in history_raw[-10:]
|
||||
if isinstance(m, dict) and "role" in m and "content" in m
|
||||
]
|
||||
|
||||
try:
|
||||
diagnoser = Diagnoser(
|
||||
@@ -176,10 +179,12 @@ def register(app):
|
||||
)
|
||||
answer = diagnoser.diagnose(
|
||||
"Ты — лаконичный автоэксперт. Помни контекст диалога. Отвечай кратко, максимум 20 строк.",
|
||||
prompt,
|
||||
question,
|
||||
history=history_msgs if history_msgs else None,
|
||||
)
|
||||
except Exception as e:
|
||||
answer = f"LLM недоступен: {e}"
|
||||
except LLMError as e:
|
||||
logger.warning(f"Chat LLM failed: {e}")
|
||||
answer = str(e)
|
||||
|
||||
return jsonify({"answer": answer})
|
||||
|
||||
@@ -190,29 +195,33 @@ def register(app):
|
||||
|
||||
@app.route("/api/v1/ping-llm", methods=["GET"])
|
||||
def ping_llm():
|
||||
"""Быстрая проверка доступности LLM."""
|
||||
from flask import jsonify
|
||||
from api.config import load
|
||||
from brain.client import Diagnoser
|
||||
"""Быстрая проверка доступности LLM (с кэшем 60с)."""
|
||||
nonlocal _ping_llm_cache
|
||||
now = time.time()
|
||||
if _ping_llm_cache and (now - _ping_llm_cache.get("ts", 0)) < 60:
|
||||
return jsonify(_ping_llm_cache["data"])
|
||||
|
||||
cfg = load()
|
||||
api_key = cfg["llm"]["api_key"]
|
||||
if not api_key:
|
||||
return jsonify({"ok": False, "error": "no API key"})
|
||||
result = {"ok": False, "error": "no API key"}
|
||||
else:
|
||||
t0 = time.time()
|
||||
try:
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
diagnoser.diagnose("Отвечай одним словом.", "OK")
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
result = {"ok": True, "ms": ms}
|
||||
except Exception as e:
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
result = {"ok": False, "ms": ms, "error": "LLM unavailable"}
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
diagnoser.diagnose("Отвечай одним словом.", "OK")
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
return jsonify({"ok": True, "ms": ms})
|
||||
except Exception as e:
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
return jsonify({"ok": False, "ms": ms, "error": str(e)[:100]})
|
||||
_ping_llm_cache = {"ts": now, "data": result}
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _summary(p: dict) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user