Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
337 lines
19 KiB
C#
337 lines
19 KiB
C#
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);
|
||
}
|
||
}
|
||
|
||
}
|