Добавить сухой прогон текста по конвейеру

POST /api/admin/check-message прогоняет текст через стоп-правила, глобальные исключения, ML и ИИ без создания карточки (PipelineWorkerService.DryRunAsync), отдаёт этапы, разбор и целевой контейнер. UI тестера в настройках переведён на новый контракт. Зафиксированы решения по вложениям (медиа-посты пропускаем).
This commit is contained in:
Rustam Khalimov
2026-09-11 17:08:34 +03:00
parent 2b25915790
commit c06ee1cf79
13 changed files with 600 additions and 55 deletions
+1 -1
View File
@@ -58,7 +58,7 @@
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT | | TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | 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-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-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-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
| TD-APIMAP-COUNT | Ручной подсчёт числа операторских ручек в `api-map` (может расходиться с группировкой) | docs sweep | P3 | TECHDEBT | | TD-APIMAP-COUNT | Ручной подсчёт числа операторских ручек в `api-map` (может расходиться с группировкой) | docs sweep | P3 | TECHDEBT |
+6 -3
View File
@@ -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 отдаёт приоритет литералам, порядок сохранён для читаемости | | Статические сегменты против `{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_<dialog>_<msg>` — сообщение. Контейнеры-стадии/служебные зоны — без префикса (`planned``rejected`, `inbox`/`archive`/`trash`) | | Prefix'ы id | `c_` — карточка (единый для всех дашбордов), `b_` — контейнер-колонка, `p_` — строка очереди, `r_` — запись отсева, `cm_` — комментарий, `h_` — запись истории, `pf_` — файл, `pl_*` — ссылка, `dt_` — discovery-задача, `dl_` — запись лога, `m_<dialog>_<msg>` — сообщение. Контейнеры-стадии/служебные зоны — без префикса (`planned``rejected`, `inbox`/`archive`/`trash`) |
| Служебные админ | `admin/tick`, `admin/fts/rebuild`, `admin/check-message` — служебные, фронтом не вызываются | | Служебные админ | `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 мин) | | Фоновые циклы (не 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: <dict pump_once>, queue: int}` | | `POST /admin/tick` | Ручной тик: хранение+напоминания+разбор очереди (фронт зовёт раз в 60 с) | `{storage: {archived, purgedArchive, purgedTrash, purgedRejected}, reminders: [{id, title, containerId}] (уже «выстрелившие», после SSE), pipeline: <dict pump_once>, queue: int}` |
| `POST /admin/fts/rebuild` | Пересобрать FTS-индекс | `{ok: bool, ready: bool}` | | `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/wipe` | Полный сброс (карточки+ML+счётчики) | `{ok, cardsRemoved, ml: {ok}}` *(прототип)* |
| `POST /admin/clear-cards` | Очистить карточки/очереди без сброса ML | `{ok, cardsRemoved}` *(прототип)* | | `POST /admin/clear-cards` | Очистить карточки/очереди без сброса ML | `{ok, cardsRemoved}` *(прототип)* |
| `POST /admin/pump-gate` | Шлагбаум воркера `{limit?}` | `{ok, limit, done}` *(прототип)* | | `POST /admin/pump-gate` | Шлагбаум воркера `{limit?}` | `{ok, limit, done}` *(прототип)* |
@@ -375,7 +375,10 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
### 4.10 Прочее ### 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`. - **`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?}`. - **`POST /api/ai/check`**: `{ok: bool, message: string, local?, keySet?}`.
- Комментарии карточки: `{id, by: string, text, time: string}``by` всегда «Вы», `time` «только что». - Комментарии карточки: `{id, by: string, text, time: string}``by` всегда «Вы», `time` «только что».
+3 -2
View File
@@ -9,8 +9,9 @@
> больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции > больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции
> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и > пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и
> `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`, > `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`,
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Ядро: build 5 sln 0/0, > `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
> `Deal.Tests.Unit` **1289/1289 PASS**, telegram **125/125**, фронт `build` + `lint:i18n` зелёные. > (стоп-правила → 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`. > Детали — `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-специфики
@@ -1,6 +1,29 @@
# Открытые вопросы: вложения источников (media → Storage) и просмотр исходника # Открытые вопросы: вложения источников (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. Что уже готово (не требует решений) ## 1. Что уже готово (не требует решений)
@@ -756,6 +756,11 @@ health — `GET /api/health` → `{"ok":true,"service":"deal"}`.
reason, skipped}, passed}`; этап-1 правила из настроек (длина/стоп-фразы/резюме/тип); ИИ-фильтр reason, skipped}, passed}`; этап-1 правила из настроек (длина/стоп-фразы/резюме/тип); ИИ-фильтр
тестера на этапе 2 всегда `skipped:true`. тестера на этапе 2 всегда `skipped:true`.
> **Актуально с 2026-09-11:** тестер стал сухим прогоном по всему конвейеру
> (`PipelineWorkerService.DryRunAsync`): `{passed, wouldCreateCard, targetContainer, matchHits, parsed,
> stages[]}` — стоп-правила → глобальные исключения → ML (спам/тип) → ИИ-фильтр/классификация → «без
> суммы»; без записи в систему. UI — вкладка настроек «Стоп-слова».
### 4c. Эндпоинты этапа 3 (канбан/дашборд; сессия `deal_session` обязательна, иначе 401) ### 4c. Эндпоинты этапа 3 (канбан/дашборд; сессия `deal_session` обязательна, иначе 401)
> На этапе 4 `POST /api/admin/tick` стал реальным (pipeline/pump/purge-отсева) и > На этапе 4 `POST /api/admin/tick` стал реальным (pipeline/pump/purge-отсева) и
@@ -1,11 +1,12 @@
using Deal.Api.Extensions; using Deal.Api.Extensions;
using Deal.Api.Services; 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; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тестер фильтра входящих /// Тестер конвейера: сухой прогон текста через стоп-фильтры, ML и ИИ
/// </summary> /// </summary>
public static class FilterTesterEndpoints public static class FilterTesterEndpoints
{ {
@@ -27,6 +28,7 @@ public static class FilterTesterEndpoints
return app; return app;
} }
// POST /api/admin/check-message: прогон текста по этапам конвейера без записи карточки.
private static async Task<IResult> CheckAsync( private static async Task<IResult> CheckAsync(
CheckMessageRequest body, CheckMessageRequest body,
HttpContext context, HttpContext context,
@@ -37,25 +39,9 @@ public static class FilterTesterEndpoints
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
// Резолв после 401-гейта: IncomingRules — scoped на TenantDbContext (tenant-контекст запроса). // Резолв после 401-гейта: воркер scoped на TenantDbContext (tenant-контекст запроса).
IncomingRules incomingRules = context.RequestServices.GetRequiredService<IncomingRules>(); PipelineWorkerService worker = context.RequestServices.GetRequiredService<PipelineWorkerService>();
IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct); PipelineDryRunDto result = await worker.DryRunAsync(body.Text ?? string.Empty, ct);
return Results.Ok(result);
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,
});
} }
} }
@@ -0,0 +1,21 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Результат сухого прогона текста по конвейеру (без записи в систему).
/// </summary>
/// <param name="Passed">True — текст прошёл все включённые этапы.</param>
/// <param name="WouldCreateCard">True — из текста была бы создана карточка.</param>
/// <param name="TargetContainer">Контейнер, в который попала бы карточка, если она создаётся.</param>
/// <param name="MatchHits">Совпавшие критерии правил целевого контейнера.</param>
/// <param name="Parsed">Разбор текста (поля карточки), если прогон дошёл до сборки.</param>
/// <param name="Stages">Результаты этапов в порядке прохождения.</param>
public sealed record PipelineDryRunDto(
bool Passed,
bool WouldCreateCard,
string? TargetContainer,
IReadOnlyList<MatchHitDto> MatchHits,
AiParsedCardDto? Parsed,
IReadOnlyList<PipelineDryRunStageDto> Stages);
@@ -0,0 +1,18 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Результат одного этапа сухого прогона текста по конвейеру.
/// </summary>
/// <param name="Stage">Код этапа: length|stop|resume|type|exclude|stale|ml|ai|budget.</param>
/// <param name="Pass">True — этап пропустил текст дальше.</param>
/// <param name="Skipped">True — этап выключен настройкой и не выполнялся.</param>
/// <param name="Reason">Причина блокировки (пусто — блокировки нет).</param>
/// <param name="Kw">Сработавшее слово/фраза правила, если есть.</param>
/// <param name="Label">Метка решения ML/ИИ, если применимо.</param>
public sealed record PipelineDryRunStageDto(
string Stage,
bool Pass,
bool Skipped,
string Reason,
string? Kw = null,
string? Label = null);
@@ -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;
/// <summary>
/// Сухой прогон текста по конвейеру — partial-часть <see cref="PipelineWorkerService"/>
/// </summary>
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 уверен, что это спам/не заявка";
/// <summary>
/// Прогоняет текст по этапам конвейера без записи в систему.
/// </summary>
/// <param name="text">Текст для проверки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результаты этапов, разбор и целевой контейнер (если карточка была бы создана).</returns>
public async Task<PipelineDryRunDto> DryRunAsync(string text, CancellationToken ct)
{
string trimmed = (text ?? string.Empty).Trim();
var stages = new List<PipelineDryRunStageDto>();
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<MatchHitDto> hits) = await ResolveTargetAsync(parsed, trimmed, run, ct);
return new PipelineDryRunDto(true, true, target, hits, parsed, stages);
}
// Этап ML: спам-вердикт и несовпадение типа возвращают отказ, иначе этап отмечен пройденным.
private async Task<PipelineDryRunDto?> CheckMlAsync(
string text,
WorkerRunSettings run,
List<PipelineDryRunStageDto> 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<AiParsedCardDto?> ResolveParsedAsync(
string text,
WorkerRunSettings run,
List<PipelineDryRunStageDto> 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<MatchHitDto> 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<string, double> 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<PipelineDryRunStageDto> 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,
};
}
@@ -664,4 +664,198 @@ public sealed class PipelineWorkerServiceTests
ctx.MlClient.Pushed); ctx.MlClient.Pushed);
Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); 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<string, double> { ["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<string>(),
new[] { "python" },
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<string>(),
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);
}
} }
@@ -14,6 +14,59 @@ const testText = ref(
) )
const testResult = ref(null) 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(() => [ const wantedOptions = computed(() => [
{ id: 'both', label: t('settings.wantedBoth') }, { id: 'both', label: t('settings.wantedBoth') },
@@ -346,28 +399,42 @@ function useGoodSample() {
<div v-if="testResult" class="mt-3 space-y-2 text-[12.5px]"> <div v-if="testResult" class="mt-3 space-y-2 text-[12.5px]">
<div <div
v-for="(stage, i) in testResult.stages"
:key="i"
class="flex items-start gap-2 rounded-lg px-3 py-2 border" class="flex items-start gap-2 rounded-lg px-3 py-2 border"
:class="testResult.stage1.pass ? 'border-online/25 bg-online/5' : 'border-danger/25 bg-danger/5'" :class="stage.skipped ? 'border-white/10 bg-white/[0.02]' : stage.pass ? 'border-online/25 bg-online/5' : 'border-danger/25 bg-danger/5'"
> >
<Icon :name="testResult.stage1.pass ? 'check' : 'x'" :size="14" class="mt-0.5 shrink-0" :class="testResult.stage1.pass ? 'text-online' : 'text-danger'" /> <Icon :name="stageIcon(stage)" :size="14" class="mt-0.5 shrink-0" :class="stageIconClass(stage)" />
<span class="text-low font-semibold shrink-0">{{ $t('settings.etap-1-stop-frazy') }}</span> <span class="text-low font-semibold shrink-0">{{ stageLabel(stage.stage) }}</span>
<span :class="testResult.stage1.pass ? 'text-mid' : 'text-danger'"> <span :class="stage.skipped ? 'text-low' : stage.pass ? 'text-mid' : 'text-danger'">
{{ testResult.stage1.pass ? $t('settings.projden') : $t('settings.zablokirovano') + testResult.stage1.reason }} <template v-if="stage.skipped">{{ $t('settings.etap-propushcheno') }}</template>
<template v-else-if="!stage.pass">{{ stage.reason || $t('settings.itog-zablokirovano') }}</template>
<template v-else>{{ $t('settings.projden') }}</template>
</span> </span>
<span v-if="stage.kw" class="text-low shrink-0">· {{ stage.kw }}</span>
<span v-if="stage.label" class="text-low shrink-0">· {{ stage.label }}</span>
</div> </div>
<div
v-if="!testResult.stage2.skipped" <div class="text-[11.5px] mt-1" :class="testResult.passed ? 'text-online' : 'text-danger'">
class="flex items-start gap-2 rounded-lg px-3 py-2 border" {{ testResult.passed ? $t('settings.projdet') : $t('settings.itog-zablokirovano') }}
:class="testResult.stage2.pass ? 'border-online/25 bg-online/5' : 'border-danger/25 bg-danger/5'"
>
<Icon :name="testResult.stage2.pass ? 'check' : 'x'" :size="14" class="mt-0.5 shrink-0" :class="testResult.stage2.pass ? 'text-online' : 'text-danger'" />
<span class="text-low font-semibold shrink-0">{{ $t('settings.etap-2-ii-filtr') }}</span>
<span :class="testResult.stage2.pass ? 'text-mid' : 'text-danger'">
{{ testResult.stage2.pass ? $t('settings.projden') : $t('settings.zablokirovano') + testResult.stage2.reason }}
</span>
</div> </div>
<div class="text-[11.5px] mt-1" :class="testResult.passed ? 'text-online' : 'text-low'">
{{ testResult.passed ? $t('settings.propushcheno-soobshchenie-uhodit-v') : $t('settings.otbrosheno-v-sistemu-ne-popadaet') }} <div v-if="testResult.wouldCreateCard" class="rounded-lg border border-white/10 bg-ink/40 px-3 py-2 space-y-1.5">
<div class="text-mid">{{ $t('settings.kartochka-popala-by-v-kolonku', { container: containerName(testResult.targetContainer) }) }}</div>
<div v-if="testResult.matchHits?.length">
<div class="text-low">{{ $t('settings.sovpavshie-kriterii') }}</div>
<div v-for="(hit, j) in testResult.matchHits" :key="j" class="text-mid">
{{ hit.label }}<template v-if="hit.term">: {{ hit.term }}</template><template v-if="hit.word"> ({{ hit.word }})</template>
</div>
</div>
</div>
<div v-if="testResult.parsed" class="rounded-lg border border-white/10 bg-ink/40 px-3 py-2 space-y-1">
<div class="text-low">{{ $t('settings.razbor-teksta') }}</div>
<div v-if="testResult.parsed.title" class="text-mid">{{ testResult.parsed.title }}</div>
<div v-if="parsedStack(testResult.parsed)" class="text-mid">{{ $t('settings.razbor-stek') }}: {{ parsedStack(testResult.parsed) }}</div>
<div class="text-mid">{{ $t('settings.razbor-byudzhet') }}: {{ parsedBudget(testResult.parsed.budget) }}</div>
<div class="text-mid">{{ $t('settings.razbor-tip') }}: {{ parsedKind(testResult.parsed) }}</div>
</div> </div>
</div> </div>
</div> </div>
+23 -6
View File
@@ -511,9 +511,6 @@ export const ru = {
"analiziruyu": "Анализирую…", "analiziruyu": "Анализирую…",
"predlozhit-ii": "Предложить ИИ", "predlozhit-ii": "Предложить ИИ",
"projden": "пройден", "projden": "пройден",
"zablokirovano": "заблокировано — ",
"propushcheno-soobshchenie-uhodit-v": "✓ Пропущено — сообщение уходит в классификацию",
"otbrosheno-v-sistemu-ne-popadaet": "✕ Отброшено — в систему не попадает",
"ml-spravlyaetsya-ii-mozhno-otklyuchit": "ML справляется — ИИ можно отключить", "ml-spravlyaetsya-ii-mozhno-otklyuchit": "ML справляется — ИИ можно отключить",
"ml-proveryaetsya-na-vashih-dejstviyah": "ML проверяется на ваших действиях", "ml-proveryaetsya-na-vashih-dejstviyah": "ML проверяется на ваших действиях",
"ml-gotova-no-podtverzhdenij-poka-net": "ML готова, но подтверждений пока нет", "ml-gotova-no-podtverzhdenij-poka-net": "ML готова, но подтверждений пока нет",
@@ -661,10 +658,30 @@ export const ru = {
"sohranit-prompt-filtra": "Сохранить промпт фильтра", "sohranit-prompt-filtra": "Сохранить промпт фильтра",
"sbrosit-k-primeru": "Сбросить к примеру", "sbrosit-k-primeru": "Сбросить к примеру",
"proverka-filtra": "Проверка фильтра", "proverka-filtra": "Проверка фильтра",
"vstavte-tekst-soobshcheniya-pokazhem": "Вставьте текст сообщения — покажем, пройдёт ли он оба этапа (этап 2 — реальный запрос к ИИ)", "vstavte-tekst-soobshcheniya-pokazhem": "Вставьте текст сообщения — прогоним по всему конвейеру (стоп-фильтры → ML → ИИ) и покажем, на каком этапе он остановится",
"primer-realnoj-zayavki": "пример реальной заявки", "primer-realnoj-zayavki": "пример реальной заявки",
"etap-1-stop-frazy": "Этап 1 · стоп-фразы:", "etap-dlina": "короткое сообщение",
"etap-2-ii-filtr": "Этап 2 · ИИ-фильтр:", "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": "Автоперенос в архив", "avtoperenos-v-arhiv": "Автоперенос в архив",
"kartochki-kotorye-lezhat-v-sisteme-dolshe": "Карточки, которые лежат в системе дольше срока, уходят в колонку «Архив»", "kartochki-kotorye-lezhat-v-sisteme-dolshe": "Карточки, которые лежат в системе дольше срока, уходят в колонку «Архив»",
"srok-do-arhiva": "Срок до архива", "srok-do-arhiva": "Срок до архива",
+7 -3
View File
@@ -312,16 +312,20 @@ export async function saveAiFilterPrompt() {
} }
} }
// двухэтапная проверка сообщения — реальный вызов бэкенда (этап 1 + этап 2) // сухой прогон текста по конвейеру (стоп-фильтры → ML → ИИ) без создания карточки
// контракт: PipelineDryRunDto (passed, wouldCreateCard, targetContainer, matchHits, parsed, stages)
export async function checkIncomingMessage(text) { export async function checkIncomingMessage(text) {
try { try {
return await api.post('/api/admin/check-message', { text: String(text || '') }) return await api.post('/api/admin/check-message', { text: String(text || '') })
} catch (e) { } catch (e) {
toast(errMsg(e), { icon: 'x' }) toast(errMsg(e), { icon: 'x' })
return { return {
stage1: { pass: false, reason: t('settings.oshibka-zaprosa') },
stage2: { pass: false, reason: null, skipped: true },
passed: false, passed: false,
wouldCreateCard: false,
targetContainer: null,
matchHits: [],
parsed: null,
stages: [],
} }
} }
} }