Files
Deal/src/core/Deal.Api/Endpoints/MlEndpoints.cs
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

183 lines
8.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7).
/// </summary>
/// <remarks>
/// Тела ответов 1:1 с прототипом <c>backend/app/routers/ml_routes.py</c> (L6691, L112171):
/// <c>status</c> — MlStatusResponseDto (enabled/service/reachable/stats, §4.10 L363); <c>reset</c> —
/// <c>{ok:true}</c> (мягкая ошибка {ok:false,error} зарезервирована — заглушка всегда успешна);
/// <c>predict</c> — <c>{text: первые 200, take, label, scores, hits, ready, margin, terms, type}</c>
/// (текст короче 2 символов после trim → 400 «Введите текст»); <c>candidates</c> — <c>{items}</c> реальных
/// сообщений-кандидатов канала/выборки (очередь/отсев/карточки + мнение ML, §8; MlReviewService);
/// <c>apply</c> — 404 «Исходное сообщение не найдено» либо результат ручной разметки
/// <c>{ok, learned, moved, leadId}</c> (обучение ML + перенос/корзина/отсев). ml/learn и ml/flush
/// НЕ реализуются (фронт не вызывает, api-map п.9 L399). Все эндпоинты требуют сессию: 401 {detail}
/// (Ruling 10). IMlClient/MlReviewService резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped
/// на tenant-запрос — вне него не разрешимы, паттерн SettingsEndpoints/AiCheckEndpoint).
/// </remarks>
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) &lt; 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 = "Исходное сообщение не найдено";
/// <summary>
/// Регистрирует группу /api/ml: status/reset/predict/candidates/apply.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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 L6675).
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
return Results.Ok(await mlClient.StatusAsync(ct));
}
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L7881).
private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
return Results.Ok(await mlClient.ResetAsync(ct));
}
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L8490).
private static async Task<IResult> 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<IMlClient>();
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 L112134).
private static async Task<IResult> CandidatesAsync(MlCandidatesRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
IReadOnlyList<MlCandidateDto> items = await review.CandidatesAsync(body.DialogId, body.Limit, ct);
return Results.Ok(new { items });
}
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137171).
private static async Task<IResult> ApplyAsync(MlApplyRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
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;
}