Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
This commit is contained in:
@@ -1,162 +1,162 @@
|
||||
"""Клиент автономного 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"]
|
||||
"""Клиент автономного 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"]
|
||||
|
||||
Reference in New Issue
Block a user