Стримить прогресс пакетной переклассификации
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-I18N | Переключатель языка в UI + второй язык (en) + locale-aware форматирование (`Intl`), плюрализация. Основа (вынос строк в ресурсы, `registerLocale`) готова | ТЗ §11, этап 11 | P3 | BACKLOG |
|
||||||
| BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED |
|
| BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED |
|
||||||
| BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) |
|
| 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-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-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) |
|
| 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** (см. ниже) | создана карточка (пайплайн, демо, тик) |
|
| `new_card` | объект **Card** (см. ниже) | создана карточка (пайплайн, демо, тик) |
|
||||||
| `reminder_due` | `{ "id", "title", "containerId" }` | наступило напоминание |
|
| `reminder_due` | `{ "id", "title", "containerId" }` | наступило напоминание |
|
||||||
| `toast` | `{ "text", "icon" }` | статистика тика / служебное уведомление |
|
| `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`).
|
`new_lead` больше не публикуется (переименован в `new_card`).
|
||||||
|
|
||||||
@@ -216,9 +216,10 @@
|
|||||||
сервиса — детерминированный локальный разбор (без кредов сервис не падает). `usedAi` показывает путь.
|
сервиса — детерминированный локальный разбор (без кредов сервис не падает). `usedAi` показывает путь.
|
||||||
- Одна переклассификация за раз (single-flight): при занятом проходе `{ "started": false, "busy": true }`.
|
- Одна переклассификация за раз (single-flight): при занятом проходе `{ "started": false, "busy": true }`.
|
||||||
- Аудит — событие `card_reclassified` (только при `reclassified > 0`).
|
- Аудит — событие `card_reclassified` (только при `reclassified > 0`).
|
||||||
- После успешного прохода (`started = true` и `reclassified > 0`) в канал тенанта публикуется SSE
|
- Во время пакетного прохода публикуются промежуточные SSE `cards_reclassified` с `{ "progress": true,
|
||||||
`cards_reclassified` с минимальной нагрузкой `{ "reclassified", "moved" }`; фронт перечитывает доску.
|
"done", "total", "moved", "kept", "trashed", "skipped" }` (каждые 5 карточек и на последней); по завершении —
|
||||||
Пустой inbox/всё пропущено не меняют доску — событие не шлётся. Без подписчиков — no-op.
|
финальное `{ "progress": false, "reclassified", "moved" }`; фронт показывает `done/total` и перечитывает
|
||||||
|
доску только по финальному событию.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
|
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
|
||||||
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
|
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
|
||||||
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
|
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
|
||||||
> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`; actor/tenant в access-логе. Hardening контейнеров
|
> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`; actor/tenant в access-логе; прогресс переклассификации
|
||||||
> (non-root/read-only/limits), шардированная пакетная миграция схем, единый CI (`scripts/ci.sh`).
|
> через SSE. Hardening контейнеров (non-root/read-only/limits), шардированная пакетная миграция схем,
|
||||||
> Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1321/1321 PASS**, telegram **130/130**, ai **52/52**,
|
> единый CI (`scripts/ci.sh`). Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1326/1326 PASS**,
|
||||||
> ml **38/38**, storage **9/9**, фронт `build` + `lint:i18n` зелёные.
|
> 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`.
|
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
||||||
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
||||||
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
||||||
|
|||||||
@@ -324,7 +324,23 @@ public static class CardsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
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);
|
await AppendReclassifyAuditAsync(context, result, ct);
|
||||||
PublishReclassified(context, result);
|
PublishReclassified(context, result);
|
||||||
return Results.Ok(ToReclassifyWire(result));
|
return Results.Ok(ToReclassifyWire(result));
|
||||||
@@ -386,7 +402,7 @@ public static class CardsEndpoints
|
|||||||
broker.Publish(
|
broker.Publish(
|
||||||
context.GetCurrentUser()!.TenantId,
|
context.GetCurrentUser()!.TenantId,
|
||||||
ReclassifiedEventType,
|
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);
|
private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true);
|
||||||
|
|
||||||
|
// Частота отчётов о прогрессе пакетного прохода (каждые N карточек + финальный отчёт).
|
||||||
|
private const int ProgressStep = 5;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пакетная переклассификация «Неразобранного»
|
/// Пакетная переклассификация «Неразобранного»
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ids">Опциональный список id (null/пусто — все карточки inbox).</param>
|
/// <param name="ids">Опциональный список id (null/пусто — все карточки inbox).</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <param name="progress">Наблюдатель прогресса (SSE); null — без отчётов.</param>
|
||||||
/// <returns>Итог прохода (счётчики исхода) либо <c>busy</c>, если проход уже идёт.</returns>
|
/// <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())
|
if (!gate.TryEnter())
|
||||||
{
|
{
|
||||||
@@ -66,9 +74,16 @@ public sealed class CardReclassifier(
|
|||||||
return Build(pass, attempted: 0, started: false, reason: EmptyInboxReason);
|
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)
|
foreach (CardDto card in target)
|
||||||
{
|
{
|
||||||
await ReclassifyOneAsync(card, pass, ct);
|
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);
|
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);
|
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]
|
[Fact]
|
||||||
@@ -347,6 +412,17 @@ public sealed class CardReclassifierTests
|
|||||||
Assert.Equal("@old_user", Assert.Single(ctx.Store.CardDtos).Contact);
|
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]
|
[Fact]
|
||||||
public async Task Reclassify_Busy_WhenAnotherPassHoldsGate()
|
public async Task Reclassify_Busy_WhenAnotherPassHoldsGate()
|
||||||
{
|
{
|
||||||
@@ -367,4 +443,16 @@ public sealed class CardReclassifierTests
|
|||||||
ctx.Gate.Exit();
|
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">
|
<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) }}
|
+{{ newCount(containerId) }}
|
||||||
</span>
|
</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>
|
<span class="flex-1"></span>
|
||||||
|
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ export const ru = {
|
|||||||
"colAccepted": "Колонка «{name}» принята",
|
"colAccepted": "Колонка «{name}» принята",
|
||||||
"suggestedBoards": "ИИ предложил колонок: {count} — посмотрите в списке",
|
"suggestedBoards": "ИИ предложил колонок: {count} — посмотрите в списке",
|
||||||
"reclassifying": "Переклассифицирую «Неразобранное»: {count} карточек в фоне",
|
"reclassifying": "Переклассифицирую «Неразобранное»: {count} карточек в фоне",
|
||||||
|
"reclassifyProgressTitle": "Идёт переклассификация «Неразобранного»",
|
||||||
"colSaved": "Колонка «{name}» сохранена",
|
"colSaved": "Колонка «{name}» сохранена",
|
||||||
"colCreated": "Колонка «{name}» создана — заявки будут скапливаться здесь"
|
"colCreated": "Колонка «{name}» создана — заявки будут скапливаться здесь"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const state = reactive({
|
|||||||
// мониторинг пайплайна — вкладка «Обработка» (очередь + отсев)
|
// мониторинг пайплайна — вкладка «Обработка» (очередь + отсев)
|
||||||
pTab: 'queue', // queue | rejected
|
pTab: 'queue', // queue | rejected
|
||||||
pQueue: [],
|
pQueue: [],
|
||||||
|
reclassifyProgress: null, // { done, total } — прогресс пакетной переклассификации
|
||||||
pQueueCounts: { new: 0, ai: 0, total: 0 },
|
pQueueCounts: { new: 0, ai: 0, total: 0 },
|
||||||
pRejected: [],
|
pRejected: [],
|
||||||
pRejectedCount: 0,
|
pRejectedCount: 0,
|
||||||
|
|||||||
@@ -149,10 +149,16 @@ function startRealtime() {
|
|||||||
} else if (type === 'system_status') {
|
} else if (type === 'system_status') {
|
||||||
refreshTgStatus()
|
refreshTgStatus()
|
||||||
} else if (type === 'cards_reclassified') {
|
} else if (type === 'cards_reclassified') {
|
||||||
// Переклассификация карточки/«Неразобранного» завершилась: мягко перечитываем доску и статус ML
|
if (data?.progress) {
|
||||||
// (коалесцируется с перезагрузкой инициатора — без двойного запроса).
|
// Промежуточный отчёт прохода: показываем прогресс, доску не дёргаем.
|
||||||
scheduleBoardReload()
|
state.reclassifyProgress = { done: data.done || 0, total: data.total || 0 }
|
||||||
refreshMlStatus()
|
} else {
|
||||||
|
// Проход завершён: снимаем прогресс и мягко перечитываем доску и статус ML
|
||||||
|
// (коалесцируется с перезагрузкой инициатора — без двойного запроса).
|
||||||
|
state.reclassifyProgress = null
|
||||||
|
scheduleBoardReload()
|
||||||
|
refreshMlStatus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user