Стримить прогресс пакетной переклассификации

CardReclassifier отдаёт IProgress-отчёты, эндпоинт публикует SSE cards_reclassified {progress:true,...} каждые 5 карточек и финальное событие; фронт показывает done/total и перечитывает доску только по финалу. Закрывает BL-RECLASS-SSE.
This commit is contained in:
Rustam Khalimov
2026-09-11 17:57:47 +03:00
parent ea21dc54c6
commit e62dbfafba
13 changed files with 209 additions and 16 deletions
+1 -1
View File
@@ -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<ReclassifyProgressDto>`; в 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_*`), удалены блоки `<remarks>`, `<summary>` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE |
| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `<summary>` только блочно — исправлено 5286 шт. в 833 файлах; (2) комментарии только на public/protected — понижено 2028 XML-доков с private/internal (359 файлов). **Осталось:** (3) не дублировать `<summary>` интерфейса в реализации (нужен 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) |
@@ -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
{
+4 -4
View File
@@ -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-специфики
+18 -2
View File
@@ -324,7 +324,23 @@ public static class CardsEndpoints
}
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct);
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
Guid tenantId = context.GetCurrentUser()!.TenantId;
// Синхронный IProgress: события прогресса уходят в SSE в порядке прохода, до финального события.
var progress = new InlineProgress<ReclassifyProgressDto>(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 });
}
// Аудит переклассификации: пишется только когда проход реально что-то изменил.
@@ -0,0 +1,11 @@
namespace Deal.Api.Services;
/// <summary>
/// Синхронный <see cref="IProgress{T}"/>: обработчик вызывается в потоке отчёта (без захвата контекста).
/// </summary>
/// <param name="handler">Обработчик очередного значения прогресса.</param>
public sealed class InlineProgress<T>(Action<T> handler) : IProgress<T>
{
/// <inheritdoc />
public void Report(T value) => handler(value);
}
@@ -0,0 +1,18 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Промежуточный прогресс пакетной переклассификации «Неразобранного».
/// </summary>
/// <param name="Done">Сколько карточек уже обработано.</param>
/// <param name="Total">Сколько карточек отобрано всего.</param>
/// <param name="Moved">Сколько ушло в смысловую колонку.</param>
/// <param name="Kept">Сколько осталось в «Неразобранном».</param>
/// <param name="Trashed">Сколько отправлено в корзину.</param>
/// <param name="Skipped">Сколько пропущено (нет исходного текста).</param>
public sealed record ReclassifyProgressDto(
int Done,
int Total,
int Moved,
int Kept,
int Trashed,
int Skipped);
@@ -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;
/// <summary>
/// Пакетная переклассификация «Неразобранного»
/// </summary>
/// <param name="ids">Опциональный список id (null/пусто — все карточки inbox).</param>
/// <param name="ct">Токен отмены.</param>
/// <param name="progress">Наблюдатель прогресса (SSE); null — без отчётов.</param>
/// <returns>Итог прохода (счётчики исхода) либо <c>busy</c>, если проход уже идёт.</returns>
public async Task<ReclassifyResultDto> ReclassifyInboxAsync(IReadOnlyList<string>? ids, CancellationToken ct)
public async Task<ReclassifyResultDto> ReclassifyInboxAsync(
IReadOnlyList<string>? ids,
CancellationToken ct,
IProgress<ReclassifyProgressDto>? 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);
@@ -0,0 +1,29 @@
using Deal.Api.Services;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты InlineProgress — синхронный IProgress.
/// </summary>
public sealed class InlineProgressTests
{
[Fact]
public void Report_InvokesHandlerSynchronouslyWithValue()
{
int callerThread = Environment.CurrentManagedThreadId;
int handlerThread = -1;
ReclassifyProgressDto? seen = null;
var progress = new InlineProgress<ReclassifyProgressDto>(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);
}
}
@@ -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<ReclassifyProgressDto>
{
/// <summary>
/// Полученные отчёты о прогрессе.
/// </summary>
public List<ReclassifyProgressDto> Reports { get; } = [];
/// <inheritdoc />
public void Report(ReclassifyProgressDto value) => Reports.Add(value);
}
}
@@ -205,6 +205,13 @@ const emptyHint = {
<span v-if="newCount(containerId) > 0" class="num text-[10px] font-semibold text-brand bg-brand/12 rounded-full px-1.5 py-0.5">
+{{ newCount(containerId) }}
</span>
<span
v-if="containerId === 'inbox' && state.reclassifyProgress"
class="num text-[10px] font-semibold text-brand bg-brand/12 rounded-full px-1.5 py-0.5"
:title="$t('cards.reclassifyProgressTitle')"
>
{{ state.reclassifyProgress.done }}/{{ state.reclassifyProgress.total }}
</span>
<span class="flex-1"></span>
+1
View File
@@ -389,6 +389,7 @@ export const ru = {
"colAccepted": "Колонка «{name}» принята",
"suggestedBoards": "ИИ предложил колонок: {count} — посмотрите в списке",
"reclassifying": "Переклассифицирую «Неразобранное»: {count} карточек в фоне",
"reclassifyProgressTitle": "Идёт переклассификация «Неразобранного»",
"colSaved": "Колонка «{name}» сохранена",
"colCreated": "Колонка «{name}» создана — заявки будут скапливаться здесь"
},
+1
View File
@@ -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,
+7 -1
View File
@@ -149,11 +149,17 @@ function startRealtime() {
} else if (type === 'system_status') {
refreshTgStatus()
} else if (type === 'cards_reclassified') {
// Переклассификация карточки/«Неразобранного» завершилась: мягко перечитываем доску и статус ML
if (data?.progress) {
// Промежуточный отчёт прохода: показываем прогресс, доску не дёргаем.
state.reclassifyProgress = { done: data.done || 0, total: data.total || 0 }
} else {
// Проход завершён: снимаем прогресс и мягко перечитываем доску и статус ML
// (коалесцируется с перезагрузкой инициатора — без двойного запроса).
state.reclassifyProgress = null
scheduleBoardReload()
refreshMlStatus()
}
}
})
}