Решение по 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.
172 lines
6.7 KiB
Python
172 lines
6.7 KiB
Python
"""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:<id>' | '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
|