Files
Deal/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Pump.cs
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

337 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Проход pump — partial-часть <see cref="PipelineWorkerService"/> (C32: выделено из общего файла,
/// поведение не менялось): PumpOnceAsync и «new»/«filtered»-проходы батчей (pump_once L890–1183, Ruling 8).
/// </summary>
public sealed partial class PipelineWorkerService
{
// ── Проход воркера ─────────────────────────────────────────────────────
/// <summary>
/// Один проход по очереди: батч «new» (правила/дедуп/ML) → батч «filtered» (ИИ) (pump_once L890–917).
/// </summary>
/// <remarks>
/// Порядок и счётчики 1:1 с <c>_pump_unlocked</c> L920–1183. Строки, переведённые в filtered «new»-проходом,
/// видит «filtered»-батч того же вызова (как в прототипе: запросы на общем соединении). Публикацию new_card
/// воркер НЕ делает — она живёт в Api-слое (Ruling 8/9: из PumpOnce созданные карточки возвращаются в
/// <see cref="PipelinePumpResult.CreatedCards"/>, публикует вызывающий — admin/tick или фоновый цикл T11).
/// Исключения (хранилище/писатель) пробрасываются наружу — как в прототипе, очередь останавливается до
/// следующего тика, а упавшая строка остаётся в очереди со своим dedup-claim.
/// </remarks>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сводка прохода: счётчики решений + созданные карточки (SSE new_card).</returns>
public async Task<PipelinePumpResult> 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<QueueItemDto> 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<QueueItemDto> 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);
}
}
}