using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Pipeline.Application;
///
/// Проход pump — partial-часть (C32: выделено из общего файла,
/// поведение не менялось): PumpOnceAsync и «new»/«filtered»-проходы батчей (pump_once L890–1183, Ruling 8).
///
public sealed partial class PipelineWorkerService
{
// ── Проход воркера ─────────────────────────────────────────────────────
///
/// Один проход по очереди: батч «new» (правила/дедуп/ML) → батч «filtered» (ИИ) (pump_once L890–917).
///
///
/// Порядок и счётчики 1:1 с _pump_unlocked L920–1183. Строки, переведённые в filtered «new»-проходом,
/// видит «filtered»-батч того же вызова (как в прототипе: запросы на общем соединении). Публикацию new_card
/// воркер НЕ делает — она живёт в Api-слое (Ruling 8/9: из PumpOnce созданные карточки возвращаются в
/// , публикует вызывающий — admin/tick или фоновый цикл T11).
/// Исключения (хранилище/писатель) пробрасываются наружу — как в прототипе, очередь останавливается до
/// следующего тика, а упавшая строка остаётся в очереди со своим dedup-claim.
///
/// Токен отмены.
/// Сводка прохода: счётчики решений + созданные карточки (SSE new_card).
public async Task PumpOnceAsync(CancellationToken ct)
{
// Настройки воркера читаются ОДИН раз на pump (Ruling 8): переопределения тенанта — одним GetAllAsync,
// значения/дефолты резолвятся в снимок, который передаётся во все проверки сообщений батча и
// LocalFieldsParser (раньше настройки читались на каждое сообщение — до 16 чтений таблицы за pump).
WorkerRunSettings runSettings = await LoadRunSettingsAsync(_settings, ct);
var state = new PumpState();
await PumpNewPassAsync(state, runSettings, ct);
await PumpFilteredPassAsync(state, runSettings, ct);
await TrackDecisionsAsync(state, ct);
return state.ToResult();
}
// ── «new»-проход: правила → дедуп → ML (python L923–1065) ──────────────
// Батч сообщений статуса new: устарело/правила/повтор отсекаются, остальные — ML или в filtered.
// Настройки (сроки/флаги ML/ИИ/бюджета) — из снимка прохода: читаются один раз, не на каждое сообщение.
// state: Накопители результата pump (счётчики/карточки).
// run: Снимок настроек воркера на проход (Ruling 8).
// ct: Токен отмены.
private async Task PumpNewPassAsync(PumpState state, WorkerRunSettings run, CancellationToken ct)
{
IReadOnlyList rows = await _store.ListAsync(PipelineQueueStatuses.New, NewBatchLimit, ct);
foreach (QueueItemDto row in rows)
{
bool force = row.Force;
// Устарело: только не force; старше срока до автоархива — не заводим в систему (L929–932, L836–844).
if (!force && IsStaleAsync(row, run))
{
await DropStaleAsync(row, run, ct);
continue;
}
// Этап-1 правила: длина → стоп-фразы → резюме → тип (L933–939; source=stop, stage=kind).
if (!force)
{
IncomingRulesResult verdict = await _rules.CheckAsync(row.Text, ct);
if (!verdict.Pass)
{
await RejectRowAsync(row, SourceStop, verdict.Kind, verdict.Reason ?? string.Empty, verdict.Kw, ct);
await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct);
continue;
}
// Глобальные исключения тенанта (§5.14): стоп ДО дедупа/ML/ИИ, чтобы не тратить токены;
// причина называет конкретное сработавшее исключение (source=stop).
GlobalExclusionResult? exclusion = GlobalExclusionRules.Match(row.Text, run.GlobalExclusions);
if (exclusion is not null)
{
await RejectRowAsync(row, SourceStop, exclusion.Kind, exclusion.Reason, exclusion.Kw, ct);
await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct);
continue;
}
}
// Дедуп по нормализованному тексту: повтор — отсев «система»; иначе заявляем хэш (L940–951).
string digest = DedupHasher.Hash(row.Text);
if (await _store.ExistsAsync(digest, ct))
{
await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct);
await DropRowAsync(row, digest, withDedup: true, ct);
continue;
}
// Claim атомарен (INSERT … ON CONFLICT DO NOTHING): false — хэш уже заявлен ПАРАЛЛЕЛЬНЫМ проходом
// pump после нашей проверки ExistsAsync. Карточку не создаём (иначе два прохода завели бы две) —
// отсев «повтор» + снятие строки; чужую заявку НЕ удаляем (свяжется с карточкой победителя, Ruling 8).
if (!await _store.ClaimAsync(digest, ct))
{
await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct);
await _store.RemoveAsync(row.Id, ct);
continue;
}
state.Staged++;
// ML-слот (Ruling 5): включён (mlEnabled не false) и не force — force идёт мимо ML к ИИ (L963–965).
if (run.MlEnabled && !force)
{
MlPredictResultDto decision = await PredictSafelyAsync(row.Text, ct);
string? label = decision.Label;
// Модель готова и уверена: spam → отсев; доска без правил/не-suggested → карточка (L969–1025).
if (decision.Ready && decision.Take && !string.IsNullOrEmpty(label))
{
if (label == MlLearningLabels.Spam)
{
double score = decision.Scores.TryGetValue(MlLearningLabels.Spam, out double value) ? value : 0.0;
string reason = string.Format(
CultureInfo.InvariantCulture,
MlSpamReasonFormat,
score.ToString("F2", CultureInfo.InvariantCulture));
await RejectRowAsync(row, SourceMl, StageSpamMl, reason, string.Empty, ct);
await DropRowAsync(row, digest, withDedup: true, ct);
state.MlDrop++;
continue;
}
ContainerDto? board = await _kanjStore.GetContainerAsync(label, ct);
bool boardAllowed = board is not null && !board.Suggested && !ColumnRules.HasActiveRules(board.Rules);
if (boardAllowed)
{
// Карточка из локальных полей + доска ML + тип ML (если уверен) + термины в стек.
LocalParsedFields fields = _fieldsParser.Parse(row.Text, run.Settings);
AiParsedCardDto parsed = AiCardMapper.FromLocal(fields, row.Text) with { Board = label };
if (decision.Type is { Take: true } mlType)
{
parsed = parsed with
{
IsVacancy = mlType.Label == MlLearningLabels.TypeHireLabel,
IsVacancyKnown = true,
};
}
parsed = parsed with { Stack = MergeMlTerms(decision.Terms, parsed.Stack) };
if (await SkipNoBudgetAsync(parsed, row.Text, run, ct))
{
state.NoBudget++;
await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct);
await DropRowAsync(row, digest, withDedup: true, ct);
continue;
}
await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: false, ct);
continue;
}
}
// ML уверен в типе (t:hire/t:order), даже если колонку не назначил (L1026–1061).
if (decision.Ready && decision.Type is { Take: true } typeDecision)
{
bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel;
string wanted = run.WantedType;
// Тип не под режим «что собираем» — не тратим ИИ (typeDrop, L1029–1042).
if (wanted is WantedTypeVacancy or WantedTypeFreelance)
{
bool bad = (wanted == WantedTypeFreelance && isHire)
|| (wanted == WantedTypeVacancy && !isHire);
if (bad)
{
string kindName = isHire ? MlHireKindName : MlOrderKindName;
string reason = string.Format(MlTypeDropReasonFormat, kindName, wanted);
await RejectRowAsync(row, SourceMl, StageType, reason, string.Empty, ct);
await DropRowAsync(row, digest, withDedup: true, ct);
state.TypeDrop++;
continue;
}
}
// ИИ выключен, но тип ML знает — карточка сама (inbox; L1043–1061).
if (!run.AiEnabled)
{
LocalParsedFields fields = _fieldsParser.Parse(row.Text, run.Settings);
AiParsedCardDto parsed = AiCardMapper.FromLocal(fields, row.Text) with
{
IsVacancy = isHire,
IsVacancyKnown = true,
};
if (await SkipNoBudgetAsync(parsed, row.Text, run, ct))
{
state.NoBudget++;
await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct);
await DropRowAsync(row, digest, withDedup: true, ct);
continue;
}
await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: false, ct);
continue;
}
}
}
// ML не решил — сообщение ждёт ИИ (L1062–1065).
await _store.SetStatusAsync(row.Id, PipelineQueueStatuses.Filtered, ct);
}
}
// ── «filtered»-проход: ИИ-фильтр → классификация → карточка/отсев (L1067–1180) ──
// Батч сообщений статуса filtered: локальный путь (aiEnabled=false) или фильтр+классификация ИИ.
// Настройки — из снимка прохода (читаются один раз, не на каждое сообщение).
// state: Накопители результата pump (счётчики/карточки).
// run: Снимок настроек воркера на проход (Ruling 8).
// ct: Токен отмены.
private async Task PumpFilteredPassAsync(PumpState state, WorkerRunSettings run, CancellationToken ct)
{
IReadOnlyList rows = await _store.ListAsync(PipelineQueueStatuses.Filtered, FilteredBatchLimit, ct);
foreach (QueueItemDto row in rows)
{
bool force = row.Force;
// Устарело: как в «new»-проходе, только не force (L1073–1076).
if (!force && IsStaleAsync(row, run))
{
await DropStaleAsync(row, run, ct);
continue;
}
string text = row.Text;
string digest = DedupHasher.Hash(text);
// ИИ выключен: классификацию/фильтр не зовём — карточку собирает локальный разбор (L1079–1096).
if (!run.AiEnabled)
{
// Карточку собирает локальный разбор (без вызова порта ИИ).
AiParsedCardDto localParsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text);
if (!force && await SkipNoBudgetAsync(localParsed, text, run, ct))
{
state.NoBudget++;
await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct);
await DropRowAsync(row, digest, withDedup: true, ct);
continue;
}
await CreateCardAndDropAsync(state, localParsed, row, digest, isAiPath: true, ct);
continue;
}
// ИИ-фильтр (этап 2): возврат из отсева фильтр не пересматривает; выключен/сбой — пропуск (L1097–1106).
AiFilterResultDto filter;
if (force)
{
filter = PassSkipped;
}
else
{
bool filterEnabled = run.AiFilterEnabled;
filter = filterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped;
}
// Классификация: только если фильтр пропустил; сбой — «разбора нет» (локальный путь ниже).
AiParsedCardDto? parsed = null;
if (filter.Pass)
{
try
{
parsed = await _aiClassifier.ClassifyAsync(text, ct);
}
catch (Exception)
{
// Классификатор недоступен/сбой — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail.
parsed = null;
}
// python L1108–1111: успешная классификация ИИ — тип определён по контексту (не маркерной
// эвристикой). Стемп ставится ДО создания карточки (карточка получает is_vacancy_known=true) и
// переживает force-отмену вердикта «спам»; локальный разбор aiFail/aiEnabled=false стемпа не имеет.
if (parsed is not null)
{
parsed = parsed with { IsVacancyKnown = true };
}
}
// Вердикт «спам»: не прошёл фильтр либо классификатор пометил мусором (L1116–1121). Возврат (force)
// отменяет вердикт — пользователь уже подтвердил релевантность, карточку создаём без обучения «спаму».
bool isSpam = !filter.Pass || (parsed?.IsSpam ?? false);
if (force && filter.Pass && parsed?.IsSpam == true)
{
parsed = parsed with { IsSpam = false };
isSpam = false;
}
if (isSpam)
{
// Отсев spam_ai / filter_ai + обучение ML отличать спам (вес гипотезы, L1122–1138).
string stage = !filter.Pass ? StageFilterAi : StageSpamAi;
string reason = !filter.Pass
? AiFilterReasonPrefix + (string.IsNullOrEmpty(filter.Reason) ? AiFilterDefaultReason : filter.Reason)
: AiSpamReason;
await RejectRowAsync(row, SourceAi, stage, reason, string.Empty, ct);
await _mlClient.PushAsync(text, MlLearningLabels.Spam, MlLearningLabels.AiPushWeight, ct);
await DropRowAsync(row, digest, withDedup: true, ct);
state.AiDrop++;
continue;
}
if (parsed is null)
{
// ИИ не дал разбора (сбой), но не спам — локальный разбор (L1139–1142).
parsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text);
state.AiFail++;
}
// Глобальный фильтр «без суммы» (не force; Ruling 8, L1143–1147).
if (!force && await SkipNoBudgetAsync(parsed, text, run, ct))
{
state.NoBudget++;
await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct);
await DropRowAsync(row, digest, withDedup: true, ct);
continue;
}
CardDto card = await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: true, ct);
await LearnFromAiCardAsync(card, parsed, text, ct);
}
}
}