Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,212 @@
# Поиск и подключение каналов (Discovery) — дизайн
> Исторический документ (дизайн Discovery прототипа LeadRadar, 2026-09-04). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
Дата: 2026-09-04
Статус: согласован с пользователем (правки от 2026-09-04 учтены)
## 1. Цель
Пользователь даёт системе «задание»: найти Telegram-каналы и группы, в которых мы ещё
**не состоим**, по описанию цели (например, «вакансии и фриланс для разработки») и
подключить их к мониторингу. Система сама ищет кандидатов, оценивает их (по метаданным,
языку и содержанию сообщений) и показывает человеку список «на рассмотрение»; человек
решает — вступить и мониторить или отклонить. Возможен режим авто-вступления в рамках
суточных квот и с паузами против бана.
Ключевое правило: **источники, в которых мы уже состоим (вступили/мониторим), исключаются
сразу и безусловно — независимо от запроса, ключей и настроек задачи.** Это глобальное
правило системы: действует на всех этапах (поиск → оценка → вступление) и для всех задач.
## 2. Ограничения Telegram API (факты, на которых строится дизайн)
1. Глобального «поиска по критериям» в API нет. `contacts.search(q)` возвращает
публичные каналы/группы/боты по **имени/username/запросу** — без фильтров по
участникам, языку и содержимому. Всю дальнейшую фильтрацию делаем сами.
2. Число участников/описание — через `channels.getFullChannel`. Для публичных каналов
доступно без вступления; для групп часто доступно только членам.
3. Чтение истории без вступления: публичные **каналы** — обычно можно; публичные
**группы** — только если история открыта; иначе — только членам.
4. Массовый поиск/чтение/вступления с юзер-аккаунта ограничены эмпирически — нужны
квоты, паузы и обработка `FloodWaitError`.
## 3. Понятия
- **Задача (task)** — конфиг поиска: описание цели, ключи, фильтры, план, режим
авто-вступления, статус/счётчики. Задач может быть несколько.
- **Кандидат (candidate)** — найденный источник (канал/группа, для форумов — оценка по
темам). Проходит стадии: `new → evaluated → review → joined | rejected`.
- **Метки кандидата** — человекочитаемые пометки: «закрытая группа/канал», «форум»,
«не прочитано», «участники не подтверждены», «язык не подтверждён», «есть проходные
темы».
- **Чёрный список** — источники, отклонённые пользователем; поиск их больше не
возвращает (снимается вручную).
- **BanGuard** — единый менеджер квот и пауз для всех действий discovery
(search/read/join/leave), общий для задач.
## 4. Задача: конфигурация и правила создания
Поля задачи:
| Поле | Назначение | По умолчанию |
| --- | --- | --- |
| `name` | название задачи | — |
| `description` | описание цели (что ищем) | — |
| `keywords` | поисковые ключи (генерирует ИИ, редактируются перед стартом) | [] |
| `minSubscribers` | минимум участников (0 = не важно) | 0 |
| `lang` | язык источников (`ru` / `any`) | `ru` |
| `threshold` | доля подходящих сообщений, % | 40 |
| `sampleSize` | сколько сообщений смотреть при оценке | 10 |
| `planJoins` | план вступлений N | 1..50 |
| `autoJoin` | авто-вступление подходящих | false |
| `status` | `draft → running → paused → done | failed` | draft |
Правила создания:
- **Бюджет планов:** сумма `planJoins` всех задач в статусе не `done/failed` + `planJoins`
новой ≤ суточного лимита вступлений (по умолчанию 50). Задача с планом 50 не даёт
создать другую; план 25 оставляет максимум 25.
- Запуск возможен только после генерации/подтверждения ключей.
- При редактировании активной задачи план нельзя увеличить сверх свободного бюджета.
## 5. Пайплайн поиска (каскад фильтров)
Выполняется фоновым воркером задачи строго через BanGuard (по одному действию, с паузами).
Для каждого кандидата фильтры идут **по нарастающей стоимости**; при первом «нет»
источник пропускается и берётся следующий:
1. **Поиск**`contacts.search` по каждому ключу (с паузами). Кандидаты
дедуплицируются по `dialog_id`/username.
2. **«Мы не состоим» — глобальный фильтр, применяется сразу и безусловно:** как только
источник найден (независимо от запроса/ключей), он отбрасывается, если уже есть
в `dialogs` (вступили/мониторим), в чёрном списке или уже обрабатывается/вступил/ждёт
рассмотрения в другой задаче (глобальная дедупликация кандидатов). Остальные фильтры
(участники/язык/контент) применяются уже после этого. Проверка повторяется
непосредственно перед вступлением (между оценкой и join'ом кандидат мог быть добавлен
вручную).
3. **Число участников** — если `minSubscribers` задано:
- значение получено и меньше минимума → пропуск;
- значение получить не удалось → **не пропускаем**, ставим метку «участники не
подтверждены».
4. **Язык** — если `lang=ru`: по выборке сообщений эвристикой кириллицы (без ИИ);
не удалось прочитать → метка «язык не подтверждён» (не пропуск).
5. **Содержимое** — оценка выборки сообщений (см. §6).
Пометки «не подтверждено» — не ошибка, а сигнал человеку на экране рассмотрения.
## 6. Оценка содержимого (по темам, для форумов)
- **Что считается «подходящим сообщением»:** сообщение проходит те же правила, что в
основной системе (этап 1 → ML → ИИ), но **профиль оценки = профиль задачи**
(описание + ключи задачи), а не глобальные настройки дашборда. Оценка ничего не
создаёт: ни карточек, ни очереди, ни обучения ML.
- Если ИИ выключен — оценка локальным разбором/ML.
- **Каналы:** читаем до `sampleSize` последних сообщений; доля подходящих ≥ `threshold`
→ в «на рассмотрение».
- **Открытые группы:** то же; чтение не удалось → «на рассмотрение» с меткой
«открытая группа, не прочитана».
- **Закрытые группы** (нашлись по ключам, история скрыта): сразу «на рассмотрение» с
меткой «закрытая группа/канал» (+ метки неподтверждённых фильтров). Пользователь
вступает сам.
- **Форумы (группы с темами):** группа раскладывается по темам (`reply_to_top_id`):
читаем выборку по активным темам, оценка считается **по темам** («тема: подходит
X из N»). Группа подходящая, если есть ≥1 проходная тема. В превью — список тем с
пометками проходная/нет. Имена тем, если API не отдаёт без членства, подставляем
сниппетом первого сообщения темы.
- Порог «40%» применяется к сообщениям темы/канала; если в выборке меньше 3
содержательных сообщений — кандидат идёт «на рассмотрение» с меткой «мало сообщений».
## 7. «На рассмотрение» и действия человека
Экран по задаче содержит списки: **В обработке / На рассмотрении / Вступили /
Отклонены**, плюс история.
Кандидат на рассмотрении показывает: тип (канал/группа/форум), число участников,
метки, долю «подходит X из N» и **почему подошло** (перечень подходящих сообщений/тем
с причинами — как блок «попала по фильтру» в карточках), превью сообщений (для
форумов — по темам).
Действия:
- **«Вступить и мониторить»** — `channels.joinChannel` (по username), добавление в
`dialogs` с `monitor=1`, backfill последних ~10 сообщений. Ручной клик — **вне квот**.
После вступления источник автоматически попадает под правило «мы состоим» и из
поиска исключается.
- **«Отклонить»** — источник в чёрный список (исключается из поиска во всех задачах).
Если для оценки пришлось вступать — выходим (`channels.leaveChannel`) в рамках квот.
Чёрный список редактируется вручную (можно снять).
- **Закрытые группы:** вместо авто-вступления — кнопка-ссылка `t.me/<username>`; система
замечает вступление при синхронизации диалогов и предлагает добавить источник в
мониторинг (метка «вступили, добавить в мониторинг?»).
## 8. Авто-вступление, квоты и анти-бан (BanGuard)
- Суточный лимит вступлений — **50** (настройка), общий для всех задач, считаются только
автоматические вступления. Ручные — без ограничений.
- Авто-вступление включается на задачу (`autoJoin`). Подходящие кандидаты вступают сами.
- Интервалы между автоматическими вступлениями: **случайно 50–70 секунд**; по одному
действию, без параллелей. Поиск и чтение — мягкие паузы (единицы секунд + джиттер,
переиспользуем значения анти-бана из telegram.py).
- Задача «выполнена» при достижении плана вступлений. Если за сутки упёрлись в общий
бюджет — авто-режим продолжает на следующий день (новый суточный бюджет).
- `FloodWaitError` → пауза по секундам из ответа + запас; авто-вступления останавливаются
до следующего дня при флуде. Общий «стоп-кран» — пауза всего discovery.
- Все квоты/интервалы — настройки в UI.
## 9. Хранилище
| Таблица | Назначение / ключевые поля |
| --- | --- |
| `disc_tasks` | задачи: name, description, keywords(JSON), min_subscribers, lang, threshold, sample_size, plan_joins, auto_join, status, counters (found/evaluated/joined/rejected), created/updated |
| `disc_candidates` | dialog_id/username/name/kind(channel|group|forum)/hue, participants, lang_ru, join_failures, marks(JSON), topics(JSON: {topicId,title,fitCount,total,fitRatio,passed}), fit_ratio, status(new/review/joined/rejected), task_id, times |
| `disc_blacklist` | dialog_id, name, reason, created_at |
| `disc_log` | история задачи: task_id, event(search/evaluate/join/leave/flood/error/review/blacklist), text, created_at |
Дубли кандидатов не создаются; источник, попавший в другую задачу или `dialogs`,
из поиска исключается (правило «мы состоим» — глобальное).
Реализация: у кандидата нет транзитного статуса `evaluated` (счётчик оценённых — на
задаче); `join_failures` — неудачные авто-вступления подряд, после 3 кандидат удаляется.
## 10. API
- `GET/POST/PATCH/DELETE /api/discovery/tasks` (создание с валидацией бюджета планов),
`POST /api/discovery/tasks/{id}/start|pause`
- `POST /api/discovery/tasks/{id}/generate-keywords` — ИИ генерирует ключи по описанию
- `GET /api/discovery/tasks/{id}/candidates?status=review|joined|rejected`
- `POST /api/discovery/candidates/{id}/join` (вступить и мониторить), `.../reject`
- `GET /api/discovery/blacklist`, `DELETE /api/discovery/blacklist/{dialog_id}`
- `GET /api/discovery/tasks/{id}/log`
## 11. UI
Подвкладка **«Поиск»** на экране «Каналы»:
- список задач (статус, прогресс, план/вступили, авто-режим) + «Новая задача»;
- мастер задачи: описание → «Сгенерировать ключи ИИ» → редактирование ключей →
фильтры/план/авто-режим → запуск;
- по задаче: статус-лента (поиск → оценка → вступление), вкладки «В обработке /
На рассмотрении / Вступили / Отклонены», история;
- кандидат на рассмотрении раскрывается с превью и действиями; форум — по темам;
- настройки квот (лимит/интервалы) — в том же экране или «Настройки → Telegram».
## 12. Интеграция с существующим кодом
- Фоновый воркер discovery — отдельный цикл в `main.py` (как `_pipeline_loop`),
сервис `app/services/discovery.py`, Telegram-действия — методы `TelegramManager`
(поиск/join/leave/чтение) с общим pacing.
- Переиспользуем: `rules/stage1/ML/AI` для оценки сообщений (новый лёгкий вызов с
профилем задачи, без записи карточек), список `dialogs` для фильтра «мы состоим»,
синхронизацию диалогов для авто-добавления закрытых групп.
- К основному пайплайну карточек, ML и ТЗ-логике не прикасаемся.
## 13. Вне рамок (сейчас)
- Агрегаторы-каталоги как источник кандидатов.
- «Похожие каналы» (`getChannelRecommendations`) от наших подписок — отдельная опция позже.
- Авто-вступление в закрытые группы по инвайт-ссылкам (глобальным поиском они не находятся).
## 14. Значения по умолчанию (настраиваются в UI)
суточный лимит вступлений = 50; интервал авто-вступлений = 50–70 с; выборка = 10
сообщений; порог = 40%; мин. содержательных сообщений для оценки = 3; язык = ru.
@@ -0,0 +1,86 @@
# Открытые вопросы: вложения источников (media → Storage) и просмотр исходника
Дата: 2026-09-11. Статус: решения владельца получены (см. §0).
## 0. Решения владельца (2026-09-11)
- **А) Медиа-посты пропускаем.** Сообщения без текста (только медиа/вложение) в систему не попадают.
Извлечение вложений Telegram и выгрузка их в Storage не делаются. Generic-контракт по-прежнему умеет
нести `DataRef` — этим смогут пользоваться другие источники (файл/диск/таблица) и ручные вложения карточки.
- **Б) Проверка без живого Telegram** — реализуем с юнит-тестами на фейковой сессии/фейковом Storage,
без реального API.
- **В)** Объяснение термина — в §1.5. **Решение: делаем.** Реализован remote-просмотр: `TelegramService.ReadSource`,
`ITelegramGateway.ReadSourceAsync`, `TelegramSourceContentProvider` (Kind=telegram) в ядре,
`GET /api/cards/{id}/source` и кнопка «Обновить из источника» в подробной карточке.
## 1.5. Что такое «remote-просмотр исходника»
Карточка хранит **ссылку на источник** (`SourceRef`) и **содержимое** (`SourceContent`). Содержимое попадает
в карточку в момент приёма. «Просмотр исходника» — это возможность по кнопке догрузить/показать **оригинальное
сообщение у источника** (то, что было в канале/письме/строке), если контент в карточке устарел или урезан.
Сейчас содержимое уже отдаётся в `CardDto.content` и через `GET /api/cards/{id}/source`. Для локальных
источников этого достаточно. Для **внешних** источников (например Telegram) данные лежат не в ядре, а в
сервисе-владельце; чтобы их догрузить, ядру нужен провайдер `ISourceContentProvider` для `kind`, который
ходит по gRPC к сервису-владельцу (условный RPC `ReadSource(dialogId, msgId)`) и возвращает исходный текст/медиа.
Это и есть «remote-просмотр» — расширение extension-point, которое не требуется до появления реальной
необходимости (напр. если карточки хранят урезанный текст или нужно открыть живой первоисточник).
## 1. Что уже готово (не требует решений)
- Единый контракт источника несёт вложения: `SourceContent.Data: IReadOnlyList<DataRef>`
ссылки на объекты Storage-сервиса (`DataRef.Id/Ref/Kind/MimeType/...`).
- Контракт входящего потока (`src/contracts/sources.proto`, `PushSource`) передаёт
`DataRefProto`/`ContactRefProto` — источник может прислать вложения сразу со ссылками.
- Storage-сервис (`src/storage-service/Deal.Storage`, `storage.proto`) умеет `Upload/Download/Stat/Delete`,
сам определяет `kind`/`mimeType`/размеры (контент-снифинг), бэкенд — MinIO.
- Ядро хранит `SourceContent` карточки (в т.ч. `Data`) и отдаёт его в `CardDto.content` и через
`GET /api/cards/{id}/source` (extension-point `ISourceContentProvider` + `SourceContentResolver`).
- Фронт рендерит вложения: `SourceContentView.vue` (image/video/audio/document/archive по `kind`),
ссылки и контакты — списками.
## 2. Проблемная часть (требует живого Telegram)
Извлечение и выгрузка медиа из Telegram не проверяемы офлайн:
1. **Медиа-сообщения сейчас отбрасываются.** `TlMessageMapper.ToMessage`
(`src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs`) принимает только `Message`
с непустым `message` (текстом). Посты с одним вложением и подписью (`media` + `caption`) не попадают
в поток вообще. Нужно: определять `Message.media`, читать `caption`, тип/размеры/длительность.
2. **Скачивание и выгрузка.** Требуется `client.DownloadMedia(...)` (WTelegram) → поток →
`StorageService.Upload(meta + data)``DataRefProto`. В telegram-сервисе нет gRPC-клиента Storage
и соответствующей конфигурации в compose (endpoint/токен). Проверить можно только с реальным
Telegram-аккаунтом и живым MinIO.
3. **Подпись без текста.** Даже если вложение извлечено, в посте может не быть текста: нужен ли такой
пост «карточкой» (сейчас `PipelineIngestService` пропускает записи без `Content.Text`)? Предлагается
принимать запись, если есть текст **или** вложения/ссылки/контакты, а классификацию медиа-онли
строить по подписи (`caption`) и метаданным. Требуется подтверждение продуктовой логики.
4. **Просмотр исходника из другого контура.** «Открыть исходник» для remote-источников (Telegram — это
лишь один из них) требует провайдера `ISourceContentProvider`, который ходит по gRPC к сервису-владельцу
источника (новый RPC, например `ReadSource(dialogId, msgId)`), возвращая текст/медиа. Это тоже
живой Telegram.
## 3. Предлагаемый план (после подтверждения)
1. `TelegramMessage` расширить моделью `TelegramAttachment` (caption, fileName, mimeType, size, width,
height, durationSec, `Task<Stream> Open(cancellationToken)`), заполнять в `TlMessageMapper` из
`Message.media`/`Document`/`Photo`.
2. В telegram-сервисе добавить `StorageClient` (gRPC, `Deal.Grpc.Storage`) + `SourceAttachmentUploader`:
загрузка каждого вложения → `DataRefProto`.
3. `DialogProtoMapper.ToSourceRequest(message, dataRefs)` — прокинуть `content.data` и `caption`.
4. `PipelineIngestService`: принимать запись при непустом тексте **или** непустых `Data`/`Links`/`Contacts`
(нужно продуктовое решение по п.2.3).
5. `ISourceContentProvider` для `kind="telegram"` — gRPC-провайдер к telegram-сервису (RPC `ReadSource`).
6. Настройки: endpoint/токен Storage в `deploy/compose.dev.yml`/`compose.prod.yml` для telegram-сервиса.
## 4. Что нужно от владельца
- А) Делать ли медиа-сообщения без текста карточками (по подписи/метаданным), или пропускать?
- Б) Для проверки вложений нужны живые Telegram api_id/api_hash и работающий MinIO — будет ли прогон
на вашей стороне, или реализуем «слепо» с юнит-тестами на фейковой сессии и фейковом Storage?
- В) Нужен ли remote-просмотр исходника (`ReadSource`) в этом объёме, или достаточно того, что
содержимое хранится в карточке?
Пока эти пункты не закрыты, они вынесены в `backlog.md` (`TD-STORE-ATTACH`, `TD-SOURCE-PROVIDER`),
а generic-часть (контракт, Storage-сервис, хранение, API, рендер) реализована.
@@ -0,0 +1,193 @@
# Дизайн: единый контракт источника + общий Storage-сервис данных
Дата: 2026-09-11. Статус: реализовано в ядре (домен, Storage-сервис, персистентность, конвейер, wire, фронт); адаптер/провайдер telegram-сервиса и Storage-выгрузка — следующие шаги. Контракт не плодит типы вложений; файлы — в общем Storage.
## 1. Принцип
1. **Единый строго типизированный контракт.** Любой источник (Telegram, WhatsApp, Avito, сайт, файл,
Excel) через адаптер приводит данные к одному типу `SourceItem`. Ядро, AI и ML работают только с ним.
2. **Данные файлов — в общем Storage-сервисе.** Каждый сервис-источник сам выгружает свои данные
(картинки, видео, аудио, документы, любые файлы) в общий Storage с **токеном валидации**. Storage сам
определяет тип и метаданные. В контракте хранится **ссылка** на файл, а не сам файл.
3. **Никаких подтипов вложений в контракте.** Не плодим `ImagePart/VideoPart/...`; есть универсальный
`DataRef` с полем `Kind`, которое заполняет Storage.
4. Ссылки, контакты и прочее, что **не является файлом**, идут отдельными полями контента.
5. В ядре нет Telegram-полей и слова Telegram (только в telegram-сервисе); в комментариях нет упоминаний
задач/этапов/ТЗ.
## 2. Единый контракт (Deal.Modules.Cards)
```csharp
public sealed record SourceItem
{
public required SourceRef Source { get; init; }
public required SourceContent Content { get; init; }
}
public sealed record SourceRef
{
public required string Kind { get; init; } // "telegram", "whatsapp", "avito", "file", "excel", ...
public string? ExternalId { get; init; } // id в источнике (сообщение/строка/файл)
public string? DisplayName { get; init; } // подпись в UI
public string? OriginRef { get; init; } // url / deep-link / путь
public string? Author { get; init; }
public DateTimeOffset ReceivedAt { get; init; }
public IReadOnlyDictionary<string, string>? Extra { get; init; }
}
public sealed record SourceContent
{
public string? Text { get; init; } // основной текст
public string? Html { get; init; } // разметка (если есть)
public string? Author { get; init; } // отправитель
public string? Subject { get; init; } // тема/заголовок
public IReadOnlyList<DataRef> Data { get; init; } = []; // ссылки на файлы в Storage
public IReadOnlyList<string>? Links { get; init; } // ссылки (не файлы)
public IReadOnlyList<ContactRef>? Contacts { get; init; } // контакты
public IReadOnlyDictionary<string, string>? Extra { get; init; } // прочее (не файл/не ссылка/не контакт)
}
```
`DataRef` — ссылка на объект в Storage; тип и метаданные определил Storage (nullable, чтобы не плодить типы):
```csharp
public sealed record DataRef
{
public required string Id { get; init; } // идентификатор объекта в Storage
public required string Ref { get; init; } // ссылка (url/путь) для скачивания/отображения
public string? Kind { get; init; } // определил Storage: image/video/audio/document/archive/other
public string? MimeType { get; init; }
public string? FileName { get; init; }
public long? Size { get; init; }
public int? Width { get; init; }
public int? Height { get; init; }
public double? DurationSec { get; init; }
public string? PreviewRef { get; init; } // превью/thumbnail
public string? Caption { get; init; }
public int? Order { get; init; }
public IReadOnlyDictionary<string, string>? Meta { get; init; } // прочие метаданные от Storage
}
```
`ContactRef`: `Name?`, `Phone?`, `Email?`, `Url?`, `Kind?` (контакт может быть квалифицирован).
## 3. Storage-сервис (общий)
Отдельный сервис (как ai/ml/telegram), владелец — данные. Источники и ядро только ссылаются на объекты.
- **Загрузка:** `Upload(stream, token, fileName?) → DataRef`. Каждый сервис-источник выгружает свои данные
**сам**, передавая **токен валидации** (сервисный токен/mTLS — уже есть в gRPC-обвязке).
- **Определение типа:** Storage сам решает `Kind`/`MimeType`/размеры/длительность (контент-снифинг);
контракт типы не задаёт.
- **Чтение:** `Get(id) → (stream, DataRef)` либо выдача ссылки/временного URL.
- **Бэкенд:** объектное хранилище (MinIO/S3). Путь/бакет — по тенанту.
- **Владение:** единый общий сервис; каждый источник пишет в него со своим токеном, ядро/AI/ML читают по ссылке.
## 4. Адаптеры источников
```csharp
public interface ISourceAdapter { string Kind { get; } SourceItem Normalize(object native); }
```
Владельцы: `telegram` → telegram-сервис; `local` → ручное создание (Cards); `whatsapp`/`avito`/`web`/`file`/
`excel` → соответствующий сервис. Файлы адаптер сам выгружает в Storage и кладёт в контракт `DataRef`.
## 5. Загрузка исходника карточки
Единый способ: по `SourceRef.Kind` — провайдер, возвращающий `SourceContent` (для файла — через Storage по
`DataRef.Ref`, для сообщения — у источника). `ISourceContentProvider { Kind; LoadAsync(SourceRef) }` + реестр.
API ядра: `GET /api/cards/{id}/source` → generic контент.
## 6. Персистентность
В карточках вместо плоских Telegram-колонок:
- `SourceKind` (text); `SourceJson` (jsonb, `SourceRef`);
- `ContentJson` (jsonb, `SourceContent` — текст + `DataRef`-ссылки + прочее);
- `SourceText` (text, FTS);
- `SourceRefUrl` (text?, `OriginRef`).
Конвертер контента общий (без per-source сериализаторов). Миграции: старые удаляем → новый init с нуля.
## 7. Wire и фронт
- `CardDto.Source` = `{ kind, displayName?, originRef?, receivedAt }`.
- `GET /api/cards/{id}/source``{ text?, html?, author?, subject?, data[], links[], contacts[], extra? }`.
- Фронт: generic блок источника + универсальный просмотрщик (по `DataRef.Kind` — картинка/видео/аудио/файл;
ссылки/контакты — списками).
## 8. Этапы
1. Домен: `SourceItem/SourceRef/SourceContent/DataRef/ContactRef`; удалить Telegram-маркеры из Cards.
2. Storage-сервис: контракт gRPC, определение типа, токен валидации, бэкенд MinIO; регистрация.
3. Персистентность: `SourceKind/SourceJson/ContentJson/SourceText/SourceRefUrl`, общий конвертер, новый init,
маппинг KanbanStore.
4. Pipeline: приём `SourceItem`, загрузка вложений в Storage адаптером, без Telegram-полей.
5. Wire/API: generic `Source` в `CardDto`, `GET /api/cards/{id}/source`, провайдеры.
6. Frontend: generic источник + универсальный просмотрщик.
7. Telegram: адаптер + провайдер исходника (только в telegram-сервисе) + выгрузка в Storage.
8. Комментарии: убрать упоминания Telegram из ядра и задачи/этапы — везде.
## 9. Реализация: зафиксированные сигнатуры
### Ядро: домен
- `SourceRefs` (Deal.Modules.Cards/Application/Sources): `Empty`, `DefaultHue = "#666"`,
`HueKey = "hue"`, расширения `DedupeKey()` (вид|оригинал|внешний id), `ResolveHue()`.
- `CardSnapshot`: вместо `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId`
`SourceRef Source` + `SourceContent Content`; `ReceivedAt` остаётся.
- `CardDto`: вместо `Channel`/`SourceMsg`/`SourceDialogId`/`SourceMsgId`/прежнего `Source`
`SourceRef Source` + `SourceContent Content`; `ReceivedAtMs` остаётся. `CardChannelDto`/`CardSourceDto` удалены.
- `ICardStore.GetCardBySourceAsync(SourceRef source, CancellationToken ct)`.
### Ядро: конвейер
- `QueuedMessage { required SourceItem Item; bool Force; }`.
- `QueueItemDto { string Id; SourceRef Source; SourceContent Content; string Text; string Status;
long MsgAtMs; long QueuedAtMs; bool Force; }` (JsonIgnore на Force).
- `RejectRecord { SourceRef Source; SourceContent Content; string Text; long MsgAtMs;
string DecidedBy; string Stage; string Reason; string Kw; string? DeterministicId; }`
(`DeterministicId = r_{Kind}_{OriginRef}_{ExternalId}`).
- `RejectedItemDto`: `Source`/`Content`, `DecidedBy`/`DecidedByLabel` (решение), остальное как было.
- `IPipelineStore.ExistsDuplicateAsync(SourceRef source, CancellationToken ct)`.
- `PipelineChannelDto` удалён.
### Схема БД (схема тенанта)
- Cards: удалить `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId`;
добавить `SourceKind`, `SourceExternalId`, `SourceOriginRef` (text, для запросов), `SourceJson` (text),
`ContentJson` (text), `SourceText` (text). FTS: Title+Summary+SourceText+Contact.
- QueueItems: удалить `DialogId/ChannelName/ChannelHandle/ChannelHue/MsgId`; добавить
`SourceKey` (text, уникальный ключ дедупа), `SourceJson`, `ContentJson`. `Text/MsgAt/Status/Force/CreatedAt/UpdatedAt` остаются.
- RejectedItems: удалить `DialogId/MsgId/ChannelName/ChannelHandle/ChannelHue`; добавить
`SourceKey`, `SourceJson`, `ContentJson`. FTS — по `Text`.
- Миграции tenant: старые удалить, сгенерировать новый init с нуля (данных нет).
### Маппинг источника
- Telegram-адаптер (в ядре — тонкий край приёма): `Kind="telegram"`, `ExternalId=MsgId`,
`OriginRef=DialogId`, `DisplayName=ChannelName`, `Extra["hue"]=ChannelHue` (иначе дефолт),
`ReceivedAt=msgAt`, `Content.Text=Text`, `Content.Author=ChannelName`.
- Дашборды/карточки/конвейер работают только с `SourceRef`/`SourceContent`; Telegram-поля не проходят дальше адаптера.
### Входящий поток (generic, 2026-09-11)
- `src/contracts/sources.proto` → сервис `SourceIngressService.PushSource` с generic-типами
`SourceRefProto`/`SourceContentProto`/`DataRefProto`/`ContactRefProto`; вложения — ссылки на Storage.
- Ядро: `Deal.Api/Sources/SourceIngressGrpcService` (приём) + `SourceProtoMapper` (proto → домен) +
`ISourceIngestObserver` (вторичная обработка принятой записи, сбой наблюдателя не влияет на приём) +
`IngressTenantResolver` (тенант по metadata).
- Из `telegram.proto` удалён `IngressService.PushMessage` (остались `SyncDialogs`/`ReportStatus`);
telegram-сервис шлёт записи через `PushSource` (`kind="telegram"`). Превью каталога/TgMessages сохраняет
`TelegramSourceIngestObserver` (ядро, telegram-модуль — единственное место с telegram-спецификой приёма).
- Любой другой источник (whatsapp/avito/файл/excel) шлёт тот же `PushSource` со своим `source.kind`.
### Remote-просмотр исходника (2026-09-11)
- `TelegramService.ReadSource(ReadSourceRequest{dialog_id, msg_id})` → `ReadSourceReply{found, text?, time?}`
(`src/contracts/telegram.proto`); telegram-сервис достаёт конкретное сообщение
(`ISessionClient.GetMessageAsync` → TL `Messages_GetMessages`). Медиа без текста → `found=false`.
- Ядро: `ITelegramGateway.ReadSourceAsync` + `TelegramSourceContentProvider` (`ISourceContentProvider`,
`Kind="telegram"`, `Deal.Infrastructure/Integrations/Sources`) — резолвится `SourceContentResolver`.
- `GET /api/cards/{id}/source` отдаёт результат провайдера либо сохранённое содержимое карточки.
Фронт: кнопка «Обновить из источника» в подробной карточке (`CardDrawer.vue` → `loadCardSource`).
@@ -0,0 +1,53 @@
# Дизайн: разбиение проектов на логические папки (namespace = папка)
Дата: 2026-09-11. Статус: согласовано владельцем (решения 1–5).
## 1. Цель
Упорядочить код по назначению: вместо «свалки» файлов разных видов в одной папке с единым
`namespace` — подпапки по назначению, при этом `namespace` соответствует пути папки.
## 2. Таксономия папок (по назначению)
| Папка | Что кладём |
| --- | --- |
| `Abstractions/` | интерфейсы `I*.cs` |
| `Services/` | прикладная логика: `*Service`, `*WorkerService.*`, `*Guard`, `*Pacer`, `*Evaluator`, `*Counter`, `*Detector`, `*Normalizer`, `*Matcher`, `*Composer`, `*Cleaner`, `*Classifier`, `*Mapper`, `*Builder`, `*Writer`, `*Recomputer`, `*Suggester`, `*Filler`, `*Generator`, `*Hasher` и аналогичные исполнители |
| `Models/` | доменные типы: сущности, value-объекты, enum, статусы/виды, константные реестры (`*Statuses`, `*Kinds`, `*Prefixes`, `*Keys`, `*Events`, `*Periods`, `*Sources`, `*Field`, `*Defaults`) |
| `Dtos/` | транспортные типы: `*Dto`, `*Request`, `*Response`, `*Patch` |
| `Extensions/` | `*Extensions` |
| `Options/` | `*Options` |
| `Exceptions/` | `*Exception` |
| `Registrars/` | `*ModuleRegistrar` |
Существующие feature-папки (`ColumnRules`, `Parse`, существующие `Models`) сохраняются.
## 3. Правила
1. `namespace` строго соответствует пути папки.
2. Имена типов и публичные контракты не меняются — только расположение и `namespace`.
3. Один тип = один файл (уже соблюдается).
4. Частичные классы (`Foo.cs`, `Foo.Part.cs`) переносятся вместе.
5. Тестовые проекты группируются по областям: `Modules/<X>`, `Api`, `Infrastructure` и т.п.,
`namespace` = `Deal.Tests.Unit.<Область>`.
## 4. Механика переноса (на проект)
1. Классифицировать файлы по таблице §2.
2. Перенести файлы в подпапки и заменить `namespace`.
3. Миграция `using`: в файлах-потребителях заменить несуществующий старый `using <OldNs>;` на
`using` всех новых подпространств (пере-добавление безопасно; при коллизии имён — ручное разрешение).
Файлы внутри проекта-источника получают `using` соседних подпространств.
4. `dotnet build` → исправить остатки (полные имена, `cref`), `dotnet test`.
5. Отдельный коммит (русский) после каждого проекта.
## 5. Порядок
Пилот — `Deal.Modules.Cards` (чистый домен). Далее: остальные `Deal.Modules.*`, затем
`Deal.Infrastructure`, `Deal.Api`, `Deal.Contracts`/`Deal.SharedKernel`, сервисы `telegram/ai/ml`,
затем тестовые проекты. После каждого шага — сборка + тесты + коммит.
## 6. Риски
- Коллизия простых имён при пере-добавлении `using` → разрешается вручную по ошибкам сборки.
- Не забыть `cref`/полные имена в XML-док и `nameof` — выявляются сборкой.