Стримить прогресс пакетной переклассификации
CardReclassifier отдаёт IProgress-отчёты, эндпоинт публикует SSE cards_reclassified {progress:true,...} каждые 5 карточек и финальное событие; фронт показывает done/total и перечитывает доску только по финалу. Закрывает BL-RECLASS-SSE.
This commit is contained in:
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
@@ -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-специфики
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -389,6 +389,7 @@ export const ru = {
|
||||
"colAccepted": "Колонка «{name}» принята",
|
||||
"suggestedBoards": "ИИ предложил колонок: {count} — посмотрите в списке",
|
||||
"reclassifying": "Переклассифицирую «Неразобранное»: {count} карточек в фоне",
|
||||
"reclassifyProgressTitle": "Идёт переклассификация «Неразобранного»",
|
||||
"colSaved": "Колонка «{name}» сохранена",
|
||||
"colCreated": "Колонка «{name}» создана — заявки будут скапливаться здесь"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user