diff --git a/backlog.md b/backlog.md index bb418e0..a2807b6 100644 --- a/backlog.md +++ b/backlog.md @@ -57,7 +57,7 @@ | TD-DBL-CLICK | Двойная перезагрузка доски у инициатора batch-reclassify (ответ + SSE) | этап 12, E | P3 | TECHDEBT | | TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT | | TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT | -| TD-SOURCE-PROVIDER | `GET /api/cards/{id}/source` + `ISourceContentProvider` + реестр провайдеров; ленивая догрузка `SourceContent` (сейчас контент отдаётся прямо в `CardDto`) | generic source 2026-09-11 | P1 | BACKLOG | +| TD-SOURCE-PROVIDER | `ISourceContentProvider`/`SourceContentResolver` и `GET /api/cards/{id}/source` добавлены; осталось — реализовать провайдеры источников (telegram/local/file) с ленивой догрузкой | generic source 2026-09-11 | P1 | BACKLOG | | TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером + `TelegramSourceContentProvider` (только telegram-сервис) + рендер `DataRef` в UI | generic source 2026-09-11 | P1 | BACKLOG | | TD-TG-CORE-SPLIT | Перенести оставшуюся Telegram-специфику ядра (`TelegramStore`, таблицы `Dialogs`/`TgMessages`, Discovery, ingress-proto) в telegram-сервис; generic ingress-контракт источников | generic source 2026-09-11 | P2 | TECHDEBT | | TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG | diff --git a/docs/api/api-map.md b/docs/api/api-map.md index edae171..e184436 100644 --- a/docs/api/api-map.md +++ b/docs/api/api-map.md @@ -162,6 +162,7 @@ | `POST /{cardId}/files` | Загрузить файлы (multipart, поле `files`) | FormData `files` | → карточка (с обновлённым `files`) | | `GET /{cardId}/files/{fileId}/download` | Скачать (stream из MinIO/локального store) | — | `application/octet-stream`, `Content-Disposition: attachment`; 410/404 | | `DELETE /{cardId}/files/{fileId}` | Открепить файл | — | → карточка | +| `GET /{cardId}/source` | Содержимое источника: провайдер по `source.kind` либо сохранённое в карточке | — | `SourceContent`; 404 «Карточка не найдена» | | `POST /{cardId}/reminder` | Напоминание карточке | `{at: }` | → карточка; 400 «Поле at (epoch-ms) обязательно» | | `DELETE /{cardId}/reminder` | Снять напоминание | — | → карточка | | `POST /{cardId}/reminder/snooze` | Отложить на +24 ч | — | → карточка | @@ -318,12 +319,12 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/ ### 4.5 Очередь и отсев (вкладка «Обработка») -Очередь (`GET /pipeline/queue` item): `{id, dialogId, msgId: int|null, text, status: "new"|"filtered", ch:{name,handle,hue}, msgAt: ms, queuedAt: ms}` — фронт читает все (кроме dialogId/msgId/queuedAt, которые используются только как справочные; UI показывает text, статус-бейдж, канал). +Очередь (`GET /pipeline/queue` item): `{id, source: SourceRef, content: SourceContent, text, status: "new"|"filtered", msgAt: ms, queuedAt: ms}` — UI показывает `text`, статус-бейдж и подпись источника (`source.displayName`, цвет `source.extra.hue`). -Отсев (`GET /pipeline/rejected` item): `{id, dialogId, msgId, text, stage, stageLabel, reason, kw, source, sourceLabel, ch:{name,handle,hue}, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`. +Отсев (`GET /pipeline/rejected` item): `{id, source: SourceRef, content: SourceContent, text, stage, stageLabel, reason, kw, decidedBy, decidedByLabel, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`. - `stage` ∈ `length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup`; `stageLabel` — подпись («короткое сообщение», «стоп-фраза», «спам (ML)», …). -- `source` ∈ `stop|ml|ai|stale|dup`; `sourceLabel` ∈ «правила|ML|ИИ|система». -- Фронт читает: `id, stageLabel, kw, reason, source, sourceLabel, text, ch, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `source==='dup'` или `returned`. +- `decidedBy` ∈ `stop|ml|ai|stale|dup`; `decidedByLabel` ∈ «правила|ML|ИИ|система». +- Фронт читает: `id, stageLabel, kw, reason, decidedBy, decidedByLabel, text, source, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `decidedBy==='dup'` или `returned`. ### 4.6 Настройки (settings) — все ключи ответа `GET/PATCH /api/settings` (camelCase; значения по умолчанию из `constants.DEFAULT_SETTINGS`) diff --git a/docs/superpowers/STATUS.md b/docs/superpowers/STATUS.md index addbf62..22aab98 100644 --- a/docs/superpowers/STATUS.md +++ b/docs/superpowers/STATUS.md @@ -7,7 +7,8 @@ > персистентность, конвейер, wire, фронт) переведено с Telegram-полей карточки на generic-тип > `SourceItem` (`SourceRef` + `SourceContent`, вложения — `DataRef` → общий Storage). Дашборды/канбан/конвейер > больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции -> пересозданы с нуля (init). Ядро: build 4 sln 0/0, `Deal.Tests.Unit` **1277/1277 PASS**, фронт `build` + +> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и +> `GET /api/cards/{id}/source`. Ядро: build 4 sln 0/0, `Deal.Tests.Unit` **1280/1280 PASS**, фронт `build` + > `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`. > Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений > telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs index 2adcd02..e1b6379 100644 --- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs @@ -4,6 +4,7 @@ using Deal.Api.Extensions; using Deal.Api.Services; using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Tenants.Application.Models; @@ -45,6 +46,9 @@ public static class CardDetailsEndpoints // Вложенный путь установки/снятия напоминания. private const string ReminderPath = "/{cardId}/reminder"; + // Вложенный путь ленивой загрузки содержимого источника. + private const string SourcePath = "/{cardId}/source"; + // Вложенный путь «напомнить позже». private const string ReminderSnoozePath = "/{cardId}/reminder/snooze"; @@ -94,6 +98,7 @@ public static class CardDetailsEndpoints cards.MapPost(FilesPath, UploadFilesAsync); cards.MapGet(FileDownloadPath, DownloadFileAsync); cards.MapDelete(FileItemPath, RemoveFileAsync); + cards.MapGet(SourcePath, ReadSourceAsync); cards.MapPost(ReminderPath, SetReminderAsync); cards.MapDelete(ReminderPath, ClearReminderAsync); cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync); @@ -408,6 +413,27 @@ public static class CardDetailsEndpoints // cardId: Id карточки. // ct: Токен отмены. // Возвращает: 200 с единой карточкой либо 404. + // Читает содержимое источника карточки: провайдер по виду источника либо сохранённое в карточке. + // context: Контекст запроса (для резолва CardsService). + // cardId: Id карточки. + // ct: Токен отмены. + // Возвращает: 200 с SourceContent либо 404. + private static async Task ReadSourceAsync( + HttpContext context, + string cardId, + CancellationToken ct) + { + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardDto? card = await cardsService.GetCardAsync(cardId, ct); + if (card is null) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + SourceContent content = await cardsService.ResolveSourceAsync(card, ct); + return Results.Ok(content); + } + private static async Task ReadCardAsync( HttpContext context, string cardId, diff --git a/src/core/Deal.Modules.Cards/Application/Sources/ISourceContentProvider.cs b/src/core/Deal.Modules.Cards/Application/Sources/ISourceContentProvider.cs new file mode 100644 index 0000000..15d55bd --- /dev/null +++ b/src/core/Deal.Modules.Cards/Application/Sources/ISourceContentProvider.cs @@ -0,0 +1,20 @@ +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Загрузка содержимого записи источника по ссылке. +/// +public interface ISourceContentProvider +{ + /// + /// Вид источника, который обслуживает провайдер. + /// + public string Kind { get; } + + /// + /// Загружает содержимое записи источника. + /// + /// Ссылка на источник. + /// Токен отмены. + /// Содержимое записи либо null, если источник не отдал данные. + public Task LoadAsync(SourceRef source, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Cards/Application/Sources/SourceContentResolver.cs b/src/core/Deal.Modules.Cards/Application/Sources/SourceContentResolver.cs new file mode 100644 index 0000000..ba26810 --- /dev/null +++ b/src/core/Deal.Modules.Cards/Application/Sources/SourceContentResolver.cs @@ -0,0 +1,42 @@ +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Выбирает провайдера содержимого по виду источника. +/// +public sealed class SourceContentResolver +{ + private readonly Dictionary _providers; + + /// + /// Создаёт реестр провайдеров содержимого. + /// + /// Зарегистрированные провайдеры; последний по виду перекрывает предыдущих. + public SourceContentResolver(IEnumerable providers) + { + _providers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ISourceContentProvider provider in providers) + { + _providers[provider.Kind] = provider; + } + } + + /// + /// Есть ли провайдер для вида источника. + /// + /// Ссылка на источник. + /// true — содержимое можно загрузить лениво. + public bool CanResolve(SourceRef source) => _providers.ContainsKey(source.Kind); + + /// + /// Загружает содержимое записи источника. + /// + /// Ссылка на источник. + /// Токен отмены. + /// Содержимое записи либо null — провайдера нет/источник не отдал данные. + public Task ResolveAsync(SourceRef source, CancellationToken ct) + { + return _providers.TryGetValue(source.Kind, out ISourceContentProvider? provider) + ? provider.LoadAsync(source, ct) + : Task.FromResult(null); + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs index 087e2a6..dcd28fb 100644 --- a/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs +++ b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs @@ -1,3 +1,4 @@ +using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Settings.Application.Abstractions; using Microsoft.Extensions.DependencyInjection; @@ -17,6 +18,7 @@ public static class KanbanModuleRegistrar services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Source.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Source.cs new file mode 100644 index 0000000..c02c220 --- /dev/null +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Source.cs @@ -0,0 +1,30 @@ +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Содержимое источника карточки — partial-часть +/// +public sealed partial class CardsService +{ + /// + /// Загружает содержимое источника карточки. + /// + /// Карточка, для которой нужно содержимое. + /// Токен отмены. + /// Содержимое от провайдера источника либо сохранённое в карточке. + public async Task ResolveSourceAsync(CardDto card, CancellationToken ct) + { + if (_sourceResolver is not null && _sourceResolver.CanResolve(card.Source)) + { + SourceContent? loaded = await _sourceResolver.ResolveAsync(card.Source, ct); + if (loaded is not null) + { + return loaded; + } + } + + return card.Content; + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs index f67aab1..eb49497 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs @@ -1,4 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; +using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions; // Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён @@ -19,6 +20,7 @@ public sealed partial class CardsService private readonly ISettingsStore _settings; private readonly IMlClient _mlClient; private readonly IFileStorage _storage; + private readonly SourceContentResolver? _sourceResolver; /// /// Создаёт сервис карточек над портами модуля @@ -27,16 +29,19 @@ public sealed partial class CardsService /// KV-хранилище настроек тенанта (курсы, напоминания). /// Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts. /// Файловое хранилище вложений карточки (объекты файлов). + /// Реестр провайдеров содержимого источника; null — только содержимое карточки. public CardsService( ICardStore store, ISettingsStore settings, IMlClient mlClient, - IFileStorage storage) + IFileStorage storage, + SourceContentResolver? sourceResolver = null) { _store = store; _settings = settings; _mlClient = mlClient; _storage = storage; + _sourceResolver = sourceResolver; } diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceContentResolverTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceContentResolverTests.cs new file mode 100644 index 0000000..4639965 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceContentResolverTests.cs @@ -0,0 +1,56 @@ +using Deal.Modules.Cards.Application.Sources; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты выбора провайдера содержимого источника — SourceContentResolver. +/// +public sealed class SourceContentResolverTests +{ + [Fact] + public async Task ResolveAsync_KnownKind_ReturnsProviderContent() + { + var provider = new FakeProvider("test"); + var resolver = new SourceContentResolver([provider]); + SourceRef source = SourceItemFactory.Source(); + SourceContent expected = SourceItemFactory.Content("из провайдера"); + + provider.Content = expected; + + Assert.True(resolver.CanResolve(source)); + SourceContent? actual = await resolver.ResolveAsync(source, CancellationToken.None); + Assert.Same(expected, actual); + } + + [Fact] + public async Task ResolveAsync_UnknownKind_ReturnsNull() + { + var resolver = new SourceContentResolver([]); + SourceRef source = SourceItemFactory.Source(); + + Assert.False(resolver.CanResolve(source)); + Assert.Null(await resolver.ResolveAsync(source, CancellationToken.None)); + } + + [Fact] + public async Task ResolveAsync_KindMatchIsCaseInsensitive() + { + var provider = new FakeProvider("Telegram"); + var resolver = new SourceContentResolver([provider]); + SourceRef source = SourceItemFactory.Source(kind: "telegram"); + provider.Content = SourceItemFactory.Content("из провайдера"); + + Assert.True(resolver.CanResolve(source)); + Assert.NotNull(await resolver.ResolveAsync(source, CancellationToken.None)); + } + + private sealed class FakeProvider(string kind) : ISourceContentProvider + { + public string Kind { get; } = kind; + + public SourceContent? Content { get; set; } + + public Task LoadAsync(SourceRef source, CancellationToken ct) => + Task.FromResult(Content); + } +}