using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Api.Endpoints;
///
/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7).
///
///
/// Тела ответов 1:1 с прототипом backend/app/routers/ml_routes.py (L66–91, L112–171):
/// status — MlStatusResponseDto (enabled/service/reachable/stats, §4.10 L363); reset —
/// {ok:true} (мягкая ошибка {ok:false,error} зарезервирована — заглушка всегда успешна);
/// predict — {text: первые 200, take, label, scores, hits, ready, margin, terms, type}
/// (текст короче 2 символов после trim → 400 «Введите текст»); candidates — {items} реальных
/// сообщений-кандидатов канала/выборки (очередь/отсев/карточки + мнение ML, §8; MlReviewService);
/// apply — 404 «Исходное сообщение не найдено» либо результат ручной разметки
/// {ok, learned, moved, leadId} (обучение ML + перенос/корзина/отсев). ml/learn и ml/flush
/// НЕ реализуются (фронт не вызывает, api-map п.9 L399). Все эндпоинты требуют сессию: 401 {detail}
/// (Ruling 10). IMlClient/MlReviewService резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped
/// на tenant-запрос — вне него не разрешимы, паттерн SettingsEndpoints/AiCheckEndpoint).
///
public static class MlEndpoints
{
// Префикс группы /api/ml (Ruling 8: MapMlEndpoints).
private const string MlGroupPrefix = "/api/ml";
// OpenAPI-тег группы (в прототипе роутер ml — ml_routes.py).
private const string MlOpenApiTag = "ml";
// Путь статуса ML (GET).
private const string StatusPath = "/status";
// Путь сброса модели (POST).
private const string ResetPath = "/reset";
// Путь проверки ML на тексте (POST).
private const string PredictPath = "/predict";
// Путь разбора сообщений канала (POST).
private const string CandidatesPath = "/candidates";
// Путь ручного решения по сообщению (POST).
private const string ApplyPath = "/apply";
// Минимальная длина текста для проверки (ml_routes.py L87: len(text) < 2 → 400).
private const int MinPredictTextLength = 2;
// Длина текста в ответе predict: первые 200 символов (ml_routes.py L90 text[:200]).
private const int PredictTextPreviewLength = 200;
// Сообщение 400 для слишком короткого текста (ml_routes.py L88, план Task 9 L348).
private const string EnterTextDetail = "Введите текст";
// Сообщение 404 apply: исходное сообщение не найдено (ml_routes.py L142, план Task 9 L352).
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
///
/// Регистрирует группу /api/ml: status/reset/predict/candidates/apply.
///
/// Построитель маршрутов приложения.
/// Построитель маршрутов для цепочки вызовов.
public static IEndpointRouteBuilder MapMlEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(MlGroupPrefix).WithTags(MlOpenApiTag);
group.MapGet(StatusPath, StatusAsync);
group.MapPost(ResetPath, ResetAsync);
group.MapPost(PredictPath, PredictAsync);
group.MapPost(CandidatesPath, CandidatesAsync);
group.MapPost(ApplyPath, ApplyAsync);
return app;
}
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75).
private static async Task StatusAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IMlClient mlClient = context.RequestServices.GetRequiredService();
return Results.Ok(await mlClient.StatusAsync(ct));
}
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L78–81).
private static async Task ResetAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IMlClient mlClient = context.RequestServices.GetRequiredService();
return Results.Ok(await mlClient.ResetAsync(ct));
}
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L84–90).
private static async Task PredictAsync(MlPredictRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
string text = (body.Text ?? string.Empty).Trim();
if (text.Length < MinPredictTextLength)
{
return EndpointResults.BadRequest(EnterTextDetail);
}
IMlClient mlClient = context.RequestServices.GetRequiredService();
MlPredictResultDto result = await mlClient.PredictAsync(text, ct);
// Ответ 1:1 с ml_routes.py L90: {"text": <первые 200>, **результат предсказания}.
string preview = text.Length <= PredictTextPreviewLength
? text
: text[..PredictTextPreviewLength];
return Results.Ok(new
{
text = preview,
take = result.Take,
label = result.Label,
scores = result.Scores,
hits = result.Hits,
ready = result.Ready,
margin = result.Margin,
terms = result.Terms,
type = result.Type,
});
}
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134).
private static async Task CandidatesAsync(MlCandidatesRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
MlReviewService review = context.RequestServices.GetRequiredService();
IReadOnlyList items = await review.CandidatesAsync(body.DialogId, body.Limit, ct);
return Results.Ok(new { items });
}
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171).
private static async Task ApplyAsync(MlApplyRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
MlReviewService review = context.RequestServices.GetRequiredService();
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
if (result is null)
{
return EndpointResults.NotFound(MessageNotFoundDetail);
}
if (result.Error is not null)
{
return EndpointResults.BadRequest(result.Error);
}
return Results.Ok(new
{
ok = result.Ok,
learned = result.Learned,
moved = result.Moved,
leadId = result.LeadId,
});
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}