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

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,20 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Запись чёрного списка Discovery — элемент GET /api/discovery/blacklist (api-map §3.8 L213).
/// </summary>
/// <remarks>
/// Поля 1:1 с _blacklist_view discovery.py L161167 и строкой disc_blacklist (db.py L180185). Список общий для
/// всех задач (не привязан к DiscTasks): источники из него пропускаются поиском (add_candidate) и повторной
/// проверкой перед авто-вступлением (воркер). Снимается вручную (DELETE …/blacklist/{dialogId}) или при ручном
/// join. Повторное добавление того же источника обновляет Name/Reason и сохраняет CreatedAt (ON CONFLICT L572575).
/// </remarks>
/// <param name="DialogId">Подписанный id источника (первичный ключ).</param>
/// <param name="Name">Имя источника (пусто → DialogId).</param>
/// <param name="Reason">Причина добавления («отклонено вручную», метка воркера и т.п.).</param>
/// <param name="CreatedAt">Время добавления, epoch-ms.</param>
public sealed record DiscoveryBlacklistDto(
string DialogId,
string Name,
string Reason,
long CreatedAt);
@@ -0,0 +1,44 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Кандидат задачи Discovery — элемент GET …/candidates и ответ join/reject (api-map §4.8 L355).
/// </summary>
/// <remarks>
/// Поля 1:1 с _candidate_view discovery.py L140158 и строкой disc_candidates (db.py L159176). marks/topics —
/// JSON-колонки, наружу всегда списки (marks — строки-метки, topics — элементы <see cref="DiscoveryTopicDto"/> для
/// форумов). Статус — <see cref="Deal.Modules.Discovery.Application.DiscoveryCandidateStatuses"/>; переводы в
/// joined/rejected — только через mark_joined/mark_rejected.
/// </remarks>
/// <param name="DialogId">Подписанный id источника (первичный ключ кандидата; как Dialogs.Id).</param>
/// <param name="TaskId">Id задачи поиска, которой принадлежит кандидат.</param>
/// <param name="Name">Отображаемое имя источника (пусто → DialogId).</param>
/// <param name="Username">Username (handle) источника; пуст, если нет публичного username.</param>
/// <param name="Kind">Тип источника: channel|group|forum (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryCandidateKinds"/>).</param>
/// <param name="Hue">Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»; дефолт «#666»).</param>
/// <param name="Participants">Число участников (после discovery_info); null — неизвестно.</param>
/// <param name="LangRu">Язык источника: true — русский, false — не русский, null — не определён.</param>
/// <param name="Marks">Метки оценки («закрытая группа (история скрыта)», «мало сообщений», …).</param>
/// <param name="Topics">Оценка тем форума (kind=forum; для каналов/групп пуст).</param>
/// <param name="FitRatio">Доля подходящих сообщений оценки (0..1); null — контент не оценён.</param>
/// <param name="Status">Статус кандидата (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryCandidateStatuses"/>).</param>
/// <param name="AutoJoined">Вступили автоматически (воркером); false — вручную (join из UI).</param>
/// <param name="JoinFailures">Неудачные авто-вступления подряд (3 → кандидат удаляется, Task 18).</param>
/// <param name="CreatedAt">Время добавления, epoch-ms.</param>
/// <param name="UpdatedAt">Время последнего изменения, epoch-ms.</param>
public sealed record DiscoveryCandidateDto(
string DialogId,
string TaskId,
string Name,
string Username,
string Kind,
string Hue,
int? Participants,
bool? LangRu,
IReadOnlyList<string> Marks,
IReadOnlyList<DiscoveryTopicDto> Topics,
double? FitRatio,
string Status,
bool AutoJoined,
int JoinFailures,
long CreatedAt,
long UpdatedAt);
@@ -0,0 +1,64 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Патч кандидата (set_candidate discovery.py L456494; поля — в нотации кандидата).
/// </summary>
/// <remarks>
/// null — поле не меняется; не-null значение записывается (marks/topics — полной заменой JSON-массива,
/// пустой список очищает). Пустые после Trim name/username/kind/hue НЕ затирают текущее значение
/// (python L480482: «or row[col]» — оценка воркера не должна стирать имя фолбэком). participants:null из
/// патча в python очищает колонку; в .NET null означает «не менять» — очистка не нужна (участников всегда
/// присылает discovery_info либо поле не трогается).
/// </remarks>
public sealed record DiscoveryCandidatePatch
{
/// <summary>
/// Новое имя (после Trim; пустое — не менять).
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Новый username (после Trim; пустой — не менять).
/// </summary>
public string? Username { get; init; }
/// <summary>
/// Новый тип источника (после Trim; пустой — не менять).
/// </summary>
public string? Kind { get; init; }
/// <summary>
/// Новый цвет (после Trim; пустой — не менять).
/// </summary>
public string? Hue { get; init; }
/// <summary>
/// Новое число участников (null — не менять).
/// </summary>
public int? Participants { get; init; }
/// <summary>
/// Новый признак языка (true/false — меняет; null — не менять).
/// </summary>
public bool? LangRu { get; init; }
/// <summary>
/// Новые метки оценки (полная замена; null — не менять).
/// </summary>
public IReadOnlyList<string>? Marks { get; init; }
/// <summary>
/// Новые темы форума (полная замена; null — не менять).
/// </summary>
public IReadOnlyList<DiscoveryTopicDto>? Topics { get; init; }
/// <summary>
/// Новая доля подходящих сообщений (null — не менять).
/// </summary>
public double? FitRatio { get; init; }
/// <summary>
/// Новый флаг авто-вступления (у mark_joined ставится отдельным методом; патч — для воркера).
/// </summary>
public bool? AutoJoined { get; init; }
}
@@ -0,0 +1,42 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Полная запись нового кандидата задачи (INSERT disc_candidates; discovery.py L435450).
/// </summary>
/// <remarks>
/// Write-модель: содержит только заполняемые при добавлении поля. Служебные значения INSERT прототипа
/// L436–449 проставляет хранилище: Participants=NULL, LangRu=NULL, Marks=«[]», Topics=«[]», FitRatio=NULL,
/// Status=<c>new</c>, AutoJoined=false, JoinFailures=0; CreatedAt/UpdatedAt — UTC-now.
/// </remarks>
public sealed record DiscoveryCandidateRow
{
/// <summary>
/// Подписанный id источника (первичный ключ DiscCandidates).
/// </summary>
public string DialogId { get; init; } = string.Empty;
/// <summary>
/// Id задачи поиска.
/// </summary>
public string TaskId { get; init; } = string.Empty;
/// <summary>
/// Имя источника (пустое → DialogId нормализует сервис).
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Username источника (пуст, если нет публичного username).
/// </summary>
public string Username { get; init; } = string.Empty;
/// <summary>
/// Тип источника: channel|group|forum.
/// </summary>
public string Kind { get; init; } = "channel";
/// <summary>
/// Цвет источника (дефолт «#666»).
/// </summary>
public string Hue { get; init; } = "#666";
}
@@ -0,0 +1,21 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Запись лога задачи Discovery — элемент GET /api/discovery/tasks/{id}/log (api-map §3.8 L215).
/// </summary>
/// <remarks>
/// Поля 1:1 с _log_view discovery.py L170177 и строкой disc_log (db.py L189195). Событие — каталог
/// <see cref="Deal.Modules.Discovery.Application.DiscoveryLogEvents"/> (search|skip|review|join_auto|join_manual|
/// reject|flood|error|done|…). Список — последние события задачи, новые сверху (ORDER BY created_at DESC).
/// </remarks>
/// <param name="Id">Короткий id записи (префикс <c>dl_</c>).</param>
/// <param name="TaskId">Id задачи поиска.</param>
/// <param name="Event">Событие (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryLogEvents"/>).</param>
/// <param name="Text">Текст/детали события (русская строка 1:1 с прототипом).</param>
/// <param name="CreatedAt">Время события, epoch-ms.</param>
public sealed record DiscoveryLogDto(
string Id,
string TaskId,
string Event,
string Text,
long CreatedAt);
@@ -0,0 +1,58 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Создание задачи поиска — вход POST /api/discovery/tasks (DiscoveryTaskDraft: поля с дефолтами сервиса).
/// </summary>
/// <remarks>
/// 1:1 с TaskCreate discovery_routes.py L5060 и нормализацией create_task discovery.py L234282: name — строка
/// (обязательная, Trim); description/keywords/minSubscribers/lang/autoJoin имеют дефолты в сервисе;
/// threshold/sampleSize/planJoins — null → дефолты (threshold/sampleSize — из настроек discEvalThreshold/
/// discEvalSample, planJoins — 1). plan_joins дополнительно проходит план-бюджет (DiscoveryPlanGuard).
/// </remarks>
public sealed record DiscoveryTaskDraft
{
/// <summary>
/// Название задачи (после Trim непустое — иначе 400 «Укажите название задачи»).
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Описание ниши/цели (может быть пустым).
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// Ключевые слова поиска; null → пустой список (start до добавления ключей — 400).
/// </summary>
public IReadOnlyList<string>? Keywords { get; init; }
/// <summary>
/// Минимальное число участников; null → 0.
/// </summary>
public int? MinSubscribers { get; init; }
/// <summary>
/// Язык источников: ru|any; null/иное → ru.
/// </summary>
public string? Lang { get; init; }
/// <summary>
/// Порог подходящих сообщений, % (кламп 1..100); null → настройка discEvalThreshold.
/// </summary>
public int? Threshold { get; init; }
/// <summary>
/// Размер выборки сообщений при оценке (кламп ≥1); null → настройка discEvalSample.
/// </summary>
public int? SampleSize { get; init; }
/// <summary>
/// План авто-вступлений (1..discJoinLimit + бюджет); null → 1.
/// </summary>
public int? PlanJoins { get; init; }
/// <summary>
/// Авто-вступления воркером; null → false.
/// </summary>
public bool? AutoJoin { get; init; }
}
@@ -0,0 +1,49 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Задача поиска Discovery — элемент GET /api/discovery/tasks и ответ всех мутаций (api-map §4.8 L353).
/// </summary>
/// <remarks>
/// Поля 1:1 с _task_view discovery.py L116137 и строкой disc_tasks (db.py L136156). JSON-поле keywords
/// наружу всегда список строк; статус — <see cref="Deal.Modules.Discovery.Application.DiscoveryTaskStatuses"/>.
/// createdAt/updatedAt — epoch-ms (времена хранятся UTC, на границе переводятся в ms — конвенция проекта).
/// </remarks>
/// <param name="Id">Короткий id задачи (префикс <c>dt_</c>).</param>
/// <param name="Name">Название задачи (обязательное, Trim).</param>
/// <param name="Description">Описание ниши/цели (источник для ИИ-генерации ключей, Task 19).</param>
/// <param name="Keywords">Ключевые слова поиска (JSON-массив; пусто — start возвращает 400).</param>
/// <param name="MinSubscribers">Минимальное число участников (0 — не фильтровать).</param>
/// <param name="Lang">Язык источников: <c>ru</c>|<c>any</c>.</param>
/// <param name="Threshold">Порог подходящих сообщений оценки, % (1..100; дефолт discEvalThreshold).</param>
/// <param name="SampleSize">Размер выборки сообщений при оценке (≥1; дефолт discEvalSample).</param>
/// <param name="PlanJoins">План авто-вступлений задачи (1..discJoinLimit; занимает суточный бюджет).</param>
/// <param name="AutoJoin">Авто-вступления воркером включены (иначе кандидатов вступает человек).</param>
/// <param name="Status">Статус задачи (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryTaskStatuses"/>).</param>
/// <param name="SearchIdx">Индекс текущего ключа поиска (прогресс прохода по keywords).</param>
/// <param name="SearchDone">Проход по всем ключам завершён (searchIdx ≥ keywords.Count).</param>
/// <param name="Found">Найдено кандидатов (счётчик found).</param>
/// <param name="Evaluated">Оценено/пропущено кандидатов (счётчик evaluated).</param>
/// <param name="Joined">Вступили (счётчик joined; joined ≥ PlanJoins → задача done).</param>
/// <param name="Rejected">Отклонено (счётчик rejected).</param>
/// <param name="CreatedAt">Время создания, epoch-ms.</param>
/// <param name="UpdatedAt">Время последнего изменения, epoch-ms.</param>
public sealed record DiscoveryTaskDto(
string Id,
string Name,
string Description,
IReadOnlyList<string> Keywords,
int MinSubscribers,
string Lang,
int Threshold,
int SampleSize,
int PlanJoins,
bool AutoJoin,
string Status,
int SearchIdx,
bool SearchDone,
int Found,
int Evaluated,
int Joined,
int Rejected,
long CreatedAt,
long UpdatedAt);
@@ -0,0 +1,58 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Патч задачи поиска — вход PATCH /api/discovery/tasks/{id} (все поля optional; TaskPatch discovery_routes.py L6271).
/// </summary>
/// <remarks>
/// null — поле не меняется; не-null значение записывается (JSON-список keywords — полной заменой, пустой
/// список очищает ключи). План-бюджет: увеличение PlanJoins относительно текущего значения проверяется
/// DiscoveryPlanGuard с исключением самой задачи (patch_task discovery.py L292298); границы значений
/// (threshold/minSubscribers/sampleSize/lang) нормализует сервис как _validate_task_values L213231.
/// </remarks>
public sealed record DiscoveryTaskPatch
{
/// <summary>
/// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип).
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Новое описание (пустая строка очищает).
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Новые ключевые слова (полная замена; null — не менять).
/// </summary>
public IReadOnlyList<string>? Keywords { get; init; }
/// <summary>
/// Новый минимум участников (кламп ≥0).
/// </summary>
public int? MinSubscribers { get; init; }
/// <summary>
/// Новый язык: ru|any (иное → ru).
/// </summary>
public string? Lang { get; init; }
/// <summary>
/// Новый порог оценки, % (кламп 1..100).
/// </summary>
public int? Threshold { get; init; }
/// <summary>
/// Новый размер выборки (кламп ≥1).
/// </summary>
public int? SampleSize { get; init; }
/// <summary>
/// Новый план авто-вступлений (рост — с проверкой бюджета, см. xml-doc типа).
/// </summary>
public int? PlanJoins { get; init; }
/// <summary>
/// Новый флаг авто-вступлений (false — выключить).
/// </summary>
public bool? AutoJoin { get; init; }
}
@@ -0,0 +1,64 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Полная запись для создания задачи поиска (IProjectStore-стиль write-псевдоним строки DiscTasks).
/// </summary>
/// <remarks>
/// Write-модель: содержит полное состояние новой задачи, включая готовый id (<c>dt_...</c>, генерирует модуль —
/// <see cref="Deal.Modules.Discovery.Application.DiscoveryIdPrefixes"/>). Служебные значения, вычисленные до
/// записи: Status=<c>draft</c>, SearchIdx=0, SearchDone=false, счётчики Found/Evaluated/Joined/Rejected=0 —
/// как в INSERT прототипа discovery.py L260280. CreatedAt/UpdatedAt проставляет хранилище (UTC-now);
/// keywords адаптер сериализует в JSON при записи.
/// </remarks>
public sealed record DiscoveryTaskRow
{
/// <summary>
/// Готовый id задачи (префикс <c>dt_</c>), сгенерированный модулем.
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Название задачи (после Trim; непустое — валидирует сервис).
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Описание ниши/цели (может быть пустым).
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// Ключевые слова поиска (JSON-список строк, очищенный от пустых).
/// </summary>
public IReadOnlyList<string> Keywords { get; init; } = Array.Empty<string>();
/// <summary>
/// Минимальное число участников (0 — не фильтровать).
/// </summary>
public int MinSubscribers { get; init; }
/// <summary>
/// Язык источников: ru|any (дефолт ru).
/// </summary>
public string Lang { get; init; } = "ru";
/// <summary>
/// Порог подходящих сообщений оценки, % (1..100).
/// </summary>
public int Threshold { get; init; }
/// <summary>
/// Размер выборки сообщений при оценке (≥1).
/// </summary>
public int SampleSize { get; init; }
/// <summary>
/// План авто-вступлений (1..discJoinLimit; занимает суточный бюджет).
/// </summary>
public int PlanJoins { get; init; }
/// <summary>
/// Авто-вступления воркером включены.
/// </summary>
public bool AutoJoin { get; init; }
}
@@ -0,0 +1,23 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Элемент topics кандидата-форума — результат оценки темы (api-map §4.8 L355, discovery_worker L336344).
/// </summary>
/// <remarks>
/// Форма 1:1 с python discovery_worker._evaluate_content L336344: {topicId, title, fitCount, total, fitRatio,
/// passed}. Заполняется оценкой только для kind=forum (для каналов/групп topics пуст); passed — тема прошла
/// порог (eval passed L229237). Хранится JSON-массивом в DiscCandidates.TopicsJson и наружу отдаётся как есть.
/// </remarks>
/// <param name="TopicId">Id темы форума (из Telegram).</param>
/// <param name="Title">Заголовок темы (пуст — тема без названия).</param>
/// <param name="FitCount">Сколько сообщений темы подошли под описание задачи.</param>
/// <param name="Total">Всего содержательных сообщений темы в выборке.</param>
/// <param name="FitRatio">Доля подходящих сообщений (fitCount / total; 0 при пустой выборке).</param>
/// <param name="Passed">Вердикт темы: прошла ли порог задачи (threshold).</param>
public sealed record DiscoveryTopicDto(
string TopicId,
string Title,
int FitCount,
int Total,
double FitRatio,
bool Passed);