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); } } }