Добавить сухой прогон текста по конвейеру
POST /api/admin/check-message прогоняет текст через стоп-правила, глобальные исключения, ML и ИИ без создания карточки (PipelineWorkerService.DryRunAsync), отдаёт этапы, разбор и целевой контейнер. UI тестера в настройках переведён на новый контракт. Зафиксированы решения по вложениям (медиа-посты пропускаем).
This commit is contained in:
+1
-1
@@ -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
@@ -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` «только что».
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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": "Срок до архива",
|
||||||
|
|||||||
@@ -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: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user