Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
163 lines
7.0 KiB
Python
163 lines
7.0 KiB
Python
"""Клиент автономного 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"]
|