Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -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 L1–81, план Task 18, Ruling 10).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Суточный лимит (discJoinLimit, дефолт 50) считается по DiscLog: число событий event='join_auto' за текущие
|
||||
/// UTC-сутки (дискретность совпадает с логом mark_joined авто-вступления — дискретный счётчик 1:1 с python
|
||||
/// L29–35). При FloodWait от Telegram воркер зовёт <see cref="NoteFloodAsync"/> — блокировка авто-вступлений до
|
||||
/// конца суток (внутренний ключ SettingsKeys.DiscFloodDay, epoch-ms начала суток). Стоп-кран discPaused —
|
||||
/// ручная пауза вступлений. <see cref="CanAutoJoinAsync"/> — общий вердикт «разрешено ли следующее авто-
|
||||
/// вступление». Настройки читаются типизированным снимком TenantSettingsSnapshot (C30): отсутствие
|
||||
/// строки/повреждение → SettingsDefaults.
|
||||
/// <para>
|
||||
/// Паузу между вступлениями (discJoinDelayMin..Max, 50–70 с) гард НЕ держит — её делает <see cref="IDiscoveryPacer"/>
|
||||
/// (рандом недоступен тестам; 1:1 ban_guard.wait_join_delay L44–56).
|
||||
/// </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 L69–71).
|
||||
/// </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 L29–35).
|
||||
/// </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 L64–66).
|
||||
/// </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 L38–41).
|
||||
/// </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 L59–62).
|
||||
/// </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 L23–26).
|
||||
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 L64–66: 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 L568–589).
|
||||
/// </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 L568–578; существующая запись обновляется).
|
||||
/// </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 L581–582; нет строки — 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 L585–589).
|
||||
/// </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 L299–307). Личные чаты/боты поиском не предлагаются.
|
||||
/// </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 L16–18). Активные статусы (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 L503–504: только 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 L385–563).
|
||||
/// </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 L550–551).
|
||||
/// </summary>
|
||||
public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили";
|
||||
|
||||
/// <summary>
|
||||
/// 400 set_candidate_status: не new/review (set_candidate_status L503–504).
|
||||
/// </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 L385–397).
|
||||
/// </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 L400–402; для эндпоинтов 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 L409–453).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Исключения (возврат null + лог skip, 1:1 L418–430): источник есть в Dialogs («уже мониторится (мы состоим)»),
|
||||
/// в чёрном списке или уже добавлен в статусе new/review/joined. Устаревшая запись rejected заменяется новой
|
||||
/// (L431–433), счётчик 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 L456–494; зовёт воркер Task 18).
|
||||
/// </summary>
|
||||
/// <remarks>Пустые после Trim name/username/kind/hue не затирают текущее значение (L480–482).</remarks>
|
||||
/// <param name="taskId">Id задачи (принадлежность кандидата проверяется — python L464–466).</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 (L463–466) — задача/кандидат исчезли между шагами воркера.
|
||||
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 L497–515; 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 L523–538).
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="auto">True — авто-вступление воркера; false — ручное (join из UI, Task 19).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен (L528–529).</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 L541–563).
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="reason">Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен (L552–553).</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 L518–520; 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 L468–484).
|
||||
// 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 L480–482 «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 L443–446).
|
||||
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 L359–368).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Соответствие колонкам db.py L150–153: 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 L197–226: 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 L174–194):
|
||||
/// <list type="number">
|
||||
/// <item>текст пустой/короче 10 символов → «слишком короткое» (False, эвристика);</item>
|
||||
/// <item>ML при mlEnabled (<see cref="IMlClient.PredictAsync"/>): take + label=spam → «ML: спам» (False);
|
||||
/// сбой прогноза не роняет оценку (python L101–107: «не уверен»);</item>
|
||||
/// <item>ИИ при aiEnabled (<see cref="IAiTools.EvaluateFitAsync"/>): любая ошибка (Local — NotSupportedException,
|
||||
/// gRPC — AiUnavailableException/…, Ruling 10) ловится и оценка продолжается эвристикой (python L186–194);</item>
|
||||
/// <item>эвристика: любой ключ задачи входит в очищенный текст без учёта регистра.</item>
|
||||
/// </list>
|
||||
/// <see cref="GroupByTopic"/> — группировка выборки форума по topic_id (null → «main») с заголовками-сниппетами
|
||||
/// (python L86–117); <see cref="Passed"/> — вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥
|
||||
/// threshold, % (python L229–237). Результаты по сообщениям <see cref="DiscoveryMessageFit"/> собирает
|
||||
/// <see cref="EvaluateSampleAsync"/> в агрегат <see cref="DiscoveryEvalSample"/> (fit_count/total/fit_ratio,
|
||||
/// python L197–226). Язык выборки — отдельный детектор <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 L197–226).
|
||||
/// </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 L174–194).
|
||||
/// </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 L101–107) — решает ИИ/эвристика.
|
||||
}
|
||||
}
|
||||
|
||||
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 L191–192).
|
||||
}
|
||||
}
|
||||
|
||||
return Heuristic(task, raw);
|
||||
}
|
||||
|
||||
// Эвристика: ключ задачи входит в очищенный текст без учёта регистра (python _heuristic L144–150).
|
||||
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 L229–237).
|
||||
/// </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 L96–117).
|
||||
/// </summary>
|
||||
/// <remarks>Заголовок группы — сниппет первого непустого текста (python _topic_title L86–93, ≤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 L86–93).
|
||||
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 L30–32 (<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 L62–83, план Task 18).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чистая функция без зависимостей: доля кириллических букв (базовый блок U+0400–U+04FF) среди всех букв
|
||||
/// выборки. Пороги python L36–37: ≥0.15 → True (язык русский), ≤0.03 → False (не русский), иначе — None
|
||||
/// («не подтверждён»: воркер не отсекает кандидата, а помечает меткой). Пустая выборка без букв тоже даёт
|
||||
/// None. Вызывается только для задач с lang=ru (discovery_worker L280–289).
|
||||
/// </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 L62–83).
|
||||
/// </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 L187–188, api-map §3.8).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Каталог 1:1 с комментарием db.py L187–188: 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 L594–608).
|
||||
/// </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 L594–598: 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 L601–608).
|
||||
/// </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 L174–194).
|
||||
/// </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 & 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 L44–56).
|
||||
/// </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 L80–111, 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 (L97–110).
|
||||
/// </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 L108–110). Формат: {used}, {limit}, {plan}.
|
||||
/// </summary>
|
||||
public const string BudgetExceededFormat =
|
||||
"Бюджет авто-вступлений исчерпан: задачи уже занимают {0} из {1} в сутки, ещё {2} не влезает";
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница plan_joins: суточный лимит авто-вступлений (python _plan_limit L80–82).
|
||||
/// </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 L94–100).
|
||||
/// </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 L103–111).
|
||||
/// </summary>
|
||||
/// <param name="planJoins">План создаваемой/итоговый план увеличиваемой задачи.</param>
|
||||
/// <param name="excludeTaskId">Id задачи, исключаемой из занятого бюджета (patch-рост плана); null — создание.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <exception cref="DiscoveryValidationException">Бюджет исчерпан (текст python L108–110).</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 L332–339).
|
||||
/// </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 сбрасывает прогресс поиска, L332–339).</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 L234–381).
|
||||
/// </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 L242–243).
|
||||
/// </summary>
|
||||
public const string NameRequiredDetail = "Укажите название задачи";
|
||||
|
||||
/// <summary>
|
||||
/// 400 start: у задачи нет ключевых слов (start_task L330).
|
||||
/// </summary>
|
||||
public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу";
|
||||
|
||||
/// <summary>
|
||||
/// Все задачи, старые первыми (list_tasks L189–191; воркер берёт самую старую running).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Задачи в порядке создания.</returns>
|
||||
public Task<IReadOnlyList<DiscoveryTaskDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
return store.ListTasksAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Одна задача по id (get_task L194–196).
|
||||
/// </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 L234–282: имя, план 1..discJoinLimit, бюджет активных задач).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 с прототипом: name обязательное (после Trim); planJoins — дефолт 1 + <see cref="DiscoveryPlanGuard"/>;
|
||||
/// threshold/sampleSize — из настроек discEvalThreshold/discEvalSample (дефолты 40/10); границы значений
|
||||
/// клампятся как _validate_task_values L213–231. Задача создаётся draft, поиск не запущен; ключи ИИ-генерации
|
||||
/// при создании НЕ запрашиваются (endpoint generate-keywords — Task 19, прототип discovery_routes L189–211).
|
||||
/// </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 L285–311; рост plan_joins — с проверкой бюджета).
|
||||
/// </summary>
|
||||
/// <remarks>Поля патча нормализуются как _validate_task_values L213–231 (клампы/Trim); увеличение PlanJoins
|
||||
/// сверх текущего значения проходит <see cref="DiscoveryPlanGuard.AssertBudgetAsync"/> с исключением задачи.
|
||||
/// Пустой/полностью null патч — возврат текущей задачи без записи (python L289–290).</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 L289–290: пустой патч — возврат текущей задачи без записи (updated_at не бампается).
|
||||
return current;
|
||||
}
|
||||
|
||||
await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false);
|
||||
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет задачу вместе с кандидатами и логом (delete_task L314–318; чёрный список общий).
|
||||
/// </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 L321–346): keywords непустые; status=running.
|
||||
/// </summary>
|
||||
/// <remarks>Повторный старт завершённой/упавшей (done/failed) сбрасывает прогресс поиска (свежий проход по
|
||||
/// ключам, L332–339); продолжение из paused сохраняет search_idx/счётчики (L342–344).</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 L349–356; прогресс поиска сохраняется).
|
||||
/// </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 L359–368; 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 L371–380): 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 L213–231: 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 L226–227).
|
||||
// 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 L96–117).
|
||||
/// </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 L8–9). В .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 L5–6: {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 L5–6/L74–89).
|
||||
public sealed partial class DiscoveryWorkerService
|
||||
{
|
||||
// ── Действия тика (python discovery_worker L5–6) ──
|
||||
/// <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 L74–89) ──
|
||||
// Минимальный объём содержательной выборки для вердикта оценки (python _MIN_CONTENT L77).
|
||||
private const int MinContentMessages = 3;
|
||||
|
||||
// Ошибки поиска одного ключа подряд, после которых ключ пропускается (python L80).
|
||||
private const int SearchErrorsToSkip = 3;
|
||||
|
||||
// Неудачные авто-вступления подряд, после которых кандидат удаляется (python L417–419).
|
||||
private const int MaxJoinFailures = 3;
|
||||
|
||||
// Верхняя граница результатов глобального поиска (прототип discovery_search default 30).
|
||||
private const int SearchResultLimit = 30;
|
||||
|
||||
// ── Метки кандидата (marks; python discovery_worker L85–89) ──
|
||||
/// <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 L228–315/_evaluate_content L318–356) + перевод в review.
|
||||
public sealed partial class DiscoveryWorkerService
|
||||
{
|
||||
// Шаг оценки первого кандидата status='new' (все ветки — одно действие; python _eval_step L228–315).
|
||||
// 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 L234–249) ──
|
||||
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 L244–248).
|
||||
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 L252–265) ──
|
||||
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 L267–277) ──
|
||||
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 L269–276).
|
||||
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 L279–289) ──
|
||||
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 L291–296) ──
|
||||
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 L318–356) ──
|
||||
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 L346–347)
|
||||
}
|
||||
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 L154–172; статус пишет лог 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 L820–826). Модуль чистый (без 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 L99–106; 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 L359–439).
|
||||
public sealed partial class DiscoveryWorkerService
|
||||
{
|
||||
// Шаг авто-вступления одного кандидата status='review' (python _join_step L359–439).
|
||||
// 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 L364–374).
|
||||
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 L376–377).
|
||||
await _pacer.WaitJoinDelayAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// За время паузы задача/кандидат/состояние BanGuard могли измениться: вступаем только если кандидат
|
||||
// всё ещё есть и в review, задача ещё running с autoJoin, мы не состоим и авто-вступления разрешены
|
||||
// (стоп-кран/flood/лимит могли включиться во время паузы) — иначе выходим без join (python L379–394).
|
||||
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 L399–402).
|
||||
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 L404–410).
|
||||
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 L424–438).
|
||||
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 L434–437: 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 L177–225) и лог завершения прохода.
|
||||
public sealed partial class DiscoveryWorkerService
|
||||
{
|
||||
// Шаг поиска: один ключ keywords[SearchIdx] → кандидаты + advance_search (python _search_step L177–225).
|
||||
// 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 L139–144).
|
||||
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 L196–203).
|
||||
_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 L211–214).
|
||||
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 L147–151).
|
||||
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 L444–484).
|
||||
/// Приоритеты внутри тика:
|
||||
/// <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; неопределённо — метка),
|
||||
/// <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 L85–89); темы форума (topics) — только для kind=forum
|
||||
/// (L336–344). Все логи — через <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 L444–484).
|
||||
/// </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 L446–451).
|
||||
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 L458–461).
|
||||
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 L463–466).
|
||||
foreach (DiscoveryTaskDto task in running)
|
||||
{
|
||||
if (!task.SearchDone)
|
||||
{
|
||||
return await SearchStepAsync(task, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Оценка: первый кандидат status='new' (самая старая задача — первой; python L468–472).
|
||||
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 L474–482).
|
||||
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 L480–481).
|
||||
return DiscoveryWorkerOutcome.None;
|
||||
}
|
||||
|
||||
return await JoinStepAsync(task, reviewCandidates[0], ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return DiscoveryWorkerOutcome.None;
|
||||
}
|
||||
|
||||
// Задача выполнила план вступлений: status=done + лог done (python _finish_done L126–136).
|
||||
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 L44–56).
|
||||
/// </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 L189–318) ──
|
||||
|
||||
/// <summary>
|
||||
/// Все задачи, старые первыми (list_tasks L189–191: ORDER BY created_at ASC).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Задачи в порядке создания; пусто — задач нет.</returns>
|
||||
public Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Одна задача по id (get_task L194–196).
|
||||
/// </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 L260–280; 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 L285–311).
|
||||
/// </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 L314–318; чёрный список общий — не трогается).
|
||||
/// </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 L321–346).
|
||||
/// </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 (L333–339); False — draft/paused: только статус (L342–344).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — задача обновлена; false — строки нет.</returns>
|
||||
public Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Переводит задачу в paused (pause_task L349–356; прогресс поиска сохраняется).
|
||||
/// </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 L126–136: план вступлений выполнен; 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 L359–368: поле += 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 L371–380: 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 L85–91: 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; L385–520) ──
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты задачи, старые первыми (list_candidates L385–397); 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 L400–402; ключ — источник, кандидат один).
|
||||
/// </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 L29–35: суточная
|
||||
/// квота авто-вступлений по 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 (воркер L411–416; 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 L418–419).
|
||||
/// </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 L421–422, воркер _we_are_in L114–123).
|
||||
/// </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 L436–449: 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 L518–520; повторный вызов безопасен).
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task DeleteCandidateAsync(string dialogId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Правка полей кандидата (set_candidate UPDATE L456–494; 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 L497–515: только эти статусы; 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 L531–534; 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 L555–558; 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; L568–589) ──
|
||||
|
||||
/// <summary>
|
||||
/// Помечает источник в чёрном списке (add_blacklist L568–578: 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 L581–582; нет строки — 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 L577–578).
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Запись или null, если строки нет.</returns>
|
||||
public Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Весь чёрный список, новые записи первыми (list_blacklist L585–589: ORDER BY created_at DESC).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Записи от новых к старым; пусто — список пуст.</returns>
|
||||
public Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct);
|
||||
|
||||
// ── Лог задачи (disc_log; L594–608) ──
|
||||
|
||||
/// <summary>
|
||||
/// Пишет строку лога (add_log L594–598; 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 L601–608: 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 L161–167 и строкой disc_blacklist (db.py L180–185). Список общий для
|
||||
/// всех задач (не привязан к DiscTasks): источники из него пропускаются поиском (add_candidate) и повторной
|
||||
/// проверкой перед авто-вступлением (воркер). Снимается вручную (DELETE …/blacklist/{dialogId}) или при ручном
|
||||
/// join. Повторное добавление того же источника обновляет Name/Reason и сохраняет CreatedAt (ON CONFLICT L572–575).
|
||||
/// </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 L140–158 и строкой disc_candidates (db.py L159–176). 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 L456–494; поля — в нотации кандидата).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// null — поле не меняется; не-null значение записывается (marks/topics — полной заменой JSON-массива,
|
||||
/// пустой список очищает). Пустые после Trim name/username/kind/hue НЕ затирают текущее значение
|
||||
/// (python L480–482: «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 L435–450).
|
||||
/// </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 L170–177 и строкой disc_log (db.py L189–195). Событие — каталог
|
||||
/// <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 L50–60 и нормализацией create_task discovery.py L234–282: 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 L116–137 и строкой disc_tasks (db.py L136–156). 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 L62–71).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// null — поле не меняется; не-null значение записывается (JSON-список keywords — полной заменой, пустой
|
||||
/// список очищает ключи). План-бюджет: увеличение PlanJoins относительно текущего значения проверяется
|
||||
/// DiscoveryPlanGuard с исключением самой задачи (patch_task discovery.py L292–298); границы значений
|
||||
/// (threshold/minSubscribers/sampleSize/lang) нормализует сервис как _validate_task_values L213–231.
|
||||
/// </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 L260–280. 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 L336–344).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Форма 1:1 с python discovery_worker._evaluate_content L336–344: {topicId, title, fitCount, total, fitRatio,
|
||||
/// passed}. Заполняется оценкой только для kind=forum (для каналов/групп topics пуст); passed — тема прошла
|
||||
/// порог (eval passed L229–237). Хранится 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);
|
||||
Reference in New Issue
Block a user