using Deal.Api.Events; using Deal.Api.Extensions; using Deal.Api.Models; using Deal.Api.Services; using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; using Deal.Api.Dtos; namespace Deal.Api.Endpoints; /// /// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords /// (план Task 14 L476–479; прототип dashboard_routes.py L395–409). /// /// /// Контракт 1:1 с прототипом и api-map §3.2 L120–121: suggest-columns → {ok:true, created:N} /// либо {ok:false, reason, cooldown?}; suggest-keywords → {ok:true, keywords:[…]} либо /// {ok:false, reason}. Причины — мягкие ошибки (HTTP 200 с ok:false + reason), статусы 4xx/5xx /// не мапятся. Оба требуют сессию: 401 {detail} без куки (как остальные эндпоинты контейнеров); порт /// IColumnSuggester резолвится из RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст /// запроса). При успехе suggest-columns публикуется SSE-toast «ИИ предложил колонок: N — откройте и /// решите» (sparkles, 1:1 с suggest.py L162); boards_changed НЕ шлём (Ruling 5: фронт перечитывает /// доски сам после ok). Публикации — из эндпоинта (Ruling 5): без подписчиков — no-op. /// public static class AiSuggestEndpoints { // Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406). private const string AiGroupPrefix = "/api/ai"; // Путь предложения колонок (dashboard_routes.py L395). private const string SuggestColumnsPath = "/suggest-columns"; // Путь предложения ключевых слов (dashboard_routes.py L404). private const string SuggestKeywordsPath = "/suggest-keywords"; // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py). private const string OpenApiTag = "dashboard"; // SSE-тип события тоста (Ruling 5; api.js слушает 'toast'). private const string ToastEventType = "toast"; // Текст тоста после успешных предложений колонок (suggest.py L162, 1:1). private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите"; // Иконка тоста предложений колонок (sparkles, 1:1 с прототипом). private const string SparklesIcon = "sparkles"; /// /// Регистрирует POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords. /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapAiSuggestEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup(AiGroupPrefix).WithTags(OpenApiTag); group.MapPost(SuggestColumnsPath, SuggestColumnsAsync); group.MapPost(SuggestKeywordsPath, SuggestKeywordsAsync); return app; } // POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений. // Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт // SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/ // «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3). private static async Task SuggestColumnsAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } IColumnSuggester suggester = context.RequestServices.GetRequiredService(); SuggestColumnsResultDto result = await suggester.SuggestColumnsAsync(ct); if (result.Ok) { SseBroker broker = context.RequestServices.GetRequiredService(); broker.Publish( context.GetCurrentUser()!.TenantId, ToastEventType, new { text = string.Format(SuggestToastTextFormat, result.Created), icon = SparklesIcon }); } return Results.Ok(result); } // POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»). // Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200). private static async Task SuggestKeywordsAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } IColumnSuggester suggester = context.RequestServices.GetRequiredService(); SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct); return Results.Ok(result); } }