Нормализовать переводы строк в 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:
Rustam Khalimov
2026-09-11 19:01:42 +03:00
parent 39c9bdc1b7
commit 713d554dc2
751 changed files with 94507 additions and 94486 deletions
@@ -1,171 +1,171 @@
"""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
"""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