"""Клиент автономного ML-сервиса + локальный outbox обучения. Основное приложение НИКОГДА не обучает ML напрямую «в своей» базе: каждое действие пользователя синхронно пишется в таблицу ml_outbox, а фоновый воркер (main._ml_sync_loop) отправляет накопленное в ML-сервис батчами. Использование ML в пайплайне включается настройкой `mlEnabled`; обучение идёт всегда. """ from __future__ import annotations import logging import time import httpx from .. import config from ..db import store log = logging.getLogger("leadradar.mlclient") DECISIONS_ML = "mlDecisions" DECISIONS_AI = "aiDecisions" # Веса обучающих сигналов: действия пользователя — истина (1.0), решения ИИ — # гипотезы (меньше), чтобы реальные действия со временем перевешивали ошибки ИИ. USER_WEIGHT = 1.0 AI_WEIGHT = 0.4 RULE_WEIGHT = 0.6 # кэш статуса сервиса (обновляется фоновым циклом; живёт не дольше 15 c) _cached: dict = {"at": 0, "data": None, "reachable": False} def _now() -> int: return time.time_ns() // 1_000_000 # ─── Обучение: всегда пишем в outbox ────────────────────────────────────── def push(text: str, label: str, delta: float = 1.0) -> None: """Действие пользователя -> событие обучения (гарантированно, локально).""" text = (text or "").strip() label = str(label or "").strip() if not text or not label: return store.execute( "INSERT INTO ml_outbox(id, text, label, delta, created_at) VALUES (?, ?, ?, ?, ?)", [store.uid("mle_"), text[:6000], label, delta, _now()], ) def outbox_len() -> int: return int(store.scalar("SELECT count(*) FROM ml_outbox") or 0) async def flush_outbox(batch: int = 100) -> int: """Отправляет накопленные события в ML-сервис небольшими порциями. Один learn-batch из сотен сообщений надолго блокирует ML-сервис (модель пишет каждый термин отдельным INSERT) и упирается в таймаут; порции по 20 строк проходят быстро и не роняют сервис. """ chunk = 10 total = 0 while total < batch: rows = store.query( "SELECT id, text, label, delta FROM ml_outbox ORDER BY created_at LIMIT ?", [chunk], ) if not rows: break items = [{"text": r["text"], "label": r["label"], "delta": r["delta"]} for r in rows] try: await _post("/learn-batch", {"items": items}, timeout=config.ML_TIMEOUT + 10) except Exception as exc: # noqa: BLE001 log.warning("ml outbox flush failed (%d rows): %s", len(rows), exc) break ids = [r["id"] for r in rows] store.execute(f"DELETE FROM ml_outbox WHERE id IN ({','.join('?' * len(ids))})", ids) total += len(rows) log.info("ml outbox flushed: %d", len(rows)) return total # ─── HTTP ───────────────────────────────────────────────────────────────── async def _post(path: str, body: dict, timeout: float | None = None) -> dict: async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client: resp = await client.post(config.ML_URL + path, json=body) resp.raise_for_status() return resp.json() async def _get(path: str, timeout: float | None = None) -> dict: async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client: resp = await client.get(config.ML_URL + path) resp.raise_for_status() return resp.json() async def predict(text: str) -> dict: """Предсказание. При сбое/недоступности сервиса — «не уверен» (решит ИИ).""" try: return await _post("/predict", {"text": (text or "")[:6000]}) except Exception as exc: # noqa: BLE001 log.debug("ml predict unavailable: %s", exc) return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False} async def reset_model() -> dict: """Полный сброс ML-модели + очистка очереди обучения. Старая модель (в т.ч. «мусорные» классы удалённых колонок) стирается, события обучения из outbox тоже удаляются — иначе они сразу «переобучат» модель на старых данных. """ try: await _post("/reset", {}, timeout=30) except Exception as exc: # noqa: BLE001 log.warning("ml reset failed: %s", exc) return {"ok": False, "error": str(exc)} store.execute("DELETE FROM ml_outbox") await refresh_status() return {"ok": True} async def refresh_status() -> dict: global _cached try: data = await _get("/status", timeout=3) _cached = {"at": _now(), "data": data, "reachable": True} except Exception as exc: # noqa: BLE001 log.debug("ml status unavailable: %s", exc) _cached = {"at": _now(), "data": _cached["data"], "reachable": False} return _cached["data"] or {} def snapshot() -> dict: """Локальная статистика + последний известный статус ML-сервиса (без HTTP).""" fresh = _cached["data"] or {} return { "ml": int(store.get_setting(DECISIONS_ML) or 0), "ai": int(store.get_setting(DECISIONS_AI) or 0), "learning": int(store.scalar("SELECT count(*) FROM learning_log") or 0), "ready": bool(fresh.get("ready")), "classes": fresh.get("classes", {}), "learned": int(fresh.get("learned") or 0), "reachable": _cached["reachable"], "outbox": outbox_len(), } def track_decisions(ml: int = 0, ai: int = 0) -> None: if ml: store.set_setting(DECISIONS_ML, int(store.get_setting(DECISIONS_ML) or 0) + ml) if ai: store.set_setting(DECISIONS_AI, int(store.get_setting(DECISIONS_AI) or 0) + ai) def is_enabled() -> bool: """Использовать ли ML в пайплайне (настройка UI). Обучение — всегда.""" return store.get_setting("mlEnabled") is not False and _cached["reachable"]