Почистить комментарии от ссылок на ТЗ и обрывков
Удаление целых //-блоков со ссылками (Task/Ruling/этап/ТЗ/§/ дизайн-док/api-map/python/прототип) вместо построчного вырезания — без обрывков фраз; снят боилерплейт <param>/<returns>; то же в .proto.
This commit is contained in:
@@ -15,7 +15,6 @@ public sealed class ConnectionStringProvider
|
||||
{
|
||||
_baseConnectionString = configuration.GetConnectionString("DealPostgres")
|
||||
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
|
||||
// Опциональная роль мигратора: задаётся только в проде (см. техдок §10/§13).
|
||||
_migratorConnectionString = configuration.GetConnectionString("DealMigrator");
|
||||
}
|
||||
|
||||
|
||||
@@ -151,11 +151,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
}
|
||||
}
|
||||
|
||||
// request: Запрос проверки (поля статуса провайдера).
|
||||
// name: Имя провайдера из каталога AiProviders.
|
||||
// ok: Результат подключения.
|
||||
// message: Сообщение ветки.
|
||||
// Возвращает: DTO ответа (наружу — camelCase).
|
||||
private static AiCheckResultDto BuildResult(
|
||||
AiCheckRequest request,
|
||||
string name,
|
||||
@@ -174,8 +169,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
KeyMasked: MaskKey(request.ApiKey));
|
||||
}
|
||||
|
||||
// key: Ключ открытым текстом.
|
||||
// Возвращает: Маскированная строка.
|
||||
private static string MaskKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
@@ -191,10 +184,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4));
|
||||
}
|
||||
|
||||
// baseUrl: Эффективный базовый URL из конфигурации провайдера.
|
||||
// apiStyle: Стиль API провайдера (null — OpenAI-совместимый).
|
||||
// modelsUri: URL списка моделей (валиден только при возврате true).
|
||||
// Возвращает: True — URL построен; False — base URL не абсолютный http(s) (SSRF-гейт).
|
||||
private static bool TryBuildModelsUri(
|
||||
string baseUrl,
|
||||
string? apiStyle,
|
||||
|
||||
@@ -87,8 +87,6 @@ public sealed class AiProviderConfigBuilder
|
||||
return config;
|
||||
}
|
||||
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Id провайдера (каталога AiProviders).
|
||||
private async Task<string> ReadProviderIdAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct);
|
||||
|
||||
@@ -62,7 +62,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
|
||||
return await _paidClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
|
||||
// Запрет гейта — фильтр через Local-реализацию {pass:true, skipped:true} (семантика «фильтр недоступен»,
|
||||
_logger.LogDebug(
|
||||
"ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.FilterAsync(text, ct);
|
||||
@@ -76,14 +75,11 @@ public sealed class BudgetedAiClassifier : IAiClassifier
|
||||
return await _paidClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
// без платного ИИ, приём не блокируется.
|
||||
_logger.LogDebug(
|
||||
"ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — можно звать платного исполнителя.
|
||||
private async Task<bool> IsPaidAllowedAsync(CancellationToken ct)
|
||||
{
|
||||
BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
|
||||
|
||||
@@ -90,8 +90,6 @@ public sealed class BudgetedAiTools : IAiTools
|
||||
state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError);
|
||||
}
|
||||
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Состояние бюджета тенанта на сейчас.
|
||||
private async Task<BudgetStateDto> GateStateAsync(CancellationToken ct)
|
||||
=> await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
|
||||
|
||||
|
||||
@@ -100,9 +100,6 @@ public sealed class CbrRateSource : IRatesSource
|
||||
return rates;
|
||||
}
|
||||
|
||||
// currency: Пара «код валюты → объект {Value, Nominal}».
|
||||
// rate: Курс единицы валюты к рублю (валиден при возврате true).
|
||||
// Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой).
|
||||
private static bool TryParseCurrency(JsonProperty currency, out double rate)
|
||||
{
|
||||
rate = 0;
|
||||
|
||||
@@ -89,7 +89,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
CallOptions(tenantId.Value, ct));
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
|
||||
// Фильтр применён (воркер звал его только при aiFilterEnabled и не force) — skipped=false
|
||||
return new AiFilterResultDto(
|
||||
Pass: reply.Pass,
|
||||
Reason: reply.HasReason ? reply.Reason : null,
|
||||
@@ -142,7 +141,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
if (!reply.Ok)
|
||||
{
|
||||
// Модель не вернула разбираемый JSON после ретраев — контрактная ok=false (README ai.proto):
|
||||
_logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(NoJsonAnswerText);
|
||||
}
|
||||
@@ -167,27 +165,18 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// ct: Токен отмены вызова.
|
||||
// Возвращает: Опции вызова с заголовками, deadline и отменой.
|
||||
private CallOptions CallOptions(string tenantId, CancellationToken ct)
|
||||
=> new(
|
||||
headers: _connection.CreateMetadata(tenantId),
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
// секреты/тела ответов не логируются и в текст не попадают).
|
||||
// exception: Исключение RPC-вызова.
|
||||
// Возвращает: Текст ошибки.
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
// text: Строка.
|
||||
// max: Лимит.
|
||||
// Возвращает: Усечённая строка.
|
||||
private static string SliceCodePoints(string text, int max)
|
||||
{
|
||||
return text.Length <= max ? text : SliceByCodePoints(text, max);
|
||||
|
||||
@@ -158,27 +158,18 @@ public sealed class GrpcAiTools : IAiTools
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// ct: Токен отмены вызова.
|
||||
// Возвращает: Опции вызова с заголовками, deadline и отменой.
|
||||
private CallOptions CallOptions(string tenantId, CancellationToken ct)
|
||||
=> new(
|
||||
headers: _connection.CreateMetadata(tenantId),
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
// секреты/тела ответов не логируются и в текст не попадают).
|
||||
// exception: Исключение RPC-вызова.
|
||||
// Возвращает: Текст ошибки.
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
// text: Строка.
|
||||
// max: Лимит.
|
||||
// Возвращает: Усечённая строка.
|
||||
private static string SliceCodePoints(string text, int max)
|
||||
{
|
||||
if (text.Length <= max)
|
||||
|
||||
@@ -133,7 +133,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
new PredictRequest { Text = text ?? string.Empty },
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct));
|
||||
|
||||
// бюджет/lifetime AI-счётчик не затрагиваются (локальная модель бесплатна).
|
||||
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
|
||||
return MapPredict(reply);
|
||||
}
|
||||
@@ -246,8 +245,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
}
|
||||
}
|
||||
|
||||
// reply: Ответ ml-service.
|
||||
// Возвращает: DTO статуса модели.
|
||||
private static MlServiceStatusDto MapStatus(StatusReply reply)
|
||||
{
|
||||
return new MlServiceStatusDto(
|
||||
@@ -260,8 +257,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
Accuracy: reply.Eval?.Accuracy ?? 0.0));
|
||||
}
|
||||
|
||||
// reply: Ответ ml-service.
|
||||
// Возвращает: DTO предсказания.
|
||||
private static MlPredictResultDto MapPredict(PredictReply reply)
|
||||
{
|
||||
return new MlPredictResultDto(
|
||||
@@ -289,10 +284,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
Margin: decision.Margin);
|
||||
}
|
||||
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// deadline: Лимит времени вызова.
|
||||
// ct: Токен отмены вызова.
|
||||
// Возвращает: Опции вызова с заголовками, deadline и отменой.
|
||||
private CallOptions CallOptions(
|
||||
string tenantId,
|
||||
TimeSpan deadline,
|
||||
@@ -327,8 +318,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
Learned: 0,
|
||||
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
|
||||
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
|
||||
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await _store.GetAsync(SettingsKeys.MlEnabled, ct);
|
||||
|
||||
@@ -398,11 +398,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcTelegramClient запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// TReply: Тип ответа RPC.
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// ct: Токен отмены вызова.
|
||||
// call: Вызов клиента (принимает клиент и CallOptions).
|
||||
// Возвращает: Ответ RPC.
|
||||
private async Task<TReply> CallAsync<TReply>(
|
||||
TenantId tenantId,
|
||||
TimeSpan deadline,
|
||||
@@ -418,13 +413,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
return await call(client, options);
|
||||
}
|
||||
|
||||
// Нормализует транспортные сбои в RpcException «Telegram не подключён»; RpcException домена — как есть.
|
||||
// Отмена по токену вызывающего пробрасывается без нормализации (не сбой сервиса). Доменные
|
||||
// RPC-ошибки (INVALID_ARGUMENT/FAILED_PRECONDITION/…) несут канонический detail — их трогать нельзя:
|
||||
// exception: Исключение вызова.
|
||||
// tenantId: Id тенанта (лог).
|
||||
// operation: Имя RPC (лог-аудит).
|
||||
// Возвращает: Исключение для проброса: транспортный сбой — нормализованный RpcException.
|
||||
private Exception TranslateTransportFailure(
|
||||
Exception exception,
|
||||
TenantId tenantId,
|
||||
@@ -436,8 +424,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
return exception;
|
||||
}
|
||||
|
||||
// Доменная RPC-ошибка сервиса (INVALID_ARGUMENT/FAILED_PRECONDITION/NOT_FOUND…) несёт канонический
|
||||
// Unavailable с detail (сервис сам ответил причиной) — тоже как есть.
|
||||
if (exception is RpcException rpc &&
|
||||
(rpc.StatusCode != StatusCode.Unavailable || !string.IsNullOrEmpty(rpc.Status.Detail)))
|
||||
{
|
||||
@@ -448,8 +434,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
return new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
|
||||
}
|
||||
|
||||
// entries: Записи каталога telegram-service.
|
||||
// Возвращает: Записи в форме контракта (username → handle).
|
||||
private static IReadOnlyList<TelegramDialogEntryDto> MapEntries(Google.Protobuf.Collections.RepeatedField<DialogEntry> entries)
|
||||
{
|
||||
return entries
|
||||
|
||||
@@ -84,7 +84,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
/// <inheritdoc />
|
||||
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
|
||||
{
|
||||
// свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
|
||||
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
|
||||
List<string> texts = cards
|
||||
.Where(card => card.Col != CardIds.Trash
|
||||
@@ -107,12 +106,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
|
||||
}
|
||||
|
||||
// inbox: Снимок «Неразобранного» (карточки планов берутся из него).
|
||||
// plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
|
||||
// Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
|
||||
// Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
|
||||
private async Task<int> StoreSuggestedColumnsAsync(
|
||||
IReadOnlyList<CardDto> inbox,
|
||||
IReadOnlyList<SuggestedColumnPlan> plans,
|
||||
@@ -176,9 +169,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
return created;
|
||||
}
|
||||
|
||||
// Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
|
||||
private async Task<bool> WithinCooldownAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
|
||||
@@ -204,7 +194,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
}
|
||||
}
|
||||
|
||||
// ct: Токен отмены.
|
||||
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
|
||||
settings.SetAsync(
|
||||
SettingsKeys.LastSuggestAt,
|
||||
|
||||
@@ -75,12 +75,9 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
|
||||
double delta,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// пустые — тихий no-op, text[:6000], id mle_+hex) — в MlOutboxQueue, общем для Local/Grpc-адаптеров.
|
||||
await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct);
|
||||
}
|
||||
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
|
||||
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await store.GetAsync(SettingsKeys.MlEnabled, ct);
|
||||
|
||||
@@ -4,8 +4,6 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
// тихий no-op, text обрезается до 6000 символов (без разрыва суррогатной пары), id — mle_ +
|
||||
// и в Local-, и в gRPC-режиме сигнал сначала пишется в outbox, отправку в ml-service делает фоновый
|
||||
internal static class MlOutboxQueue
|
||||
{
|
||||
internal const int MaxLearningTextLength = 6000;
|
||||
@@ -42,14 +40,9 @@ internal static class MlOutboxQueue
|
||||
ct);
|
||||
}
|
||||
|
||||
// Возвращает: Короткий id записи очереди.
|
||||
private static string NewOutboxId()
|
||||
=> KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant();
|
||||
|
||||
// Обрезает текст до MaxLearningTextLength символов, не разбивая суррогатную пару на конце.
|
||||
// text: Текст (уже trim-нут).
|
||||
// Возвращает: Первые 6000 символов (или весь текст, если короче).
|
||||
// (text[:6000]) режет по code points — хвостовой high-surrogate убираем, чтобы в БД не ушла «битая» пара.
|
||||
private static string TruncateText(string text)
|
||||
{
|
||||
if (text.Length <= MaxLearningTextLength)
|
||||
|
||||
@@ -76,7 +76,6 @@ public sealed class TokenUsageRecorder
|
||||
}
|
||||
|
||||
await AddToLifetimeAsync(usage, ct);
|
||||
// token_usage_events (без tenantId в метках).
|
||||
DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion);
|
||||
await RecordEventAsync(
|
||||
provider,
|
||||
@@ -102,7 +101,6 @@ public sealed class TokenUsageRecorder
|
||||
CancellationToken ct)
|
||||
{
|
||||
long promptTokens = EstimateTokens(text);
|
||||
// что и событие token_usage_events, kind=ml).
|
||||
DealMetrics.RecordMlUsage(promptTokens);
|
||||
await RecordEventAsync(
|
||||
provider,
|
||||
@@ -170,8 +168,6 @@ public sealed class TokenUsageRecorder
|
||||
return id;
|
||||
}
|
||||
|
||||
// usage: Оценка токенов ответа.
|
||||
// ct: Токен отмены.
|
||||
private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct)
|
||||
{
|
||||
JsonObject? current = await ReadAsync(ct);
|
||||
|
||||
@@ -38,10 +38,8 @@ public static class FileStorageRegistrar
|
||||
/// <summary>
|
||||
/// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации.
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов.</param>
|
||||
/// <param name="configuration">Конфигурация приложения (секция Storage + env-алиасы DEAL_MINIO_*).</param>
|
||||
/// <param name="contentRootPath">ContentRoot приложения — база для дефолтного корня data/attachments.</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealFileStorage(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
@@ -51,7 +49,6 @@ public static class FileStorageRegistrar
|
||||
|
||||
StorageOptions options = ReadOptions(configuration);
|
||||
|
||||
// недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO).
|
||||
if (options.Minio.IsConfigured())
|
||||
{
|
||||
services.AddSingleton<IFileStorage>(serviceProvider =>
|
||||
|
||||
@@ -40,8 +40,6 @@ public sealed class LocalFileStorage : IFileStorage
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
// может быть прочитан эндпоинтом раньше; перемотаемые потоки сбрасываем (неперемотаемые читаются
|
||||
// с текущей позиции, как есть). Выравнивание с Minio-адаптером PutAsync.
|
||||
if (content.CanSeek && content.Position != 0)
|
||||
{
|
||||
content.Position = 0;
|
||||
@@ -95,9 +93,6 @@ public sealed class LocalFileStorage : IFileStorage
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// objectKey: Ключ объекта (сегменты по '/', без «.»/«..»).
|
||||
// Возвращает: Полный путь файла под root.
|
||||
// Исключение ArgumentException: objectKey пуст либо содержит обходные сегменты «.»/«..».
|
||||
private string ResolvePath(string objectKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objectKey))
|
||||
|
||||
@@ -77,7 +77,6 @@ public sealed class MinioFileStorage : IFileStorage
|
||||
|
||||
await EnsureBucketAsync(ct);
|
||||
|
||||
// (Content-Length), поэтому поток буферизуется — Local-адаптер буферизации не требует.
|
||||
if (content.CanSeek && content.Position != 0)
|
||||
{
|
||||
content.Position = 0;
|
||||
@@ -134,7 +133,6 @@ public sealed class MinioFileStorage : IFileStorage
|
||||
{
|
||||
try
|
||||
{
|
||||
// → null-семантика порта). Иные ошибки (MinIO недоступен) уходят вызывающему — он мапит их в 404.
|
||||
ObjectStat stat = await _client.StatObjectAsync(
|
||||
new StatObjectArgs().WithBucket(_bucket).WithObject(objectKey),
|
||||
ct);
|
||||
@@ -167,7 +165,6 @@ public sealed class MinioFileStorage : IFileStorage
|
||||
}
|
||||
}
|
||||
|
||||
// ct: Токен отмены.
|
||||
private async Task EnsureBucketAsync(CancellationToken ct)
|
||||
{
|
||||
if (_bucketChecked)
|
||||
@@ -193,7 +190,6 @@ public sealed class MinioFileStorage : IFileStorage
|
||||
}
|
||||
catch (MinioException exception)
|
||||
{
|
||||
// Бакет не проверить/создать (MinIO недоступен и т.п.): put продолжит и упадёт с понятной
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Не удалось проверить/создать бакет MinIO «{Bucket}»: {Message}",
|
||||
|
||||
@@ -36,7 +36,6 @@ public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
|
||||
// Сортировка пространства «Выбранные» — updated_at DESC.
|
||||
builder.HasIndex(x => x.UpdatedAt).IsDescending();
|
||||
|
||||
// Полнотекстовый вектор карточки (russian): Title+Summary+SourceMsg+Contact — вычисляемая STORED-
|
||||
builder.Property(x => x.SearchTsv)
|
||||
.HasComputedColumnSql(
|
||||
"to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))",
|
||||
|
||||
@@ -107,8 +107,6 @@ public sealed class AuditLogStore(DealDbContext dbContext) : IAuditLogStore
|
||||
return query;
|
||||
}
|
||||
|
||||
// limit: Запрошенный размер.
|
||||
// Возвращает: Клампированное значение.
|
||||
private static int ClampLimit(int limit) => Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit));
|
||||
|
||||
private static AuditRecordDto ToAuditRecordDto(AuditLogEntity entity) =>
|
||||
|
||||
@@ -72,7 +72,6 @@ public sealed class InviteStore(DealDbContext dbContext) : IInviteStore
|
||||
DateTimeOffset activatedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// иначе параллельный отзыв/активация не перезаписываются (ExecuteUpdate выполняется одним оператором SQL).
|
||||
int affected = await dbContext.Invites
|
||||
.Where(i => i.Code == code && i.Status == InviteStatuses.Pending)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
|
||||
@@ -15,7 +15,6 @@ public sealed partial class KanbanStore
|
||||
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
|
||||
if (query.Col is null)
|
||||
{
|
||||
// в работу, живёт в том же пространстве «Выбранных», а не на дашборде.
|
||||
queryable = queryable.Where(card => !SelectedStageIds.Contains(card.Col));
|
||||
}
|
||||
else
|
||||
@@ -41,9 +40,6 @@ public sealed partial class KanbanStore
|
||||
return Array.Empty<CardDto>();
|
||||
}
|
||||
|
||||
// исключены, порядок — ts_rank DESC, ReceivedAt DESC, limit 12. plainto_tsquery со стоп-словами даёт
|
||||
// пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё равно отработает. Всё параметризовано
|
||||
// (никакой конкатенации пользовательского ввода); LIKE-паттерн — как в отсев-поиске PipelineStore.
|
||||
string pattern = $"%{query}%";
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.FromSqlInterpolated(
|
||||
@@ -133,7 +129,6 @@ public sealed partial class KanbanStore
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
|
||||
{
|
||||
// через ExecuteUpdate потребовало бы двух запросов, чтение со слежением проще и точнее.
|
||||
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
@@ -155,7 +150,6 @@ public sealed partial class KanbanStore
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
|
||||
{
|
||||
// валюты и отсутствие конверсии очищают поля (BudgetCur/ConvCur = ""), PrevCol/ArchivedAt не трогаются.
|
||||
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
@@ -205,7 +199,6 @@ public sealed partial class KanbanStore
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteForeverAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
// «сирота» заблокирует повторное создание карточки при перечитывании канала.
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await _dbContext.DedupEntries
|
||||
.Where(entry => entry.LeadId == cardId)
|
||||
@@ -219,7 +212,6 @@ public sealed partial class KanbanStore
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ClearColAsync(string col, CancellationToken ct)
|
||||
{
|
||||
// корзины/архива тоже должна снимать «сирот», иначе текст из удалённой карточки не пройдёт дедуп.
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await _dbContext.DedupEntries
|
||||
.Where(entry => _dbContext.Cards.Any(card => card.Col == col && card.Id == entry.LeadId))
|
||||
|
||||
@@ -30,8 +30,6 @@ public sealed partial class KanbanStore
|
||||
string text,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис,
|
||||
// прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void).
|
||||
_dbContext.LeadComments.Add(new LeadCommentEntity
|
||||
{
|
||||
Id = commentId,
|
||||
@@ -64,7 +62,6 @@ public sealed partial class KanbanStore
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
// действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми.
|
||||
return await _dbContext.CardMoves
|
||||
.AsNoTracking()
|
||||
.Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive)
|
||||
|
||||
@@ -34,7 +34,6 @@ public sealed partial class KanbanStore
|
||||
CardPatch patch,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Точечная правка по присутствующим полям патча: null-поле не меняется, JSON-поля заменяются
|
||||
CardEntity? entity = await _dbContext.Cards
|
||||
.SingleOrDefaultAsync(card => card.Id == cardId, ct);
|
||||
if (entity is null)
|
||||
@@ -152,7 +151,6 @@ public sealed partial class KanbanStore
|
||||
long atMs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Смена контейнера-стадии: Col=containerId, сброс напоминания, updated_at=atMs, история + запись
|
||||
CardEntity? entity = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(card => card.Id == cardId, ct);
|
||||
@@ -206,7 +204,6 @@ public sealed partial class KanbanStore
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ClearStageAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
// снимает каскад FK LeadComments → Cards.
|
||||
return await _dbContext.Cards
|
||||
.Where(card => card.Col == containerId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
@@ -13,7 +13,6 @@ public sealed partial class KanbanStore
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
// Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного»
|
||||
List<string> boardIds = await _dbContext.Containers
|
||||
.AsNoTracking()
|
||||
.Where(container => container.Kind == ContainerKinds.Board)
|
||||
@@ -34,7 +33,6 @@ public sealed partial class KanbanStore
|
||||
DateTimeOffset archivedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто;
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
@@ -94,7 +92,6 @@ public sealed partial class KanbanStore
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
|
||||
{
|
||||
// пересчёт читает только бюджетные/conv-поля, комментарии не нужны.
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.BudgetCur != string.Empty
|
||||
|
||||
@@ -204,7 +204,6 @@ public sealed partial class KanbanStore : ICardStore
|
||||
private static CardCommentDto ToCommentDto(LeadCommentEntity entity) =>
|
||||
new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt));
|
||||
|
||||
// timestamp: Время события (получения сообщения/добавления комментария).
|
||||
private static string HumanAge(DateTimeOffset timestamp)
|
||||
{
|
||||
long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds());
|
||||
|
||||
@@ -90,8 +90,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
|
||||
string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected);
|
||||
DateTimeOffset rejectedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Upsert по детерминированному id: повторное отбрасывание того же сообщения (dialog+msgId)
|
||||
// аудит возврата (returned/returnedAt/returnReason) при повторном отсеве переживает.
|
||||
await dbContext.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"""
|
||||
INSERT INTO "RejectedItems" ("Id", "DialogId", "MsgId", "ChannelName", "ChannelHandle", "ChannelHue", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason")
|
||||
@@ -139,7 +137,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
|
||||
return Array.Empty<RejectedItemDto>();
|
||||
}
|
||||
|
||||
// plainto_tsquery со стоп-словами даёт пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё
|
||||
List<RejectedItemEntity> fts = await dbContext.RejectedItems
|
||||
.FromSqlInterpolated(
|
||||
$"""
|
||||
@@ -154,7 +151,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
|
||||
var rows = fts.Select(ToRejectedItemDto).ToList();
|
||||
var seen = new HashSet<string>(fts.Select(item => item.Id), StringComparer.Ordinal);
|
||||
|
||||
// последнего перестроения вектора и слова, которых нет в словаре морфологии. limitLike — как
|
||||
string pattern = $"%{query}%";
|
||||
List<RejectedItemEntity> like = await dbContext.RejectedItems
|
||||
.AsNoTracking()
|
||||
@@ -236,8 +232,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
// NOTHING — параллельные дубли одного текста не проходят (сырой SQL: EF-вставка упала бы на PK).
|
||||
// Число вставленных строк — результат: 1 = заявка за этим вызовом, 0 = хэш уже заявлен другим
|
||||
int inserted = await dbContext.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"""
|
||||
INSERT INTO "DedupEntries" ("Hash", "LeadId", "CreatedAt")
|
||||
@@ -251,7 +245,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
// (LeadId IS NOT NULL), не трогаем — иначе повторный текст прошёл бы дедуп после отсева.
|
||||
await dbContext.DedupEntries
|
||||
.Where(entry => entry.Hash == hash && entry.LeadId == null)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
@@ -271,8 +264,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteByCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
// дублируется тем же DELETE (см. KanbanStore.DeleteForeverAsync/PurgeAsync/ClearColAsync) —
|
||||
// чтобы Kanban-адаптер не зависел от порта Pipeline; этот метод — для потребителей модуля.
|
||||
await dbContext.DedupEntries
|
||||
.Where(entry => entry.LeadId == cardId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
@@ -146,8 +146,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
|
||||
string hue,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// discovery-вступления — метаданные источника, monitor=TRUE, backfilled=FALSE (разбор подхватит
|
||||
// первый фоновый Backfill). Значения нормализованы вызывающим (DialogsService).
|
||||
DialogEntity? existing = await dbContext.Dialogs.FirstOrDefaultAsync(dialog => dialog.Id == dialogId, ct);
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
if (existing is null)
|
||||
@@ -234,8 +232,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
|
||||
row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null)).ToList();
|
||||
}
|
||||
|
||||
// row: Строка Dialogs.
|
||||
// Возвращает: Форма §4.8: type = kind (EN-канон), on = monitor, last {text, time}.
|
||||
private static TelegramDialogDto ToDialogDto(DialogEntity row)
|
||||
{
|
||||
long? lastAtMs = row.LastAt?.ToUnixTimeMilliseconds();
|
||||
|
||||
@@ -164,10 +164,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
|
||||
return true;
|
||||
}
|
||||
|
||||
// tenantId: Тенант.
|
||||
// defaults: Дефолт-параметры создаваемой строки.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Отслеживаемая строка лимита.
|
||||
private async Task<TenantLimitEntity> LoadOrCreateAsync(
|
||||
Guid tenantId,
|
||||
TokenLimitDefaults defaults,
|
||||
@@ -196,8 +192,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
|
||||
return created;
|
||||
}
|
||||
|
||||
// entity: Отслеживаемая строка лимита.
|
||||
// ct: Токен отмены.
|
||||
private async Task ResetIfPeriodExpiredAsync(TenantLimitEntity entity, CancellationToken ct)
|
||||
{
|
||||
if (ResetIfExpired(entity, _utcNow()))
|
||||
@@ -249,12 +243,8 @@ public sealed class TenantLimitStore : ITenantLimitStore
|
||||
return reset;
|
||||
}
|
||||
|
||||
// entity: Строка лимита (после ленивого reset).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Состояние бюджета тенанта.
|
||||
private async Task<BudgetStateDto> ToStateDtoAsync(TenantLimitEntity entity, CancellationToken ct)
|
||||
{
|
||||
// Статус тенанта — из public.tenants тем же контекстом; отсутствие строки трактуем suspended
|
||||
string? tenantStatus = await _dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Id == entity.TenantId)
|
||||
|
||||
@@ -30,62 +30,44 @@ public static class ServiceCollectionExtensions
|
||||
/// <summary>
|
||||
/// Регистрирует EF-адаптеры портов модулей
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов.</param>
|
||||
/// <param name="tenantLimitDefaults">Дефолт-бюджет лениво создаваемых строк tenant_limits. Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs.</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealPersistence(this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null)
|
||||
{
|
||||
services.AddScoped<IAuthStore, AuthStore>();
|
||||
|
||||
// схемы public — отдельный порт от IAuthStore (оператор ≠ пользователь тенанта).
|
||||
services.AddScoped<IOperatorAuthStore, OperatorAuthStore>();
|
||||
services.AddScoped<ITenantRepository, TenantRepository>();
|
||||
|
||||
services.AddScoped<IAuditLogStore, AuditLogStore>();
|
||||
|
||||
// + агрегаты для аналитики; порт без Update/Delete.
|
||||
services.AddScoped<ITokenUsageEventStore, TokenUsageEventStore>();
|
||||
|
||||
services.AddScoped<IInviteStore, InviteStore>();
|
||||
|
||||
// дефолт-бюджет из конфигурации (Program.cs) — ленивый GetOrCreate новой строки использует его;
|
||||
// DealDbContext уже зарегистрирован в Api (AddDbContext до AddDealPersistence).
|
||||
services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore(
|
||||
provider.GetRequiredService<DealDbContext>(),
|
||||
tenantLimitDefaults ?? TokenBudgetDefaults.Default));
|
||||
|
||||
// общее хранилище распределённого rate limiting (auth/api/gRPC) и учёта попыток входа. Scoped
|
||||
// (DealDbContext); лимитер резолвит его в собственном scope на каждое приобретение.
|
||||
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>();
|
||||
|
||||
// контекст — scoped TenantDbContext запроса (см. AddDbContext в Deal.Api).
|
||||
services.AddScoped<ISettingsStore, SettingsStore>();
|
||||
|
||||
// KV-хранилище глобальных (системных) настроек оператора (ТЗ §4.1/§8.1): таблица
|
||||
// public.global_settings, контекст — системный DealDbContext (public-схема). Ключи Telegram
|
||||
// задаёт оператор, ядро читает их для команд входа (TelegramKeysService).
|
||||
services.AddScoped<IGlobalSettingsStore, GlobalSettingsStore>();
|
||||
|
||||
services.AddScoped<ICardStore, KanbanStore>();
|
||||
|
||||
// KanbanStore при жёстком удалении карточки чистит DedupEntries напрямую тем же TenantDbContext
|
||||
services.AddScoped<IPipelineStore, PipelineStore>();
|
||||
|
||||
services.AddScoped<IMlLearningStore, MlLearningStore>();
|
||||
|
||||
services.AddScoped<ITelegramStore, TelegramStore>();
|
||||
|
||||
// тенанта. Проверки «уже мониторится» читают таблицу Dialogs (владелец — Telegram) тем же TenantDbContext.
|
||||
services.AddScoped<IDiscoveryStore, DiscoveryStore>();
|
||||
|
||||
// ConnectionStringProvider — он регистрируется в Deal.Api (Program.cs) как singleton.
|
||||
services.AddScoped<ITenantProvisioner, TenantProvisioningService>();
|
||||
|
||||
// параллелизм + логирование прогресса поверх ITenantRepository и ITenantProvisioner.
|
||||
services.AddScoped<TenantSchemaMigrationService>();
|
||||
|
||||
// Выбранных vs дашборд-контейнер» живёт в адаптере, а не в эндпоинте. Scoped —
|
||||
// композирует scoped-сервис карточек в рамках tenant-запроса.
|
||||
services.AddScoped<ICardMover, CardMover>();
|
||||
return services;
|
||||
}
|
||||
@@ -93,11 +75,9 @@ public static class ServiceCollectionExtensions
|
||||
/// <summary>
|
||||
/// Регистрирует адаптеры внешних интеграций
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов.</param>
|
||||
/// <param name="mlOptions">Конфигурация секции <c>Services:Ml</c> — выбор реализации IMlClient.</param>
|
||||
/// <param name="aiOptions">Конфигурация секции <c>Services:Ai</c> — выбор реализации IAiClassifier/IAiTools.</param>
|
||||
/// <param name="telegramOptions">Конфигурация секции <c>Services:Telegram</c> — выбор реализации ITelegramGateway.</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealIntegrations(
|
||||
this IServiceCollection services,
|
||||
MlServiceOptions mlOptions,
|
||||
@@ -105,9 +85,6 @@ public static class ServiceCollectionExtensions
|
||||
TelegramServiceOptions telegramOptions,
|
||||
MtlsCertificates? mtlsCertificates = null)
|
||||
{
|
||||
// остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос
|
||||
// клиентским сертификатом и проверяет CA сервера (fail-fast на загрузку — в MtlsCertificates.Load).
|
||||
// событий. Регистрируется независимо от AI-режима — им пользуется и gRPC ML-клиент (событие kind=ml).
|
||||
services.AddScoped<TokenUsageRecorder>();
|
||||
|
||||
if (mlOptions.UseLocal)
|
||||
@@ -116,8 +93,6 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
else
|
||||
{
|
||||
// gRPC-клиент ml-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
|
||||
// KV/таблицы тенанта (как LocalMlClient) и ходит в сервис по metadata tenant-id/service-token.
|
||||
services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates));
|
||||
services.AddSingleton<MlStatusCache>();
|
||||
services.AddScoped<GrpcMlClient>();
|
||||
@@ -125,18 +100,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
||||
}
|
||||
|
||||
// LocalColumnSuggester читает карточки через ICardStore, считает группы ядром SuggestHeuristics
|
||||
// (модуль Kanban) и создаёт доски suggested=true через ContainersService; scoped — его зависимости
|
||||
// gRPC-клиентом ai-service с тем же контрактом.
|
||||
services.AddScoped<IColumnSuggester, LocalColumnSuggester>();
|
||||
|
||||
// (UseLocal=true) либо gRPC-адаптер ai-service (UseLocal=false). LocalAiClassifier разбирает сообщение
|
||||
// ядром LocalFieldsParser модуля Pipeline (маркерная гипотеза типа: is_vacancy_known=false, board=null —
|
||||
// «смысловые колонки до ИИ не назначаем») и всегда пропускает ИИ-фильтр {pass:true, skipped:true};
|
||||
// scoped — LocalFieldsParser читает маркеры hireMarkers/levelTerms из KV-настроек тенанта (ISettingsStore).
|
||||
// GrpcAiClassifier строит промпты/контекст классификации (AiClassifyContextBuilder модуля Pipeline,
|
||||
// регистрируется AddPipelineModule), ходит в ai-service с ProviderConfig из настроек (расшифровка apiKey)
|
||||
// и копит usage в KV aiTokenUsage; недоступность/ok=false → AiUnavailableException — воркер падает в
|
||||
if (aiOptions.UseLocal)
|
||||
{
|
||||
services.AddScoped<IAiClassifier, LocalAiClassifier>();
|
||||
@@ -144,16 +109,10 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
else
|
||||
{
|
||||
// gRPC-клиент ai-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
|
||||
// в сервис по metadata tenant-id/service-token; usage ответов списывает TokenUsageRecorder
|
||||
services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates));
|
||||
services.AddScoped<AiProviderConfigBuilder>();
|
||||
services.AddScoped<GrpcAiClassifier>();
|
||||
// исчерпанном бюджете/приостановке уводит вызов на детерминированный бесплатный локальный разбор
|
||||
// (LocalAiClassifier; LocalFieldsParser регистрирует AddPipelineModule в Program.cs). Инструменты локального
|
||||
services.AddScoped<LocalAiClassifier>();
|
||||
// Декораторы бюджетного гейта (порядок Grpc → Budgeted → наружу): перед каждым платным вызовом
|
||||
// GetStateAsync (ITenantLimitStore, scoped) — исчерпано/приостановлено → Local-классификатор либо
|
||||
services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier(
|
||||
provider.GetRequiredService<GrpcAiClassifier>(),
|
||||
provider.GetRequiredService<LocalAiClassifier>(),
|
||||
@@ -168,10 +127,6 @@ public static class ServiceCollectionExtensions
|
||||
provider.GetRequiredService<ILogger<BudgetedAiTools>>()));
|
||||
}
|
||||
|
||||
// локальная заглушка dev LocalTelegramGateway (нейтральный no-op/idle — сервис не поднят); при
|
||||
// UseLocal=false — gRPC-клиент GrpcTelegramClient (telegram.proto) + синглтон-транспорт
|
||||
// TelegramGrpcConnection (fail-fast, как MlGrpcConnection). Scoped-адаптер: tenant-id для metadata
|
||||
// берёт из ITenantContext (AsyncLocal) scope вызова (HTTP-запрос/tenant-циклы ингресса).
|
||||
if (telegramOptions.UseLocal)
|
||||
{
|
||||
services.AddSingleton<ITelegramGateway, LocalTelegramGateway>();
|
||||
@@ -189,9 +144,7 @@ public static class ServiceCollectionExtensions
|
||||
/// <summary>
|
||||
/// Регистрирует сервисы шифрования секретов
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов.</param>
|
||||
/// <param name="contentRootPath">ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key.</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath)
|
||||
{
|
||||
EncryptionKeyProvider keyProvider = new(contentRootPath);
|
||||
|
||||
@@ -19,8 +19,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
|
||||
TransitionContext ctx,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по
|
||||
// цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного
|
||||
CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
|
||||
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
|
||||
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
|
||||
|
||||
Reference in New Issue
Block a user