Integrations -> Abstractions/Exceptions/Extensions/Models/Options/ Services (включая Storage); Persistence-конфигурации -> Configurations; корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos. namespace приведён к путям, using добавлены/дедуплицированы, FQN обновлены.
545 lines
27 KiB
C#
545 lines
27 KiB
C#
using System.Text.Json;
|
||
using Deal.Api.Endpoints.RequestModels;
|
||
using Deal.Api.Http;
|
||
using Deal.Contracts.Integrations;
|
||
using Deal.Contracts.Integrations.Models;
|
||
using Deal.Modules.Discovery.Application.Abstractions;
|
||
using Deal.Modules.Discovery.Application.Exceptions;
|
||
using Deal.Modules.Discovery.Application.Extensions;
|
||
using Deal.Modules.Discovery.Application.Models;
|
||
using Deal.Modules.Discovery.Application.Registrars;
|
||
using Deal.Modules.Discovery.Application.Services;
|
||
using Deal.Modules.Settings.Application.Abstractions;
|
||
using Deal.Modules.Settings.Application.Models;
|
||
using Deal.Modules.Settings.Application.Registrars;
|
||
using Deal.Modules.Settings.Application.Services;
|
||
using Deal.Modules.Telegram.Application;
|
||
using Deal.Api.Services;
|
||
using Deal.Api.Dtos;
|
||
|
||
namespace Deal.Api.Endpoints;
|
||
|
||
/// <summary>
|
||
/// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// 13 эндпоинтов 1:1 с <c>backend/app/routers/discovery_routes.py</c> (prefix /api/discovery): tasks
|
||
/// (list/create/patch/delete/start/pause), generate-keywords (мягкая ошибка HTTP 200 {keywords: [], error},
|
||
/// Ruling 11), candidates (статус-фильтр), join/reject (ручные, вне квот воркера), blacklist, log. Тела ответов —
|
||
/// DTO модуля Discovery (camelCase, §4.8 L353–355) и {items: [...]} для списков (конвенция api-map §1);
|
||
/// 404-семантика сервисов (null/KeyError python) — «Задача не найдена»/«Кандидат не найден»,
|
||
/// 400-семантика — <see cref="DiscoveryValidationException"/> (ValueError python) с текстом причины 1:1.
|
||
/// Ручной join — как worker-авто-join (Task 18): RPC Join через <see cref="ITelegramGateway"/> → строка каталога
|
||
/// Dialogs (монитор on) + зеркало через <see cref="DialogsService.AddDiscoveredMonitoredAsync"/> → фоновый первый
|
||
/// разбор (<see cref="TelegramBackfillScheduler"/>, python-_spawn) → снятие чёрного списка → mark_joined(auto:false);
|
||
/// ошибка Telegram → 400 с текстом причины. Все эндпоинты требуют сессию: 401 {detail} (Ruling 10); сервисы
|
||
/// резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints).
|
||
/// </remarks>
|
||
public static class DiscoveryEndpoints
|
||
{
|
||
// Префикс группы /api/discovery (python: router prefix, discovery_routes.py L26).
|
||
private const string DiscoveryGroupPrefix = "/api/discovery";
|
||
|
||
// OpenAPI-тег группы (в прототипе роутер discovery — discovery_routes.py).
|
||
private const string DiscoveryOpenApiTag = "discovery";
|
||
|
||
// Путь списка задач (GET).
|
||
private const string TasksPath = "/tasks";
|
||
|
||
// Путь одной задачи: обновление (PATCH) / удаление (DELETE).
|
||
private const string TaskPath = "/tasks/{task_id}";
|
||
|
||
// Путь запуска поиска (POST).
|
||
private const string TaskStartPath = "/tasks/{task_id}/start";
|
||
|
||
// Путь паузы поиска (POST).
|
||
private const string TaskPausePath = "/tasks/{task_id}/pause";
|
||
|
||
// Путь ИИ-генерации ключевых слов по описанию задачи (POST).
|
||
private const string TaskGenerateKeywordsPath = "/tasks/{task_id}/generate-keywords";
|
||
|
||
// Путь списка кандидатов задачи со статус-фильтром (GET).
|
||
private const string TaskCandidatesPath = "/tasks/{task_id}/candidates";
|
||
|
||
// Путь ручного вступления в кандидата (POST).
|
||
private const string CandidateJoinPath = "/candidates/{dialog_id}/join";
|
||
|
||
// Путь отклонения кандидата в чёрный список (POST).
|
||
private const string CandidateRejectPath = "/candidates/{dialog_id}/reject";
|
||
|
||
// Путь чёрного списка (GET) и снятия записи (DELETE).
|
||
private const string BlacklistPath = "/blacklist";
|
||
|
||
// Путь записи чёрного списка (DELETE).
|
||
private const string BlacklistItemPath = "/blacklist/{dialog_id}";
|
||
|
||
// Путь лога задачи (GET).
|
||
private const string TaskLogPath = "/tasks/{task_id}/log";
|
||
|
||
// 404 create/start/patch/candidates/log: задачи нет (python _task_or_404 L83–87).
|
||
private const string TaskNotFoundDetail = "Задача не найдена";
|
||
|
||
// 404 join/reject: кандидата нет (python _candidate_or_404 L90–94).
|
||
private const string CandidateNotFoundDetail = "Кандидат не найден";
|
||
|
||
// 400 join: уже вступили (python L235–237).
|
||
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
|
||
|
||
// 400 reject: источник уже вступили (python L256–258).
|
||
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
|
||
|
||
// 400 join: ошибка Telegram при вступлении (python L240–242, текст с @username).
|
||
private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}";
|
||
|
||
// Причина отклонения вручную для чёрного списка/лога (python L260: reason="отклонено вручную").
|
||
private const string ManualRejectReason = "отклонено вручную";
|
||
|
||
// Мягкая ошибка generate-keywords: ИИ выключен (python _ai_unavailable_reason L99–100).
|
||
private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)";
|
||
|
||
// Мягкая ошибка generate-keywords: описания нет (python L201–202).
|
||
private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи";
|
||
|
||
// Страховочный потолок числа сгенерированных ключей (python _KEYWORDS_LIMIT L31: промпт просит 10–16).
|
||
private const int KeywordsLimit = 30;
|
||
|
||
// Потолок длины одного ключа (python _KEYWORD_LENGTH_LIMIT L33: короткие фразы для поиска Telegram).
|
||
private const int KeywordLengthLimit = 60;
|
||
|
||
/// <summary>
|
||
/// Регистрирует группу /api/discovery: 13 эндпоинтов (tasks + generate-keywords + candidates + join/reject + blacklist + log).
|
||
/// </summary>
|
||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||
public static IEndpointRouteBuilder MapDiscoveryEndpoints(this IEndpointRouteBuilder app)
|
||
{
|
||
var group = app.MapGroup(DiscoveryGroupPrefix).WithTags(DiscoveryOpenApiTag);
|
||
|
||
group.MapGet(TasksPath, ListTasksAsync);
|
||
group.MapPost(TasksPath, CreateTaskAsync);
|
||
group.MapPatch(TaskPath, PatchTaskAsync);
|
||
group.MapDelete(TaskPath, DeleteTaskAsync);
|
||
group.MapPost(TaskStartPath, StartTaskAsync);
|
||
group.MapPost(TaskPausePath, PauseTaskAsync);
|
||
group.MapPost(TaskGenerateKeywordsPath, GenerateKeywordsAsync);
|
||
group.MapGet(TaskCandidatesPath, ListCandidatesAsync);
|
||
group.MapPost(CandidateJoinPath, JoinCandidateAsync);
|
||
group.MapPost(CandidateRejectPath, RejectCandidateAsync);
|
||
group.MapGet(BlacklistPath, ListBlacklistAsync);
|
||
group.MapDelete(BlacklistItemPath, RemoveBlacklistAsync);
|
||
group.MapGet(TaskLogPath, TaskLogAsync);
|
||
|
||
return app;
|
||
}
|
||
|
||
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143).
|
||
private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||
IReadOnlyList<DiscoveryTaskDto> items = await tasks.ListAsync(ct);
|
||
return Results.Ok(new { items });
|
||
}
|
||
|
||
// POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе).
|
||
private static async Task<IResult> CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
try
|
||
{
|
||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||
DiscoveryTaskDto task = await tasks.CreateAsync(ToDraft(body), ct);
|
||
return Results.Ok(task);
|
||
}
|
||
catch (DiscoveryValidationException exception)
|
||
{
|
||
return EndpointResults.BadRequest(exception.Message);
|
||
}
|
||
}
|
||
|
||
// PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154–161; 404/400).
|
||
private static async Task<IResult> PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
try
|
||
{
|
||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||
DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
|
||
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
|
||
}
|
||
catch (DiscoveryValidationException exception)
|
||
{
|
||
return EndpointResults.BadRequest(exception.Message);
|
||
}
|
||
}
|
||
|
||
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168).
|
||
private static async Task<IResult> DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||
bool deleted = await tasks.DeleteAsync(task_id, ct);
|
||
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail);
|
||
}
|
||
|
||
// POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171–179; пустые ключи → 400).
|
||
private static async Task<IResult> StartTaskAsync(string task_id, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
try
|
||
{
|
||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||
DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct);
|
||
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
|
||
}
|
||
catch (DiscoveryValidationException exception)
|
||
{
|
||
return EndpointResults.BadRequest(exception.Message);
|
||
}
|
||
}
|
||
|
||
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187).
|
||
private static async Task<IResult> PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||
DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct);
|
||
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
|
||
}
|
||
|
||
// POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи
|
||
// (generate_keywords L189–211). ИИ выключен/недоступен/нет описания → HTTP 200 {keywords: [], error}.
|
||
// Очистка ключей ответа — CleanKeywords (python _clean_keywords L111–128: ≤30, ≤60
|
||
// символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints).
|
||
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
|
||
private static async Task<IResult> GenerateKeywordsAsync(string task_id, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
|
||
if (task is null)
|
||
{
|
||
return EndpointResults.NotFound(TaskNotFoundDetail);
|
||
}
|
||
|
||
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
|
||
if (!await ReadAiEnabledAsync(settings, ct))
|
||
{
|
||
return Results.Ok(new { keywords = Array.Empty<string>(), error = AiDisabledDetail });
|
||
}
|
||
|
||
string description = (task.Description ?? string.Empty).Trim();
|
||
if (description.Length == 0)
|
||
{
|
||
return Results.Ok(new { keywords = Array.Empty<string>(), error = NoDescriptionDetail });
|
||
}
|
||
|
||
try
|
||
{
|
||
IAiTools aiTools = context.RequestServices.GetRequiredService<IAiTools>();
|
||
AiGenerateKeywordsResultDto result = await aiTools.GenerateKeywordsAsync(description, ct);
|
||
if (result.Ok)
|
||
{
|
||
return Results.Ok(new { keywords = CleanKeywords(result.Keywords) });
|
||
}
|
||
|
||
// Недоступность провайдера/сервиса — мягкая ошибка для UI (Ruling 11), HTTP 200.
|
||
return Results.Ok(new { keywords = Array.Empty<string>(), error = result.Error ?? ServiceUnavailableText });
|
||
}
|
||
catch (NotSupportedException exception)
|
||
{
|
||
// Локальный режим: ai-service не подключён — инструменты недоступны (LocalAiTools, Task 15).
|
||
return Results.Ok(new { keywords = Array.Empty<string>(), error = exception.Message });
|
||
}
|
||
}
|
||
|
||
// GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром
|
||
// new|review|joined|rejected (list_candidates L216–224; невалидный статус — пустой список).
|
||
private static async Task<IResult> ListCandidatesAsync(string task_id, string? status, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
|
||
if (task is null)
|
||
{
|
||
return EndpointResults.NotFound(TaskNotFoundDetail);
|
||
}
|
||
|
||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
|
||
return Results.Ok(new { items });
|
||
}
|
||
|
||
// POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226–251).
|
||
// RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка →
|
||
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
|
||
private static async Task<IResult> JoinCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
|
||
if (row is null)
|
||
{
|
||
return EndpointResults.NotFound(CandidateNotFoundDetail);
|
||
}
|
||
|
||
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
||
{
|
||
return EndpointResults.BadRequest(AlreadyJoinedDetail);
|
||
}
|
||
|
||
string username = (row.Username ?? string.Empty).Trim().TrimStart('@');
|
||
try
|
||
{
|
||
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
|
||
await gateway.JoinAsync(username, ct);
|
||
}
|
||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||
{
|
||
string reason = TelegramEndpoints.GatewayErrorText(exception);
|
||
return EndpointResults.BadRequest(string.Format(JoinFailedFormat, username, reason));
|
||
}
|
||
|
||
// Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service
|
||
// (python add_dialog_monitored L242; решение T18: локальную строку пишет Api-слой).
|
||
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
|
||
await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct);
|
||
|
||
// Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill
|
||
// (python _spawn(_backfill_quiet) L244–245; источник уже в каталоге и мониторится).
|
||
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
|
||
|
||
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
|
||
await blacklist.RemoveAsync(dialog_id, ct);
|
||
|
||
try
|
||
{
|
||
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
|
||
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined);
|
||
}
|
||
catch (DiscoveryValidationException exception)
|
||
{
|
||
return EndpointResults.BadRequest(exception.Message);
|
||
}
|
||
}
|
||
|
||
// POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список
|
||
// (reject_candidate L253–264; уже вступившего — нельзя, 400).
|
||
private static async Task<IResult> RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
|
||
if (row is null)
|
||
{
|
||
return EndpointResults.NotFound(CandidateNotFoundDetail);
|
||
}
|
||
|
||
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
||
{
|
||
return EndpointResults.BadRequest(JoinedRejectDetail);
|
||
}
|
||
|
||
try
|
||
{
|
||
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
|
||
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected);
|
||
}
|
||
catch (DiscoveryValidationException exception)
|
||
{
|
||
return EndpointResults.BadRequest(exception.Message);
|
||
}
|
||
}
|
||
|
||
// GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271).
|
||
private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
|
||
IReadOnlyList<DiscoveryBlacklistDto> items = await blacklist.ListAsync(ct);
|
||
return Results.Ok(new { items });
|
||
}
|
||
|
||
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274–277).
|
||
private static async Task<IResult> RemoveBlacklistAsync(string dialog_id, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
|
||
await blacklist.RemoveAsync(dialog_id, ct);
|
||
return Results.Ok(new { ok = true });
|
||
}
|
||
|
||
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282–285), события от новых к старым.
|
||
private static async Task<IResult> TaskLogAsync(string task_id, HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
|
||
if (task is null)
|
||
{
|
||
return EndpointResults.NotFound(TaskNotFoundDetail);
|
||
}
|
||
|
||
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
|
||
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
|
||
return Results.Ok(new { items });
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111–128).
|
||
/// </summary>
|
||
/// <remarks>Повтор считается по <c>casefold</c> python: здесь — регистронезависимое сравнение
|
||
/// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — <see cref="KeywordsLimit"/>.</remarks>
|
||
/// <param name="raw">Сырые ключи ответа модели (null — пусто).</param>
|
||
/// <returns>Очищенные ключи (не более 30, каждый ≤60 символов).</returns>
|
||
public static IReadOnlyList<string> CleanKeywords(IEnumerable<string>? raw)
|
||
{
|
||
if (raw is null)
|
||
{
|
||
return Array.Empty<string>();
|
||
}
|
||
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var outList = new List<string>();
|
||
foreach (string? item in raw)
|
||
{
|
||
if (item is null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string keyword = item.Trim();
|
||
if (keyword.Length == 0 || keyword.Length > KeywordLengthLimit || !seen.Add(keyword))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
outList.Add(keyword);
|
||
if (outList.Count >= KeywordsLimit)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
return outList;
|
||
}
|
||
|
||
// Фолбэк-текст недоступного ИИ, если адаптер причину не вернул (мягкая ошибка, Ruling 11).
|
||
private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд";
|
||
|
||
// Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults).
|
||
// settings: KV-хранилище настроек тенанта.
|
||
// ct: Токен отмены.
|
||
// Возвращает: True — ИИ включён (ветки выключателя отрабатывает вызывающий, Ruling 10/11).
|
||
private static async Task<bool> ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct)
|
||
{
|
||
SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct);
|
||
if (row is null)
|
||
{
|
||
return SettingsDefaults.AiEnabled;
|
||
}
|
||
|
||
try
|
||
{
|
||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||
return document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||
? document.RootElement.GetBoolean()
|
||
: SettingsDefaults.AiEnabled;
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
return SettingsDefaults.AiEnabled;
|
||
}
|
||
}
|
||
|
||
// Маппит тело создания в черновик сервиса (значения пограничные передаются как есть — дефолты в сервисе).
|
||
// body: Тело запроса (wire-поля camelCase).
|
||
// Возвращает: Черновик создания задачи (DiscoveryTaskDraft).
|
||
private static DiscoveryTaskDraft ToDraft(DiscoveryTaskCreateBody body)
|
||
{
|
||
return new DiscoveryTaskDraft
|
||
{
|
||
Name = body.Name,
|
||
Description = body.Description ?? string.Empty,
|
||
Keywords = body.Keywords,
|
||
MinSubscribers = body.MinSubscribers,
|
||
Lang = body.Lang,
|
||
Threshold = body.Threshold,
|
||
SampleSize = body.SampleSize,
|
||
PlanJoins = body.PlanJoins,
|
||
AutoJoin = body.AutoJoin,
|
||
};
|
||
}
|
||
|
||
// Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)).
|
||
// body: Тело запроса (wire-поля camelCase).
|
||
// Возвращает: Патч задачи (DiscoveryTaskPatch).
|
||
private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body)
|
||
{
|
||
return new DiscoveryTaskPatch
|
||
{
|
||
Name = body.Name,
|
||
Description = body.Description,
|
||
Keywords = body.Keywords,
|
||
MinSubscribers = body.MinSubscribers,
|
||
Lang = body.Lang,
|
||
Threshold = body.Threshold,
|
||
SampleSize = body.SampleSize,
|
||
PlanJoins = body.PlanJoins,
|
||
AutoJoin = body.AutoJoin,
|
||
};
|
||
}
|
||
}
|