Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
183 lines
8.4 KiB
C#
183 lines
8.4 KiB
C#
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> (L66–91, L112–171):
|
||
/// <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) < 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 L66–75).
|
||
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 L78–81).
|
||
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 L84–90).
|
||
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 L112–134).
|
||
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 L137–171).
|
||
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;
|
||
}
|