diff --git a/backlog.md b/backlog.md index 1bd61ce..0067932 100644 --- a/backlog.md +++ b/backlog.md @@ -13,7 +13,7 @@ | BL-I18N | Переключатель языка в UI + второй язык (en) + locale-aware форматирование (`Intl`), плюрализация. Основа (вынос строк в ресурсы, `registerLocale`) готова | ТЗ §11, этап 11 | P3 | BACKLOG | | BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED | | BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) | -| BL-RECLASS-SSE | Стриминг-прогресс пакетной переклассификации + финальный тост (сейчас синхронный проход + событие `cards_reclassified`) | этап 12, D | P3 | BACKLOG | +| BL-RECLASS-SSE | **Сделано (2026-09-11):** пакетная переклассификация отдаёт промежуточный прогресс через SSE `cards_reclassified` (`{progress:true,done,total,moved,kept,trashed,skipped}`) и финальное событие (`{progress:false,reclassified,moved}`); `CardReclassifier.ReclassifyInboxAsync` принимает `IProgress`; в UI — индикатор `done/total` в шапке «Неразобранного» | этап 12, D | P3 | DONE | | TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto` (наружу уже единый) | этап 9/11 | P3 | TECHDEBT | | TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки ``, `` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE | | TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `` только блочно — исправлено 5286 шт. в 833 файлах; (2) комментарии только на public/protected — понижено 2028 XML-доков с private/internal (359 файлов). **Осталось:** (3) не дублировать `` интерфейса в реализации (нужен Roslyn-анализ); (4) явная реализация интерфейсов там, где возможно (61 интерфейс, точечный ревью). Скрипты: `scripts/fix_summary_blocks.py`, `scripts/fix_private_docs.py`. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | TECHDEBT (1,2 — DONE; 3,4 — BACKLOG) | diff --git a/docs/architecture/2026-09-10-unified-api-contract.md b/docs/architecture/2026-09-10-unified-api-contract.md index 7eb1ff0..5066ecb 100644 --- a/docs/architecture/2026-09-10-unified-api-contract.md +++ b/docs/architecture/2026-09-10-unified-api-contract.md @@ -30,7 +30,7 @@ | `new_card` | объект **Card** (см. ниже) | создана карточка (пайплайн, демо, тик) | | `reminder_due` | `{ "id", "title", "containerId" }` | наступило напоминание | | `toast` | `{ "text", "icon" }` | статистика тика / служебное уведомление | -| `cards_reclassified` | `{ "reclassified", "moved" }` | завершена переклассификация карточки/«Неразобранного» | +| `cards_reclassified` | промежуточный — `{ "progress": true, "done", "total", "moved", "kept", "trashed", "skipped" }`; финал — `{ "progress": false, "reclassified", "moved" }` | прогресс/завершение переклассификации «Неразобранного» | `new_lead` больше не публикуется (переименован в `new_card`). @@ -216,9 +216,10 @@ сервиса — детерминированный локальный разбор (без кредов сервис не падает). `usedAi` показывает путь. - Одна переклассификация за раз (single-flight): при занятом проходе `{ "started": false, "busy": true }`. - Аудит — событие `card_reclassified` (только при `reclassified > 0`). -- После успешного прохода (`started = true` и `reclassified > 0`) в канал тенанта публикуется SSE - `cards_reclassified` с минимальной нагрузкой `{ "reclassified", "moved" }`; фронт перечитывает доску. - Пустой inbox/всё пропущено не меняют доску — событие не шлётся. Без подписчиков — no-op. +- Во время пакетного прохода публикуются промежуточные SSE `cards_reclassified` с `{ "progress": true, + "done", "total", "moved", "kept", "trashed", "skipped" }` (каждые 5 карточек и на последней); по завершении — + финальное `{ "progress": false, "reclassified", "moved" }`; фронт показывает `done/total` и перечитывает + доску только по финальному событию. ```json { diff --git a/docs/superpowers/STATUS.md b/docs/superpowers/STATUS.md index 6b06d83..d74fb79 100644 --- a/docs/superpowers/STATUS.md +++ b/docs/superpowers/STATUS.md @@ -12,10 +12,10 @@ > `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру > (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр > исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника». -> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`; actor/tenant в access-логе. Hardening контейнеров -> (non-root/read-only/limits), шардированная пакетная миграция схем, единый CI (`scripts/ci.sh`). -> Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1321/1321 PASS**, telegram **130/130**, ai **52/52**, -> ml **38/38**, storage **9/9**, фронт `build` + `lint:i18n` зелёные. +> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`; actor/tenant в access-логе; прогресс переклассификации +> через SSE. Hardening контейнеров (non-root/read-only/limits), шардированная пакетная миграция схем, +> единый CI (`scripts/ci.sh`). Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1326/1326 PASS**, +> telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9**, фронт `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/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index 1a7f056..6be52f1 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -324,7 +324,23 @@ public static class CardsEndpoints } CardReclassifier reclassifier = context.RequestServices.GetRequiredService(); - ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct); + SseBroker broker = context.RequestServices.GetRequiredService(); + Guid tenantId = context.GetCurrentUser()!.TenantId; + // Синхронный IProgress: события прогресса уходят в SSE в порядке прохода, до финального события. + var progress = new InlineProgress(value => broker.Publish( + tenantId, + ReclassifiedEventType, + new + { + progress = true, + done = value.Done, + total = value.Total, + moved = value.Moved, + kept = value.Kept, + trashed = value.Trashed, + skipped = value.Skipped, + })); + ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct, progress); await AppendReclassifyAuditAsync(context, result, ct); PublishReclassified(context, result); return Results.Ok(ToReclassifyWire(result)); @@ -386,7 +402,7 @@ public static class CardsEndpoints broker.Publish( context.GetCurrentUser()!.TenantId, ReclassifiedEventType, - new { reclassified = result.Reclassified, moved = result.Moved }); + new { reclassified = result.Reclassified, moved = result.Moved, progress = false }); } // Аудит переклассификации: пишется только когда проход реально что-то изменил. diff --git a/src/core/Deal.Api/Services/InlineProgress.cs b/src/core/Deal.Api/Services/InlineProgress.cs new file mode 100644 index 0000000..192658f --- /dev/null +++ b/src/core/Deal.Api/Services/InlineProgress.cs @@ -0,0 +1,11 @@ +namespace Deal.Api.Services; + +/// +/// Синхронный : обработчик вызывается в потоке отчёта (без захвата контекста). +/// +/// Обработчик очередного значения прогресса. +public sealed class InlineProgress(Action handler) : IProgress +{ + /// + public void Report(T value) => handler(value); +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyProgressDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyProgressDto.cs new file mode 100644 index 0000000..b62381d --- /dev/null +++ b/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyProgressDto.cs @@ -0,0 +1,18 @@ +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Промежуточный прогресс пакетной переклассификации «Неразобранного». +/// +/// Сколько карточек уже обработано. +/// Сколько карточек отобрано всего. +/// Сколько ушло в смысловую колонку. +/// Сколько осталось в «Неразобранном». +/// Сколько отправлено в корзину. +/// Сколько пропущено (нет исходного текста). +public sealed record ReclassifyProgressDto( + int Done, + int Total, + int Moved, + int Kept, + int Trashed, + int Skipped); diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs index e9c89e8..ad864dd 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs @@ -44,12 +44,20 @@ public sealed class CardReclassifier( private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true); + // Частота отчётов о прогрессе пакетного прохода (каждые N карточек + финальный отчёт). + private const int ProgressStep = 5; + /// /// Пакетная переклассификация «Неразобранного» /// /// Опциональный список id (null/пусто — все карточки inbox). + /// Токен отмены. + /// Наблюдатель прогресса (SSE); null — без отчётов. /// Итог прохода (счётчики исхода) либо busy, если проход уже идёт. - public async Task ReclassifyInboxAsync(IReadOnlyList? ids, CancellationToken ct) + public async Task ReclassifyInboxAsync( + IReadOnlyList? ids, + CancellationToken ct, + IProgress? progress = null) { if (!gate.TryEnter()) { @@ -66,9 +74,16 @@ public sealed class CardReclassifier( return Build(pass, attempted: 0, started: false, reason: EmptyInboxReason); } + progress?.Report(new ReclassifyProgressDto(0, target.Count, 0, 0, 0, 0)); + int done = 0; foreach (CardDto card in target) { await ReclassifyOneAsync(card, pass, ct); + done++; + if (progress is not null && (done % ProgressStep == 0 || done == target.Count)) + { + progress.Report(new ReclassifyProgressDto(done, target.Count, pass.Moved, pass.Kept, pass.Trashed, pass.Skipped)); + } } return Build(pass, attempted: target.Count, started: true, reason: null); diff --git a/src/core/tests/Deal.Tests.Unit/Api/InlineProgressTests.cs b/src/core/tests/Deal.Tests.Unit/Api/InlineProgressTests.cs new file mode 100644 index 0000000..7fe0fe1 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Api/InlineProgressTests.cs @@ -0,0 +1,29 @@ +using Deal.Api.Services; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты InlineProgress — синхронный IProgress. +/// +public sealed class InlineProgressTests +{ + [Fact] + public void Report_InvokesHandlerSynchronouslyWithValue() + { + int callerThread = Environment.CurrentManagedThreadId; + int handlerThread = -1; + ReclassifyProgressDto? seen = null; + var progress = new InlineProgress(value => + { + handlerThread = Environment.CurrentManagedThreadId; + seen = value; + }); + var expected = new ReclassifyProgressDto(5, 12, 3, 2, 0, 0); + + progress.Report(expected); + + Assert.Equal(expected, seen); + Assert.Equal(callerThread, handlerThread); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs index 932bbfc..7518ada 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs @@ -316,6 +316,71 @@ public sealed class CardReclassifierTests Assert.Equal(1, result.Reclassified); } + // ─── Прогресс пакетного прохода ──────────────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_EmptyTarget_DoesNotReportProgress() + { + Context ctx = CreateContext(); + var progress = new RecordingProgress(); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); + + Assert.Equal(CardReclassifier.EmptyInboxReason, result.Reason); + Assert.Empty(progress.Reports); + } + + [Fact] + public async Task ReclassifyInbox_TwelveCards_ReportsEveryFiveAndFinal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + SeedCards(ctx, 12); + var progress = new RecordingProgress(); + + await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); + + Assert.Equal(new[] { 0, 5, 10, 12 }, progress.Reports.Select(r => r.Done)); + Assert.All(progress.Reports, r => Assert.Equal(12, r.Total)); + Assert.All(progress.Reports, r => Assert.Equal(r.Done, r.Moved + r.Kept + r.Trashed + r.Skipped)); + } + + [Fact] + public async Task ReclassifyInbox_LessThanStep_ReportsOnlyStartAndFinal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + SeedCards(ctx, 3); + var progress = new RecordingProgress(); + + await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); + + Assert.Equal(new[] { 0, 3 }, progress.Reports.Select(r => r.Done)); + Assert.All(progress.Reports, r => Assert.Equal(3, r.Total)); + } + + [Fact] + public async Task ReclassifyInbox_ProgressDoneMonotonicAndFinalReachesTotal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + SeedCards(ctx, 12); + var progress = new RecordingProgress(); + + await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); + + Assert.NotEmpty(progress.Reports); + int previous = -1; + foreach (ReclassifyProgressDto report in progress.Reports) + { + Assert.True(report.Done >= previous); + previous = report.Done; + } + + Assert.Equal(12, progress.Reports[^1].Done); + Assert.Equal(progress.Reports[^1].Done, progress.Reports[^1].Total); + } + // ─── Одна карточка и замок ───────────────────────────────────────────── [Fact] @@ -347,6 +412,17 @@ public sealed class CardReclassifierTests Assert.Equal("@old_user", Assert.Single(ctx.Store.CardDtos).Contact); } + // Засевает N карточек «Неразобранного» с исходным текстом (порядок по времени получения). + // ctx: Контекст теста. + // count: Сколько карточек засеять. + private static void SeedCards(Context ctx, int count) + { + for (int i = 0; i < count; i++) + { + ctx.Store.SeedCard(Card($"c_{i}", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: i)); + } + } + [Fact] public async Task Reclassify_Busy_WhenAnotherPassHoldsGate() { @@ -367,4 +443,16 @@ public sealed class CardReclassifierTests ctx.Gate.Exit(); } } + + // Записывающий наблюдатель прогресса: складывает отчёты в порядке поступления. + private sealed class RecordingProgress : IProgress + { + /// + /// Полученные отчёты о прогрессе. + /// + public List Reports { get; } = []; + + /// + public void Report(ReclassifyProgressDto value) => Reports.Add(value); + } } diff --git a/src/frontend/src/components/card/ContainerColumn.vue b/src/frontend/src/components/card/ContainerColumn.vue index 08860ef..3327e7c 100644 --- a/src/frontend/src/components/card/ContainerColumn.vue +++ b/src/frontend/src/components/card/ContainerColumn.vue @@ -205,6 +205,13 @@ const emptyHint = { +{{ newCount(containerId) }} + + {{ state.reclassifyProgress.done }}/{{ state.reclassifyProgress.total }} + diff --git a/src/frontend/src/i18n/locales/ru.js b/src/frontend/src/i18n/locales/ru.js index a7347dd..8cb4bce 100644 --- a/src/frontend/src/i18n/locales/ru.js +++ b/src/frontend/src/i18n/locales/ru.js @@ -389,6 +389,7 @@ export const ru = { "colAccepted": "Колонка «{name}» принята", "suggestedBoards": "ИИ предложил колонок: {count} — посмотрите в списке", "reclassifying": "Переклассифицирую «Неразобранное»: {count} карточек в фоне", + "reclassifyProgressTitle": "Идёт переклассификация «Неразобранного»", "colSaved": "Колонка «{name}» сохранена", "colCreated": "Колонка «{name}» создана — заявки будут скапливаться здесь" }, diff --git a/src/frontend/src/store/core.js b/src/frontend/src/store/core.js index f07901d..34253ea 100644 --- a/src/frontend/src/store/core.js +++ b/src/frontend/src/store/core.js @@ -158,6 +158,7 @@ export const state = reactive({ // мониторинг пайплайна — вкладка «Обработка» (очередь + отсев) pTab: 'queue', // queue | rejected pQueue: [], + reclassifyProgress: null, // { done, total } — прогресс пакетной переклассификации pQueueCounts: { new: 0, ai: 0, total: 0 }, pRejected: [], pRejectedCount: 0, diff --git a/src/frontend/src/store/lifecycle.js b/src/frontend/src/store/lifecycle.js index 8c2be24..a7276b3 100644 --- a/src/frontend/src/store/lifecycle.js +++ b/src/frontend/src/store/lifecycle.js @@ -149,10 +149,16 @@ function startRealtime() { } else if (type === 'system_status') { refreshTgStatus() } else if (type === 'cards_reclassified') { - // Переклассификация карточки/«Неразобранного» завершилась: мягко перечитываем доску и статус ML - // (коалесцируется с перезагрузкой инициатора — без двойного запроса). - scheduleBoardReload() - refreshMlStatus() + if (data?.progress) { + // Промежуточный отчёт прохода: показываем прогресс, доску не дёргаем. + state.reclassifyProgress = { done: data.done || 0, total: data.total || 0 } + } else { + // Проход завершён: снимаем прогресс и мягко перечитываем доску и статус ML + // (коалесцируется с перезагрузкой инициатора — без двойного запроса). + state.reclassifyProgress = null + scheduleBoardReload() + refreshMlStatus() + } } }) }