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:
Repinoid
2026-05-31 16:56:21 +03:00
parent eab73cd76a
commit 6893ae33f5
5 changed files with 177 additions and 100 deletions
+47 -11
View File
@@ -18,10 +18,24 @@ from pathlib import Path
class Database:
def __init__(self, path: str | Path = "elmer.db"):
self.path = Path(path)
self.conn = sqlite3.connect(str(self.path))
self.conn = sqlite3.connect(str(self.path), timeout=30, check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA busy_timeout=30000")
self._init_schema()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
def close(self):
if self.conn:
self.conn.close()
self.conn = None
def _init_schema(self):
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS cars (
@@ -113,22 +127,40 @@ class Database:
llm_success INTEGER DEFAULT 0,
-- Сырые данные (JSON)
raw_responses TEXT
raw_responses TEXT,
-- Идемпотентность
request_id TEXT UNIQUE,
response_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at);
CREATE INDEX IF NOT EXISTS idx_sessions_vin ON sessions(vin);
CREATE INDEX IF NOT EXISTS idx_sessions_mac ON sessions(elm_mac);
CREATE INDEX IF NOT EXISTS idx_sessions_aid ON sessions(android_id);
CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at);
CREATE INDEX IF NOT EXISTS idx_sessions_vin ON sessions(vin);
CREATE INDEX IF NOT EXISTS idx_sessions_mac ON sessions(elm_mac);
CREATE INDEX IF NOT EXISTS idx_sessions_aid ON sessions(android_id);
CREATE INDEX IF NOT EXISTS idx_sessions_request_id ON sessions(request_id);
""")
self.conn.commit()
# ── sessions ──────────────────────────────────────────
def get_cached_response(self, request_id: str) -> dict | None:
"""Возвращает сохранённый ответ сессии по request_id, или None."""
row = self.conn.execute(
"SELECT response_json FROM sessions WHERE request_id = ?", (request_id,)
).fetchone()
if row and row["response_json"]:
return json.loads(row["response_json"])
return None
def save_session(self, client_info: dict, responses: list[dict],
diagnosis: str = "", llm_model: str = "",
llm_duration_ms: int = 0, llm_success: bool = False):
"""Сохраняет сводную запись о сессии."""
llm_duration_ms: int = 0, llm_success: bool = False,
request_id: str = "", response_json: dict | None = None):
"""Сохраняет сводную запись о сессии.
Если request_id передан и уже существует — silently return (идемпотентность).
"""
ci = client_info
# Подсчёт DTC/PID из ответов
@@ -151,8 +183,10 @@ class Database:
vin = None
break
resp_json_str = json.dumps(response_json, ensure_ascii=False) if response_json else None
self.conn.execute("""
INSERT INTO sessions (
INSERT OR IGNORE INTO sessions (
client_ip, real_ip, user_agent, content_length,
phone_model, phone_maker, android_version, android_sdk,
app_version, android_id,
@@ -163,9 +197,9 @@ class Database:
transport, mock_mode,
diagnosis_text, diagnosis_len, llm_model,
llm_duration_ms, llm_success,
raw_responses
raw_responses, request_id, response_json
) VALUES (?,?,?,?, ?,?,?,?, ?,?, ?,?,?, ?,?,?, ?,?,?, ?,?,?, ?,?,
?,?,?, ?,?, ?)
?,?,?, ?,?, ?,?,?)
""", (
ci.get("client_ip"), ci.get("real_ip"), ci.get("user_agent"),
ci.get("content_length"),
@@ -179,6 +213,8 @@ class Database:
diagnosis, len(diagnosis), llm_model,
llm_duration_ms, 1 if llm_success else 0,
json.dumps(responses, ensure_ascii=False) if responses else None,
request_id if request_id else None,
resp_json_str,
))
self.conn.commit()