diff --git a/backlog.md b/backlog.md index 9f1cc6f..5d3ff8d 100644 --- a/backlog.md +++ b/backlog.md @@ -58,7 +58,7 @@ | TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT | | TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT | | 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` данными. Требует живого Telegram API (см. `docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md`) | generic source 2026-09-11 | P1 | BACKLOG | +| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются. Остались на будущее: вложения прочих источников (файл/диск/таблица) и `ISourceContentProvider` для remote-просмотра | generic source 2026-09-11 | P3 | BACKLOG | | TD-TG-CORE-SPLIT | Перенести оставшуюся Telegram-специфику ядра (`TelegramStore`, таблицы `Dialogs`/`TgMessages`, Discovery) в telegram-сервис. **Сделано (2026-09-11):** входящий поток переведён на generic `sources.proto`/`PushSource`, `PushMessage` удалён, приём в ядре generic (`SourceIngressGrpcService`). Осталось: каталог (`SyncDialogs`/`ReportStatus`), `TelegramStore`, Discovery | generic source 2026-09-11 | P2 | TECHDEBT | | TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG | | TD-APIMAP-COUNT | Ручной подсчёт числа операторских ручек в `api-map` (может расходиться с группировкой) | docs sweep | P3 | TECHDEBT | diff --git a/docs/api/api-map.md b/docs/api/api-map.md index e184436..86502ce 100644 --- a/docs/api/api-map.md +++ b/docs/api/api-map.md @@ -23,7 +23,7 @@ | Статические сегменты против `{id}` | Литералы объявляются до `{id}`: `/cards/counts`, `/cards/clear-col`, `/cards/clear-rejected`, `/cards/reclassify`, `/cards/mark-all-seen`, `/cards/mark-col-seen`, `/cards/take` — до `/cards/{cardId}`; `/containers/state`, `/containers/reorder` — до `/containers/{containerId}`; `/rejected/clear` — до `/rejected/{rejId}`. ASP.NET Core отдаёт приоритет литералам, порядок сохранён для читаемости | | Prefix'ы id | `c_` — карточка (единый для всех дашбордов), `b_` — контейнер-колонка, `p_` — строка очереди, `r_` — запись отсева, `cm_` — комментарий, `h_` — запись истории, `pf_` — файл, `pl_*` — ссылка, `dt_` — discovery-задача, `dl_` — запись лога, `m__` — сообщение. Контейнеры-стадии/служебные зоны — без префикса (`planned`…`rejected`, `inbox`/`archive`/`trash`) | | Служебные админ | `admin/tick`, `admin/fts/rebuild`, `admin/check-message` — служебные, фронтом не вызываются | -| Проверка фильтра/тестера | `POST /api/admin/check-message` — имитация этапов пайплайна | +| Проверка фильтра/тестера | `POST /api/admin/check-message` — сухой прогон текста по всему конвейеру (стоп-правила → ML → ИИ) без создания карточки | | Фоновые циклы (не API) | storage-тик (30 с): автоархив/очистка + напоминания; pipeline-воркер (2 с); discovery-воркер (5 с); ML outbox (10 с); suggest (180 с); tg sweep (30 с); rates (30 мин) | --- @@ -103,7 +103,7 @@ |---|---|---| | `POST /admin/tick` | Ручной тик: хранение+напоминания+разбор очереди (фронт зовёт раз в 60 с) | `{storage: {archived, purgedArchive, purgedTrash, purgedRejected}, reminders: [{id, title, containerId}] (уже «выстрелившие», после SSE), pipeline: , queue: int}` | | `POST /admin/fts/rebuild` | Пересобрать FTS-индекс | `{ok: bool, ready: bool}` | -| `POST /admin/check-message` | Тестер фильтра (этап 1 + этап 2) | см. §4.10 | +| `POST /admin/check-message` | Сухой прогон текста по конвейеру (стоп-правила → ML → ИИ) | см. §4.10 | | `POST /admin/wipe` | Полный сброс (карточки+ML+счётчики) | `{ok, cardsRemoved, ml: {ok}}` *(прототип)* | | `POST /admin/clear-cards` | Очистить карточки/очереди без сброса ML | `{ok, cardsRemoved}` *(прототип)* | | `POST /admin/pump-gate` | Шлагбаум воркера `{limit?}` | `{ok, limit, done}` *(прототип)* | @@ -375,7 +375,10 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/ ### 4.10 Прочее - **`GET /api/ml/status`**: `{enabled: bool, service: {ready, classes: {label: n}, learned: int, eval: {count, correct, accuracy}}, reachable: bool, stats: {ml, ai, learning, ready, classes, learned, reachable, outbox}}`. Фронт читает: `reachable`, `service.ready/classes/learned/eval.{count,correct,accuracy}`, `stats.outbox`. -- **`POST /api/admin/check-message`**: `{stage1: {pass: bool, reason: string|null}, stage2: {pass: bool, reason: string|null, skipped: bool}, passed: bool}` — при ошибке ИИ `stage2={pass:true,reason:null,skipped:true}`. +- **`POST /api/admin/check-message`** (сухой прогон конвейера): `{text}` → + `{passed, wouldCreateCard, targetContainer, matchHits, parsed, stages:[{stage, pass, skipped, reason, kw, label}]}`. + Коды `stage`: `length|stop|resume|type|exclude|ml|ai|spam_ai|budget`; `skipped=true` — этап выключен + настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится. - **`POST /api/ai/check`**: `{ok: bool, message: string, local?, keySet?}`. - Комментарии карточки: `{id, by: string, text, time: string}` — `by` всегда «Вы», `time` «только что». diff --git a/docs/superpowers/STATUS.md b/docs/superpowers/STATUS.md index c495a5e..618e23b 100644 --- a/docs/superpowers/STATUS.md +++ b/docs/superpowers/STATUS.md @@ -9,8 +9,9 @@ > больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции > пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и > `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`, -> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Ядро: build 5 sln 0/0, -> `Deal.Tests.Unit` **1289/1289 PASS**, telegram **125/125**, фронт `build` + `lint:i18n` зелёные. +> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру +> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Ядро: build 5 sln 0/0, +> `Deal.Tests.Unit` **1298/1298 PASS**, telegram **125/125**, фронт `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/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md b/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md index 22ddbf3..cef6d56 100644 --- a/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md +++ b/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md @@ -1,6 +1,29 @@ # Открытые вопросы: вложения источников (media → Storage) и просмотр исходника -Дата: 2026-09-11. Статус: часть требует живого внешнего контура (Telegram API) для проверки. +Дата: 2026-09-11. Статус: решения владельца получены (см. §0). + +## 0. Решения владельца (2026-09-11) + +- **А) Медиа-посты пропускаем.** Сообщения без текста (только медиа/вложение) в систему не попадают. + Извлечение вложений Telegram и выгрузка их в Storage не делаются. Generic-контракт по-прежнему умеет + нести `DataRef` — этим смогут пользоваться другие источники (файл/диск/таблица) и ручные вложения карточки. +- **Б) Проверка без живого Telegram** — реализуем с юнит-тестами на фейковой сессии/фейковом Storage, + без реального API. +- **В)** Объяснение термина — в §1.5. + +## 1.5. Что такое «remote-просмотр исходника» + +Карточка хранит **ссылку на источник** (`SourceRef`) и **содержимое** (`SourceContent`). Содержимое попадает +в карточку в момент приёма. «Просмотр исходника» — это возможность по кнопке догрузить/показать **оригинальное +сообщение у источника** (то, что было в канале/письме/строке), если контент в карточке устарел или урезан. + +Сейчас содержимое уже отдаётся в `CardDto.content` и через `GET /api/cards/{id}/source`. Для локальных +источников этого достаточно. Для **внешних** источников (например Telegram) данные лежат не в ядре, а в +сервисе-владельце; чтобы их догрузить, ядру нужен провайдер `ISourceContentProvider` для `kind`, который +ходит по gRPC к сервису-владельцу (условный RPC `ReadSource(dialogId, msgId)`) и возвращает исходный текст/медиа. + +Это и есть «remote-просмотр» — расширение extension-point, которое не требуется до появления реальной +необходимости (напр. если карточки хранят урезанный текст или нужно открыть живой первоисточник). ## 1. Что уже готово (не требует решений) diff --git a/docs/technical/Техническая-документация-Дейл.md b/docs/technical/Техническая-документация-Дейл.md index 6c7aa3a..fb3aa50 100644 --- a/docs/technical/Техническая-документация-Дейл.md +++ b/docs/technical/Техническая-документация-Дейл.md @@ -756,6 +756,11 @@ health — `GET /api/health` → `{"ok":true,"service":"deal"}`. reason, skipped}, passed}`; этап-1 правила из настроек (длина/стоп-фразы/резюме/тип); ИИ-фильтр тестера на этапе 2 всегда `skipped:true`. + > **Актуально с 2026-09-11:** тестер стал сухим прогоном по всему конвейеру + > (`PipelineWorkerService.DryRunAsync`): `{passed, wouldCreateCard, targetContainer, matchHits, parsed, + > stages[]}` — стоп-правила → глобальные исключения → ML (спам/тип) → ИИ-фильтр/классификация → «без + > суммы»; без записи в систему. UI — вкладка настроек «Стоп-слова». + ### 4c. Эндпоинты этапа 3 (канбан/дашборд; сессия `deal_session` обязательна, иначе 401) > На этапе 4 `POST /api/admin/tick` стал реальным (pipeline/pump/purge-отсева) и diff --git a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs index 65bb083..aa78c98 100644 --- a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs @@ -1,11 +1,12 @@ using Deal.Api.Extensions; using Deal.Api.Services; -using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; namespace Deal.Api.Endpoints; /// -/// Тестер фильтра входящих +/// Тестер конвейера: сухой прогон текста через стоп-фильтры, ML и ИИ /// public static class FilterTesterEndpoints { @@ -27,6 +28,7 @@ public static class FilterTesterEndpoints return app; } + // POST /api/admin/check-message: прогон текста по этапам конвейера без записи карточки. private static async Task CheckAsync( CheckMessageRequest body, HttpContext context, @@ -37,25 +39,9 @@ public static class FilterTesterEndpoints return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } - // Резолв после 401-гейта: IncomingRules — scoped на TenantDbContext (tenant-контекст запроса). - IncomingRules incomingRules = context.RequestServices.GetRequiredService(); - IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct); - - if (!stage1.Pass) - { - return Results.Ok(new - { - stage1 = new { pass = stage1.Pass, reason = stage1.Reason }, - stage2 = new { pass = false, reason = (string?)null, skipped = true }, - passed = false, - }); - } - - return Results.Ok(new - { - stage1 = new { pass = stage1.Pass, reason = stage1.Reason }, - stage2 = new { pass = true, reason = (string?)null, skipped = true }, - passed = true, - }); + // Резолв после 401-гейта: воркер scoped на TenantDbContext (tenant-контекст запроса). + PipelineWorkerService worker = context.RequestServices.GetRequiredService(); + PipelineDryRunDto result = await worker.DryRunAsync(body.Text ?? string.Empty, ct); + return Results.Ok(result); } } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunDto.cs new file mode 100644 index 0000000..c0bce4a --- /dev/null +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunDto.cs @@ -0,0 +1,21 @@ +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Результат сухого прогона текста по конвейеру (без записи в систему). +/// +/// True — текст прошёл все включённые этапы. +/// True — из текста была бы создана карточка. +/// Контейнер, в который попала бы карточка, если она создаётся. +/// Совпавшие критерии правил целевого контейнера. +/// Разбор текста (поля карточки), если прогон дошёл до сборки. +/// Результаты этапов в порядке прохождения. +public sealed record PipelineDryRunDto( + bool Passed, + bool WouldCreateCard, + string? TargetContainer, + IReadOnlyList MatchHits, + AiParsedCardDto? Parsed, + IReadOnlyList Stages); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunStageDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunStageDto.cs new file mode 100644 index 0000000..5ac87d8 --- /dev/null +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunStageDto.cs @@ -0,0 +1,18 @@ +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Результат одного этапа сухого прогона текста по конвейеру. +/// +/// Код этапа: length|stop|resume|type|exclude|stale|ml|ai|budget. +/// True — этап пропустил текст дальше. +/// True — этап выключен настройкой и не выполнялся. +/// Причина блокировки (пусто — блокировки нет). +/// Сработавшее слово/фраза правила, если есть. +/// Метка решения ML/ИИ, если применимо. +public sealed record PipelineDryRunStageDto( + string Stage, + bool Pass, + bool Skipped, + string Reason, + string? Kw = null, + string? Label = null); diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.DryRun.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.DryRun.cs new file mode 100644 index 0000000..b3282a0 --- /dev/null +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.DryRun.cs @@ -0,0 +1,206 @@ +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Сухой прогон текста по конвейеру — partial-часть +/// +public sealed partial class PipelineWorkerService +{ + private const string StageExcludeDry = "exclude"; + + private const string StageMlDry = "ml"; + + private const string StageAiDry = "ai"; + + private const string StageBudgetDry = "budget"; + + private const string EmptyTextReason = "пустое сообщение"; + + private const string MlSpamDryReason = "ML уверен, что это спам/не заявка"; + + /// + /// Прогоняет текст по этапам конвейера без записи в систему. + /// + /// Текст для проверки. + /// Токен отмены. + /// Результаты этапов, разбор и целевой контейнер (если карточка была бы создана). + public async Task DryRunAsync(string text, CancellationToken ct) + { + string trimmed = (text ?? string.Empty).Trim(); + var stages = new List(); + + if (trimmed.Length == 0) + { + stages.Add(new PipelineDryRunStageDto(SourceStop, false, false, EmptyTextReason)); + return Rejected(stages); + } + + WorkerRunSettings run = await LoadRunSettingsAsync(_settings, ct); + + IncomingRulesResult stop = await _rules.CheckAsync(trimmed, ct); + stages.Add(new PipelineDryRunStageDto(StopStageCode(stop), stop.Pass, false, stop.Reason ?? string.Empty, stop.Kw)); + if (!stop.Pass) + { + return Rejected(stages); + } + + GlobalExclusionResult? exclusion = GlobalExclusionRules.Match(trimmed, run.GlobalExclusions); + if (exclusion is not null) + { + stages.Add(new PipelineDryRunStageDto(StageExcludeDry, false, false, exclusion.Reason, exclusion.Kw)); + return Rejected(stages); + } + + stages.Add(new PipelineDryRunStageDto(StageExcludeDry, true, false, string.Empty)); + + PipelineDryRunDto? mlRejection = await CheckMlAsync(trimmed, run, stages, ct); + if (mlRejection is not null) + { + return mlRejection; + } + + AiParsedCardDto? parsed = await ResolveParsedAsync(trimmed, run, stages, ct); + if (parsed is null) + { + return Rejected(stages); + } + + if (await SkipNoBudgetAsync(parsed, trimmed, run, ct)) + { + stages.Add(new PipelineDryRunStageDto(StageBudgetDry, false, false, NoBudgetReason)); + return Rejected(stages, parsed); + } + + stages.Add(new PipelineDryRunStageDto(StageBudgetDry, true, false, string.Empty)); + + (string target, IReadOnlyList hits) = await ResolveTargetAsync(parsed, trimmed, run, ct); + return new PipelineDryRunDto(true, true, target, hits, parsed, stages); + } + + // Этап ML: спам-вердикт и несовпадение типа возвращают отказ, иначе этап отмечен пройденным. + private async Task CheckMlAsync( + string text, + WorkerRunSettings run, + List stages, + CancellationToken ct) + { + if (!run.MlEnabled) + { + stages.Add(new PipelineDryRunStageDto(StageMlDry, true, true, string.Empty)); + return null; + } + + MlPredictResultDto decision = await PredictSafelyAsync(text, ct); + if (decision.Ready && decision.Take && decision.Label == MlLearningLabels.Spam) + { + stages.Add(new PipelineDryRunStageDto(StageMlDry, false, false, MlSpamDryReason, Label: decision.Label)); + return Rejected(stages); + } + + stages.Add(new PipelineDryRunStageDto(StageMlDry, true, false, string.Empty, Label: decision.Label)); + + if (decision.Ready && decision.Type is { Take: true } typeDecision) + { + bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel; + if (WantedTypeRejects(run.WantedType, isHire)) + { + string kindName = isHire ? MlHireKindName : MlOrderKindName; + string reason = string.Format(MlTypeDropReasonFormat, kindName, run.WantedType); + stages.Add(new PipelineDryRunStageDto(StageType, false, false, reason, Label: typeDecision.Label)); + return Rejected(stages); + } + } + + return null; + } + + // Этап ИИ: фильтр, классификация и спам-вердикт; выключенный ИИ — локальный разбор. + private async Task ResolveParsedAsync( + string text, + WorkerRunSettings run, + List stages, + CancellationToken ct) + { + if (!run.AiEnabled) + { + stages.Add(new PipelineDryRunStageDto(StageAiDry, true, true, string.Empty)); + return AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); + } + + AiFilterResultDto filter = run.AiFilterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped; + stages.Add(new PipelineDryRunStageDto(StageAiDry, filter.Pass, filter.Skipped, filter.Reason ?? string.Empty)); + if (!filter.Pass) + { + return null; + } + + AiParsedCardDto? parsed = null; + try + { + parsed = await _aiClassifier.ClassifyAsync(text, ct); + } + catch (Exception) + { + parsed = null; + } + + if (parsed is not null) + { + parsed = parsed with { IsVacancyKnown = true }; + if (parsed.IsSpam) + { + stages.Add(new PipelineDryRunStageDto(StageSpamAi, false, false, AiSpamReason)); + return null; + } + + return parsed; + } + + return AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); + } + + // Целевой контейнер: доска разбора при соответствии правилам, иначе «Неразобранное». + private async Task<(string Target, IReadOnlyList Hits)> ResolveTargetAsync( + AiParsedCardDto parsed, + string text, + WorkerRunSettings run, + CancellationToken ct) + { + string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim(); + if (boardCandidate is null) + { + return (CardIds.Inbox, []); + } + + IReadOnlyDictionary rates = run.Settings.TryGetRatesCache()?.Rates ?? MockRates.Values; + ContainerDto? board = await _kanjStore.GetContainerAsync(boardCandidate, ct); + return board is not null && ColumnRules.ContainerAccepts(board.Rules, text, rates) + ? (board.Id, ColumnRules.ComputeHits(board.Rules, text, rates)) + : (CardIds.Inbox, []); + } + + private static PipelineDryRunDto Rejected( + IReadOnlyList stages, + AiParsedCardDto? parsed = null) => + new(false, false, null, [], parsed, stages); + + private static string StopStageCode(IncomingRulesResult stop) => + stop.Kind.Length > 0 ? stop.Kind : SourceStop; + + private static bool WantedTypeRejects(string wanted, bool isHire) => + wanted switch + { + WantedTypeFreelance => isHire, + WantedTypeVacancy => !isHire, + _ => false, + }; +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs index dd062ad..22e27b7 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs @@ -664,4 +664,198 @@ public sealed class PipelineWorkerServiceTests ctx.MlClient.Pushed); Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); } + + // ─── DryRunAsync: сухой прогон текста по конвейеру ───────────────────── + + // Этап сухого прогона по коду этапа. + // result: Результат прогона. + // stage: Код этапа (stop/exclude/ml/ai/spam_ai/budget). + // Возвращает: Этап прогона. + private static PipelineDryRunStageDto Stage(PipelineDryRunDto result, string stage) => + result.Stages.Single(item => item.Stage == stage); + + [Fact] + public async Task DryRun_EmptyText_RejectsStopWithEmptyMessageReason() + { + Context ctx = CreateContext(); + + PipelineDryRunDto result = await ctx.Worker.DryRunAsync(" ", default); + + Assert.False(result.Passed); + Assert.False(result.WouldCreateCard); + Assert.Null(result.Parsed); + PipelineDryRunStageDto stage = Assert.Single(result.Stages); + Assert.Equal("stop", stage.Stage); + Assert.False(stage.Pass); + Assert.Equal("пустое сообщение", stage.Reason); + } + + [Fact] + public async Task DryRun_StopPhraseHit_RejectsAtStopStageWithPhraseAsKw() + { + Context ctx = CreateContext(); + + PipelineDryRunDto result = await ctx.Worker.DryRunAsync( + "Отличный сервис! Взаимный пиар в чатах и каналах — продвижение без бюджета и вложений", + default); + + Assert.False(result.Passed); + Assert.False(result.WouldCreateCard); + PipelineDryRunStageDto stage = Assert.Single(result.Stages); + Assert.Equal("stop", stage.Stage); + Assert.False(stage.Pass); + Assert.Equal("взаимный пиар", stage.Kw); + Assert.Equal("стоп-фраза «взаимный пиар»", stage.Reason); + } + + [Fact] + public async Task DryRun_MlAndAiDisabled_PassesWithLocalParseAndSkippedStages() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.MlEnabled, Json(false)); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + + PipelineDryRunDto result = await ctx.Worker.DryRunAsync( + "Разработчик на python для парсера маркетплейсов, бюджет 2000$, пишите в личку", + default); + + Assert.True(result.Passed); + Assert.True(result.WouldCreateCard); + Assert.Equal(KanbanColumns.Inbox, result.TargetContainer); + Assert.NotNull(result.Parsed); + Assert.True(Stage(result, "ml").Skipped); + Assert.True(Stage(result, "ai").Skipped); + Assert.Equal(0, ctx.MlClient.PredictCalls); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); + } + + [Fact] + public async Task DryRun_MlReadyAndSpam_RejectsAtMlStageWithLabel() + { + Context ctx = CreateContext(); + ctx.MlClient.Predict = new MlPredictResultDto( + Take: true, + Label: "spam", + Scores: new Dictionary { ["spam"] = 0.9 }, + Hits: 4, + Ready: true, + Margin: 0.55, + Terms: new[] { "заработок" }, + Type: null); + + PipelineDryRunDto result = await ctx.Worker.DryRunAsync( + "Инвестируй в наш фонд и получай пассивный доход каждый день без риска", + default); + + Assert.False(result.Passed); + Assert.False(result.WouldCreateCard); + PipelineDryRunStageDto stage = Stage(result, "ml"); + Assert.False(stage.Pass); + Assert.Equal("spam", stage.Label); + } + + [Fact] + public async Task DryRun_AiFilterBlocks_RejectsAtAiStageWithoutClassification() + { + Context ctx = CreateContext(); + ctx.AiClassifier.FilterResult = new AiFilterResultDto(Pass: false, Reason: "реклама и скам", Skipped: false); + + PipelineDryRunDto result = await ctx.Worker.DryRunAsync( + "Забери бесплатный курс по трейдингу по ссылке внизу поста, доход гарантирован", + default); + + Assert.False(result.Passed); + Assert.False(result.WouldCreateCard); + Assert.Null(result.Parsed); + PipelineDryRunStageDto stage = Stage(result, "ai"); + Assert.False(stage.Pass); + Assert.Equal("реклама и скам", stage.Reason); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); + } + + [Fact] + public async Task DryRun_AiClassifiesSpam_RejectsAtSpamAiStage() + { + Context ctx = CreateContext(); + ctx.AiClassifier.ClassifyResult = Parsed("Заработок на крипте", isSpam: true); + + PipelineDryRunDto result = await ctx.Worker.DryRunAsync( + "Заработок на крипте 300 процентов в месяц, подпишись на канал за бесплатным курсом", + default); + + Assert.False(result.Passed); + Assert.False(result.WouldCreateCard); + Assert.Null(result.Parsed); + Assert.False(Stage(result, "spam_ai").Pass); + } + + [Fact] + public async Task DryRun_GlobalExclusionKeyword_RejectsAtExcludeStageBeforeMl() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.ExcludeKeywords, Json(new[] { "крипт" })); + + PipelineDryRunDto result = await ctx.Worker.DryRunAsync( + "Заработок на крипте 300 процентов в месяц, подпишись на канал за бесплатным курсом", + default); + + Assert.False(result.Passed); + Assert.False(result.WouldCreateCard); + PipelineDryRunStageDto stage = Stage(result, "exclude"); + Assert.False(stage.Pass); + Assert.Equal("крипт", stage.Kw); + Assert.Equal("глобальное исключение: слово/технология «крипт»", stage.Reason); + Assert.Equal(0, ctx.MlClient.PredictCalls); + } + + [Fact] + public async Task DryRun_NoBudgetForHireWithRequiredFilter_RejectsAtBudgetStage() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.BudgetRequiredHire, Json(true)); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + + PipelineDryRunDto result = await ctx.Worker.DryRunAsync( + "Вакансия: python-разработчик в команду, удалённая работа, опыт от трёх лет", + default); + + Assert.False(result.Passed); + Assert.False(result.WouldCreateCard); + PipelineDryRunStageDto stage = Stage(result, "budget"); + Assert.False(stage.Pass); + Assert.Equal( + "включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет", + stage.Reason); + } + + [Fact] + public async Task DryRun_TextWithAmountAndAcceptingBoard_TargetsBoardWithMatchHits() + { + Context ctx = CreateContext(); + ctx.KanjStore.SeedBoard(new ContainerDto + { + Id = "b_py", + Rules = new ContainerRulesDto( + "any", + Array.Empty(), + new[] { "python" }, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null), + }); + const string text = "Вакансия: python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.AiClassifier.ClassifyResult = Parsed( + "Python-разработчик", + budget: new AiBudgetDto(2000, 2000, "USD"), + isVacancy: true, + board: "b_py"); + + PipelineDryRunDto result = await ctx.Worker.DryRunAsync(text, default); + + Assert.True(result.Passed); + Assert.True(result.WouldCreateCard); + Assert.Equal("b_py", result.TargetContainer); + Assert.NotEmpty(result.MatchHits); + } } diff --git a/src/frontend/src/components/settings/StopTab.vue b/src/frontend/src/components/settings/StopTab.vue index a4c3868..242a2d8 100644 --- a/src/frontend/src/components/settings/StopTab.vue +++ b/src/frontend/src/components/settings/StopTab.vue @@ -14,6 +14,59 @@ const testText = ref( ) const testResult = ref(null) +// Коды этапов конвейера (бэкенд) → ключи словаря. Неизвестный код показываем как есть. +const STAGE_KEYS = { + length: 'settings.etap-dlina', + stop: 'settings.etap-stop-fraza', + resume: 'settings.etap-rezyume-soiskatelya', + type: 'settings.etap-tip-ne-podhodit', + exclude: 'settings.etap-globalnoe-isklyuchenie', + ml: 'settings.etap-ml', + ai: 'settings.etap-ii', + spam_ai: 'settings.etap-ii-spam', + budget: 'settings.etap-bez-summy', +} + +function stageLabel(code) { + return STAGE_KEYS[code] ? t(STAGE_KEYS[code]) : String(code || '') +} + +function stageIcon(stage) { + if (stage.skipped) return 'more' + return stage.pass ? 'check' : 'x' +} + +function stageIconClass(stage) { + if (stage.skipped) return 'text-low' + return stage.pass ? 'text-online' : 'text-danger' +} + +// id контейнера → человеческое имя (inbox/доска); неизвестный id оставляем как есть. +function containerName(id) { + if (!id) return '' + return state.containers?.find((c) => c.id === id)?.name || String(id) +} + +// Бюджет разбора: «from–to CUR» либо одна сумма; null — без бюджета. +function parsedBudget(budget) { + if (!budget) return t('settings.bez-byudzheta') + const parts = [] + if (budget.from != null && budget.to != null) parts.push(`${budget.from}\u2013${budget.to}`) + else if (budget.from != null) parts.push(`${budget.from}`) + else if (budget.to != null) parts.push(`${budget.to}`) + if (budget.cur) parts.push(budget.cur) + return parts.length ? parts.join(' ') : t('settings.bez-byudzheta') +} + +function parsedStack(parsed) { + return Array.isArray(parsed?.stack) ? parsed.stack.join(', ') : '' +} + +function parsedKind(parsed) { + const kind = parsed?.isVacancy ? t('settings.najm') : t('settings.razovyj-zakaz') + return parsed?.isVacancyKnown === false ? `${kind} (${t('settings.predpolozhitelno')})` : kind +} + // Варианты «типов заявок» — тексты из словаря (шаблон не держит строк). const wantedOptions = computed(() => [ { id: 'both', label: t('settings.wantedBoth') }, @@ -346,28 +399,42 @@ function useGoodSample() {
- - {{ $t('settings.etap-1-stop-frazy') }} - - {{ testResult.stage1.pass ? $t('settings.projden') : $t('settings.zablokirovano') + testResult.stage1.reason }} + + {{ stageLabel(stage.stage) }} + + + + + · {{ stage.kw }} + · {{ stage.label }}
-
- - {{ $t('settings.etap-2-ii-filtr') }} - - {{ testResult.stage2.pass ? $t('settings.projden') : $t('settings.zablokirovano') + testResult.stage2.reason }} - + +
+ {{ testResult.passed ? $t('settings.projdet') : $t('settings.itog-zablokirovano') }}
-
- {{ testResult.passed ? $t('settings.propushcheno-soobshchenie-uhodit-v') : $t('settings.otbrosheno-v-sistemu-ne-popadaet') }} + +
+
{{ $t('settings.kartochka-popala-by-v-kolonku', { container: containerName(testResult.targetContainer) }) }}
+
+
{{ $t('settings.sovpavshie-kriterii') }}
+
+ {{ hit.label }} +
+
+
+ +
+
{{ $t('settings.razbor-teksta') }}
+
{{ testResult.parsed.title }}
+
{{ $t('settings.razbor-stek') }}: {{ parsedStack(testResult.parsed) }}
+
{{ $t('settings.razbor-byudzhet') }}: {{ parsedBudget(testResult.parsed.budget) }}
+
{{ $t('settings.razbor-tip') }}: {{ parsedKind(testResult.parsed) }}
diff --git a/src/frontend/src/i18n/locales/ru.js b/src/frontend/src/i18n/locales/ru.js index 2a0d134..9ec6c33 100644 --- a/src/frontend/src/i18n/locales/ru.js +++ b/src/frontend/src/i18n/locales/ru.js @@ -511,9 +511,6 @@ export const ru = { "analiziruyu": "Анализирую…", "predlozhit-ii": "Предложить ИИ", "projden": "пройден", - "zablokirovano": "заблокировано — ", - "propushcheno-soobshchenie-uhodit-v": "✓ Пропущено — сообщение уходит в классификацию", - "otbrosheno-v-sistemu-ne-popadaet": "✕ Отброшено — в систему не попадает", "ml-spravlyaetsya-ii-mozhno-otklyuchit": "ML справляется — ИИ можно отключить", "ml-proveryaetsya-na-vashih-dejstviyah": "ML проверяется на ваших действиях", "ml-gotova-no-podtverzhdenij-poka-net": "ML готова, но подтверждений пока нет", @@ -661,10 +658,30 @@ export const ru = { "sohranit-prompt-filtra": "Сохранить промпт фильтра", "sbrosit-k-primeru": "Сбросить к примеру", "proverka-filtra": "Проверка фильтра", - "vstavte-tekst-soobshcheniya-pokazhem": "Вставьте текст сообщения — покажем, пройдёт ли он оба этапа (этап 2 — реальный запрос к ИИ)", + "vstavte-tekst-soobshcheniya-pokazhem": "Вставьте текст сообщения — прогоним по всему конвейеру (стоп-фильтры → ML → ИИ) и покажем, на каком этапе он остановится", "primer-realnoj-zayavki": "пример реальной заявки", - "etap-1-stop-frazy": "Этап 1 · стоп-фразы:", - "etap-2-ii-filtr": "Этап 2 · ИИ-фильтр:", + "etap-dlina": "короткое сообщение", + "etap-stop-fraza": "стоп-фраза", + "etap-rezyume-soiskatelya": "резюме соискателя", + "etap-tip-ne-podhodit": "тип не подходит", + "etap-globalnoe-isklyuchenie": "глобальное исключение", + "etap-ml": "ML", + "etap-ii": "ИИ-фильтр", + "etap-ii-spam": "ИИ: спам", + "etap-bez-summy": "без суммы", + "etap-propushcheno": "пропущено (выключено)", + "projdet": "пройдёт", + "itog-zablokirovano": "заблокировано", + "kartochka-popala-by-v-kolonku": "карточка попала бы в колонку {container}", + "sovpavshie-kriterii": "совпавшие критерии", + "razbor-teksta": "разбор текста", + "razbor-stek": "Стек", + "razbor-byudzhet": "Бюджет", + "razbor-tip": "Тип", + "najm": "найм", + "razovyj-zakaz": "разовый заказ", + "predpolozhitelno": "предположительно", + "bez-byudzheta": "без бюджета", "avtoperenos-v-arhiv": "Автоперенос в архив", "kartochki-kotorye-lezhat-v-sisteme-dolshe": "Карточки, которые лежат в системе дольше срока, уходят в колонку «Архив»", "srok-do-arhiva": "Срок до архива", diff --git a/src/frontend/src/store/settings.js b/src/frontend/src/store/settings.js index e8ca3dc..21e302b 100644 --- a/src/frontend/src/store/settings.js +++ b/src/frontend/src/store/settings.js @@ -312,16 +312,20 @@ export async function saveAiFilterPrompt() { } } -// двухэтапная проверка сообщения — реальный вызов бэкенда (этап 1 + этап 2) +// сухой прогон текста по конвейеру (стоп-фильтры → ML → ИИ) без создания карточки +// контракт: PipelineDryRunDto (passed, wouldCreateCard, targetContainer, matchHits, parsed, stages) export async function checkIncomingMessage(text) { try { return await api.post('/api/admin/check-message', { text: String(text || '') }) } catch (e) { toast(errMsg(e), { icon: 'x' }) return { - stage1: { pass: false, reason: t('settings.oshibka-zaprosa') }, - stage2: { pass: false, reason: null, skipped: true }, passed: false, + wouldCreateCard: false, + targetContainer: null, + matchHits: [], + parsed: null, + stages: [], } } }