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; }