Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,119 @@
using System.Text.Json;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Бан-гард авто-вступлений Discovery: суточный лимит, flood-день, стоп-кран (1:1 ban_guard.py L181, план Task 18, Ruling 10).
/// </summary>
/// <remarks>
/// Суточный лимит (discJoinLimit, дефолт 50) считается по DiscLog: число событий event='join_auto' за текущие
/// UTC-сутки (дискретность совпадает с логом mark_joined авто-вступления — дискретный счётчик 1:1 с python
/// L2935). При FloodWait от Telegram воркер зовёт <see cref="NoteFloodAsync"/> — блокировка авто-вступлений до
/// конца суток (внутренний ключ SettingsKeys.DiscFloodDay, epoch-ms начала суток). Стоп-кран discPaused —
/// ручная пауза вступлений. <see cref="CanAutoJoinAsync"/> — общий вердикт «разрешено ли следующее авто-
/// вступление». Настройки читаются типизированным снимком TenantSettingsSnapshot (C30): отсутствие
/// строки/повреждение → SettingsDefaults.
/// <para>
/// Паузу между вступлениями (discJoinDelayMin..Max, 5070 с) гард НЕ держит — её делает <see cref="IDiscoveryPacer"/>
/// (рандом недоступен тестам; 1:1 ban_guard.wait_join_delay L4456).
/// </para>
/// </remarks>
public sealed class DiscoveryBanGuard
{
private readonly IDiscoveryStore _store;
private readonly ISettingsStore _settings;
private readonly Func<DateTimeOffset> _utcNow;
/// <summary>
/// Создаёт бан-гард.
/// </summary>
/// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param>
/// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</param>
/// <param name="utcNow">Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон
/// FakeDiscoveryStore). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
public DiscoveryBanGuard(IDiscoveryStore store, ISettingsStore settings, Func<DateTimeOffset>? utcNow = null)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(settings);
_store = store;
_settings = settings;
_utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
}
/// <summary>
/// Ручной стоп-кран авто-вступлений (setting discPaused; ban_guard.global_paused L6971).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — вступления на паузе (воркер не делает сетевых шагов).</returns>
public async Task<bool> GlobalPausedAsync(CancellationToken ct)
{
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct);
return settingsSnapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused);
}
/// <summary>
/// Авто-вступления за текущие UTC-сутки (ban_guard.joins_today_auto L2935).
/// </summary>
/// <remarks>События DiscLog event='join_auto' с CreatedAt ≥ начала текущих UTC-суток (все тенанты держат
/// времена UTC; счётчик учитывает и строки ручных вступлений с auto=false? — нет: фильтр по событию join_auto).</remarks>
/// <param name="ct">Токен отмены.</param>
/// <returns>Число авто-вступлений сегодня (0 — вступлений нет).</returns>
public Task<int> JoinsTodayAutoAsync(CancellationToken ct)
=> _store.CountLogEventAsync(DiscoveryLogEvents.JoinAuto, StartOfDayUtc(), ct);
/// <summary>
/// Была ли flood-блокировка в текущие UTC-сутки (ban_guard.flood_today L6466).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — <see cref="NoteFloodAsync"/> фиксировалась сегодня (стоп до конца суток).</returns>
public async Task<bool> FloodTodayAsync(CancellationToken ct)
{
TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct);
return IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs());
}
/// <summary>
/// Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет стоп-крана
/// (ban_guard.can_auto_join L3841).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — следующее авто-вступление допустимо.</returns>
public async Task<bool> CanAutoJoinAsync(CancellationToken ct)
{
// Один типизированный снимок настроек на вердикт (C30): discJoinLimit/discFloodDay/discPaused
// читаются одним GetAllAsync вместо трёх GetAsync.
TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct);
int limit = snapshot.GetInt(SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit);
int joined = await JoinsTodayAutoAsync(ct).ConfigureAwait(false);
bool floodToday = IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs());
bool paused = snapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused);
return joined < Math.Max(1, limit) && !floodToday && !paused;
}
/// <summary>
/// Фиксирует FloodWait: блокировка авто-вступлений до конца суток (ban_guard.note_flood L5962).
/// </summary>
/// <remarks>Запись идемпотентна в течение суток: DiscFloodDay = epoch-ms начала текущих UTC-суток
/// (повторный note_flood в тот же день перезаписывает тем же значением).</remarks>
/// <param name="ct">Токен отмены.</param>
public Task NoteFloodAsync(CancellationToken ct)
=> _settings.SetAsync(SettingsKeys.DiscFloodDay, JsonSerializer.Serialize(StartOfDayMs()), ct);
// Начало текущих UTC-суток (python _start_of_day_ms L2326).
private DateTimeOffset StartOfDayUtc()
{
DateTimeOffset now = _utcNow().ToUniversalTime();
return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero);
}
// Начало текущих UTC-суток в epoch-ms (python L25).
private long StartOfDayMs() => StartOfDayUtc().ToUnixTimeMilliseconds();
// Flood-день совпал с текущими сутками (ban_guard.flood_today L6466: 0 — блокировок не было).
// floodDay: Epoch-ms начала суток flood-блокировки (0 — не было).
// startOfDayMs: Начало текущих UTC-суток в epoch-ms.
// Возвращает: True — flood фиксировался сегодня.
private static bool IsFloodToday(long floodDay, long startOfDayMs)
=> floodDay != 0 && floodDay == startOfDayMs;
}
@@ -0,0 +1,58 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Сервис чёрного списка Discovery — добавление/снятие/список (1:1 add_blacklist/remove_blacklist/list_blacklist L568589).
/// </summary>
/// <remarks>
/// Чистый сервис поверх <see cref="IDiscoveryStore"/> (таблица DiscBlacklist схемы тенанта). Список общий для
/// всех задач: источники из него пропускаются поиском и авто-вступлением (проверки делает кандидатный сервис и
/// воркер). Повторное добавление того же источника обновляет Name/Reason, CreatedAt сохраняется (ON CONFLICT
/// L572–575) — «перезапись» записи чёрного списка. Снимается вручную (DELETE …/blacklist) и при ручном join
/// (эндпоинты Task 19 зовут <see cref="RemoveAsync"/>).
/// </remarks>
public sealed class DiscoveryBlacklistService(IDiscoveryStore store)
{
/// <summary>
/// Помечает источник в чёрном списке (add_blacklist L568578; существующая запись обновляется).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="name">Имя источника (пустое → DialogId, python L575).</param>
/// <param name="reason">Причина добавления («отклонено вручную», метка воркера).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Запись чёрного списка (echo после upsert; CreatedAt — первое добавление).</returns>
public async Task<DiscoveryBlacklistDto> AddAsync(string dialogId, string name, string reason, CancellationToken ct)
{
string normalizedName = (name ?? string.Empty).Trim();
if (normalizedName.Length == 0)
{
normalizedName = dialogId;
}
await store.UpsertBlacklistAsync(dialogId, normalizedName, reason ?? string.Empty, ct).ConfigureAwait(false);
return await store.GetBlacklistAsync(dialogId, ct).ConfigureAwait(false)
?? throw new InvalidOperationException("add_blacklist: строка не прочиталась после upsert");
}
/// <summary>
/// Снимает источник с чёрного списка (remove_blacklist L581582; нет строки — no-op).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Завершается после удаления строки.</returns>
public Task RemoveAsync(string dialogId, CancellationToken ct)
{
return store.RemoveBlacklistAsync(dialogId, ct);
}
/// <summary>
/// Весь чёрный список, новые записи первыми (list_blacklist L585589).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Записи от новых к старым.</returns>
public Task<IReadOnlyList<DiscoveryBlacklistDto>> ListAsync(CancellationToken ct)
{
return store.ListBlacklistAsync(ct);
}
}
@@ -0,0 +1,26 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Типы источников-кандидатов Discovery (колонка DiscCandidates.Kind; api-map §4.8, discovery_worker _kind_code).
/// </summary>
/// <remarks>
/// EN-канон контракта 1:1 с кандидатами Telegram: channel — канал, group — группа, forum — форум (группа с
/// темами; оценка идёт по темам, discovery_worker L299307). Личные чаты/боты поиском не предлагаются.
/// </remarks>
public static class DiscoveryCandidateKinds
{
/// <summary>
/// Канал.
/// </summary>
public const string Channel = "channel";
/// <summary>
/// Группа.
/// </summary>
public const string Group = "group";
/// <summary>
/// Форум (группа с темами; topics заполняются оценкой).
/// </summary>
public const string Forum = "forum";
}
@@ -0,0 +1,54 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Статусы кандидата Discovery (колонка DiscCandidates.Status; discovery.py L171, api-map §4.8).
/// </summary>
/// <remarks>
/// Цепочка 1:1 с прототипом: new — найден поиском, ждёт оценки; review — оценён, ждёт решения (воркер/человек);
/// joined — вступили (только mark_joined: счётчик задачи + лог join_auto/join_manual); rejected — отклонён
/// (только mark_rejected: счётчик задачи + лог + чёрный список). Переводы в joined/rejected минуя mark_* —
/// запрещены (discovery.py L1618). Активные статусы (python _ACTIVE_CANDIDATE L35) запрещают повторное
/// добавление источника в задачу.
/// </remarks>
public static class DiscoveryCandidateStatuses
{
/// <summary>
/// Найден поиском, ещё не оценён.
/// </summary>
public const string New = "new";
/// <summary>
/// Оценён (метки/фит проставлены), ждёт вступления/решения.
/// </summary>
public const string Review = "review";
/// <summary>
/// Вступили в источник (терминал: счётчик joined задачи).
/// </summary>
public const string Joined = "joined";
/// <summary>
/// Отклонён и помещен в чёрный список (терминал: счётчик rejected).
/// </summary>
public const string Rejected = "rejected";
/// <summary>
/// Статусы, при которых повторное добавление источника запрещено (python _ACTIVE_CANDIDATE L35).
/// </summary>
/// <param name="status">Статус кандидата.</param>
/// <returns>True — new/review/joined (add_candidate пропускает источник с логом skip).</returns>
public static bool IsActive(string status)
{
return status is New or Review or Joined;
}
/// <summary>
/// Разрешён ли перевод через set_candidate_status (python L503504: только new/review).
/// </summary>
/// <param name="status">Запрашиваемый статус.</param>
/// <returns>True — new/review (joined/rejected выставляются только через mark_joined/mark_rejected).</returns>
public static bool IsTransitionAllowed(string status)
{
return status is New or Review;
}
}
@@ -0,0 +1,329 @@
using Deal.Contracts.Integrations;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Сервис кандидатов Discovery — add с исключениями, set_candidate, review-перевод, mark_joined/rejected, delete (1:1 L385563).
/// </summary>
/// <remarks>
/// Чистый оркестратор поверх <see cref="IDiscoveryStore"/> (DiscCandidates + счётчики DiscTasks + проверки Dialogs/
/// DiscBlacklist), <see cref="DiscoveryLogService"/> (skip/review/join/reject-лог) и <see cref="DiscoveryBlacklistService"/>
/// (mark_rejected пишет в чёрный список). 404-семантика — null-результат; 400 — <see cref="DiscoveryValidationException"/>
/// (например, mark_rejected вступившего). Переводы в joined/rejected — ТОЛЬКО mark_joined/mark_rejected (счётчики
/// задачи, лог, чёрный список); add_candidate пропускает источники, которые уже мониторятся/в чёрном списке/уже
/// кандидаты (new/review/joined), а устаревшая запись rejected заменяется новой (L431–433).
/// </remarks>
public sealed class DiscoveryCandidatesService(
IDiscoveryStore store,
DiscoveryLogService log,
DiscoveryBlacklistService blacklist)
{
/// <summary>
/// 400 mark_rejected: источник уже joined (mark_rejected L550551).
/// </summary>
public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили";
/// <summary>
/// 400 set_candidate_status: не new/review (set_candidate_status L503504).
/// </summary>
public const string TransitionNotAllowedFormat = "Статус {0} выставляется через mark_joined/mark_rejected";
// Skip-лог add_candidate: источник уже в Dialogs (add_candidate L419).
private const string SkipMonitoredFormat = "пропущен {0}: источник уже мониторится (мы состоим)";
// Skip-лог add_candidate: источник в чёрном списке (L422).
private const string SkipBlacklistedFormat = "пропущен {0}: источник в чёрном списке";
// Skip-лог add_candidate: кандидат уже есть (L429).
private const string SkipActiveFormat = "пропущен {0}: кандидат уже есть (статус {1})";
// Review-лог перевода кандидата (set_candidate_status L513).
private const string ReviewLogFormat = "кандидат {0} переведён в review";
// Join-лог mark_joined (L536).
private const string JoinedLogFormat = "вступили в {0}";
// Reject-лог mark_rejected (L560): текст по умолчанию.
private const string RejectedLogFormat = "отклонён {0}";
/// <summary>
/// Кандидаты задачи, старые первыми (list_candidates L385397).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="status">Статус-фильтр (new|review|joined|rejected); null — все.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Кандидаты задачи (marks/topics — списками).</returns>
public Task<IReadOnlyList<DiscoveryCandidateDto>> ListAsync(string taskId, string? status, CancellationToken ct)
{
return store.ListCandidatesAsync(taskId, status, ct);
}
/// <summary>
/// Кандидат по dialog_id (echo _get_candidate L400402; для эндпоинтов join/reject Task 19).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Кандидат или null (404 «Кандидат не найден»).</returns>
public Task<DiscoveryCandidateDto?> GetAsync(string dialogId, CancellationToken ct)
{
return store.GetCandidateAsync(dialogId, ct);
}
/// <summary>
/// Добавляет найденный источник как кандидата задачи (add_candidate L409453).
/// </summary>
/// <remarks>
/// Исключения (возврат null + лог skip, 1:1 L418430): источник есть в Dialogs («уже мониторится (мы состоим)»),
/// в чёрном списке или уже добавлен в статусе new/review/joined. Устаревшая запись rejected заменяется новой
/// (L431433), счётчик found увеличивается (L451), статус нового кандидата — new.
/// </remarks>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="name">Имя источника (пустое → DialogId).</param>
/// <param name="username">Username источника (пуст, если нет публичного).</param>
/// <param name="kind">Тип источника: channel|group|forum (пусто → channel).</param>
/// <param name="hue">Цвет источника (пусто → «#666»).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Новый кандидат либо null — источник пропущен (в лог записан skip).</returns>
public async Task<DiscoveryCandidateDto?> AddAsync(
string taskId, string dialogId, string name, string username, string kind, string hue, CancellationToken ct)
{
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (task is null)
{
// python _task_or_raise L416 бросает KeyError; в .NET — null (воркер вызывает add_candidate только
// для существующих running-задач; строки нет → источник просто не добавляется).
return null;
}
if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false))
{
await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipMonitoredFormat, dialogId), ct)
.ConfigureAwait(false);
return null;
}
if (await store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false))
{
await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipBlacklistedFormat, dialogId), ct)
.ConfigureAwait(false);
return null;
}
DiscoveryCandidateDto? existing = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (existing is not null && DiscoveryCandidateStatuses.IsActive(existing.Status))
{
await log.AddAsync(
taskId,
DiscoveryLogEvents.Skip,
string.Format(SkipActiveFormat, dialogId, existing.Status),
ct).ConfigureAwait(false);
return null;
}
if (existing is not null)
{
// Устаревшая rejected-запись (например, после remove_blacklist): перезаписываем новым кандидатом (L431–433).
await store.DeleteCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
var row = new DiscoveryCandidateRow
{
DialogId = dialogId,
TaskId = taskId,
Name = TrimOr(name, dialogId),
Username = TrimOr(username, string.Empty),
Kind = TrimOr(kind, DiscoveryCandidateKinds.Channel),
Hue = TrimOr(hue, SourceDefaults.DefaultHue),
};
await store.CreateCandidateAsync(row, ct).ConfigureAwait(false);
await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
/// <summary>
/// Обновляет поля кандидата по результатам оценки (set_candidate L456494; зовёт воркер Task 18).
/// </summary>
/// <remarks>Пустые после Trim name/username/kind/hue не затирают текущее значение (L480482).</remarks>
/// <param name="taskId">Id задачи (принадлежность кандидата проверяется — python L464466).</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="patch">Изменяемые поля (null — не менять).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи).</returns>
public async Task<DiscoveryCandidateDto?> SetAsync(
string taskId, string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct)
{
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (task is null || current is null || current.TaskId != taskId)
{
// python: KeyError (L463466) — задача/кандидат исчезли между шагами воркера.
return null;
}
DiscoveryCandidatePatch normalized = NormalizePatch(patch, current);
bool hasChanges = normalized.Name is not null || normalized.Username is not null || normalized.Kind is not null
|| normalized.Hue is not null || normalized.Participants is not null || normalized.LangRu is not null
|| normalized.Marks is not null || normalized.Topics is not null || normalized.FitRatio is not null
|| normalized.AutoJoined is not null;
if (!hasChanges)
{
return current;
}
await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
/// <summary>
/// Переводит кандидата в new/review (set_candidate_status L497515; review пишет лог).
/// </summary>
/// <remarks>joined/rejected переводятся только через <see cref="MarkJoinedAsync"/>/<see cref="MarkRejectedAsync"/>
/// (там счётчики/чёрный список/лог). В review воркер переводит оценённых кандидатов (finish_review, Task 18).</remarks>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="status">Новый статус: new|review.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Обновлённый кандидат либо null (кандидата нет).</returns>
/// <exception cref="DiscoveryValidationException">Статус не new/review.</exception>
public async Task<DiscoveryCandidateDto?> SetStatusAsync(string dialogId, string status, CancellationToken ct)
{
if (!DiscoveryCandidateStatuses.IsTransitionAllowed(status))
{
throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status));
}
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false);
if (status == DiscoveryCandidateStatuses.Review)
{
await log.AddAsync(
current.TaskId,
DiscoveryLogEvents.Review,
string.Format(ReviewLogFormat, dialogId),
ct).ConfigureAwait(false);
}
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
/// <summary>
/// Вступили в источник: status=joined, счётчик joined задачи, лог join_auto/join_manual (mark_joined L523538).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="auto">True — авто-вступление воркера; false — ручное (join из UI, Task 19).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен (L528529).</returns>
public async Task<DiscoveryCandidateDto?> MarkJoinedAsync(string dialogId, bool auto, CancellationToken ct)
{
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
if (current.Status == DiscoveryCandidateStatuses.Joined)
{
return current;
}
await store.SetCandidateJoinedAsync(dialogId, auto, ct).ConfigureAwait(false);
await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false);
string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual;
await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
/// <summary>
/// Отклоняет кандидата: status=rejected, счётчик rejected, лог reject, чёрный список (mark_rejected L541563).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="reason">Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен (L552553).</returns>
/// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception>
public async Task<DiscoveryCandidateDto?> MarkRejectedAsync(string dialogId, string reason, CancellationToken ct)
{
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
if (current.Status == DiscoveryCandidateStatuses.Joined)
{
throw new DiscoveryValidationException(RejectJoinedDetail);
}
if (current.Status == DiscoveryCandidateStatuses.Rejected)
{
return current;
}
await store.SetCandidateRejectedAsync(dialogId, ct).ConfigureAwait(false);
await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Rejected, 1, ct).ConfigureAwait(false);
string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason;
await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false);
await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
/// <summary>
/// Удаляет кандидата (delete_candidate L518520; skip-ветки воркера; повторный вызов безопасен).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Завершается после удаления строки.</returns>
public Task DeleteAsync(string dialogId, CancellationToken ct)
{
return store.DeleteCandidateAsync(dialogId, ct);
}
// Нормализует патч кандидата (1:1 set_candidate L468484).
// patch: Патч из запроса.
// current: Текущий кандидат (для «не затирать пустым» L480–482).
// Возвращает: Патч с нормализованными значениями (null-поля — «не менять»).
private static DiscoveryCandidatePatch NormalizePatch(DiscoveryCandidatePatch patch, DiscoveryCandidateDto current)
{
return new DiscoveryCandidatePatch
{
Name = NormalizeKeptOr(patch.Name, current.Name),
Username = NormalizeKeptOr(patch.Username, current.Username),
Kind = NormalizeKeptOr(patch.Kind, current.Kind),
Hue = NormalizeKeptOr(patch.Hue, current.Hue),
Participants = patch.Participants,
LangRu = patch.LangRu,
Marks = patch.Marks?.Select(mark => (string)mark).ToList(),
Topics = patch.Topics?.ToList(),
FitRatio = patch.FitRatio,
AutoJoined = patch.AutoJoined,
};
}
// Строковое поле патча: Trim; пустой результат — «не менять» (python L480482 «or row[col]»).
// value: Значение патча (null — не менялось).
// current: Текущее значение строки.
// Возвращает: Обрезанное значение или null (поле не меняется).
private static string? NormalizeKeptOr(string? value, string current)
{
if (value is null)
{
return null;
}
string trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
// Обрезка строки с дефолтом при пустом результате (add_candidate L443446).
private static string TrimOr(string value, string fallback)
{
string trimmed = (value ?? string.Empty).Trim();
return trimmed.Length == 0 ? fallback : trimmed;
}
}
@@ -0,0 +1,32 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Счётчики прогресса задачи поиска (колонки DiscTasks Found/Evaluated/Joined/Rejected; discovery.py L359368).
/// </summary>
/// <remarks>
/// Соответствие колонкам db.py L150153: found — найдено кандидатов поиском, evaluated — оценено/пропущено,
/// joined — вступили (терминал плана: joined ≥ planJoins → done), rejected — отклонено. bump_counter прототипа
/// принимает имя колонки строкой; в .NET поле типизировано перечислением (валидация L361–362 — на этапе компиляции).
/// </remarks>
public enum DiscoveryCounterField
{
/// <summary>
/// Счётчик «found»: найденные источники (add_candidate).
/// </summary>
Found,
/// <summary>
/// Счётчик «evaluated»: оценённые/пропущенные кандидаты (воркер).
/// </summary>
Evaluated,
/// <summary>
/// Счётчик «joined»: вступившие источники (mark_joined).
/// </summary>
Joined,
/// <summary>
/// Счётчик «rejected»: отклонённые источники (mark_rejected).
/// </summary>
Rejected,
}
@@ -0,0 +1,14 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Агрегат оценки выборки сообщений (python evaluate_sample L197226: fit_count/total/fit_ratio/per_message).
/// </summary>
/// <param name="FitCount">Сколько сообщений подошли под задачу.</param>
/// <param name="Total">Всего сообщений выборки.</param>
/// <param name="FitRatio">Доля подходящих (fit_count / total; 0 при пустой выборке).</param>
/// <param name="PerMessage">Вердикты по сообщениям во входном порядке.</param>
public sealed record DiscoveryEvalSample(
int FitCount,
int Total,
double FitRatio,
IReadOnlyList<DiscoveryMessageFit> PerMessage);
@@ -0,0 +1,319 @@
using System.Text;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Оценка содержания кандидата: фит сообщений под задачу поиска (1:1 discovery_eval.py целиком, план Task 18).
/// </summary>
/// <remarks>
/// Каскад оценки одного сообщения (python evaluate_message L174194):
/// <list type="number">
/// <item>текст пустой/короче 10 символов → «слишком короткое» (False, эвристика);</item>
/// <item>ML при mlEnabled (<see cref="IMlClient.PredictAsync"/>): take + label=spam → «ML: спам» (False);
/// сбой прогноза не роняет оценку (python L101107: «не уверен»);</item>
/// <item>ИИ при aiEnabled (<see cref="IAiTools.EvaluateFitAsync"/>): любая ошибка (Local — NotSupportedException,
/// gRPC — AiUnavailableException/…, Ruling 10) ловится и оценка продолжается эвристикой (python L186194);</item>
/// <item>эвристика: любой ключ задачи входит в очищенный текст без учёта регистра.</item>
/// </list>
/// <see cref="GroupByTopic"/> — группировка выборки форума по topic_id (null → «main») с заголовками-сниппетами
/// (python L86117); <see cref="Passed"/> — вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥
/// threshold, % (python L229237). Результаты по сообщениям <see cref="DiscoveryMessageFit"/> собирает
/// <see cref="EvaluateSampleAsync"/> в агрегат <see cref="DiscoveryEvalSample"/> (fit_count/total/fit_ratio,
/// python L197226). Язык выборки — отдельный детектор <see cref="DiscoveryLangDetector"/> (зовёт воркер).
/// </remarks>
public sealed class DiscoveryEvaluator
{
// Минимальная длина сообщения для содержательной оценки (python _MIN_TEXT_LEN L39).
private const int MinTextLength = 10;
// Лимит текста, уходящего ИИ-провайдеру, в символах (python _AI_TEXT_LIMIT L41).
private const int AiTextLimit = 4000;
// Потолок причины из ИИ (python _AI_REASON_LIMIT L43).
private const int AiReasonLimit = 200;
// Минимальный объём содержательной выборки для вердикта оценки (python _MIN_CONTENT L77).
private const int MinContentMessages = 3;
// topic_id=null в выборке/группировке → общая тема «main» (python _MAIN_TOPIC L47).
private const string MainTopic = "main";
// Длина заголовка темы-сниппета (python _TITLE_LIMIT L45).
private const int TopicTitleLimit = 60;
// Причина по умолчанию при фите ИИ (python _ai_reason L170).
private const string AiFitReasonDefault = "подходит";
// Причина по умолчанию при не-фите ИИ (python _ai_reason L170).
private const string AiNotFitReasonDefault = "не подходит";
private readonly ISettingsStore _settings;
private readonly IMlClient _mlClient;
private readonly IAiTools _aiTools;
/// <summary>
/// Создаёт оценщик содержания кандидатов Discovery.
/// </summary>
/// <param name="settings">KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада).</param>
/// <param name="mlClient">ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»).</param>
/// <param name="aiTools">ИИ-порт (фит при aiEnabled; сбой — эвристика, Ruling 10).</param>
public DiscoveryEvaluator(ISettingsStore settings, IMlClient mlClient, IAiTools aiTools)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(mlClient);
ArgumentNullException.ThrowIfNull(aiTools);
_settings = settings;
_mlClient = mlClient;
_aiTools = aiTools;
}
/// <summary>
/// Последовательная оценка всех сообщений выборки (python evaluate_sample L197226).
/// </summary>
/// <remarks>fit_count/total/fit_ratio считаются по всей выборке; per-message повторяет входной порядок
/// (воркеру нужны только агрегаты; per-message — для тестов и будущих разборов).</remarks>
/// <param name="task">Задача поиска (description/keywords — промпт и эвристика).</param>
/// <param name="texts">Тексты сообщений выборки (порядок — входной).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Агрегат выборки: fit_count, total, fit_ratio, per-message.</returns>
public async Task<DiscoveryEvalSample> EvaluateSampleAsync(
DiscoveryTaskDto task, IReadOnlyList<string> texts, CancellationToken ct)
{
// Флаги веток (mlEnabled/aiEnabled) не меняются в пределах выборки: один типизированный снимок
// настроек на выборку (C30) вместо двух GetAsync на каждое сообщение.
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false);
var perMessage = new List<DiscoveryMessageFit>(texts?.Count ?? 0);
int fitCount = 0;
foreach (string text in texts ?? [])
{
DiscoveryMessageFit fit =
await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false);
perMessage.Add(fit);
if (fit.Fit)
{
fitCount++;
}
}
int total = perMessage.Count;
return new DiscoveryEvalSample(
fitCount,
total,
total > 0 ? (double)fitCount / total : 0.0,
perMessage);
}
/// <summary>
/// Оценка фита одного сообщения под задачу: каскад короткое → ML-спам → ИИ → эвристика (python L174194).
/// </summary>
/// <param name="task">Задача поиска (description/keywords).</param>
/// <param name="text">Текст сообщения.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Вердикт: fit + причина + источник (heuristic|ml|ai).</returns>
public async Task<DiscoveryMessageFit> EvaluateMessageAsync(DiscoveryTaskDto task, string text, CancellationToken ct)
{
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false);
return await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false);
}
// Чистое ядро оценки одного сообщения по фиксированному снимку настроек (см. EvaluateMessageAsync).
// task: Задача поиска (description/keywords).
// text: Текст сообщения.
// settingsSnapshot: Снимок настроек тенанта (mlEnabled/aiEnabled).
// ct: Токен отмены.
// Возвращает: Вердикт: fit + причина + источник (heuristic|ml|ai).
private async Task<DiscoveryMessageFit> EvaluateMessageCoreAsync(
DiscoveryTaskDto task, string text, TenantSettingsSnapshot settingsSnapshot, CancellationToken ct)
{
string raw = text ?? string.Empty;
if (raw.Trim().Length < MinTextLength)
{
return new DiscoveryMessageFit(false, "слишком короткое", "heuristic");
}
if (settingsSnapshot.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled))
{
try
{
MlPredictResultDto prediction = await _mlClient.PredictAsync(raw, ct).ConfigureAwait(false);
if (prediction.Take && string.Equals(prediction.Label, MlLearningLabels.Spam, StringComparison.OrdinalIgnoreCase))
{
return new DiscoveryMessageFit(false, "ML: спам", "ml");
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Прогноз недоступен («не уверен», python ml_client.predict L101107) — решает ИИ/эвристика.
}
}
if (settingsSnapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled))
{
try
{
AiEvaluateFitResultDto result = await _aiTools.EvaluateFitAsync(
SliceCodePoints(raw, AiTextLimit),
task.Description ?? string.Empty,
task.Keywords,
ct).ConfigureAwait(false);
string reason = (result.Reason ?? string.Empty).Trim();
if (reason.Length == 0)
{
reason = result.Fit ? AiFitReasonDefault : AiNotFitReasonDefault;
}
return new DiscoveryMessageFit(result.Fit, SliceCodePoints(reason, AiReasonLimit), "ai");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Сбой ИИ (нет ключа/сеть/не-JSON; Local — NotSupportedException) не роняет оценку (python L191192).
}
}
return Heuristic(task, raw);
}
// Эвристика: ключ задачи входит в очищенный текст без учёта регистра (python _heuristic L144150).
private static DiscoveryMessageFit Heuristic(DiscoveryTaskDto task, string text)
{
string haystack = CleanShort(text);
foreach (string keyword in task.Keywords)
{
string kw = (keyword ?? string.Empty).Trim();
if (kw.Length > 0 && haystack.Contains(kw.ToLowerInvariant(), StringComparison.Ordinal))
{
return new DiscoveryMessageFit(true, $"совпал ключ \"{kw}\"", "heuristic");
}
}
return new DiscoveryMessageFit(false, "нет совпадений с ключами", "heuristic");
}
/// <summary>
/// Вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥ threshold, % (python passed L229237).
/// </summary>
/// <param name="sample">Агрегат оценки выборки/темы.</param>
/// <param name="thresholdPercent">Порог задачи (1..100; дефолт discEvalThreshold).</param>
/// <returns>True — источник/тема проходят порог (каналы часто разбавляют контент офтопом — доля, не сплошное).</returns>
public static bool Passed(DiscoveryEvalSample sample, int thresholdPercent)
{
return sample.Total >= MinContentMessages && sample.FitRatio * 100 >= Math.Max(1, thresholdPercent);
}
/// <summary>
/// Группирует сообщения выборки по topic_id (null → «main») и сортирует группы по числу сообщений
/// (убыв.), порядок сообщений внутри группы — входной (python group_by_topic L96117).
/// </summary>
/// <remarks>Заголовок группы — сниппет первого непустого текста (python _topic_title L8693, ≤60 символов,
/// whitespace схлопнут). Возвращаемые группы переиспользуют исходные DTO (без копий).</remarks>
/// <param name="messages">Сообщения выборки (для форумов заполнен TopicId).</param>
/// <returns>Группы от большей к меньшей; пусто — выборки нет.</returns>
public static IReadOnlyList<DiscoveryTopicGroup> GroupByTopic(IReadOnlyList<TelegramEvalMessageDto> messages)
{
var groups = new Dictionary<string, List<TelegramEvalMessageDto>>(StringComparer.Ordinal);
var order = new List<string>();
foreach (TelegramEvalMessageDto message in messages ?? [])
{
string key = message.TopicId is long topicId ? topicId.ToString() : MainTopic;
if (!groups.TryGetValue(key, out List<TelegramEvalMessageDto>? bucket))
{
bucket = [];
groups[key] = bucket;
order.Add(key);
}
bucket.Add(message);
}
var outGroups = new List<DiscoveryTopicGroup>(order.Count);
foreach (string key in order)
{
List<TelegramEvalMessageDto> bucket = groups[key];
outGroups.Add(new DiscoveryTopicGroup(key, TopicTitle(bucket), bucket));
}
// python L116: sort(key=len(messages), reverse=True) — стабильная сортировка сохраняет входной порядок равных.
return outGroups
.OrderByDescending(group => group.Messages.Count)
.ToList();
}
// Сниппет первого непустого текста темы (python _topic_title L8693).
private static string TopicTitle(IReadOnlyList<TelegramEvalMessageDto> messages)
{
foreach (TelegramEvalMessageDto message in messages)
{
string text = (message.Text ?? string.Empty).Trim();
if (text.Length > 0)
{
return SliceCodePoints(CollapseWhitespace(text), TopicTitleLimit);
}
}
return string.Empty;
}
// Очистка текста для эвристики: схлопывание whitespace (python clean_short ≈ «\n+»→« » + trim).
private static string CleanShort(string text)
{
string collapsed = CollapseWhitespace(text);
return collapsed.ToLowerInvariant();
}
// Схлопывает любые пробельные последовательности в один пробел и обрезает края (python " ".join(split())).
private static string CollapseWhitespace(string text)
{
var builder = new StringBuilder(text.Length);
bool pendingSpace = false;
foreach (char ch in text)
{
if (char.IsWhiteSpace(ch))
{
pendingSpace = builder.Length > 0;
continue;
}
if (pendingSpace)
{
builder.Append(' ');
pendingSpace = false;
}
builder.Append(ch);
}
return builder.ToString();
}
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
private static string SliceCodePoints(string text, int max)
{
if (text.Length <= max)
{
return text;
}
var builder = new StringBuilder(max);
int taken = 0;
for (int index = 0; index < text.Length && taken < max; index++)
{
bool pair = char.IsHighSurrogate(text[index])
&& index + 1 < text.Length
&& char.IsLowSurrogate(text[index + 1]);
builder.Append(text[index]);
if (pair)
{
index++;
builder.Append(text[index]);
}
taken++;
}
return builder.ToString();
}
}
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Префиксы коротких id модуля Discovery и их генерация (прототип store.uid в discovery.py).
/// </summary>
/// <remarks>
/// Префиксы 1:1 с discovery.py L3032 (<c>_ID_TASK = "dt_"</c>, <c>_ID_LOG = "dl_"</c>) и api-map §1:
/// <c>dt_</c> — задача поиска (DiscTasks), <c>dl_</c> — запись лога задачи (DiscLog). Случайная часть —
/// 12 hex-символов (6 байт CSPRNG), как общий генератор PrefixId модуля Kanban (uuid4().hex[:12] прототипа);
/// модуль Discovery зависит только от ST + Contracts (план Task 17), поэтому генератор вынесен сюда, а не в
/// Kanban. Id генерирует модуль и передаёт в хранилище готовыми (порт id не создаёт).
/// </remarks>
public static class DiscoveryIdPrefixes
{
// Размер случайной части в байтах: 6 байт → 12 hex-символов (uuid4().hex[:12]).
private const int RandomHexBytes = 6;
/// <summary>
/// Префикс id задачи поиска (таблица DiscTasks; discovery.py store.uid("dt_")).
/// </summary>
public const string Task = "dt_";
/// <summary>
/// Префикс id записи лога задачи (таблица DiscLog; discovery.py store.uid("dl_")).
/// </summary>
public const string Log = "dl_";
/// <summary>
/// Новый id задачи поиска: <c>dt_</c> + 12 случайных hex-символов.
/// </summary>
public static string NewTaskId() => New(Task);
/// <summary>
/// Новый id записи лога: <c>dl_</c> + 12 случайных hex-символов.
/// </summary>
public static string NewLogId() => New(Log);
// Новый id: префикс + 12 случайных hex-символов в нижнем регистре.
// prefix: Префикс типа записи (см. Task/Log).
// Возвращает: Короткий id записи (например, dt_1a2b3c4d5e6f).
private static string New(string prefix)
{
return prefix + Convert.ToHexString(RandomNumberGenerator.GetBytes(RandomHexBytes)).ToLowerInvariant();
}
}
@@ -0,0 +1,64 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Детектор «русскости» выборки сообщений кандидата (1:1 discovery_eval.detect_lang_ru L6283, план Task 18).
/// </summary>
/// <remarks>
/// Чистая функция без зависимостей: доля кириллических букв (базовый блок U+0400–U+04FF) среди всех букв
/// выборки. Пороги python L3637: ≥0.15 → True (язык русский), ≤0.03 → False (не русский), иначе — None
/// («не подтверждён»: воркер не отсекает кандидата, а помечает меткой). Пустая выборка без букв тоже даёт
/// None. Вызывается только для задач с lang=ru (discovery_worker L280289).
/// </remarks>
public static class DiscoveryLangDetector
{
// Верхний порог доли кириллицы: ≥ него — язык русский (python _RU_RATIO_HI L36).
private const double RuRatioHigh = 0.15;
// Нижний порог доли кириллицы: ≤ него — язык не русский (python _RU_RATIO_LO L37).
private const double RuRatioLow = 0.03;
/// <summary>
/// Определяет язык выборки по доле кириллицы (detect_lang_ru L6283).
/// </summary>
/// <param name="texts">Тексты сообщений выборки (пустые/без букв — None).</param>
/// <returns>True — русский, False — не русский, null — неопределённо (между порогами/нет букв).</returns>
public static bool? DetectRu(IEnumerable<string?> texts)
{
int letters = 0;
int cyrillic = 0;
foreach (string? text in texts ?? [])
{
foreach (char ch in text ?? string.Empty)
{
if (!char.IsLetter(ch))
{
continue;
}
letters++;
if (ch is >= '\u0400' and <= '\u04FF')
{
cyrillic++;
}
}
}
if (letters == 0)
{
return null;
}
double ratio = (double)cyrillic / letters;
if (ratio >= RuRatioHigh)
{
return true;
}
if (ratio <= RuRatioLow)
{
return false;
}
return null;
}
}
@@ -0,0 +1,62 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// События лога задачи Discovery (колонка DiscLog.Event; discovery.py L187188, api-map §3.8).
/// </summary>
/// <remarks>
/// Каталог 1:1 с комментарием db.py L187188: search (завершён проход по ключам), skip (источник пропущен),
/// review (кандидат переведён в review), join_auto (авто-вступление воркером), join_manual (ручное вступление),
/// leave, reject (отклонён), flood (флуд-стоп), error (сбой шага), done (план задачи выполнен).
/// </remarks>
public static class DiscoveryLogEvents
{
/// <summary>
/// Поиск: проход по ключам завершён.
/// </summary>
public const string Search = "search";
/// <summary>
/// Источник пропущен (мониторится/в чёрном списке/уже кандидат/мало участников/…).
/// </summary>
public const string Skip = "skip";
/// <summary>
/// Кандидат переведён в review (set_candidate_status).
/// </summary>
public const string Review = "review";
/// <summary>
/// Авто-вступление воркера (mark_joined(auto:true), счётчик суточной квоты).
/// </summary>
public const string JoinAuto = "join_auto";
/// <summary>
/// Ручное вступление (mark_joined(auto:false), вне квот).
/// </summary>
public const string JoinManual = "join_manual";
/// <summary>
/// Выход из источника (зарезервировано прототипом).
/// </summary>
public const string Leave = "leave";
/// <summary>
/// Кандидат отклонён (mark_rejected → чёрный список).
/// </summary>
public const string Reject = "reject";
/// <summary>
/// FloodWait: авто-вступления остановлены до конца суток.
/// </summary>
public const string Flood = "flood";
/// <summary>
/// Сбой шага воркера/поиска (не роняет задачу).
/// </summary>
public const string Error = "error";
/// <summary>
/// План авто-вступлений задачи выполнен (воркер).
/// </summary>
public const string Done = "done";
}
@@ -0,0 +1,61 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Сервис лога задач Discovery — запись событий и чтение истории (1:1 add_log/task_log discovery.py L594608).
/// </summary>
/// <remarks>
/// Чистый сервис поверх <see cref="IDiscoveryStore"/> (таблица DiscLog схемы тенанта). Id записи (dl_) генерирует
/// модуль (<see cref="DiscoveryIdPrefixes"/>); CreatedAt проставляет хранилище. Пишут лог кандидатный сервис
/// (skip/review/join/reject), воркер (Task 18: search/flood/error/done) и сервисы задач — единая точка входа.
/// Чтение — последние события, новые сверху (GET …/log); limit клампится 1..500, дефолт 100 (python L603).
/// </remarks>
public sealed class DiscoveryLogService(IDiscoveryStore store)
{
// Верхняя граница выборки лога (python task_log L603).
private const int MaxLogLimit = 500;
// Нижняя граница выборки лога (python task_log L603).
private const int MinLogLimit = 1;
// Размер выборки по умолчанию (python task_log L601).
private const int DefaultLogLimit = 100;
/// <summary>
/// Пишет событие в лог задачи (add_log L594598: id dl_, событие и текст 1:1 с python).
/// </summary>
/// <param name="taskId">Id задачи поиска.</param>
/// <param name="logEvent">Событие (см. <see cref="DiscoveryLogEvents"/>).</param>
/// <param name="text">Текст/детали события (русская строка 1:1 с прототипом).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Завершается после записи.</returns>
public Task AddAsync(string taskId, string logEvent, string text, CancellationToken ct)
{
return store.AddLogAsync(DiscoveryIdPrefixes.NewLogId(), taskId, logEvent, text ?? string.Empty, ct);
}
/// <summary>
/// Последние события задачи, новые сверху (task_log L601608).
/// </summary>
/// <param name="taskId">Id задачи поиска.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>События задачи от новых к старым (до 100; пусто — лога нет).</returns>
public Task<IReadOnlyList<DiscoveryLogDto>> TaskLogAsync(string taskId, CancellationToken ct)
{
return TaskLogAsync(taskId, DefaultLogLimit, ct);
}
/// <summary>
/// Последние события задачи с выборкой (limit клампится 1..500, как python L603).
/// </summary>
/// <param name="taskId">Id задачи поиска.</param>
/// <param name="limit">Запрошенный размер выборки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>События задачи от новых к старым.</returns>
public Task<IReadOnlyList<DiscoveryLogDto>> TaskLogAsync(string taskId, int limit, CancellationToken ct)
{
int clamped = Math.Max(MinLogLimit, Math.Min(MaxLogLimit, limit));
return store.ListTaskLogAsync(taskId, clamped, ct);
}
}
@@ -0,0 +1,9 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Вердикт фита одного сообщения (python evaluate_message L174194).
/// </summary>
/// <param name="Fit">True — сообщение относится к сфере/задаче.</param>
/// <param name="Reason">Краткая причина вердикта.</param>
/// <param name="Source">Источник решения: heuristic|ml|ai.</param>
public sealed record DiscoveryMessageFit(bool Fit, string Reason, string Source);
@@ -0,0 +1,49 @@
using Deal.Modules.Settings.Application;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// DI-регистрация модуля Discovery. Паттерн «port &amp; adapter» (Ruling 9, план Task 17).
/// </summary>
/// <remarks>
/// Регистрируются только сервисы модуля. Порт-адаптер <c>IDiscoveryStore</c> → DiscoveryStore реализован в
/// Deal.Infrastructure и регистрируется там (AddDealPersistence) — модуль не знает про EF. Зависимости модуля —
/// Deal.Modules.Settings (порт ISettingsStore: discJoinLimit/discEvalThreshold/discEvalSample) и Deal.Contracts;
/// реверс-зависимостей нет (Global Constraints). Вызывается из Program.cs Deal.Api (AddDiscoveryModule,
/// план Task 19 — эндпоинты; воркер Task 18 добавляет свои сервисы в тот же регистратор).
/// </remarks>
public static class DiscoveryModuleRegistrar
{
/// <summary>
/// Регистрирует сервисы модуля Discovery в контейнере.
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
/// <remarks>
/// Все сервисы scoped: их зависимости (IDiscoveryStore → TenantDbContext схемы тенанта, ISettingsStore)
/// живут в рамках tenant-запроса/tenant-скоупа воркера (эталон TelegramModuleRegistrar).
/// </remarks>
public static IServiceCollection AddDiscoveryModule(this IServiceCollection services)
{
services.AddScoped<DiscoveryPlanGuard>();
services.AddScoped<DiscoveryTasksService>();
services.AddScoped<DiscoveryCandidatesService>();
services.AddScoped<DiscoveryBlacklistService>();
services.AddScoped<DiscoveryLogService>();
// Task 18 (воркер/оценка/бан-гард): DiscoveryWorkerService разрешается в tenant-скоупе цикла
// (DiscoveryWorkerScheduler); IDiscoverySearchErrorCounter — singleton (счётчик ошибок ключей живёт
// дольше scoped-воркера, как глобальный dict прототипа). DiscoveryBanGuard регистрируется фабрикой,
// чтобы не резолвить опциональный параметр utcNow (дефолт — DateTimeOffset.UtcNow).
services.AddSingleton<DiscoverySearchErrorCounter>();
services.AddSingleton<IDiscoverySearchErrorCounter>(sp => sp.GetRequiredService<DiscoverySearchErrorCounter>());
services.AddScoped<DiscoveryEvaluator>();
services.AddScoped<IDiscoveryPacer, DiscoveryPacer>();
services.AddScoped(sp => new DiscoveryBanGuard(
sp.GetRequiredService<IDiscoveryStore>(),
sp.GetRequiredService<ISettingsStore>()));
services.AddScoped<DiscoveryWorkerService>();
return services;
}
}
@@ -0,0 +1,34 @@
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Продовая реализация <see cref="IDiscoveryPacer"/>: случайная пауза из настроек тенанта (ban_guard L4456).
/// </summary>
/// <remarks>
/// Защита инварианта min ≤ max как в python L47–51: настройки мог изменить один конец интервала (single-key
/// PATCH), поэтому при инверсии концы меняются местами. Обе настройки не заданы/≤0 — паузы нет (крайний случай
/// L52–53). Детерминизма нет: интервал сэмплируется из <see cref="Random.Shared"/>.
/// </remarks>
public sealed class DiscoveryPacer(ISettingsStore settings) : IDiscoveryPacer
{
/// <inheritdoc />
public async Task WaitJoinDelayAsync(CancellationToken ct)
{
TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
int low = snapshot.GetInt(SettingsKeys.DiscJoinDelayMin, SettingsDefaults.DiscJoinDelayMin);
int high = snapshot.GetInt(SettingsKeys.DiscJoinDelayMax, SettingsDefaults.DiscJoinDelayMax);
if (low <= 0 && high <= 0)
{
return;
}
if (low > high)
{
(low, high) = (high, low);
}
double seconds = low + Random.Shared.NextDouble() * (high - low);
await Task.Delay(TimeSpan.FromSeconds(seconds), ct).ConfigureAwait(false);
}
}
@@ -0,0 +1,81 @@
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// План-бюджет авто-вступлений Discovery: лимит и правило суммы (python L80111, Ruling 9).
/// </summary>
/// <remarks>
/// Правило 1:1 с discovery.py: суточный лимит discJoinLimit (настройка, дефолт 50 — SettingsDefaults) — верхняя
/// граница plan_joins одной задачи (<see cref="AssertPlanAsync"/>); сумма plan_joins активных задач (status NOT IN
/// done/failed) плюс новая/увеличиваемая задача не должна превышать лимит (<see cref="AssertBudgetAsync"/>).
/// Итог семантики: задача на 50 занимает весь бюджет (другую создать нельзя); задача на 25 оставляет остаток
/// 25 (следующая может быть не больше 25). Тексты 400 — 1:1 с python (L97110).
/// </remarks>
public sealed class DiscoveryPlanGuard(IDiscoveryStore store, ISettingsStore settings)
{
/// <summary>
/// 400: план меньше 1 (python L97 «plan_joins должен быть не меньше 1»).
/// </summary>
public const string PlanTooSmallDetail = "plan_joins должен быть не меньше 1";
/// <summary>
/// 400: план больше суточного лимита (python L100). Формат: {plan}, {limit}.
/// </summary>
public const string PlanTooBigFormat = "plan_joins {0} больше суточного лимита авто-вступлений ({1})";
/// <summary>
/// 400: бюджет исчерпан (python L108110). Формат: {used}, {limit}, {plan}.
/// </summary>
public const string BudgetExceededFormat =
"Бюджет авто-вступлений исчерпан: задачи уже занимают {0} из {1} в сутки, ещё {2} не влезает";
/// <summary>
/// Верхняя граница plan_joins: суточный лимит авто-вступлений (python _plan_limit L8082).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>discJoinLimit настройки (дефолт 50) с нижней границей 1.</returns>
public async Task<int> PlanLimitAsync(CancellationToken ct)
{
int limit = (await TenantSettingsSnapshot.LoadAsync(settings, ct))
.GetInt(SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit);
return Math.Max(1, limit);
}
/// <summary>
/// Проверяет план новой задачи: 1..discJoinLimit (python _assert_plan L94100).
/// </summary>
/// <param name="planJoins">Запрашиваемый план авто-вступлений.</param>
/// <param name="ct">Токен отмены.</param>
/// <exception cref="DiscoveryValidationException">План меньше 1 или больше суточного лимита (тексты python).</exception>
public async Task AssertPlanAsync(int planJoins, CancellationToken ct)
{
if (planJoins < 1)
{
throw new DiscoveryValidationException(PlanTooSmallDetail);
}
int limit = await PlanLimitAsync(ct).ConfigureAwait(false);
if (planJoins > limit)
{
throw new DiscoveryValidationException(string.Format(PlanTooBigFormat, planJoins, limit));
}
}
/// <summary>
/// Проверяет бюджет: занято активными задачами + новая ≤ discJoinLimit (python _assert_budget L103111).
/// </summary>
/// <param name="planJoins">План создаваемой/итоговый план увеличиваемой задачи.</param>
/// <param name="excludeTaskId">Id задачи, исключаемой из занятого бюджета (patch-рост плана); null — создание.</param>
/// <param name="ct">Токен отмены.</param>
/// <exception cref="DiscoveryValidationException">Бюджет исчерпан (текст python L108110).</exception>
public async Task AssertBudgetAsync(int planJoins, string? excludeTaskId, CancellationToken ct)
{
int limit = await PlanLimitAsync(ct).ConfigureAwait(false);
int used = await store.SumActivePlanAsync(excludeTaskId, ct).ConfigureAwait(false);
if (used + planJoins > limit)
{
throw new DiscoveryValidationException(string.Format(BudgetExceededFormat, used, limit, planJoins));
}
}
}
@@ -0,0 +1,82 @@
using System.Collections.Concurrent;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Потокобезопасная реализация <see cref="IDiscoverySearchErrorCounter"/> (ConcurrentDictionary + TTL).
/// </summary>
/// <remarks>
/// Записи ошибок живут <see cref="EntryTtlSeconds"/> с последнего инкремента: удалённая/завершённая задача не
/// копит строку вечно (Security/quality review — эвикция), а серия «3 ошибки подряд» всё равно успевает
/// накопиться (тики поиска идут раз в 5 с, окно TTL на порядки шире). Эвикция ленивая — при Next/Reset
/// (фоновых таймеров нет, как у прототипа: dict памяти процесса); часы инъекцией (в проде — UtcNow).
/// </remarks>
public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
{
/// <summary>
/// Время жизни записи счётчика ошибок с последнего инкремента — 1 час.
/// </summary>
public const int EntryTtlSeconds = 3600;
/// <summary>
/// Запись счётчика: число ошибок подряд + момент последнего инкремента (epoch-мс, UTC).
/// </summary>
/// <param name="Count">Число ошибок подряд.</param>
/// <param name="UpdatedAtMs">Момент последнего инкремента.</param>
public sealed record Entry(int Count, long UpdatedAtMs);
private readonly ConcurrentDictionary<string, Entry> _failures = new(StringComparer.Ordinal);
// Часы счётчика (в проде — UtcNow; тесты подменяют для проверки TTL).
private readonly Func<DateTimeOffset> _utcNow;
/// <summary>
/// Создаёт счётчик с системными часами (DateTimeOffset.UtcNow).
/// </summary>
public DiscoverySearchErrorCounter()
: this(() => DateTimeOffset.UtcNow)
{
}
/// <summary>
/// Создаёт счётчик с заданными часами (тесты TTL).
/// </summary>
/// <param name="utcNow">Источник текущего времени (UTC).</param>
public DiscoverySearchErrorCounter(Func<DateTimeOffset> utcNow)
{
ArgumentNullException.ThrowIfNull(utcNow);
_utcNow = utcNow;
}
/// <inheritdoc />
public int Next(string taskId)
{
EvictExpired();
Entry fresh = _failures.AddOrUpdate(
taskId,
static (_, nowMs) => new Entry(1, nowMs),
static (_, current, nowMs) => new Entry(current.Count + 1, nowMs),
_utcNow().ToUnixTimeMilliseconds());
return fresh.Count;
}
/// <inheritdoc />
public void Reset(string taskId)
{
EvictExpired();
_failures.TryRemove(taskId, out _);
}
// Удаляет записи, не обновлявшиеся дольше EntryTtlSeconds (ленивая эвикция).
private void EvictExpired()
{
long thresholdMs = _utcNow().ToUnixTimeMilliseconds() - (long)TimeSpan.FromSeconds(EntryTtlSeconds).TotalMilliseconds;
foreach (KeyValuePair<string, Entry> pair in _failures)
{
if (pair.Value.UpdatedAtMs < thresholdMs)
{
_failures.TryRemove(pair.Key, out _);
}
}
}
}
@@ -0,0 +1,48 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Статусы задачи поиска Discovery (колонка DiscTasks.Status; discovery.py L147, api-map §4.8).
/// </summary>
/// <remarks>
/// draft — создана, поиск не запускался; running — поиск/оценка/вступления идут; paused — поставлена на паузу
/// вручную; done — план авто-вступлений выполнен (воркер, discovery_worker _finish_done); failed — упала/остановлена
/// (зарезервировано прототипом). done/failed — терминальные: не занимают бюджет plan_joins (python _DONE_TASK L37)
/// и при повторном start сбрасывают прогресс поиска (start_task L332339).
/// </remarks>
public static class DiscoveryTaskStatuses
{
/// <summary>
/// Создана, поиск ещё не запускался (значение по умолчанию при INSERT).
/// </summary>
public const string Draft = "draft";
/// <summary>
/// Поиск запущен (обрабатывается воркером).
/// </summary>
public const string Running = "running";
/// <summary>
/// Поставлена на паузу вручную (POST …/pause); прогресс поиска сохраняется.
/// </summary>
public const string Paused = "paused";
/// <summary>
/// План авто-вступлений выполнен (терминальный; повторный start сбрасывает прогресс).
/// </summary>
public const string Done = "done";
/// <summary>
/// Упала/остановлена (терминальный статус прототипа; повторный start сбрасывает прогресс).
/// </summary>
public const string Failed = "failed";
/// <summary>
/// Проверка терминального статуса: задача не занимает бюджет plan_joins (python _DONE_TASK L37).
/// </summary>
/// <param name="status">Статус задачи.</param>
/// <returns>True — done/failed (повторный start сбрасывает прогресс поиска, L332339).</returns>
public static bool IsFinished(string status)
{
return status is Done or Failed;
}
}
@@ -0,0 +1,296 @@
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Сервис задач поиска Discovery — create/patch/delete/start/pause/advance/bump (план Task 17, 1:1 discovery.py L234381).
/// </summary>
/// <remarks>
/// Чистый оркестратор поверх портов <see cref="IDiscoveryStore"/> (таблицы DiscTasks схемы тенанта),
/// <see cref="ISettingsStore"/> (дефолты discEvalThreshold/discEvalSample) и <see cref="DiscoveryPlanGuard"/>
/// (план-бюджет, Ruling 9). 404-семантика — null-результат (эндпоинт Task 19 отвечает «Задача не найдена»);
/// 400-семантика — <see cref="DiscoveryValidationException"/> с текстом python (создание без имени/бюджет,
/// start без ключевых слов). Прогресс поиска (SearchIdx/SearchDone/cчётчики) ведут методы
/// <see cref="AdvanceSearchAsync"/> и <see cref="BumpCounterAsync"/> — их зовут кандидатный сервис и воркер (Task 18).
/// </remarks>
public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings)
{
/// <summary>
/// 400 create: пустое название после Trim (create_task L242243).
/// </summary>
public const string NameRequiredDetail = "Укажите название задачи";
/// <summary>
/// 400 start: у задачи нет ключевых слов (start_task L330).
/// </summary>
public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу";
/// <summary>
/// Все задачи, старые первыми (list_tasks L189191; воркер берёт самую старую running).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задачи в порядке создания.</returns>
public Task<IReadOnlyList<DiscoveryTaskDto>> ListAsync(CancellationToken ct)
{
return store.ListTasksAsync(ct);
}
/// <summary>
/// Одна задача по id (get_task L194196).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задача или null (404 «Задача не найдена» у эндпоинта).</returns>
public Task<DiscoveryTaskDto?> GetAsync(string taskId, CancellationToken ct)
{
return store.GetTaskAsync(taskId, ct);
}
/// <summary>
/// Создаёт задачу поиска (create_task L234282: имя, план 1..discJoinLimit, бюджет активных задач).
/// </summary>
/// <remarks>
/// 1:1 с прототипом: name обязательное (после Trim); planJoins — дефолт 1 + <see cref="DiscoveryPlanGuard"/>;
/// threshold/sampleSize — из настроек discEvalThreshold/discEvalSample (дефолты 40/10); границы значений
/// клампятся как _validate_task_values L213231. Задача создаётся draft, поиск не запущен; ключи ИИ-генерации
/// при создании НЕ запрашиваются (endpoint generate-keywords — Task 19, прототип discovery_routes L189211).
/// </remarks>
/// <param name="draft">Поля новой задачи (см. <see cref="DiscoveryTaskDraft"/>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Созданная задача (полный DTO).</returns>
/// <exception cref="DiscoveryValidationException">Пустое имя / план вне 1..discJoinLimit / бюджет исчерпан.</exception>
public async Task<DiscoveryTaskDto> CreateAsync(DiscoveryTaskDraft draft, CancellationToken ct)
{
string name = (draft.Name ?? string.Empty).Trim();
if (name.Length == 0)
{
throw new DiscoveryValidationException(NameRequiredDetail);
}
int planJoins = draft.PlanJoins ?? 1;
await planGuard.AssertPlanAsync(planJoins, ct).ConfigureAwait(false);
await planGuard.AssertBudgetAsync(planJoins, excludeTaskId: null, ct).ConfigureAwait(false);
string lang = draft.Lang is "ru" or "any" ? draft.Lang : "ru";
// Дефолты threshold/sampleSize — из типизированного снимка настроек (C30: один GetAllAsync на создание).
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
int threshold = Clamp(
settingsSnapshot.GetInt(SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold),
min: 1, max: 100);
int sampleSize = Math.Max(1,
settingsSnapshot.GetInt(SettingsKeys.DiscEvalSample, SettingsDefaults.DiscEvalSample));
if (draft.Threshold is int requestedThreshold)
{
threshold = Clamp(requestedThreshold, min: 1, max: 100);
}
if (draft.SampleSize is int requestedSample)
{
sampleSize = Math.Max(1, requestedSample);
}
var row = new DiscoveryTaskRow
{
Id = DiscoveryIdPrefixes.NewTaskId(),
Name = name,
Description = draft.Description ?? string.Empty,
Keywords = CleanKeywords(draft.Keywords),
MinSubscribers = Math.Max(0, draft.MinSubscribers ?? 0),
Lang = lang,
Threshold = threshold,
SampleSize = sampleSize,
PlanJoins = planJoins,
AutoJoin = draft.AutoJoin ?? false,
};
await store.CreateTaskAsync(row, ct).ConfigureAwait(false);
DiscoveryTaskDto created = await store.GetTaskAsync(row.Id, ct).ConfigureAwait(false)
?? throw new InvalidOperationException("create_task: строка не прочиталась после INSERT");
return created;
}
/// <summary>
/// Обновляет поля задачи (patch_task L285311; рост plan_joins — с проверкой бюджета).
/// </summary>
/// <remarks>Поля патча нормализуются как _validate_task_values L213231 (клампы/Trim); увеличение PlanJoins
/// сверх текущего значения проходит <see cref="DiscoveryPlanGuard.AssertBudgetAsync"/> с исключением задачи.
/// Пустой/полностью null патч — возврат текущей задачи без записи (python L289290).</remarks>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="patch">Изменяемые поля (null — не меняется).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Обновлённая задача либо null (404 «Задача не найдена»).</returns>
/// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception>
public async Task<DiscoveryTaskDto?> PatchAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch);
if (normalized.PlanJoins is int newPlan)
{
await planGuard.AssertPlanAsync(newPlan, ct).ConfigureAwait(false);
if (newPlan > current.PlanJoins)
{
await planGuard.AssertBudgetAsync(newPlan, excludeTaskId: taskId, ct).ConfigureAwait(false);
}
}
if (!HasChanges(normalized))
{
// python L289290: пустой патч — возврат текущей задачи без записи (updated_at не бампается).
return current;
}
await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
}
/// <summary>
/// Удаляет задачу вместе с кандидатами и логом (delete_task L314318; чёрный список общий).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — задача удалена; false — строки нет (404 у эндпоинта).</returns>
public async Task<bool> DeleteAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return false;
}
return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false);
}
/// <summary>
/// Запускает поиск (start_task L321346): keywords непустые; status=running.
/// </summary>
/// <remarks>Повторный старт завершённой/упавшей (done/failed) сбрасывает прогресс поиска (свежий проход по
/// ключам, L332–339); продолжение из paused сохраняет search_idx/счётчики (L342344).</remarks>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задача в running либо null (404).</returns>
/// <exception cref="DiscoveryValidationException">Ключевых слов нет (текст start_task L330).</exception>
public async Task<DiscoveryTaskDto?> StartAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
if (current.Keywords.Count == 0)
{
throw new DiscoveryValidationException(NoKeywordsDetail);
}
bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status);
await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
}
/// <summary>
/// Ставит задачу на паузу (pause_task L349356; прогресс поиска сохраняется).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задача в paused либо null (404).</returns>
public async Task<DiscoveryTaskDto?> PauseAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
}
/// <summary>
/// Увеличивает счётчик прогресса задачи (bump_counter L359368; n ≤ 0 — no-op, python max(0, n)).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="field">Счётчик (found/evaluated/joined/rejected).</param>
/// <param name="n">Приращение (по умолчанию 1).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — счётчик увеличен; false — задачи нет.</returns>
public Task<bool> BumpCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct)
{
return store.BumpTaskCounterAsync(taskId, field, Math.Max(0, n), ct);
}
/// <summary>
/// Продвигает индекс поиска (advance_search L371380): search_idx+1; конец ключей → search_done.
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — задача обновлена; false — строки нет.</returns>
public async Task<bool> AdvanceSearchAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return false;
}
int nextIndex = current.SearchIdx + 1;
bool searchDone = nextIndex >= current.Keywords.Count;
return await store.AdvanceSearchAsync(taskId, nextIndex, searchDone, ct).ConfigureAwait(false);
}
// Нормализует патч задачи (1:1 _validate_task_values L213231: Trim/клампы/язык/ключи).
// patch: Патч из запроса.
// Возвращает: Патч с нормализованными значениями (null-поля сохраняются как «не менять»).
private static DiscoveryTaskPatch NormalizeTaskPatch(DiscoveryTaskPatch patch)
{
int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null;
int? sampleSize = patch.SampleSize is int sample ? Math.Max(1, sample) : null;
int? minSubscribers = patch.MinSubscribers is int min ? Math.Max(0, min) : null;
string? lang = patch.Lang is "ru" or "any" ? patch.Lang : patch.Lang is null ? null : "ru";
string? name = patch.Name is null ? null : patch.Name.Trim();
string? description = patch.Description;
return new DiscoveryTaskPatch
{
Name = name,
Description = description,
Keywords = patch.Keywords is null ? null : CleanKeywords(patch.Keywords),
MinSubscribers = minSubscribers,
Lang = lang,
Threshold = threshold,
SampleSize = sampleSize,
PlanJoins = patch.PlanJoins,
AutoJoin = patch.AutoJoin,
};
}
// Проверка: патч содержит хотя бы одно изменяемое поле (python L289 «if not cols»).
// patch: Нормализованный патч.
// Возвращает: True — есть поле к записи.
private static bool HasChanges(DiscoveryTaskPatch patch)
{
return patch.Name is not null || patch.Description is not null || patch.Keywords is not null
|| patch.MinSubscribers is not null || patch.Lang is not null || patch.Threshold is not null
|| patch.SampleSize is not null || patch.PlanJoins is not null || patch.AutoJoin is not null;
}
// Очищает список ключей: Trim + без пустых (python L226227).
// keywords: Сырые ключи (null — пусто).
// Возвращает: Список непустых ключей.
private static IReadOnlyList<string> CleanKeywords(IReadOnlyList<string>? keywords)
{
return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword))
.Select(keyword => keyword.Trim())
.ToList() ?? new List<string>();
}
// Кламп значения в границы [min, max] (эталон clamp python).
private static int Clamp(int value, int min, int max)
{
return Math.Max(min, Math.Min(max, value));
}
}
@@ -0,0 +1,14 @@
using Deal.Contracts.Integrations.Models;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Группа сообщений форума по теме (python group_by_topic L96117).
/// </summary>
/// <param name="TopicKey">Ключ темы: id темы строкой либо «main» (topic_id=null).</param>
/// <param name="Title">Сниппет первого непустого текста темы (≤60 символов; пуст — тема без текста).</param>
/// <param name="Messages">Сообщения группы во входном порядке.</param>
public sealed record DiscoveryTopicGroup(
string TopicKey,
string Title,
IReadOnlyList<TelegramEvalMessageDto> Messages);
@@ -0,0 +1,22 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Доменная ошибка запроса Discovery — 400-семантика (аналог ValueError discovery.py).
/// </summary>
/// <remarks>
/// Прототип бросает ValueError с русским текстом причины, а роутеры переводят его в HTTP 400 {detail}
/// (discovery_routes.py L89). В .NET сервисы модуля бросают это исключение с тем же текстом; эндпоинты
/// (Task 19) ловят его и отвечают 400. 404-семантика (KeyError прототипа) остаётся null-результатом методов
/// (конвенция модулей этапов 1–5) — см. xml-doc сервисов.
/// </remarks>
public sealed class DiscoveryValidationException : Exception
{
/// <summary>
/// Создаёт ошибку с текстом 400-детали (1:1 текст ValueError прототипа).
/// </summary>
/// <param name="detail">Текст причины для {detail} ответа.</param>
public DiscoveryValidationException(string detail)
: base(detail)
{
}
}
@@ -0,0 +1,14 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Результат одного тика discovery-воркера (python discovery_worker L56: {action, taskId}).
/// </summary>
/// <param name="Action">Действие тика (константы <see cref="DiscoveryWorkerService.ActionNone"/>…).</param>
/// <param name="TaskId">Id задачи шага (null — действия с задачей не связано).</param>
public sealed record DiscoveryWorkerOutcome(string Action, string? TaskId)
{
/// <summary>
/// Результат «работы нет» (none).
/// </summary>
public static DiscoveryWorkerOutcome None { get; } = new(DiscoveryWorkerService.ActionNone, null);
}
@@ -0,0 +1,91 @@
namespace Deal.Modules.Discovery.Application;
// Часть DiscoveryWorkerService: константы — действия тика, пороги/размеры воркера и метки кандидатов
// (1:1 python discovery_worker L56/L7489).
public sealed partial class DiscoveryWorkerService
{
// ── Действия тика (python discovery_worker L56) ──
/// <summary>
/// Тик: работы нет (паузы/нет задач/заняты шаги других задач).
/// </summary>
public const string ActionNone = "none";
/// <summary>
/// Тик: выполнен шаг поиска/закрыт проход ключей.
/// </summary>
public const string ActionSearch = "search";
/// <summary>
/// Тик: кандидат оценён и переведён в review.
/// </summary>
public const string ActionReview = "review";
/// <summary>
/// Тик: кандидат пропущен (мало участников/язык/мало подходящих/3 неудачи join).
/// </summary>
public const string ActionSkip = "skip";
/// <summary>
/// Тик: авто-вступление выполнено.
/// </summary>
public const string ActionJoin = "join";
/// <summary>
/// Тик: кандидат отклонён повторной проверкой перед авто-вступлением.
/// </summary>
public const string ActionReject = "reject";
/// <summary>
/// Тик: FloodWait — стоп авто-вступлений до конца суток.
/// </summary>
public const string ActionFlood = "flood";
/// <summary>
/// Тик: сбой шага (не роняет задачу/воркер).
/// </summary>
public const string ActionError = "error";
/// <summary>
/// Тик: задача выполнила план вступлений — done.
/// </summary>
public const string ActionDone = "done";
// ── Пороги/размеры воркера (python L7489) ──
// Минимальный объём содержательной выборки для вердикта оценки (python _MIN_CONTENT L77).
private const int MinContentMessages = 3;
// Ошибки поиска одного ключа подряд, после которых ключ пропускается (python L80).
private const int SearchErrorsToSkip = 3;
// Неудачные авто-вступления подряд, после которых кандидат удаляется (python L417419).
private const int MaxJoinFailures = 3;
// Верхняя граница результатов глобального поиска (прототип discovery_search default 30).
private const int SearchResultLimit = 30;
// ── Метки кандидата (marks; python discovery_worker L8589) ──
/// <summary>
/// Метка: число участников не подтверждено (minSubscribers задан, participants неизвестны).
/// </summary>
public const string MarkParticipantsNotConfirmed = "участники не подтверждены";
/// <summary>
/// Метка: язык не подтверждён (доля кириллицы между порогами).
/// </summary>
public const string MarkLangNotConfirmed = "язык не подтверждён";
/// <summary>
/// Метка: канал, история недоступна без членства (контент не оценён).
/// </summary>
public const string MarkChannelNoHistory = "канал: история недоступна";
/// <summary>
/// Метка: закрытая группа — история скрыта, вступите сами (контент не оценён).
/// </summary>
public const string MarkClosedGroup = "закрытая группа (история скрыта) — вступите сами";
/// <summary>
/// Метка: содержательных сообщений выборки меньше трёх (решает человек).
/// </summary>
public const string MarkFewMessages = "мало сообщений";
}
@@ -0,0 +1,234 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
// Часть DiscoveryWorkerService: шаг оценки кандидата status='new' — инфо источника, выборка, язык, объём
// и оценка содержания (python _eval_step L228315/_evaluate_content L318356) + перевод в review.
public sealed partial class DiscoveryWorkerService
{
// Шаг оценки первого кандидата status='new' (все ветки — одно действие; python _eval_step L228315).
// task: Running-задача кандидата.
// candidate: Самый старый кандидат статуса new.
// ct: Токен отмены.
// Возвращает: review — кандидат оценён; skip — пропущен (delete); error — сбой шага.
private async Task<DiscoveryWorkerOutcome> EvalStepAsync(
DiscoveryTaskDto task, DiscoveryCandidateDto candidate, CancellationToken ct)
{
string taskId = task.Id;
string dialogId = candidate.DialogId;
List<string> marks = new(2);
// ── Инфо об источнике: kind/forum, участники, имя/username (python L234249) ──
TelegramChannelInfoDto info;
try
{
info = await _gateway.InfoAsync(dialogId, ct).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"инфо источника {dialogId}: {exception.Message}", ct)
.ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionError, taskId);
}
bool isForum = info.IsForum;
string infoKind = (info.Kind ?? string.Empty).Trim();
bool resolved = infoKind.Length > 0 || isForum;
string kind = resolved
? KindCode(infoKind, isForum)
: (string.IsNullOrEmpty(candidate.Kind) ? DiscoveryCandidateKinds.Channel : candidate.Kind);
var patch = new DiscoveryCandidatePatch
{
Kind = kind,
Hue = string.IsNullOrEmpty(info.Hue) ? null : info.Hue,
Participants = info.Participants,
};
if (resolved)
{
// Имя/username обновляем только при успешном резолве: при fallback discovery_info возвращает
// name=dialog_id и не должен затирать имя (python L244248).
patch = patch with
{
Name = string.IsNullOrWhiteSpace(info.Name) ? null : info.Name.Trim(),
Username = string.IsNullOrWhiteSpace(info.Username) ? null : info.Username.Trim(),
};
}
await _candidates.SetAsync(taskId, dialogId, patch, ct).ConfigureAwait(false);
int? participants = info.Participants;
// ── Фильтр minSubscribers (python L252265) ──
if (task.MinSubscribers > 0)
{
if (participants is null)
{
marks.Add(MarkParticipantsNotConfirmed);
}
else if (participants.Value < task.MinSubscribers)
{
await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false);
await _log.AddAsync(
taskId,
DiscoveryLogEvents.Skip,
$"{dialogId}: мало участников ({participants} < {task.MinSubscribers})",
ct).ConfigureAwait(false);
await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionSkip, taskId);
}
}
// ── Чтение истории для оценки (python L267277) ──
TelegramEvalReadDto read;
try
{
read = await _gateway.ReadForEvalAsync(dialogId, Math.Max(1, task.SampleSize), ct).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"чтение выборки {dialogId}: {exception.Message}", ct)
.ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionError, taskId);
}
IReadOnlyList<TelegramEvalMessageDto> messages = read.Messages ?? Array.Empty<TelegramEvalMessageDto>();
if (!read.Ok)
{
// История недоступна без членства: контент не оцениваем, фильтры помечаем (python L269276).
marks.Add(kind == DiscoveryCandidateKinds.Channel ? MarkChannelNoHistory : MarkClosedGroup);
if (task.Lang == "ru")
{
marks.Add(MarkLangNotConfirmed);
}
await FinishReviewAsync(taskId, dialogId, marks, langRu: null, ct: ct).ConfigureAwait(false);
await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionReview, taskId);
}
// ── Язык (только для ru-задач; python L279289) ──
bool? langRu = null;
if (task.Lang == "ru")
{
langRu = DiscoveryLangDetector.DetectRu(messages.Select(message => message.Text).ToArray());
if (langRu == false)
{
await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false);
await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{dialogId}: язык не русский", ct)
.ConfigureAwait(false);
await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionSkip, taskId);
}
if (langRu is null)
{
marks.Add(MarkLangNotConfirmed);
}
}
// ── Объём выборки: меньше 3 содержательных — решает человек (python L291296) ──
if (messages.Count < MinContentMessages)
{
marks.Add(MarkFewMessages);
await FinishReviewAsync(taskId, dialogId, marks, langRu: langRu, ct: ct).ConfigureAwait(false);
await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionReview, taskId);
}
// ── Оценка содержания (форумы — по темам; python _evaluate_content L318356) ──
int fitCount;
int total;
double fitRatio;
IReadOnlyList<DiscoveryTopicDto>? topics;
bool passed;
if (kind == DiscoveryCandidateKinds.Forum)
{
var topicRows = new List<DiscoveryTopicDto>();
int forumFit = 0;
int forumTotal = 0;
bool anyPassed = false;
foreach (DiscoveryTopicGroup group in DiscoveryEvaluator.GroupByTopic(messages))
{
DiscoveryEvalSample groupEval =
await _evaluator.EvaluateSampleAsync(task, GroupTexts(group.Messages), ct).ConfigureAwait(false);
bool groupPassed = DiscoveryEvaluator.Passed(groupEval, task.Threshold);
forumFit += groupEval.FitCount;
forumTotal += groupEval.Total;
topicRows.Add(new DiscoveryTopicDto(
group.TopicKey,
group.Title,
groupEval.FitCount,
groupEval.Total,
groupEval.FitRatio,
groupPassed));
anyPassed = anyPassed || groupPassed;
}
fitCount = forumFit;
total = forumTotal;
fitRatio = forumTotal > 0 ? (double)forumFit / forumTotal : 0.0;
topics = topicRows;
passed = anyPassed; // форум подходит, если есть хотя бы одна проходная тема (python L346347)
}
else
{
DiscoveryEvalSample eval =
await _evaluator.EvaluateSampleAsync(task, GroupTexts(messages), ct).ConfigureAwait(false);
fitCount = eval.FitCount;
total = eval.Total;
fitRatio = eval.FitRatio;
topics = null;
passed = DiscoveryEvaluator.Passed(eval, task.Threshold);
}
if (passed)
{
await FinishReviewAsync(
taskId,
dialogId,
marks,
langRu: langRu,
fitRatio: fitRatio,
topics: topics,
ct: ct).ConfigureAwait(false);
await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionReview, taskId);
}
await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false);
await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{dialogId}: мало подходящих ({fitCount} из {total})", ct)
.ConfigureAwait(false);
await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionSkip, taskId);
}
// Перевести кандидата в review с метками/оценкой (python _finish_review L154172; статус пишет лог review).
// taskId: Id задачи.
// dialogId: Id источника.
// marks: Метки (пустые отфильтровываются).
// langRu: Подтверждённый язык (null — не менять).
// fitRatio: Доля подходящих (null — контент не оценён).
// topics: Темы форума (null — не менять; для не-форумов не заполняется).
// ct: Токен отмены.
private async Task FinishReviewAsync(
string taskId,
string dialogId,
IReadOnlyList<string> marks,
bool? langRu = null,
double? fitRatio = null,
IReadOnlyList<DiscoveryTopicDto>? topics = null,
CancellationToken ct = default)
{
var patch = new DiscoveryCandidatePatch
{
Marks = marks.Where(mark => !string.IsNullOrEmpty(mark)).ToList(),
LangRu = langRu,
FitRatio = fitRatio,
Topics = topics,
};
await _candidates.SetAsync(taskId, dialogId, patch, ct).ConfigureAwait(false);
await _candidates.SetStatusAsync(dialogId, DiscoveryCandidateStatuses.Review, ct).ConfigureAwait(false);
}
}
@@ -0,0 +1,49 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
// Часть DiscoveryWorkerService: общие хелперы — распознавание FloodWait (IsFlood), нормализация kind
// источника (KindCode) и тексты сообщений для оценки (GroupTexts).
public sealed partial class DiscoveryWorkerService
{
// Flood-ли это исключение гейта: RpcException RESOURCE_EXHAUSTED c detail-префиксом «flood:»
// (контракт telegram.proto, telegram-service L820826). Модуль чистый (без Grpc.Core), поэтому признак —
// текст исключения: RpcException.Message кодирует Status как
// Status(StatusCode="ResourceExhausted", Detail="flood: FLOOD_WAIT_…") — ищем маркер «flood:»
// (префикс detail, которым сервис помечает FloodWait; обычные ошибки его не несут).
// exception: Исключение вызова гейта.
// Возвращает: True — FloodWait Telegram (стоп до конца суток, note_flood).
internal static bool IsFlood(Exception exception)
{
string message = exception.Message ?? string.Empty;
return message.StartsWith("flood:", StringComparison.OrdinalIgnoreCase)
|| message.StartsWith("flood ", StringComparison.OrdinalIgnoreCase)
|| message.Contains("flood:", StringComparison.OrdinalIgnoreCase);
}
// Нормализация kind источника: channel|group|forum (python _kind_code L99106; RU-формы каталога).
// kind: Kind из гейта (EN-канон) либо русская форма каталога.
// isForum: True — мегагруппа-форум (темы) → forum.
// Возвращает: Код кандидата: channel/group/forum (неизвестное → group).
private static string KindCode(string kind, bool isForum = false)
{
if (isForum)
{
return DiscoveryCandidateKinds.Forum;
}
return kind.Trim().ToLowerInvariant() switch
{
DiscoveryCandidateKinds.Channel or "канал" => DiscoveryCandidateKinds.Channel,
DiscoveryCandidateKinds.Forum or "форум" => DiscoveryCandidateKinds.Forum,
"chat" or "чат" => DiscoveryCandidateKinds.Group,
_ => DiscoveryCandidateKinds.Group,
};
}
// Тексты сообщений группы (входной порядок) для оценки выборки.
private static IReadOnlyList<string> GroupTexts(IReadOnlyList<TelegramEvalMessageDto> messages)
=> messages.Select(message => message.Text ?? string.Empty).ToList();
}
@@ -0,0 +1,149 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
// Часть DiscoveryWorkerService: шаг авто-вступления кандидата status='review' — повторные проверки,
// пауза, join, обработка FloodWait/неудач (python _join_step L359439).
public sealed partial class DiscoveryWorkerService
{
// Шаг авто-вступления одного кандидата status='review' (python _join_step L359439).
// task: Running-задача с autoJoin.
// candidate: Самый старый кандидат статуса review.
// ct: Токен отмены.
// Возвращает: join/reject/flood/error/skip — действие; none — выход без вступления (изменения за паузу).
private async Task<DiscoveryWorkerOutcome> JoinStepAsync(
DiscoveryTaskDto task, DiscoveryCandidateDto candidate, CancellationToken ct)
{
string taskId = task.Id;
string dialogId = candidate.DialogId;
// Между оценкой и вступлением могли вступить/отклонить источник (python L364374).
bool inDialogs = await _store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false);
if (inDialogs || await _store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false))
{
string reason = inDialogs
? "уже вступили между оценкой и авто-вступлением"
: "источник в чёрном списке (повторная проверка перед авто-вступлением)";
try
{
await _candidates.MarkRejectedAsync(dialogId, reason, ct).ConfigureAwait(false);
}
catch (DiscoveryValidationException)
{
// Кандидат уже joined (например, вступили вручную) — reject невозможен: пропускаем (python suppress).
}
return new DiscoveryWorkerOutcome(ActionReject, taskId);
}
// Спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max; python L376377).
await _pacer.WaitJoinDelayAsync(ct).ConfigureAwait(false);
// За время паузы задача/кандидат/состояние BanGuard могли измениться: вступаем только если кандидат
// всё ещё есть и в review, задача ещё running с autoJoin, мы не состоим и авто-вступления разрешены
// (стоп-кран/flood/лимит могли включиться во время паузы) — иначе выходим без join (python L379394).
DiscoveryTaskDto? taskNow = await _tasks.GetAsync(taskId, ct).ConfigureAwait(false);
DiscoveryCandidateDto? fresh = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (fresh is null
|| fresh.Status != DiscoveryCandidateStatuses.Review
|| taskNow is null
|| taskNow.Status != DiscoveryTaskStatuses.Running
|| !taskNow.AutoJoin
|| await _store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false)
|| await _store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false)
|| !await _banGuard.CanAutoJoinAsync(ct).ConfigureAwait(false))
{
return DiscoveryWorkerOutcome.None;
}
string username = (fresh.Username ?? string.Empty).Trim().TrimStart('@');
try
{
await _gateway.JoinAsync(username, ct).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
if (IsFlood(exception))
{
// FloodWait: стоп авто-вступлений до конца суток; кандидат остаётся review (python L399402).
await _banGuard.NoteFloodAsync(ct).ConfigureAwait(false);
await _log.AddAsync(
taskId,
DiscoveryLogEvents.Flood,
$"авто-вступление {dialogId}: flood — стоп до конца суток",
ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionFlood, taskId);
}
// Между паузой и неудачным join кандидата могли отклонить/удалить: счётчик и удаление трогаем
// только у живой записи в статусе review (python L404410).
DiscoveryCandidateDto? rowNow = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (rowNow is null || rowNow.Status != DiscoveryCandidateStatuses.Review)
{
return DiscoveryWorkerOutcome.None;
}
int? failures = await _store.IncrementJoinFailuresAsync(dialogId, ct).ConfigureAwait(false);
if (failures is null)
{
return DiscoveryWorkerOutcome.None;
}
if (failures.Value >= MaxJoinFailures)
{
await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false);
await _log.AddAsync(
taskId,
DiscoveryLogEvents.Skip,
$"{dialogId}: не удалось вступить (3 попытки): {exception.Message}",
ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionSkip, taskId);
}
await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"авто-вступление {dialogId}: {exception.Message}", ct)
.ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionError, taskId);
}
// Вступление состоялось: joined(auto) + каталог/зеркало (монитор on) + фоновый разбор последних
// сообщений (backfill; сбой не роняет шаг) + снятие чёрного списка (python L424438).
await _candidates.MarkJoinedAsync(dialogId, auto: true, ct).ConfigureAwait(false);
try
{
await _gateway.SetMonitorAsync(dialogId, true, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
// Зеркало сервиса обновится ближайшей SyncDialogs-синхронизацией — не ошибка шага,
// но сбой обязан быть видимым (DiscLog; Security/наблюдаемость review).
await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"включение мониторинга {dialogId}: {exception.Message}", ct)
.ConfigureAwait(false);
}
try
{
await _gateway.BackfillAsync(dialogId, force: false, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
// Вступление уже состоялось — сбой backfill не роняет шаг (python L434437: log.warning),
// но фиксируется в DiscLog, иначе «немое» глотание скрывает регулярные сбои.
await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"фоновый разбор {dialogId}: {exception.Message}", ct)
.ConfigureAwait(false);
}
await _store.RemoveBlacklistAsync(dialogId, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionJoin, taskId);
}
}
@@ -0,0 +1,102 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
// Часть DiscoveryWorkerService: шаг поиска — один ключ keywords[SearchIdx] → кандидаты + advance_search
// (python _search_step L177225) и лог завершения прохода.
public sealed partial class DiscoveryWorkerService
{
// Шаг поиска: один ключ keywords[SearchIdx] → кандидаты + advance_search (python _search_step L177225).
// task: Running-задача с незавершённым проходом.
// ct: Токен отмены.
// Возвращает: search — ключ обработан; flood/error — сбой (индекс ключа/счётчик ошибок по сценарию).
private async Task<DiscoveryWorkerOutcome> SearchStepAsync(DiscoveryTaskDto task, CancellationToken ct)
{
string taskId = task.Id;
IReadOnlyList<string> keywords = task.Keywords;
int index = task.SearchIdx;
if (keywords.Count == 0 || index >= keywords.Count)
{
// Ключи закончились/пустой список: закрываем проход без сетевого вызова (python _close_search L139144).
await _tasks.AdvanceSearchAsync(taskId, ct).ConfigureAwait(false);
await LogSearchDoneIfAnyAsync(taskId, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionSearch, taskId);
}
string keyword = keywords[index];
IReadOnlyList<TelegramDialogEntryDto> results;
try
{
results = await _gateway.SearchAsync(keyword, SearchResultLimit, ct).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
if (IsFlood(exception))
{
await _banGuard.NoteFloodAsync(ct).ConfigureAwait(false);
await _log.AddAsync(taskId, DiscoveryLogEvents.Flood, $"поиск «{keyword}»: flood — стоп до конца суток", ct)
.ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionFlood, taskId);
}
int errors = _searchErrors.Next(taskId);
if (errors >= SearchErrorsToSkip)
{
// 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен зацикливать поиск и
// блокировать оценку/вступления других задач; python L196203).
_searchErrors.Reset(taskId);
await _log.AddAsync(
taskId,
DiscoveryLogEvents.Error,
$"поиск «{keyword}»: {exception.Message} — ключ пропущен ({errors} ошибки подряд)",
ct).ConfigureAwait(false);
await _tasks.AdvanceSearchAsync(taskId, ct).ConfigureAwait(false);
await LogSearchDoneIfAnyAsync(taskId, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionError, taskId);
}
await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"поиск «{keyword}»: {exception.Message}", ct)
.ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionError, taskId);
}
_searchErrors.Reset(taskId); // успешный поиск — сброс счётчика ошибок ключа (python L207).
foreach (TelegramDialogEntryDto item in results)
{
string rawKind = (item.Kind ?? string.Empty).Trim().ToLowerInvariant();
if (rawKind == "чат" || rawKind == "chat")
{
// Люди/личные чаты и боты глобальным поиском не предлагаются (python L211214).
await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{item.Name}: личный чат/бот", ct)
.ConfigureAwait(false);
continue;
}
await _candidates.AddAsync(
taskId,
item.Id,
item.Name ?? string.Empty,
item.Handle ?? string.Empty,
KindCode(rawKind),
item.Hue ?? string.Empty,
ct).ConfigureAwait(false);
}
await _tasks.AdvanceSearchAsync(taskId, ct).ConfigureAwait(false);
await LogSearchDoneIfAnyAsync(taskId, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionSearch, taskId);
}
// Лог завершения поиска, если advance_search перевёл задачу в searchDone (python L147151).
private async Task LogSearchDoneIfAnyAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? task = await _tasks.GetAsync(taskId, ct).ConfigureAwait(false);
if (task is not null && task.SearchDone)
{
await _log.AddAsync(taskId, DiscoveryLogEvents.Search, $"поиск завершён: {task.Found} кандидатов", ct)
.ConfigureAwait(false);
}
}
}
@@ -0,0 +1,178 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Воркер Discovery: поиск → оценка → авто-вступление, один шаг за тик (1:1 discovery_worker.py целиком, план Task 18, Ruling 10).
/// </summary>
/// <remarks>
/// <see cref="TickOnceAsync"/> выполняет ОДНО действие для самой старой running-задачи и возвращает
/// {action, taskId}: «search»|«review»|«skip»|«join»|«reject»|«flood»|«error»|«done»|«none» (python L444484).
/// Приоритеты внутри тика:
/// <list type="number">
/// <item>стоп-краны: <see cref="DiscoveryBanGuard.GlobalPausedAsync"/> и flood-день — никаких действий (none);</item>
/// <item>задача достигла плана вступлений (joined ≥ planJoins) → status=done + лог done (занимаемый бюджет
/// планов освобождается сразу);</item>
/// <item>поиск: следующая задача с незавершённым проходом по ключам — один ключ keywords[searchIdx] →
/// gateway.SearchAsync (личные чаты/боты пропускаются с логом skip), каждый результат — add_candidate;
/// advance_search; после 3 ошибок подряд ключ пропускается (битый ключ не зацикливает поиск);</item>
/// <item>оценка первого кандидата status='new': gateway.InfoAsync (kind/forum/участники; minSubscribers →
/// delete+skip; участники не определены — метка), gateway.ReadForEvalAsync (история недоступна → review с
/// меткой «канал…»/«закрытая группа…»), язык ru-задач (не русский → delete+skip; неопределённо — метка),
/// &lt;3 сообщений → метка «мало сообщений», оценка содержания <see cref="DiscoveryEvaluator"/> (форумы — по
/// темам через GroupByTopic; вердикт — есть проходная тема) → review с fitRatio/topics либо delete+skip;</item>
/// <item>авто-вступление первого кандидата status='review' задачи с autoJoin (ниже оценки): повторная проверка
/// «не состоим» (Dialogs/чёрный список — mark_rejected с причиной), пауза <see cref="IDiscoveryPacer"/>
/// (50–70 с), после паузы — повторная перепроверка (кандидат/задача/BanGuard могли измениться) → join;
/// FloodWait → note_flood + лог flood (кандидат остаётся review); прочая ошибка → join_failures+1, после 3
/// неудач кандидат удаляется (лог skip); успех → mark_joined(auto) + монитор-зеркало (SetMonitorAsync) +
/// фоновый Backfill (сбой не роняет шаг) + remove_blacklist.</item>
/// </list>
/// Метки кандидата (marks) — строки-чипы (1:1 python L8589); темы форума (topics) — только для kind=forum
/// (L336344). Все логи — через <see cref="DiscoveryLogService"/> (DiscLog), счётчики прогресса — bump задачи.
/// Чистый модуль: сетевые вызовы только через <see cref="ITelegramGateway"/> (Contracts), без gRPC-типов.
/// </remarks>
public sealed partial class DiscoveryWorkerService
{
private readonly IDiscoveryStore _store;
private readonly DiscoveryTasksService _tasks;
private readonly DiscoveryCandidatesService _candidates;
private readonly DiscoveryLogService _log;
private readonly DiscoveryEvaluator _evaluator;
private readonly DiscoveryBanGuard _banGuard;
private readonly IDiscoveryPacer _pacer;
private readonly ITelegramGateway _gateway;
private readonly IDiscoverySearchErrorCounter _searchErrors;
/// <summary>
/// Создаёт воркер Discovery (чистый оркестратор; все зависимости — порты/сервисы модуля).
/// </summary>
/// <param name="store">Хранилище Discovery (микро-операции: done, join_failures, проверки «не состоим»).</param>
/// <param name="tasks">Сервис задач (список running, advance_search, bump счётчиков).</param>
/// <param name="candidates">Сервис кандидатов (add/set/status/mark_joined/mark_rejected/delete).</param>
/// <param name="log">Лог задач (события search/flood/error/done/skip).</param>
/// <param name="evaluator">Оценка содержания (фит/темы; ядро каскада discovery_eval).</param>
/// <param name="banGuard">Квоты дня/flood/стоп-кран (DiscoveryBanGuard).</param>
/// <param name="pacer">Паузы между авто-вступлениями (интерфейс — фейк в тестах).</param>
/// <param name="gateway">Гейт telegram-service (Search/Info/ReadForEval/Join/SetMonitor/Backfill).</param>
/// <param name="searchErrors">Singleton-счётчик ошибок ключей поиска; null — локальный (на инстанс воркера).</param>
public DiscoveryWorkerService(
IDiscoveryStore store,
DiscoveryTasksService tasks,
DiscoveryCandidatesService candidates,
DiscoveryLogService log,
DiscoveryEvaluator evaluator,
DiscoveryBanGuard banGuard,
IDiscoveryPacer pacer,
ITelegramGateway gateway,
IDiscoverySearchErrorCounter? searchErrors = null)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(tasks);
ArgumentNullException.ThrowIfNull(candidates);
ArgumentNullException.ThrowIfNull(log);
ArgumentNullException.ThrowIfNull(evaluator);
ArgumentNullException.ThrowIfNull(banGuard);
ArgumentNullException.ThrowIfNull(pacer);
ArgumentNullException.ThrowIfNull(gateway);
_store = store;
_tasks = tasks;
_candidates = candidates;
_log = log;
_evaluator = evaluator;
_banGuard = banGuard;
_pacer = pacer;
_gateway = gateway;
_searchErrors = searchErrors ?? new DiscoverySearchErrorCounter();
}
/// <summary>
/// Один шаг discovery-воркера (см. doc-комментарий типа и discovery_worker.tick L444484).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат действия: действие + id задачи (если применимо).</returns>
public async Task<DiscoveryWorkerOutcome> TickOnceAsync(CancellationToken ct)
{
if (await _banGuard.GlobalPausedAsync(ct).ConfigureAwait(false)
|| await _banGuard.FloodTodayAsync(ct).ConfigureAwait(false))
{
// Стоп-кран/flood-день: никаких сетевых действий (поиск/оценка/join) — воркер просто стоит (python L446451).
return DiscoveryWorkerOutcome.None;
}
IReadOnlyList<DiscoveryTaskDto> allTasks = await _tasks.ListAsync(ct).ConfigureAwait(false);
List<DiscoveryTaskDto> running = allTasks.Where(task => task.Status == DiscoveryTaskStatuses.Running).ToList();
if (running.Count == 0)
{
return DiscoveryWorkerOutcome.None;
}
// 1. План достигнут — закрываем задачу (важно до поиска/оценки/join: задачу с выполненным планом
// нельзя продолжать обрабатывать; python L458461).
foreach (DiscoveryTaskDto task in running)
{
if (task.Joined >= task.PlanJoins)
{
await FinishDoneAsync(task, ct).ConfigureAwait(false);
return new DiscoveryWorkerOutcome(ActionDone, task.Id);
}
}
// 2. Поиск: следующая running-задача с незавершённым проходом по ключам (python L463466).
foreach (DiscoveryTaskDto task in running)
{
if (!task.SearchDone)
{
return await SearchStepAsync(task, ct).ConfigureAwait(false);
}
}
// 3. Оценка: первый кандидат status='new' (самая старая задача — первой; python L468472).
foreach (DiscoveryTaskDto task in running)
{
IReadOnlyList<DiscoveryCandidateDto> newCandidates =
await _candidates.ListAsync(task.Id, DiscoveryCandidateStatuses.New, ct).ConfigureAwait(false);
if (newCandidates.Count > 0)
{
return await EvalStepAsync(task, newCandidates[0], ct).ConfigureAwait(false);
}
}
// 4. Авто-вступление: отдельный проход, приоритет ниже оценки (python L474482).
foreach (DiscoveryTaskDto task in running)
{
if (!task.AutoJoin)
{
continue;
}
IReadOnlyList<DiscoveryCandidateDto> reviewCandidates =
await _candidates.ListAsync(task.Id, DiscoveryCandidateStatuses.Review, ct).ConfigureAwait(false);
if (reviewCandidates.Count == 0)
{
continue;
}
if (!await _banGuard.CanAutoJoinAsync(ct).ConfigureAwait(false))
{
// Суточный лимит/flood/пауза — join никому нельзя (python L480481).
return DiscoveryWorkerOutcome.None;
}
return await JoinStepAsync(task, reviewCandidates[0], ct).ConfigureAwait(false);
}
return DiscoveryWorkerOutcome.None;
}
// Задача выполнила план вступлений: status=done + лог done (python _finish_done L126136).
private async Task FinishDoneAsync(DiscoveryTaskDto task, CancellationToken ct)
{
await _store.SetTaskDoneAsync(task.Id, ct).ConfigureAwait(false);
await _log.AddAsync(task.Id, DiscoveryLogEvents.Done, $"план выполнен: вступили {task.Joined} из {task.PlanJoins}", ct)
.ConfigureAwait(false);
}
}
@@ -0,0 +1,18 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Порт «пейсера» Discovery: паузы между сетевыми действиями (план Task 18, Ruling 10).
/// </summary>
/// <remarks>
/// Рандомные паузы вынесены за интерфейс, чтобы воркер тестировался без реальных ожиданий: тесты ставят фейк
/// с мгновенным возвратом, продовая реализация (<see cref="DiscoveryPacer"/>) читает discJoinDelayMin/Max из
/// настроек тенанта (50–70 с) и спит случайное время в интервале (1:1 ban_guard.wait_join_delay L4456).
/// </remarks>
public interface IDiscoveryPacer
{
/// <summary>
/// Пауза перед авто-вступлением: случайное число секунд из discJoinDelayMin..Max.
/// </summary>
/// <param name="ct">Токен отмены (остановка хоста прерывает ожидание).</param>
public Task WaitJoinDelayAsync(CancellationToken ct);
}
@@ -0,0 +1,27 @@
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Счётчик ошибок поиска одного ключа по задачам (discovery_worker._search_errors L82, план Task 18).
/// </summary>
/// <remarks>
/// В прототипе счётчик — глобальный dict памяти процесса (при рестарте сбрасывается). В ядре воркер Discovery
/// разрешается scoped-зависимостями на каждый тик тенанта, поэтому счётчик вынесен в singleton: после трёх
/// ошибок подряд одного ключа (3) ключ пропускается advance_search — битый ключ не должен зацикливать поиск.
/// Ключ — id задачи (<c>dt_…</c>): id уникальны глобально (случайная 12-hex часть), коллизий между тенантами нет.
/// Реализация хранит записи с TTL (эвикция просроченных, quality review) — удалённые задачи не копят строки.
/// </remarks>
public interface IDiscoverySearchErrorCounter
{
/// <summary>
/// Увеличивает счётчик ошибок ключа задачи и возвращает новое значение.
/// </summary>
/// <param name="taskId">Id задачи поиска.</param>
/// <returns>Число ошибок подряд после увеличения (1 — первая).</returns>
public int Next(string taskId);
/// <summary>
/// Сбрасывает счётчик задачи (успешный поиск/ключ пропущен).
/// </summary>
/// <param name="taskId">Id задачи поиска.</param>
public void Reset(string taskId);
}
@@ -0,0 +1,271 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
/// <summary>
/// Порт хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17).
/// </summary>
/// <remarks>
/// Порт объявлен в модуле Discovery (чистый, без EF) и реализуется EF-адаптером <c>DiscoveryStore</c>
/// (Deal.Infrastructure, регистрация в AddDealPersistence) — 1:1 с таблицами db.py L136–196 и операциями
/// discovery.py. Порт оперирует DTO/row-типами модуля; маппинг DTO ↔ строки (включая JSON-колонки keywords/
/// marks/topics и перевод времён в epoch-ms) выполняет адаптер вручную. Id записей (dt_/dl_) генерирует модуль
/// и передаёт готовыми; CreatedAt/UpdatedAt проставляет адаптер (UTC-now). Чтения списков сортируются как в
/// python (tasks — created_at ASC; candidates — created_at ASC; blacklist — created_at DESC; лог — created_at
/// DESC). Запись DiscCandidates в dialogs-каталог (monitored) в порт не входит — проверка «уже мониторится»
/// читает таблицу Dialogs (владелец — модуль Telegram; реверс-зависимостей нет, доступ через тот же TenantDbContext).
/// </remarks>
public interface IDiscoveryStore
{
// ── Задачи (disc_tasks; list_tasks/get_task/create_task/patch_task/delete_task L189318) ──
/// <summary>
/// Все задачи, старые первыми (list_tasks L189191: ORDER BY created_at ASC).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задачи в порядке создания; пусто — задач нет.</returns>
public Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct);
/// <summary>
/// Одна задача по id (get_task L194196).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задача или null, если строки нет.</returns>
public Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct);
/// <summary>
/// Создаёт задачу из полной записи (create_task L260280; Status=draft, поиск/счётчики=0, CreatedAt/UpdatedAt — UTC-now).
/// </summary>
/// <param name="row">Полное состояние новой задачи (id сгенерирован модулем, см. <see cref="DiscoveryTaskRow"/>).</param>
/// <param name="ct">Токен отмены.</param>
public Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct);
/// <summary>
/// Точечная правка полей по присутствующим в патче + bump UpdatedAt (patch_task L285311).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="patch">Изменяемые поля (null — поле не меняется; keywords — полная замена JSON-массива).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — задачи нет (404-семантика сервиса).</returns>
public Task<bool> PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct);
/// <summary>
/// Удаляет задачу вместе с её кандидатами и логом (delete_task L314318; чёрный список общий — не трогается).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — задача была и удалена; false — строки нет.</returns>
public Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct);
/// <summary>
/// Переводит задачу в running (start_task L321346).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="resetProgress">True — повторный старт терминальной (done/failed): search_idx=0, search_done=false,
/// счётчики found/evaluated/joined/rejected=0 (L333339); False — draft/paused: только статус (L342344).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — задача обновлена; false — строки нет.</returns>
public Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct);
/// <summary>
/// Переводит задачу в paused (pause_task L349356; прогресс поиска сохраняется).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — задача обновлена; false — строки нет.</returns>
public Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct);
/// <summary>
/// Переводит задачу в done (воркер _finish_done L126136: план вступлений выполнен; bump UpdatedAt).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — задача обновлена; false — строки нет.</returns>
public Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct);
/// <summary>
/// Увеличивает счётчик прогресса задачи (bump_counter L359368: поле += n, bump UpdatedAt).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="field">Счётчик (found/evaluated/joined/rejected).</param>
/// <param name="n">Приращение (вызывающий передаёт ≥1; ≤0 — no-op, как max(0, n) python).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — задача обновлена; false — строки нет.</returns>
public Task<bool> BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct);
/// <summary>
/// Продвигает индекс поиска (advance_search L371380: search_idx = nextIndex, search_done, bump UpdatedAt).
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="nextIndex">Новый search_idx (текущий + 1; считает сервис).</param>
/// <param name="searchDone">search_done = nextIndex ≥ keywords.Count (считает сервис).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — задача обновлена; false — строки нет.</returns>
public Task<bool> AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct);
/// <summary>
/// Сумма plan_joins активных задач (python _active_plan_sum L8591: status NOT IN done/failed).
/// </summary>
/// <param name="excludeTaskId">Id задачи, исключаемой из суммы (patch-рост плана); null — все активные.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Занятый бюджет авто-вступлений (0 — активных задач нет).</returns>
public Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct);
// ── Кандидаты (disc_candidates; L385520) ──
/// <summary>
/// Кандидаты задачи, старые первыми (list_candidates L385397); status — фильтр.
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="status">Статус-фильтр (см. <see cref="DiscoveryCandidateStatuses"/>); null — все статусы.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Кандидаты задачи (marks/topics — типизированными списками); пусто — кандидатов нет.</returns>
public Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(string taskId, string? status, CancellationToken ct);
/// <summary>
/// Кандидат по dialog_id (python _get_candidate L400402; ключ — источник, кандидат один).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Кандидат или null, если строки нет.</returns>
public Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct);
/// <summary>
/// Считает события лога по типу с CreatedAt ≥ sinceUtc (ban_guard.joins_today_auto L2935: суточная
/// квота авто-вступлений по DiscLog event='join_auto' за текущие UTC-сутки; Task 18).
/// </summary>
/// <param name="logEvent">Событие (см. <see cref="DiscoveryLogEvents"/>; воркер считает «join_auto»).</param>
/// <param name="sinceUtc">Нижняя граница CreatedAt (UTC; бан-гард передаёт начало текущих суток).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Число событий с начала суток (0 — событий нет).</returns>
public Task<int> CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct);
/// <summary>
/// Увеличивает join_failures кандидата, если запись жива и в статусе review (воркер L411416; bump UpdatedAt).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Новое значение счётчика; null — записи нет/не в review (счётчик не трогается).</returns>
public Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct);
/// <summary>
/// Проверка «уже мониторится (мы состоим)»: источник есть в каталоге Dialogs (add_candidate L418419).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — источник есть в Dialogs (добавление пропускается с логом skip).</returns>
public Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct);
/// <summary>
/// Проверка «источник в чёрном списке» (add_candidate L421422, воркер _we_are_in L114123).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — источник есть в DiscBlacklist.</returns>
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct);
/// <summary>
/// Добавляет кандидата со служебными дефолтами (add_candidate INSERT L436449: participants/lang_ru NULL,
/// marks/topics «[]», fit_ratio NULL, status new, auto_joined false; CreatedAt/UpdatedAt — UTC-now).
/// </summary>
/// <param name="row">Новые значения кандидата (см. <see cref="DiscoveryCandidateRow"/>).</param>
/// <param name="ct">Токен отмены.</param>
public Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct);
/// <summary>
/// Удаляет кандидата по dialog_id (delete_candidate L518520; повторный вызов безопасен).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
public Task DeleteCandidateAsync(string dialogId, CancellationToken ct);
/// <summary>
/// Правка полей кандидата (set_candidate UPDATE L456494; bump UpdatedAt).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="patch">Изменяемые поля (null — поле не меняется; marks/topics — полная замена JSON).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — кандидата нет.</returns>
public Task<bool> PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct);
/// <summary>
/// Переводит кандидата в new/review (set_candidate_status L497515: только эти статусы; bump UpdatedAt).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="status">Новый статус: new|review (валидирует сервис).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — кандидата нет.</returns>
public Task<bool> SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct);
/// <summary>
/// Ставит кандидату joined + auto_joined (mark_joined L531534; bump UpdatedAt).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="autoJoined">True — авто-вступление воркера; false — ручное (join из UI).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — кандидата нет.</returns>
public Task<bool> SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct);
/// <summary>
/// Ставит кандидату rejected (mark_rejected L555558; bump UpdatedAt; счётчик/лог/чёрный список — сервис).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — кандидата нет.</returns>
public Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct);
// ── Чёрный список (disc_blacklist; L568589) ──
/// <summary>
/// Помечает источник в чёрном списке (add_blacklist L568578: INSERT … ON CONFLICT DO UPDATE name/reason; CreatedAt сохраняется).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="name">Имя источника (вызывающий передаёт нормализованное: пусто → DialogId).</param>
/// <param name="reason">Причина добавления.</param>
/// <param name="ct">Токен отмены.</param>
public Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct);
/// <summary>
/// Снимает источник с чёрного списка (remove_blacklist L581582; нет строки — no-op).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct);
/// <summary>
/// Запись чёрного списка по dialog_id (echo add_blacklist L577578).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Запись или null, если строки нет.</returns>
public Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct);
/// <summary>
/// Весь чёрный список, новые записи первыми (list_blacklist L585589: ORDER BY created_at DESC).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Записи от новых к старым; пусто — список пуст.</returns>
public Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct);
// ── Лог задачи (disc_log; L594608) ──
/// <summary>
/// Пишет строку лога (add_log L594598; id <c>dl_...</c> генерирует модуль, CreatedAt — UTC-now).
/// </summary>
/// <param name="logId">Готовый id записи (префикс <c>dl_</c>).</param>
/// <param name="taskId">Id задачи поиска.</param>
/// <param name="logEvent">Событие (см. <see cref="DiscoveryLogEvents"/>).</param>
/// <param name="text">Текст/детали события.</param>
/// <param name="ct">Токен отмены.</param>
public Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct);
/// <summary>
/// Последние события задачи, новые сверху (task_log L601608: ORDER BY created_at DESC LIMIT).
/// </summary>
/// <param name="taskId">Id задачи поиска.</param>
/// <param name="limit">Сколько последних записей (кламп 1..500 выполняет сервис; дефолт 100).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>События задачи от новых к старым; пусто — лога нет.</returns>
public Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(string taskId, int limit, CancellationToken ct);
}
@@ -0,0 +1,20 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Запись чёрного списка Discovery — элемент GET /api/discovery/blacklist (api-map §3.8 L213).
/// </summary>
/// <remarks>
/// Поля 1:1 с _blacklist_view discovery.py L161167 и строкой disc_blacklist (db.py L180185). Список общий для
/// всех задач (не привязан к DiscTasks): источники из него пропускаются поиском (add_candidate) и повторной
/// проверкой перед авто-вступлением (воркер). Снимается вручную (DELETE …/blacklist/{dialogId}) или при ручном
/// join. Повторное добавление того же источника обновляет Name/Reason и сохраняет CreatedAt (ON CONFLICT L572575).
/// </remarks>
/// <param name="DialogId">Подписанный id источника (первичный ключ).</param>
/// <param name="Name">Имя источника (пусто → DialogId).</param>
/// <param name="Reason">Причина добавления («отклонено вручную», метка воркера и т.п.).</param>
/// <param name="CreatedAt">Время добавления, epoch-ms.</param>
public sealed record DiscoveryBlacklistDto(
string DialogId,
string Name,
string Reason,
long CreatedAt);
@@ -0,0 +1,44 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Кандидат задачи Discovery — элемент GET …/candidates и ответ join/reject (api-map §4.8 L355).
/// </summary>
/// <remarks>
/// Поля 1:1 с _candidate_view discovery.py L140158 и строкой disc_candidates (db.py L159176). marks/topics —
/// JSON-колонки, наружу всегда списки (marks — строки-метки, topics — элементы <see cref="DiscoveryTopicDto"/> для
/// форумов). Статус — <see cref="Deal.Modules.Discovery.Application.DiscoveryCandidateStatuses"/>; переводы в
/// joined/rejected — только через mark_joined/mark_rejected.
/// </remarks>
/// <param name="DialogId">Подписанный id источника (первичный ключ кандидата; как Dialogs.Id).</param>
/// <param name="TaskId">Id задачи поиска, которой принадлежит кандидат.</param>
/// <param name="Name">Отображаемое имя источника (пусто → DialogId).</param>
/// <param name="Username">Username (handle) источника; пуст, если нет публичного username.</param>
/// <param name="Kind">Тип источника: channel|group|forum (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryCandidateKinds"/>).</param>
/// <param name="Hue">Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»; дефолт «#666»).</param>
/// <param name="Participants">Число участников (после discovery_info); null — неизвестно.</param>
/// <param name="LangRu">Язык источника: true — русский, false — не русский, null — не определён.</param>
/// <param name="Marks">Метки оценки («закрытая группа (история скрыта)», «мало сообщений», …).</param>
/// <param name="Topics">Оценка тем форума (kind=forum; для каналов/групп пуст).</param>
/// <param name="FitRatio">Доля подходящих сообщений оценки (0..1); null — контент не оценён.</param>
/// <param name="Status">Статус кандидата (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryCandidateStatuses"/>).</param>
/// <param name="AutoJoined">Вступили автоматически (воркером); false — вручную (join из UI).</param>
/// <param name="JoinFailures">Неудачные авто-вступления подряд (3 → кандидат удаляется, Task 18).</param>
/// <param name="CreatedAt">Время добавления, epoch-ms.</param>
/// <param name="UpdatedAt">Время последнего изменения, epoch-ms.</param>
public sealed record DiscoveryCandidateDto(
string DialogId,
string TaskId,
string Name,
string Username,
string Kind,
string Hue,
int? Participants,
bool? LangRu,
IReadOnlyList<string> Marks,
IReadOnlyList<DiscoveryTopicDto> Topics,
double? FitRatio,
string Status,
bool AutoJoined,
int JoinFailures,
long CreatedAt,
long UpdatedAt);
@@ -0,0 +1,64 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Патч кандидата (set_candidate discovery.py L456494; поля — в нотации кандидата).
/// </summary>
/// <remarks>
/// null — поле не меняется; не-null значение записывается (marks/topics — полной заменой JSON-массива,
/// пустой список очищает). Пустые после Trim name/username/kind/hue НЕ затирают текущее значение
/// (python L480482: «or row[col]» — оценка воркера не должна стирать имя фолбэком). participants:null из
/// патча в python очищает колонку; в .NET null означает «не менять» — очистка не нужна (участников всегда
/// присылает discovery_info либо поле не трогается).
/// </remarks>
public sealed record DiscoveryCandidatePatch
{
/// <summary>
/// Новое имя (после Trim; пустое — не менять).
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Новый username (после Trim; пустой — не менять).
/// </summary>
public string? Username { get; init; }
/// <summary>
/// Новый тип источника (после Trim; пустой — не менять).
/// </summary>
public string? Kind { get; init; }
/// <summary>
/// Новый цвет (после Trim; пустой — не менять).
/// </summary>
public string? Hue { get; init; }
/// <summary>
/// Новое число участников (null — не менять).
/// </summary>
public int? Participants { get; init; }
/// <summary>
/// Новый признак языка (true/false — меняет; null — не менять).
/// </summary>
public bool? LangRu { get; init; }
/// <summary>
/// Новые метки оценки (полная замена; null — не менять).
/// </summary>
public IReadOnlyList<string>? Marks { get; init; }
/// <summary>
/// Новые темы форума (полная замена; null — не менять).
/// </summary>
public IReadOnlyList<DiscoveryTopicDto>? Topics { get; init; }
/// <summary>
/// Новая доля подходящих сообщений (null — не менять).
/// </summary>
public double? FitRatio { get; init; }
/// <summary>
/// Новый флаг авто-вступления (у mark_joined ставится отдельным методом; патч — для воркера).
/// </summary>
public bool? AutoJoined { get; init; }
}
@@ -0,0 +1,42 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Полная запись нового кандидата задачи (INSERT disc_candidates; discovery.py L435450).
/// </summary>
/// <remarks>
/// Write-модель: содержит только заполняемые при добавлении поля. Служебные значения INSERT прототипа
/// L436–449 проставляет хранилище: Participants=NULL, LangRu=NULL, Marks=«[]», Topics=«[]», FitRatio=NULL,
/// Status=<c>new</c>, AutoJoined=false, JoinFailures=0; CreatedAt/UpdatedAt — UTC-now.
/// </remarks>
public sealed record DiscoveryCandidateRow
{
/// <summary>
/// Подписанный id источника (первичный ключ DiscCandidates).
/// </summary>
public string DialogId { get; init; } = string.Empty;
/// <summary>
/// Id задачи поиска.
/// </summary>
public string TaskId { get; init; } = string.Empty;
/// <summary>
/// Имя источника (пустое → DialogId нормализует сервис).
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Username источника (пуст, если нет публичного username).
/// </summary>
public string Username { get; init; } = string.Empty;
/// <summary>
/// Тип источника: channel|group|forum.
/// </summary>
public string Kind { get; init; } = "channel";
/// <summary>
/// Цвет источника (дефолт «#666»).
/// </summary>
public string Hue { get; init; } = "#666";
}
@@ -0,0 +1,21 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Запись лога задачи Discovery — элемент GET /api/discovery/tasks/{id}/log (api-map §3.8 L215).
/// </summary>
/// <remarks>
/// Поля 1:1 с _log_view discovery.py L170177 и строкой disc_log (db.py L189195). Событие — каталог
/// <see cref="Deal.Modules.Discovery.Application.DiscoveryLogEvents"/> (search|skip|review|join_auto|join_manual|
/// reject|flood|error|done|…). Список — последние события задачи, новые сверху (ORDER BY created_at DESC).
/// </remarks>
/// <param name="Id">Короткий id записи (префикс <c>dl_</c>).</param>
/// <param name="TaskId">Id задачи поиска.</param>
/// <param name="Event">Событие (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryLogEvents"/>).</param>
/// <param name="Text">Текст/детали события (русская строка 1:1 с прототипом).</param>
/// <param name="CreatedAt">Время события, epoch-ms.</param>
public sealed record DiscoveryLogDto(
string Id,
string TaskId,
string Event,
string Text,
long CreatedAt);
@@ -0,0 +1,58 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Создание задачи поиска — вход POST /api/discovery/tasks (DiscoveryTaskDraft: поля с дефолтами сервиса).
/// </summary>
/// <remarks>
/// 1:1 с TaskCreate discovery_routes.py L5060 и нормализацией create_task discovery.py L234282: name — строка
/// (обязательная, Trim); description/keywords/minSubscribers/lang/autoJoin имеют дефолты в сервисе;
/// threshold/sampleSize/planJoins — null → дефолты (threshold/sampleSize — из настроек discEvalThreshold/
/// discEvalSample, planJoins — 1). plan_joins дополнительно проходит план-бюджет (DiscoveryPlanGuard).
/// </remarks>
public sealed record DiscoveryTaskDraft
{
/// <summary>
/// Название задачи (после Trim непустое — иначе 400 «Укажите название задачи»).
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Описание ниши/цели (может быть пустым).
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// Ключевые слова поиска; null → пустой список (start до добавления ключей — 400).
/// </summary>
public IReadOnlyList<string>? Keywords { get; init; }
/// <summary>
/// Минимальное число участников; null → 0.
/// </summary>
public int? MinSubscribers { get; init; }
/// <summary>
/// Язык источников: ru|any; null/иное → ru.
/// </summary>
public string? Lang { get; init; }
/// <summary>
/// Порог подходящих сообщений, % (кламп 1..100); null → настройка discEvalThreshold.
/// </summary>
public int? Threshold { get; init; }
/// <summary>
/// Размер выборки сообщений при оценке (кламп ≥1); null → настройка discEvalSample.
/// </summary>
public int? SampleSize { get; init; }
/// <summary>
/// План авто-вступлений (1..discJoinLimit + бюджет); null → 1.
/// </summary>
public int? PlanJoins { get; init; }
/// <summary>
/// Авто-вступления воркером; null → false.
/// </summary>
public bool? AutoJoin { get; init; }
}
@@ -0,0 +1,49 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Задача поиска Discovery — элемент GET /api/discovery/tasks и ответ всех мутаций (api-map §4.8 L353).
/// </summary>
/// <remarks>
/// Поля 1:1 с _task_view discovery.py L116137 и строкой disc_tasks (db.py L136156). JSON-поле keywords
/// наружу всегда список строк; статус — <see cref="Deal.Modules.Discovery.Application.DiscoveryTaskStatuses"/>.
/// createdAt/updatedAt — epoch-ms (времена хранятся UTC, на границе переводятся в ms — конвенция проекта).
/// </remarks>
/// <param name="Id">Короткий id задачи (префикс <c>dt_</c>).</param>
/// <param name="Name">Название задачи (обязательное, Trim).</param>
/// <param name="Description">Описание ниши/цели (источник для ИИ-генерации ключей, Task 19).</param>
/// <param name="Keywords">Ключевые слова поиска (JSON-массив; пусто — start возвращает 400).</param>
/// <param name="MinSubscribers">Минимальное число участников (0 — не фильтровать).</param>
/// <param name="Lang">Язык источников: <c>ru</c>|<c>any</c>.</param>
/// <param name="Threshold">Порог подходящих сообщений оценки, % (1..100; дефолт discEvalThreshold).</param>
/// <param name="SampleSize">Размер выборки сообщений при оценке (≥1; дефолт discEvalSample).</param>
/// <param name="PlanJoins">План авто-вступлений задачи (1..discJoinLimit; занимает суточный бюджет).</param>
/// <param name="AutoJoin">Авто-вступления воркером включены (иначе кандидатов вступает человек).</param>
/// <param name="Status">Статус задачи (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryTaskStatuses"/>).</param>
/// <param name="SearchIdx">Индекс текущего ключа поиска (прогресс прохода по keywords).</param>
/// <param name="SearchDone">Проход по всем ключам завершён (searchIdx ≥ keywords.Count).</param>
/// <param name="Found">Найдено кандидатов (счётчик found).</param>
/// <param name="Evaluated">Оценено/пропущено кандидатов (счётчик evaluated).</param>
/// <param name="Joined">Вступили (счётчик joined; joined ≥ PlanJoins → задача done).</param>
/// <param name="Rejected">Отклонено (счётчик rejected).</param>
/// <param name="CreatedAt">Время создания, epoch-ms.</param>
/// <param name="UpdatedAt">Время последнего изменения, epoch-ms.</param>
public sealed record DiscoveryTaskDto(
string Id,
string Name,
string Description,
IReadOnlyList<string> Keywords,
int MinSubscribers,
string Lang,
int Threshold,
int SampleSize,
int PlanJoins,
bool AutoJoin,
string Status,
int SearchIdx,
bool SearchDone,
int Found,
int Evaluated,
int Joined,
int Rejected,
long CreatedAt,
long UpdatedAt);
@@ -0,0 +1,58 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Патч задачи поиска — вход PATCH /api/discovery/tasks/{id} (все поля optional; TaskPatch discovery_routes.py L6271).
/// </summary>
/// <remarks>
/// null — поле не меняется; не-null значение записывается (JSON-список keywords — полной заменой, пустой
/// список очищает ключи). План-бюджет: увеличение PlanJoins относительно текущего значения проверяется
/// DiscoveryPlanGuard с исключением самой задачи (patch_task discovery.py L292298); границы значений
/// (threshold/minSubscribers/sampleSize/lang) нормализует сервис как _validate_task_values L213231.
/// </remarks>
public sealed record DiscoveryTaskPatch
{
/// <summary>
/// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип).
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Новое описание (пустая строка очищает).
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Новые ключевые слова (полная замена; null — не менять).
/// </summary>
public IReadOnlyList<string>? Keywords { get; init; }
/// <summary>
/// Новый минимум участников (кламп ≥0).
/// </summary>
public int? MinSubscribers { get; init; }
/// <summary>
/// Новый язык: ru|any (иное → ru).
/// </summary>
public string? Lang { get; init; }
/// <summary>
/// Новый порог оценки, % (кламп 1..100).
/// </summary>
public int? Threshold { get; init; }
/// <summary>
/// Новый размер выборки (кламп ≥1).
/// </summary>
public int? SampleSize { get; init; }
/// <summary>
/// Новый план авто-вступлений (рост — с проверкой бюджета, см. xml-doc типа).
/// </summary>
public int? PlanJoins { get; init; }
/// <summary>
/// Новый флаг авто-вступлений (false — выключить).
/// </summary>
public bool? AutoJoin { get; init; }
}
@@ -0,0 +1,64 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Полная запись для создания задачи поиска (IProjectStore-стиль write-псевдоним строки DiscTasks).
/// </summary>
/// <remarks>
/// Write-модель: содержит полное состояние новой задачи, включая готовый id (<c>dt_...</c>, генерирует модуль —
/// <see cref="Deal.Modules.Discovery.Application.DiscoveryIdPrefixes"/>). Служебные значения, вычисленные до
/// записи: Status=<c>draft</c>, SearchIdx=0, SearchDone=false, счётчики Found/Evaluated/Joined/Rejected=0 —
/// как в INSERT прототипа discovery.py L260280. CreatedAt/UpdatedAt проставляет хранилище (UTC-now);
/// keywords адаптер сериализует в JSON при записи.
/// </remarks>
public sealed record DiscoveryTaskRow
{
/// <summary>
/// Готовый id задачи (префикс <c>dt_</c>), сгенерированный модулем.
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Название задачи (после Trim; непустое — валидирует сервис).
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Описание ниши/цели (может быть пустым).
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// Ключевые слова поиска (JSON-список строк, очищенный от пустых).
/// </summary>
public IReadOnlyList<string> Keywords { get; init; } = Array.Empty<string>();
/// <summary>
/// Минимальное число участников (0 — не фильтровать).
/// </summary>
public int MinSubscribers { get; init; }
/// <summary>
/// Язык источников: ru|any (дефолт ru).
/// </summary>
public string Lang { get; init; } = "ru";
/// <summary>
/// Порог подходящих сообщений оценки, % (1..100).
/// </summary>
public int Threshold { get; init; }
/// <summary>
/// Размер выборки сообщений при оценке (≥1).
/// </summary>
public int SampleSize { get; init; }
/// <summary>
/// План авто-вступлений (1..discJoinLimit; занимает суточный бюджет).
/// </summary>
public int PlanJoins { get; init; }
/// <summary>
/// Авто-вступления воркером включены.
/// </summary>
public bool AutoJoin { get; init; }
}
@@ -0,0 +1,23 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Элемент topics кандидата-форума — результат оценки темы (api-map §4.8 L355, discovery_worker L336344).
/// </summary>
/// <remarks>
/// Форма 1:1 с python discovery_worker._evaluate_content L336344: {topicId, title, fitCount, total, fitRatio,
/// passed}. Заполняется оценкой только для kind=forum (для каналов/групп topics пуст); passed — тема прошла
/// порог (eval passed L229237). Хранится JSON-массивом в DiscCandidates.TopicsJson и наружу отдаётся как есть.
/// </remarks>
/// <param name="TopicId">Id темы форума (из Telegram).</param>
/// <param name="Title">Заголовок темы (пуст — тема без названия).</param>
/// <param name="FitCount">Сколько сообщений темы подошли под описание задачи.</param>
/// <param name="Total">Всего содержательных сообщений темы в выборке.</param>
/// <param name="FitRatio">Доля подходящих сообщений (fitCount / total; 0 при пустой выборке).</param>
/// <param name="Passed">Вердикт темы: прошла ли порог задачи (threshold).</param>
public sealed record DiscoveryTopicDto(
string TopicId,
string Title,
int FitCount,
int Total,
double FitRatio,
bool Passed);