"""ML-лаборатория: статус, проверка на сообщении, обучение на канале. Всё обучение уходит в outbox (гарантированно), фоновый цикл отправляет его в автономный ML-сервис. Использование ML в пайплайне — только по настройке `mlEnabled`; здесь можно проверить модель и вручную разметить сообщения. """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from ..auth import current_login from ..db import store from ..services import leads as leads_svc from ..services import ml_client from ..services.telegram import tg router = APIRouter(prefix="/api/ml", tags=["ml"]) class PredictBody(BaseModel): text: str class LearnBody(BaseModel): text: str label: str class CandidatesBody(BaseModel): dialogId: str limit: int = 10 class ApplyBody(BaseModel): dialogId: str msgId: int action: str # 'spam' | 'board:' | 'skip' def _msg_text(dialog_id: str, msg_id: int) -> str | None: """Текст исходного сообщения (из карточки или из messages).""" row = store.query_one( "SELECT source_msg AS text FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1", [dialog_id, msg_id], ) if row and row["text"]: return row["text"] m = store.query_one("SELECT text FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"]) return m["text"] if m else None def _lead_by_msg(dialog_id: str, msg_id: int) -> dict | None: row = store.query_one( "SELECT id FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1", [dialog_id, msg_id], ) if row: return leads_svc.get_lead(row["id"]) link = store.query_one("SELECT lead_id FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"]) if link and link["lead_id"]: return leads_svc.get_lead(link["lead_id"]) return None @router.get("/status") async def ml_status(_: str = Depends(current_login)) -> dict: """Свежий статус ML-сервиса + локальная статистика (с принудительным refresh).""" svc = await ml_client.refresh_status() return { "enabled": store.get_setting("mlEnabled") is not False, "service": svc, "reachable": ml_client._cached["reachable"], # noqa: SLF001 "stats": ml_client.snapshot(), } @router.post("/reset") async def ml_reset(_: str = Depends(current_login)) -> dict: """Полный сброс ML-модели (классы/термины) + очистка очереди обучения.""" return await ml_client.reset_model() @router.post("/predict") async def ml_predict(body: PredictBody, _: str = Depends(current_login)) -> dict: text = (body.text or "").strip() if len(text) < 2: raise HTTPException(400, "Введите текст") result = await ml_client.predict(text) return {"text": text[:200], **result} @router.post("/learn") async def ml_learn(body: LearnBody, _: str = Depends(current_login)) -> dict: """Ручная разметка: «это сообщение -> сюда». Пишется в outbox (всегда).""" text = (body.text or "").strip() label = (body.label or "").strip() if not text or not label: raise HTTPException(400, "text и label обязательны") ml_client.push(text, label) return {"ok": True, "outbox": ml_client.outbox_len()} @router.post("/flush") async def ml_flush(_: str = Depends(current_login)) -> dict: """Отправить накопленное обучение в ML-сервис немедленно (обычно — фон раз в 10 c).""" flushed = await ml_client.flush_outbox() svc = await ml_client.refresh_status() return {"ok": True, "flushed": flushed, "outbox": ml_client.outbox_len(), "service": svc} @router.post("/candidates") async def ml_candidates(body: CandidatesBody, _: str = Depends(current_login)) -> dict: """Последние сообщения канала для разбора/обучения + мнение ML по каждому.""" limit = max(1, min(body.limit, 60)) items = await tg.dialog_messages(body.dialogId, limit) out = [] for m in items: text = (m.get("text") or "").strip() if not text: continue pred = await ml_client.predict(text) if ml_client.is_enabled() else { "take": False, "label": None, "scores": {}, "ready": False} out.append( { "id": m["id"], "dialogId": body.dialogId, "text": text[:600], "time": m.get("time"), "lead": bool(m.get("lead")), "pred": {"take": pred.get("take"), "label": pred.get("label"), "scores": pred.get("scores", {})}, } ) return {"items": out} @router.post("/apply") async def ml_apply(body: ApplyBody, _: str = Depends(current_login)) -> dict: """Ручное решение по сообщению: учим ML и (если карточка есть) двигаем её.""" text = _msg_text(body.dialogId, body.msgId) if not text: raise HTTPException(404, "Исходное сообщение не найдено") action = body.action if action == "skip": return {"ok": True, "learned": False, "moved": None} lead = _lead_by_msg(body.dialogId, body.msgId) result: dict = {"ok": True, "learned": False, "moved": None, "leadId": None} if action == "spam": ml_client.push(text, "spam") result["learned"] = True if lead: leads_svc.trash_lead(lead["id"], teach=False) result["moved"] = "trash" result["leadId"] = lead["id"] elif action.startswith("board:"): board_id = action.split(":", 1)[1] if board_id != "inbox" and store.query_one("SELECT 1 FROM boards WHERE id = ?", [board_id]) is None: raise HTTPException(400, "Неизвестная доска") ml_client.push(text, board_id) result["learned"] = True if lead: # повторная разметка карточки, которая уже на доске, — просто обучение if lead["col"] != board_id: leads_svc.move_lead(lead["id"], board_id, teach=False) result["moved"] = board_id result["leadId"] = lead["id"] else: raise HTTPException(400, "Неизвестное действие") return result