Почистить комментарии от упоминаний процесса
Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками на процесс удалены; то же в .proto. Правила обновлены в docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
This commit is contained in:
@@ -7,29 +7,16 @@ using Deal.Modules.Settings.Application.Models;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-реализация проверки подключения к AI-провайдеру (Ruling 7; 1:1 settings_routes.py L195–219).
|
||||
/// HTTP-реализация проверки подключения к AI-провайдеру.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Лёгкая проверка БЕЗ LLM-вызовов: для OpenAI-совместимых — GET {base}/models, для Anthropic
|
||||
/// (api_style <c>"anthropic"</c>) — GET {base}/v1/models c заголовком x-api-key. Без ключа и для
|
||||
/// локальных провайдеров (Ollama/LM Studio) HTTP не выполняется — короткие ветки ответа.
|
||||
/// Таймаут клиента — 12 с (HttpClient настраивается DI-регистрацией AddHttpClient в Deal.Api,
|
||||
/// см. <see cref="RequestTimeoutSeconds"/>). Ключ в ответ не попадает: только keySet/keyMasked
|
||||
/// (маска — <c>ai.py</c> mask_key L53–58).
|
||||
/// SSRF-контур dev-режима (см. отчёт Task 6): провайдер обязан быть из фиксированного каталога
|
||||
/// <see cref="AiProviders"/> (allowlist), base URL — только абсолютный http(s)-адрес; host-level
|
||||
/// рестрикции нет (локальные серверы на LAN + ветка «недоступный хост» приёмки плана).
|
||||
/// </remarks>
|
||||
public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса проверки в секундах (Ruling 7 — 12 с); применяется DI-регистрацией клиента.
|
||||
/// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента.
|
||||
/// </summary>
|
||||
public const int RequestTimeoutSeconds = 12;
|
||||
|
||||
// ── Фиксированные сообщения веток (Ruling 7, 1:1 с прототипом) ──
|
||||
|
||||
// Сообщение ветки «локальный провайдер» (вместо HTTP — ping на этапе 6).
|
||||
private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)";
|
||||
|
||||
// Сообщение ветки «API-ключ не задан».
|
||||
@@ -56,7 +43,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
// Сообщение SSRF-гейта: base URL не абсолютный http(s).
|
||||
private const string InvalidBaseUrlMessage = "Недопустимый Base URL (ожидается http/https)";
|
||||
|
||||
// ── Константы протокола (референс settings_routes.py L206–209) ──
|
||||
|
||||
// Значение api_style провайдера Anthropic (AiProviderDefinition.ApiStyle).
|
||||
private const string AnthropicApiStyle = "anthropic";
|
||||
@@ -108,7 +94,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
|
||||
}
|
||||
|
||||
// Локальный провайдер (Ollama/LM Studio): HTTP наружу не ходим (Ruling 7 — ветка до ключа).
|
||||
if (request.IsLocal)
|
||||
{
|
||||
return BuildResult(request, name, ok: true, message: string.Format(LocalServerMessageTemplate, name));
|
||||
@@ -157,7 +142,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Сработал HttpClient.Timeout (12 с) — ветка сетевого сбоя (прототип ловит все исключения).
|
||||
return BuildResult(request, name, ok: false, message: TimeoutMessage);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
@@ -167,7 +151,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
}
|
||||
}
|
||||
|
||||
// Собирает ответ ветки: {ok, message} + статус провайдера (Ruling 7).
|
||||
// request: Запрос проверки (поля статуса провайдера).
|
||||
// name: Имя провайдера из каталога AiProviders.
|
||||
// ok: Результат подключения.
|
||||
@@ -191,7 +174,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
KeyMasked: MaskKey(request.ApiKey));
|
||||
}
|
||||
|
||||
// Маска ключа: пусто → "", len ≤ 8 → «x…», иначе «1234…5678» (ai.py mask_key L53–58).
|
||||
// key: Ключ открытым текстом.
|
||||
// Возвращает: Маскированная строка.
|
||||
private static string MaskKey(string key)
|
||||
@@ -209,7 +191,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4));
|
||||
}
|
||||
|
||||
// Строит URL проверки: {base}/models или {base}/v1/models (Anthropic), как в ai_check L206–207.
|
||||
// baseUrl: Эффективный базовый URL из конфигурации провайдера.
|
||||
// apiStyle: Стиль API провайдера (null — OpenAI-совместимый).
|
||||
// modelsUri: URL списка моделей (валиден только при возврате true).
|
||||
@@ -226,7 +207,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
return false;
|
||||
}
|
||||
|
||||
// rstrip("/") как в прототипе: baseUrl из настроек может заканчиваться слэшем.
|
||||
string root = baseUrl.TrimEnd('/');
|
||||
string relativePath = apiStyle == AnthropicApiStyle ? AnthropicModelsPath : OpenAiModelsPath;
|
||||
if (!Uri.TryCreate(root + relativePath, UriKind.Absolute, out Uri? endpoint))
|
||||
|
||||
@@ -6,19 +6,8 @@ using Deal.Modules.Settings.Application.Models;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service (Ruling 5: ядро расшифровывает
|
||||
/// aiConfigs и передаёт ProviderConfig в теле каждого запроса; сервис настроек тенанта не знает).
|
||||
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Эффективный конфиг 1:1 с python <c>ai.py _cfg()</c> L25–33 и формой ProviderConfig (ai.proto L70–85):
|
||||
/// активный провайдер — настройка <c>aiProvider</c> (дефолт «deepseek»), каталог — <see cref="AiProviders"/>
|
||||
/// (fallback на первый — deepseek, как python L28); из переопределения <c>aiConfigs</c> берутся
|
||||
/// apiKey/baseUrl/model, отсутствующие поля дополняются дефолтами каталога (base провайдера, первая модель);
|
||||
/// apiKey расшифровывается (значения <c>enc:</c>+… через <see cref="ISecretCipher"/>; незашифрованные ранних
|
||||
/// версий — как есть, python crypto.decrypt_text L52–61); api_style провайдера — из каталога (Anthropic —
|
||||
/// «anthropic», остальные — пусто = OpenAI-совместимый). Scoped: читает KV-настройки тенанта (ISettingsStore →
|
||||
/// scoped TenantDbContext запроса), как LocalAiClassifier/GrpcMlClient.
|
||||
/// </remarks>
|
||||
public sealed class AiProviderConfigBuilder
|
||||
{
|
||||
// Ключ aiConfigs: поле apiKey переопределения провайдера.
|
||||
@@ -30,7 +19,6 @@ public sealed class AiProviderConfigBuilder
|
||||
// Ключ aiConfigs: поле model переопределения провайдера.
|
||||
private const string ModelField = "model";
|
||||
|
||||
// Префикс зашифрованного значения apiKey (crypto.py L49: enc: + Base64(nonce‖ct‖tag)).
|
||||
private const string EncryptedPrefix = "enc:";
|
||||
|
||||
private readonly ISettingsStore _store;
|
||||
@@ -40,7 +28,7 @@ public sealed class AiProviderConfigBuilder
|
||||
/// Создаёт сборщик конфига провайдера.
|
||||
/// </summary>
|
||||
/// <param name="store">KV-хранилище настроек тенанта (aiProvider/aiConfigs).</param>
|
||||
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey (AES-GCM, Ruling 2).</param>
|
||||
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey.</param>
|
||||
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
@@ -52,7 +40,6 @@ public sealed class AiProviderConfigBuilder
|
||||
/// <summary>
|
||||
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto).</returns>
|
||||
public async Task<ProviderConfig> BuildAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -100,7 +87,6 @@ public sealed class AiProviderConfigBuilder
|
||||
return config;
|
||||
}
|
||||
|
||||
// Активный провайдер из настройки aiProvider (дефолт «deepseek», python L26).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Id провайдера (каталога AiProviders).
|
||||
private async Task<string> ReadProviderIdAsync(CancellationToken ct)
|
||||
|
||||
@@ -10,25 +10,8 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/> (Ruling 3, Task 9): поверх «платного»
|
||||
/// исполнителя (gRPC-адаптер <see cref="GrpcAiClassifier"/>) перед каждым вызовом спрашивает гейт и при запрете
|
||||
/// ИИ уводит вызов на бесплатную локальную реализацию <see cref="LocalAiClassifier"/>.
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Гейт — <see cref="ITenantLimitStore.GetStateAsync"/> (public.tenant_limits + статус тенанта, Task 8):
|
||||
/// вызов разрешён, когда <see cref="BudgetStateDto.Allowed"/> — тенант active и бюджет периода не исчерпан
|
||||
/// (UsedTokens ≥ BudgetTokens; лимит 0 запрещает ИИ уже с нулевого расхода). Запрещено — исчерпание бюджета
|
||||
/// либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). При запрете фильтр/классификация
|
||||
/// выполняются Local-реализацией — семантика aiEnabled=false/aiFail этапов 4–5: фильтр {pass:true, skipped:true},
|
||||
/// разбор ядра <see cref="LocalFieldsParser"/> (детерминированный, бесплатный) — приём и обработка сообщений не
|
||||
/// блокируются, платный ИИ не зовётся и бюджет не расходуется. Списание usage остаётся внутри gRPC-адаптера
|
||||
/// (<see cref="TokenUsageRecorder"/>, Task 8) и выполняется только по реальным платным ответам. Регистрируется
|
||||
/// в <c>AddDealIntegrations</c> только при <c>Services:Ai:UseLocal=false</c> (порядок Grpc → Budgeted → наружу);
|
||||
/// в Local-режиме адаптер и так бесплатен — декоратор не нужен. Ошибки платного исполнителя
|
||||
/// (<see cref="AiUnavailableException"/>) пробрасываются как раньше — ветки фолбэка воркера не меняются.
|
||||
/// SSE-уведомления о пересечении порогов 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь
|
||||
/// запрет только логируется (Ruling 13: стабильные строки, без секретов).
|
||||
/// </remarks>
|
||||
public sealed class BudgetedAiClassifier : IAiClassifier
|
||||
{
|
||||
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
|
||||
@@ -80,7 +63,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
|
||||
}
|
||||
|
||||
// Запрет гейта — фильтр через Local-реализацию {pass:true, skipped:true} (семантика «фильтр недоступен»,
|
||||
// Ruling 3): сообщение не блокируется, платный фильтр не зовётся.
|
||||
_logger.LogDebug(
|
||||
"ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.FilterAsync(text, ct);
|
||||
@@ -94,14 +76,12 @@ public sealed class BudgetedAiClassifier : IAiClassifier
|
||||
return await _paidClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
// Запрет гейта — локальный разбор ядра (семантика aiEnabled=false/aiFail, Ruling 3): карточка строится
|
||||
// без платного ИИ, приём не блокируется.
|
||||
_logger.LogDebug(
|
||||
"ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
// Бюджетный гейт вызова (Ruling 3): true — платный ИИ разрешён (тенант active и бюджет не исчерпан).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — можно звать платного исполнителя.
|
||||
private async Task<bool> IsPaidAllowedAsync(CancellationToken ct)
|
||||
|
||||
@@ -10,36 +10,16 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/> (Ruling 3, Task 9): поверх «платного»
|
||||
/// исполнителя (gRPC-адаптер <see cref="GrpcAiTools"/>) перед каждым вызовом спрашивает гейт и при запрете ИИ
|
||||
/// не зовёт платный инструмент: <see cref="EvaluateFitAsync"/> бросает <see cref="AiUnavailableException"/>
|
||||
/// (вызывающий — воркер Discovery — сам уходит в эвристику, код не меняется, Ruling 10),
|
||||
/// <see cref="GenerateKeywordsAsync"/> отдаёт мягкую ошибку {ok:false, keywords:[], error} (Ruling 11: эндпоинт
|
||||
/// отвечает HTTP 200 {keywords: [], error}).
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Гейт — <see cref="ITenantLimitStore.GetStateAsync"/> (public.tenant_limits + статус тенанта, Task 8): вызов
|
||||
/// разрешён, когда <see cref="BudgetStateDto.Allowed"/> — тенант active и бюджет периода не исчерпан; запрещено —
|
||||
/// исчерпание либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). Тексты запрета различают
|
||||
/// приостановку и исчерпание по <see cref="BudgetStateDto.Status"/> (стабильные строки без секретов, Ruling 13).
|
||||
/// Ошибки платного исполнителя (<see cref="AiUnavailableException"/>) пробрасываются как раньше — ветки фолбэка
|
||||
/// Discovery не меняются. Списание usage остаётся внутри gRPC-адаптера (<see cref="TokenUsageRecorder"/>, Task 8)
|
||||
/// и выполняется только по реальным платным ответам. Регистрируется в <c>AddDealIntegrations</c> только при
|
||||
/// <c>Services:Ai:UseLocal=false</c> (порядок Grpc → Budgeted → наружу). SSE-уведомления о пересечении порогов
|
||||
/// 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь запрет только логируется.
|
||||
/// </remarks>
|
||||
public sealed class BudgetedAiTools : IAiTools
|
||||
{
|
||||
// Текст мягкой ошибки generate-keywords при исчерпанном бюджете (Ruling 3).
|
||||
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
|
||||
|
||||
// Текст мягкой ошибки generate-keywords при приостановке тенанта (Ruling 3/10(5)).
|
||||
private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна";
|
||||
|
||||
// Текст исключения EvaluateFit при исчерпанном бюджете (семантика локальной обработки, Ruling 3).
|
||||
private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме";
|
||||
|
||||
// Текст исключения EvaluateFit при приостановке тенанта (Ruling 3/10(5)).
|
||||
private const string SuspendedFitError = "Тенант приостановлен — ИИ-оценка заморожена";
|
||||
|
||||
private readonly IAiTools _paidTools;
|
||||
@@ -79,7 +59,6 @@ public sealed class BudgetedAiTools : IAiTools
|
||||
return await _paidTools.GenerateKeywordsAsync(description, ct);
|
||||
}
|
||||
|
||||
// Мягкая ошибка для UI (Ruling 3/11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200.
|
||||
_logger.LogDebug(
|
||||
"generate-keywords: {Reason} — мягкая ошибка (тенант {TenantId})",
|
||||
DenyLogText(state),
|
||||
@@ -103,8 +82,6 @@ public sealed class BudgetedAiTools : IAiTools
|
||||
return await _paidTools.EvaluateFitAsync(text, description, keywords, ct);
|
||||
}
|
||||
|
||||
// Сбой ИИ-оценки не роняет оценку кандидата: воркер Discovery падает в эвристику (Ruling 3/10,
|
||||
// python L186–194 — код вызывающего не меняется).
|
||||
_logger.LogDebug(
|
||||
"evaluate-fit: {Reason} — эвристика (тенант {TenantId})",
|
||||
DenyLogText(state),
|
||||
@@ -113,7 +90,6 @@ public sealed class BudgetedAiTools : IAiTools
|
||||
state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError);
|
||||
}
|
||||
|
||||
// Текущее состояние бюджета тенанта (ленивый reset периода + Allowed/Status для гейта, Task 9).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Состояние бюджета тенанта на сейчас.
|
||||
private async Task<BudgetStateDto> GateStateAsync(CancellationToken ct)
|
||||
|
||||
@@ -6,25 +6,15 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-источник курсов ЦБ РФ: GET daily_json.js (Ruling 6, Task 8; 1:1 rates.py L43–59).
|
||||
/// HTTP-источник курсов ЦБ РФ
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Запрос — GET <c>https://www.cbr-xml-daily.ru/daily_json.js</c> (JSON-зеркало ЦБ). SSRF-контур: URL —
|
||||
/// фиксированная константа (allowlist), тенант не управляет адресом источника (в отличие от baseUrl
|
||||
/// AI-провайдеров, Task 6). Таймаут клиента — 15 с (python: <c>httpx timeout=15</c>), задаётся
|
||||
/// DI-регистрацией AddHttpClient в Deal.Api. Парсинг: <c>Valute[code].Value / Nominal</c> (1 единица
|
||||
/// валюты в рублях; Nominal может быть > 1, напр. 100 KZT), к курсам добавляется <c>RUB:1</c>.
|
||||
/// Любой сбой (HTTP-код ≠ 2xx, нераспознанное тело/запись, сетевая ошибка) → null + warning — кэш
|
||||
/// RatesService при этом не трогает (Ruling 6). Отмена вызывающего пробрасывается (не «сбой»).
|
||||
/// </remarks>
|
||||
public sealed class CbrRateSource : IRatesSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса в секундах (python rates.py L46: <c>timeout=15</c>).
|
||||
/// Таймаут HTTP-запроса в секундах.
|
||||
/// </summary>
|
||||
public const int RequestTimeoutSeconds = 15;
|
||||
|
||||
// URL JSON-зеркала курсов ЦБ (constants.py L52). Фиксированный — SSRF-allowlist.
|
||||
private const string CbrUrl = "https://www.cbr-xml-daily.ru/daily_json.js";
|
||||
|
||||
// Корневой объект ответа: валюта → {Value, Nominal, …}.
|
||||
@@ -39,7 +29,6 @@ public sealed class CbrRateSource : IRatesSource
|
||||
// Базовая валюта ответа: курсы даются к рублю.
|
||||
private const string BaseCurrency = "RUB";
|
||||
|
||||
// Курс рубля к рублю (всегда 1.0, rates.py L50).
|
||||
private const double RubToRubRate = 1.0;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
@@ -74,7 +63,6 @@ public sealed class CbrRateSource : IRatesSource
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Любой сбой HTTP/парсинга = неуспех источника (python ловит все исключения, rates.py L57–59).
|
||||
_logger.LogWarning("CBR fetch failed: {Reason}", exception.Message);
|
||||
return null;
|
||||
}
|
||||
@@ -103,7 +91,6 @@ public sealed class CbrRateSource : IRatesSource
|
||||
{
|
||||
if (!TryParseCurrency(currency, out double rate))
|
||||
{
|
||||
// Нераспознанная запись валюты: как и исключение python внутри цикла, роняет весь fetch.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -113,7 +100,6 @@ public sealed class CbrRateSource : IRatesSource
|
||||
return rates;
|
||||
}
|
||||
|
||||
// Разбирает одну запись валюты: курс = Value / Nominal, округлён до 6 знаков (rates.py L51–55).
|
||||
// currency: Пара «код валюты → объект {Value, Nominal}».
|
||||
// rate: Курс единицы валюты к рублю (валиден при возврате true).
|
||||
// Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой).
|
||||
@@ -127,7 +113,6 @@ public sealed class CbrRateSource : IRatesSource
|
||||
|
||||
JsonElement item = currency.Value;
|
||||
|
||||
// Значение по умолчанию, как в python: отсутствующий Value → 0, Nominal → 1 (иначе — сбой).
|
||||
double value = 0;
|
||||
if (item.TryGetProperty(ValuePropertyName, out JsonElement valueElement))
|
||||
{
|
||||
|
||||
@@ -12,39 +12,17 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service (Ruling 5/6, план Task 15
|
||||
/// L412–434).
|
||||
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (выбор на старте, Ruling 6).
|
||||
/// Поведение 1:1 с <c>backend/app/services/ai.py</c> filter_incoming L188–198 / classify L218–258 и ai.proto:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="FilterAsync"/> — RPC Filter (deadline 120 с, README контрактов): заполненный aiFilterPrompt
|
||||
/// из настроек (<see cref="AiClassifyContextBuilder"/>) + текст ≤4000; недоступность провайдера → RPC-ошибка →
|
||||
/// <see cref="AiUnavailableException"/> (воркер отвечает «пропустить», python L1102–1106);</item>
|
||||
/// <item><see cref="ClassifyAsync"/> — RPC Classify: system_prompt = aiPrompt+cardPrompt, user-контекст «Доски +
|
||||
/// примеры разметки + Сообщение» (собирает билдер по данным тенанта, python L226–251); ok=false/сбой →
|
||||
/// <see cref="AiUnavailableException"/> (воркер собирает локальный разбор, aiFail, python L1108–1114);
|
||||
/// ok=true → строгий маппинг JSON в <see cref="AiParsedCardDto"/> (<see cref="AiRawCardMapper"/>, 1:1
|
||||
/// normalize_stack/clean_budget/build_contacts);</item>
|
||||
/// <item>конфиг провайдера на каждый запрос — <see cref="AiProviderConfigBuilder"/> (Ruling 5: core читает
|
||||
/// aiConfigs тенанта, расшифровывает apiKey); usage ответов списывается с бюджета тенанта в tenant_limits и
|
||||
/// копится в lifetime-KV aiTokenUsage (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7).</item>
|
||||
/// </list>
|
||||
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
|
||||
/// настройки/доски/журнал тенанта читаются через scoped-хранилища (ISettingsStore/ICardStore), как
|
||||
/// LocalAiClassifier/GrpcMlClient. Ветки выключателей aiEnabled/aiFilterEnabled порт не читает — их
|
||||
/// отрабатывает воркер (Ruling 5 этапа 4).
|
||||
/// </remarks>
|
||||
public sealed class GrpcAiClassifier : IAiClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
|
||||
/// Deadline RPC ai-service — 120 с
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения фильтра (ai.py filter_incoming L193: text[:4000]).
|
||||
/// Лимит текста сообщения фильтра.
|
||||
/// </summary>
|
||||
public const int MaxFilterTextCodePoints = 4000;
|
||||
|
||||
@@ -112,7 +90,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
|
||||
// Фильтр применён (воркер звал его только при aiFilterEnabled и не force) — skipped=false
|
||||
// (python filter_incoming L194–198: {pass, reason, skipped:false}).
|
||||
return new AiFilterResultDto(
|
||||
Pass: reply.Pass,
|
||||
Reason: reply.HasReason ? reply.Reason : null,
|
||||
@@ -120,7 +97,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
// Провайдер/сервис недоступен — воркер отвечает «пропустить» (python L1102–1106: r2=pass+skipped).
|
||||
_logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(ErrorText(exception));
|
||||
}
|
||||
@@ -154,7 +130,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
// Классификатор недоступен — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail.
|
||||
_logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(ErrorText(exception));
|
||||
}
|
||||
@@ -168,7 +143,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
if (!reply.Ok)
|
||||
{
|
||||
// Модель не вернула разбираемый JSON после ретраев — контрактная ok=false (README ai.proto):
|
||||
// ядро трактует как «разбора нет» и падает в локальный путь (python: RuntimeError → raw={}).
|
||||
_logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(NoJsonAnswerText);
|
||||
}
|
||||
@@ -193,7 +167,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// ct: Токен отмены вызова.
|
||||
// Возвращает: Опции вызова с заголовками, deadline и отменой.
|
||||
@@ -203,7 +176,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
|
||||
// секреты/тела ответов не логируются и в текст не попадают).
|
||||
// exception: Исключение RPC-вызова.
|
||||
// Возвращает: Текст ошибки.
|
||||
@@ -213,7 +185,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
|
||||
// text: Строка.
|
||||
// max: Лимит.
|
||||
// Возвращает: Усечённая строка.
|
||||
|
||||
@@ -11,49 +11,30 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service (Ruling 9, план Task 15/18/19).
|
||||
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (Ruling 6). Поведение 1:1 с
|
||||
/// ai.proto GenerateKeywords/EvaluateFit и прототипом:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="GenerateKeywordsAsync"/> — RPC GenerateKeywords (deadline 120 с): конфиг провайдера из
|
||||
/// настроек, описание ≤4000 (discovery_routes L29); недоступность — мягкий {ok:false, keywords:[], error}
|
||||
/// (Ruling 11: generate-keywords-эндпоинт отдаёт HTTP 200 {keywords: [], error});</item>
|
||||
/// <item><see cref="EvaluateFitAsync"/> — RPC EvaluateFit: текст ≤4000 (discovery_eval L41) + описание и ключи
|
||||
/// задачи; сбой — <see cref="AiUnavailableException"/> (воркер Discovery падает в эвристику, Ruling 10);
|
||||
/// успех — {fit, reason} (потолок причины 200 задаёт сервис, _AI_REASON_LIMIT L43);</item>
|
||||
/// <item>usage ответов списывается с бюджета тенанта в tenant_limits и копится в lifetime-KV aiTokenUsage
|
||||
/// (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7), как у классификатора.</item>
|
||||
/// </list>
|
||||
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
|
||||
/// настройки провайдера читаются через scoped-хранилище тенанта (ISettingsStore), как GrpcAiClassifier.
|
||||
/// Выключатель aiEnabled порт не читает — его отрабатывает вызывающий (воркер/эндпоинт Discovery, Ruling 10/11).
|
||||
/// </remarks>
|
||||
public sealed class GrpcAiTools : IAiTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
|
||||
/// Deadline RPC ai-service — 120 с
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит описания ниши generate-keywords (discovery_routes L29: обрезает до 4000).
|
||||
/// Лимит описания ниши generate-keywords.
|
||||
/// </summary>
|
||||
public const int MaxDescriptionCodePoints = 4000;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения evaluate-fit (discovery_eval L41: _AI_TEXT_LIMIT=4000).
|
||||
/// Лимит текста сообщения evaluate-fit.
|
||||
/// </summary>
|
||||
public const int MaxEvalTextCodePoints = 4000;
|
||||
|
||||
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
|
||||
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
|
||||
|
||||
// Причина по умолчанию при fit=true, если сервис причину не вернул (1:1 _AI_REASON_LIMIT L170).
|
||||
private const string FitReasonDefault = "подходит";
|
||||
|
||||
// Причина по умолчанию при fit=false, если сервис причину не вернул (1:1 L170).
|
||||
private const string NotFitReasonDefault = "не подходит";
|
||||
|
||||
private readonly ITenantContext _tenantContext;
|
||||
@@ -112,7 +93,6 @@ public sealed class GrpcAiTools : IAiTools
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
// Мягкая ошибка для UI (Ruling 11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200.
|
||||
_logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value);
|
||||
return new AiGenerateKeywordsResultDto(Ok: false, Keywords: Array.Empty<string>(), Error: ErrorText(exception));
|
||||
}
|
||||
@@ -160,7 +140,6 @@ public sealed class GrpcAiTools : IAiTools
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
// Сбой ИИ-оценки не роняет оценку кандидата — воркер падает в эвристику (Ruling 10, python L191–192).
|
||||
_logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(ErrorText(exception));
|
||||
}
|
||||
@@ -179,7 +158,6 @@ public sealed class GrpcAiTools : IAiTools
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// ct: Токен отмены вызова.
|
||||
// Возвращает: Опции вызова с заголовками, deadline и отменой.
|
||||
@@ -189,7 +167,6 @@ public sealed class GrpcAiTools : IAiTools
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
|
||||
// секреты/тела ответов не логируются и в текст не попадают).
|
||||
// exception: Исключение RPC-вызова.
|
||||
// Возвращает: Текст ошибки.
|
||||
@@ -199,7 +176,6 @@ public sealed class GrpcAiTools : IAiTools
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
|
||||
// text: Строка.
|
||||
// max: Лимит.
|
||||
// Возвращает: Усечённая строка.
|
||||
|
||||
@@ -17,43 +17,22 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта IMlClient к автономному ml-service (Ruling 4/6, план Task 16 L438–445).
|
||||
/// gRPC-адаптер порта IMlClient к автономному ml-service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-заглушки при <c>Services:Ml:UseLocal=false</c> (выбор на старте, Ruling 6).
|
||||
/// Поведение 1:1 с <c>backend/app/services/ml_client.py</c> и ml.proto:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="StatusAsync"/> — статус модели из ml-service (RPC Status, deadline 10 с) с кэшем 15 с
|
||||
/// (<see cref="MlStatusCache"/>, python L30–31/127–135) + локальная статистика тенанта из KV/таблиц
|
||||
/// (счётчики ml/ai, learning = count(CardMoves), outbox = count(MlOutbox)); сервис недоступен — старые
|
||||
/// данные кэша (или «не готова») и <c>reachable=false</c>;</item>
|
||||
/// <item><see cref="PredictAsync"/> — RPC Predict (deadline 5 с); сбой/недоступность → фиксированный «не
|
||||
/// уверен» (python L101–107: решит ИИ/локальный путь воркера);</item>
|
||||
/// <item><see cref="ResetAsync"/> — RPC Reset (deadline 10 с); при успехе — очистка своей очереди
|
||||
/// MlOutbox (reset_model L110–124) и инвалидация кэша статуса; сбой — мягкий <c>{ok:false,error}</c>,
|
||||
/// очередь не трогается;</item>
|
||||
/// <item><see cref="PushAsync"/> — ВСЕГДА запись в MlOutbox через <see cref="IMlLearningStore"/> (Ruling 6:
|
||||
/// обучение гарантированно и локально; отправку батчами делает <c>MlOutboxFlushScheduler</c>);</item>
|
||||
/// <item><see cref="TrainBatchAsync"/> (IMlTrainClient) — RPC TrainBatch (deadline 30 с) для фонового флашера.</item>
|
||||
/// </list>
|
||||
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="MlGrpcConnection"/>, Ruling 1). Scoped:
|
||||
/// локальная статистика читает KV-настройки и таблицы тенанта (ISettingsStore/IMlLearningStore → scoped
|
||||
/// TenantDbContext), как LocalMlClient.
|
||||
/// </remarks>
|
||||
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline Predict — 5 с (README контрактов: локальная модель).
|
||||
/// Deadline Predict — 5 с
|
||||
/// </summary>
|
||||
public const int PredictDeadlineSeconds = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline Status/Reset — 10 с (README контрактов).
|
||||
/// Deadline Status/Reset — 10 с
|
||||
/// </summary>
|
||||
public const int StatusDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline TrainBatch — 30 с (README контрактов: батч ≤100, 1 транзакция).
|
||||
/// Deadline TrainBatch — 30 с
|
||||
/// </summary>
|
||||
public const int TrainBatchDeadlineSeconds = 30;
|
||||
|
||||
@@ -78,7 +57,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
// Кэш статуса сервиса на тенанта (15 с).
|
||||
private readonly MlStatusCache _statusCache;
|
||||
|
||||
// Recorder истории расхода (ML-событие расхода, этап 10, T2): оценка токенов входного текста.
|
||||
private readonly TokenUsageRecorder _usageRecorder;
|
||||
|
||||
// Логгер сбоев вызовов ml-service.
|
||||
@@ -92,7 +70,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
/// <param name="learningStore">Хранилище обучения ML (очередь MlOutbox + журнал).</param>
|
||||
/// <param name="connection">Транспорт ml-service (singleton-канал + service-token).</param>
|
||||
/// <param name="statusCache">Кэш статуса сервиса на тенанта (singleton).</param>
|
||||
/// <param name="usageRecorder">Recorder истории расхода (ML-событие predict, этап 10, T2).</param>
|
||||
/// <param name="usageRecorder">Recorder истории расхода.</param>
|
||||
/// <param name="logger">Логгер сбоев.</param>
|
||||
public GrpcMlClient(
|
||||
ITenantContext tenantContext,
|
||||
@@ -155,14 +133,12 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
new PredictRequest { Text = text ?? string.Empty },
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct));
|
||||
|
||||
// История расхода (этап 10, T2): ML-ответ токенов не несёт — оценка входного текста (≈chars/4),
|
||||
// бюджет/lifetime AI-счётчик не затрагиваются (локальная модель бесплатна).
|
||||
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
|
||||
return MapPredict(reply);
|
||||
}
|
||||
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
|
||||
{
|
||||
// Сервис недоступен/таймаут/отмена — «не уверен» (python predict L101–107): решит ИИ/локальный путь.
|
||||
_logger.LogDebug(exception, "ML predict недоступен (тенант {TenantId})", tenantId.Value);
|
||||
return NotReadyPrediction;
|
||||
}
|
||||
@@ -182,7 +158,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
}
|
||||
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
|
||||
{
|
||||
// Мягкая ошибка реального сервиса (python reset_model L117–121): ok:false + текст; outbox не трогаем.
|
||||
_logger.LogWarning(exception, "ML reset не удался (тенант {TenantId})", tenantId.Value);
|
||||
return new MlResetResultDto(Ok: false, Error: ErrorText(exception));
|
||||
}
|
||||
@@ -192,7 +167,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
return new MlResetResultDto(Ok: false, Error: reply.HasError ? reply.Error : DefaultResetError);
|
||||
}
|
||||
|
||||
// 1:1 reset_model L122–123: после успешного сброса сервиса — очистка своей очереди + свежий статус.
|
||||
await _learningStore.ClearOutboxAsync(ct);
|
||||
_statusCache.Invalidate(tenantId.Value);
|
||||
return new MlResetResultDto(Ok: true, Error: null);
|
||||
@@ -205,8 +179,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
double delta,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Обучение гарантированно и локально (Ruling 6): сигнал всегда пишется в MlOutbox, отправку батчами
|
||||
// делает MlOutboxFlushScheduler — и в Local-, и в gRPC-режиме (ml_client.py L6–7).
|
||||
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
|
||||
}
|
||||
|
||||
@@ -251,7 +223,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Последние известные данные (при сбое refresh останутся они — python refresh_status L132–135).
|
||||
_statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot stale);
|
||||
MlServiceStatusDto previous = stale?.Service ?? NotReadyServiceStatus;
|
||||
|
||||
@@ -275,7 +246,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
}
|
||||
}
|
||||
|
||||
// Маппит ответ Status в контрактный статус модели (поля 1:1 с MlServiceStatusDto).
|
||||
// reply: Ответ ml-service.
|
||||
// Возвращает: DTO статуса модели.
|
||||
private static MlServiceStatusDto MapStatus(StatusReply reply)
|
||||
@@ -290,7 +260,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
Accuracy: reply.Eval?.Accuracy ?? 0.0));
|
||||
}
|
||||
|
||||
// Маппит ответ Predict в контрактный результат (поля 1:1 с MlPredictResultDto).
|
||||
// reply: Ответ ml-service.
|
||||
// Возвращает: DTO предсказания.
|
||||
private static MlPredictResultDto MapPredict(PredictReply reply)
|
||||
@@ -320,7 +289,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
Margin: decision.Margin);
|
||||
}
|
||||
|
||||
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// deadline: Лимит времени вызова.
|
||||
// ct: Токен отмены вызова.
|
||||
@@ -342,7 +310,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
? "ML-сервис недоступен"
|
||||
: "ML-сервис не ответил — повторите попытку через несколько секунд";
|
||||
|
||||
// Фиксированный ответ неготовой/недоступной модели: «не уверен» (Ruling 5, ml.proto L21–23).
|
||||
private static MlPredictResultDto NotReadyPrediction => new(
|
||||
Take: false,
|
||||
Label: null,
|
||||
@@ -360,7 +327,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
Learned: 0,
|
||||
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
|
||||
|
||||
// Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
|
||||
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
|
||||
|
||||
@@ -10,44 +10,27 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service (Ruling 6/7, план Task 14).
|
||||
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-заглушки при <c>Services:Telegram:UseLocal=false</c> (выбор на старте, Ruling 6).
|
||||
/// Каждый RPC telegram.proto (TelegramService) маппится 1:1 в метод порта: подключение/отключение аккаунта
|
||||
/// (StartPhone/StartQr/SendCode/SendPassword/Logout), каталог диалогов (RefreshDialogs), мониторинг
|
||||
/// (SetMonitor/SetMonitorAll), backfill (Backfill), превью (ReadRecent) и discovery-операции (Search/GetInfo/
|
||||
/// ReadForEval/Join/Leave). Каждый вызов несёт metadata tenant-id + service-token
|
||||
/// (<see cref="TelegramGrpcConnection"/>, Ruling 1) и deadline по README контрактов (src/contracts L62–74).
|
||||
/// <para>
|
||||
/// Ошибки домена telegram-service приходят RPC-статусами с каноническими detail («Telegram не подключён»,
|
||||
/// «Сначала сохраните Telegram api_id и api_hash в настройках», «Неверный код», …) — RpcException
|
||||
/// пробрасывается наружу без изменений, текст причины решает HTTP-слой эндпоинтов (Ruling 7/8). Транспортные
|
||||
/// сбои (сервис недоступен/таймаут) нормализуются в RpcException Unavailable с detail «Telegram не подключён»
|
||||
/// — ветки эндпоинтов отвечают «не подключён», как при недоступном сервисе.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline локальных команд статуса/зеркала — 10 с (README L66).
|
||||
/// Deadline локальных команд статуса/зеркала — 10 с.
|
||||
/// </summary>
|
||||
public const int ShortDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline сетевых команд Telegram — 60 с (README L67: паузы анти-бана внутри сервиса).
|
||||
/// Deadline сетевых команд Telegram — 60 с.
|
||||
/// </summary>
|
||||
public const int CommandDeadlineSeconds = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline тяжёлых команд каталога/backfill — 120 с (README L68: iter_dialogs 500, backfill).
|
||||
/// Deadline тяжёлых команд каталога/backfill — 120 с.
|
||||
/// </summary>
|
||||
public const int LongDeadlineSeconds = 120;
|
||||
|
||||
// Detail недоступного telegram-service (Ruling 7: «недоступность сервиса → не подключён»).
|
||||
private const string NotConnectedDetail = "Telegram не подключён";
|
||||
|
||||
// Контекст текущего тенанта (id — в metadata вызовов, Ruling 1).
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
// Транспорт gRPC telegram-service (канал + metadata).
|
||||
@@ -415,10 +398,8 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcTelegramClient запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// Выполняет unary RPC с metadata tenant-id/service-token, deadline и токеном отмены (Ruling 1).
|
||||
// TReply: Тип ответа RPC.
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// deadline: Лимит времени вызова (README контрактов L62–74).
|
||||
// ct: Токен отмены вызова.
|
||||
// call: Вызов клиента (принимает клиент и CallOptions).
|
||||
// Возвращает: Ответ RPC.
|
||||
@@ -440,7 +421,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
// Нормализует транспортные сбои в RpcException «Telegram не подключён»; RpcException домена — как есть.
|
||||
// Отмена по токену вызывающего пробрасывается без нормализации (не сбой сервиса). Доменные
|
||||
// RPC-ошибки (INVALID_ARGUMENT/FAILED_PRECONDITION/…) несут канонический detail — их трогать нельзя:
|
||||
// текст причины 1:1 уходит в {detail} эндпоинтов (Ruling 7/8).
|
||||
// exception: Исключение вызова.
|
||||
// tenantId: Id тенанта (лог).
|
||||
// operation: Имя RPC (лог-аудит).
|
||||
@@ -457,7 +437,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
}
|
||||
|
||||
// Доменная RPC-ошибка сервиса (INVALID_ARGUMENT/FAILED_PRECONDITION/NOT_FOUND…) несёт канонический
|
||||
// detail (Ruling 1) — пробрасываем без изменений, текст причины 1:1 уходит в {detail} эндпоинтов.
|
||||
// Unavailable с detail (сервис сам ответил причиной) — тоже как есть.
|
||||
if (exception is RpcException rpc &&
|
||||
(rpc.StatusCode != StatusCode.Unavailable || !string.IsNullOrEmpty(rpc.Status.Detail)))
|
||||
@@ -469,7 +448,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
return new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
|
||||
}
|
||||
|
||||
// Маппит записи каталога proto (DialogEntry) в контрактный DTO каталога/поиска (Ruling 7).
|
||||
// entries: Записи каталога telegram-service.
|
||||
// Возвращает: Записи в форме контракта (username → handle).
|
||||
private static IReadOnlyList<TelegramDialogEntryDto> MapEntries(Google.Protobuf.Collections.RepeatedField<DialogEntry> entries)
|
||||
|
||||
@@ -7,31 +7,14 @@ using Deal.Modules.Pipeline.Application.Services;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IAiClassifier"/> без внешнего ИИ-сервиса (Ruling 5, план Task 6 L370–388).
|
||||
/// Локальная реализация <see cref="IAiClassifier"/> без внешнего ИИ-сервиса.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Адаптер поверх чистого ядра разбора модуля Pipeline (эталон — <see cref="LocalColumnSuggester"/>: ядро
|
||||
/// владельца + тонкий адаптер): <see cref="ClassifyAsync"/> разбирает сообщение <see cref="LocalFieldsParser"/>
|
||||
/// (pipeline.py _local_fields L718–798 — заголовок, суть, стек/грейд/бюджет/контакты по меткам и fallback,
|
||||
/// is_vacancy по hire-маркерам) и маппит в контрактный <see cref="AiParsedCardDto"/> через
|
||||
/// <see cref="AiCardMapper.FromLocal"/> (Ruling 7 — модульный маппинг используют и локальные пути воркера):
|
||||
/// бюджет нормализуется, контакты квалифицируются, блок «О заявке» заполняет только
|
||||
/// legacy-суть, тип — маркерная гипотеза: is_vacancy_known=false, board=null («смысловые колонки до ИИ не
|
||||
/// назначаем», python L954–958; карточку в колонку кладёт воркер после ContainerAccepts). Фильтр всегда
|
||||
/// <c>{pass:true, skipped:true}</c> — реального ИИ-фильтра нет, а выключатель aiFilterEnabled порт не читает
|
||||
/// (ветки выключателя отрабатывает воркер, как filter_incoming L190–192 и L1103–1106). На этапе 6 адаптер
|
||||
/// заменяется gRPC-клиентом ai-service с тем же контрактом. Scoped: LocalFieldsParser читает KV-настройки
|
||||
/// тенанта (ISettingsStore → scoped TenantDbContext запроса).
|
||||
/// </remarks>
|
||||
/// <param name="fieldsParser">Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек).</param>
|
||||
public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
// Реального ИИ-фильтра нет (Ruling 5): локальная реализация всегда пропускает. Семантика ответа 1:1
|
||||
// с ветками прототипа, где фильтр недоступен/выключен: {pass:true, reason:null, skipped:true}
|
||||
// (filter_incoming L190–198, сбой L1103–1106). Отсевы spam_ai/filter_ai станут достижимы этапом 6.
|
||||
return Task.FromResult(new AiFilterResultDto(Pass: true, Reason: null, Skipped: true));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,8 @@ using Deal.Contracts.Integrations.Models;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IAiTools"/> без внешнего ИИ-сервиса (Ruling 9, план Task 15).
|
||||
/// Локальная реализация <see cref="IAiTools"/> без внешнего ИИ-сервиса.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется при <c>Services:Ai:UseLocal=true</c> (default, Ruling 6). Методы НЕ поддерживаются — на
|
||||
/// этапе 6 локальной генерации ключей/оценки fit нет: Discovery-воркер сам выбирает эвристику (при
|
||||
/// aiEnabled=false или сбое, python discovery_eval L186–194), а generate-keywords-эндпоинт (Task 19) ловит
|
||||
/// исключение и отдаёт мягкую ошибку {keywords: [], error} (Ruling 11). NotSupportedException — явный сигнал
|
||||
/// «вызов порта в локальном режиме — ошибка сценария», чтобы будущий потребитель (Discovery) не получил
|
||||
/// молча пустые ключи/ложный fit. Scoped-зависимостей нет (экземпляр лёгкий, как LocalAiClassifier на дефолты).
|
||||
/// </remarks>
|
||||
public sealed class LocalAiTools : IAiTools
|
||||
{
|
||||
// Сообщение исключения методов (локальный режим = ai-service не подключён).
|
||||
|
||||
@@ -15,51 +15,30 @@ using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRule
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14).
|
||||
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Реализует порт <see cref="IColumnSuggester"/> поверх порта <see cref="ICardStore"/> и чистого ядра
|
||||
/// <see cref="SuggestHeuristics"/> (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync),
|
||||
/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]},
|
||||
/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE,
|
||||
/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные
|
||||
/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже
|
||||
/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ <see cref="SettingsKeys.LastSuggestAt"/>
|
||||
/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при
|
||||
/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение).
|
||||
/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178).
|
||||
/// </remarks>
|
||||
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта.</param>
|
||||
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
|
||||
public sealed class LocalColumnSuggester(
|
||||
ICardStore store,
|
||||
ISettingsStore settings,
|
||||
ContainersService containersService) : IColumnSuggester
|
||||
{
|
||||
// ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ──
|
||||
|
||||
// Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51).
|
||||
private const long CooldownSeconds = 20 * 60;
|
||||
|
||||
// ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ──
|
||||
|
||||
// Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»).
|
||||
private const string CooldownReason = "недавно предлагали — подождите";
|
||||
|
||||
// Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102).
|
||||
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
|
||||
|
||||
// Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159).
|
||||
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
|
||||
|
||||
// Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178).
|
||||
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
|
||||
|
||||
// Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа).
|
||||
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
|
||||
|
||||
// Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any).
|
||||
private const string RulesModeAny = "any";
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -80,7 +59,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
Cooldown: false);
|
||||
}
|
||||
|
||||
// Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139).
|
||||
IReadOnlyList<ContainerDto> containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
|
||||
IReadOnlyList<string> existingNames = containers
|
||||
.Where(container => !container.Suggested)
|
||||
@@ -96,7 +74,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
|
||||
if (created == 0)
|
||||
{
|
||||
// Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156).
|
||||
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
|
||||
}
|
||||
|
||||
@@ -107,7 +84,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
/// <inheritdoc />
|
||||
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
|
||||
{
|
||||
// Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом,
|
||||
// свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
|
||||
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
|
||||
List<string> texts = cards
|
||||
@@ -131,22 +107,17 @@ ContainersService containersService) : IColumnSuggester
|
||||
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
|
||||
}
|
||||
|
||||
// Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156).
|
||||
// inbox: Снимок «Неразобранного» (карточки планов берутся из него).
|
||||
// plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
|
||||
// Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
|
||||
// Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
|
||||
// раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в
|
||||
// колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested
|
||||
// L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся.
|
||||
private async Task<int> StoreSuggestedColumnsAsync(
|
||||
IReadOnlyList<CardDto> inbox,
|
||||
IReadOnlyList<SuggestedColumnPlan> plans,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233).
|
||||
HashSet<string> inboxIds = (await store.ListInboxWithSourceAsync(ct))
|
||||
.Select(card => card.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
@@ -195,7 +166,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
|
||||
if (placed == 0)
|
||||
{
|
||||
// Ничего не легло — пустое предложение не нужно (suggest.py L152–156).
|
||||
await containersService.DeleteAsync(container.Id, ct);
|
||||
continue;
|
||||
}
|
||||
@@ -207,8 +177,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
}
|
||||
|
||||
// Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
|
||||
// Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
|
||||
// пишется только после успеха, L160–161; битый KV — дефолт «никогда»).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
|
||||
private async Task<bool> WithinCooldownAsync(CancellationToken ct)
|
||||
@@ -236,7 +204,6 @@ ContainersService containersService) : IColumnSuggester
|
||||
}
|
||||
}
|
||||
|
||||
// Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())).
|
||||
// ct: Токен отмены.
|
||||
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
|
||||
settings.SetAsync(
|
||||
|
||||
@@ -8,36 +8,19 @@ using Deal.Modules.Settings.Application.Models;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IMlClient"/> без внешнего ML-сервиса (Ruling 4, план Task 5 L266–286).
|
||||
/// Локальная реализация <see cref="IMlClient"/> без внешнего ML-сервиса.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Этап 3: обучение копится локально в очередь MlOutbox (отправка в ML-сервис — фоновый воркер
|
||||
/// этапа 6), счётчики learning/outbox читаются из таблиц схемы тенанта (Ruling 4). Поведение 1:1
|
||||
/// с <c>backend/app/services/ml_client.py</c>: <c>PushAsync</c> = push L40–49 (trim text/label,
|
||||
/// пустые — no-op, text[:6000], id <c>mle_</c>+12 hex); <c>StatusAsync</c> = snapshot L138–150
|
||||
/// (learning = count(CardMoves), outbox = count(MlOutbox), ml/ai — KV-счётчики решений, на этапе 3
|
||||
/// всегда 0 — не инкрементируются); <c>ResetAsync</c> = reset_model L110–124 (чистится только
|
||||
/// MlOutbox, журнал и KV не трогаются). Модель «не готова» до этапа 4 (ready=false, classes пусты,
|
||||
/// learned=0, eval обнулён), предсказание — фиксированный «не уверен» (Ruling 5 L79–80); заглушка
|
||||
/// «жива»: reachable=true. Зависимости — порты (ISettingsStore, IMlLearningStore), а не EF:
|
||||
/// LocalMlClient остаётся unit-чистым (план Task 5). На этапе 6 адаптер заменяется gRPC-клиентом
|
||||
/// с тем же контрактом (Ruling 4 L73–74).
|
||||
/// </remarks>
|
||||
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
|
||||
/// <param name="learningStore">Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves.</param>
|
||||
public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient
|
||||
{
|
||||
// Пустой словарь классов модели (неготовая модель, Ruling 5).
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyClasses = new Dictionary<string, double>();
|
||||
|
||||
// Пустой словарь весов предсказания (неготовая модель, Ruling 5).
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyScores = new Dictionary<string, double>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
|
||||
{
|
||||
// Статус самой модели: обучение копится в outbox, реальная модель появится этапом 4 —
|
||||
// сейчас модель всегда не готова (Ruling 5).
|
||||
var service = new MlServiceStatusDto(
|
||||
Ready: false,
|
||||
Classes: EmptyClasses,
|
||||
@@ -48,9 +31,6 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
|
||||
int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct);
|
||||
int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct);
|
||||
|
||||
// Локальная статистика (ml_client.snapshot L138–150): learning = count(CardMoves),
|
||||
// outbox = count(MlOutbox) (Ruling 4); ml/ai — KV-счётчики РЕШЕНИЙ пайплайна (этап 4):
|
||||
// на этапе 3 не инкрементируются и всегда 0.
|
||||
int learning = await learningStore.CountLearningAsync(ct);
|
||||
int outbox = await learningStore.CountOutboxAsync(ct);
|
||||
|
||||
@@ -70,7 +50,6 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
|
||||
/// <inheritdoc />
|
||||
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
|
||||
{
|
||||
// Неготовая модель ничего не решает (Ruling 5 L79–80) — текст не влияет на ответ.
|
||||
return Task.FromResult(new MlPredictResultDto(
|
||||
Take: false,
|
||||
Label: null,
|
||||
@@ -85,8 +64,6 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
|
||||
/// <inheritdoc />
|
||||
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct)
|
||||
{
|
||||
// Сброс 1:1 с reset_model (L110–124): чистится только очередь обучения MlOutbox; журнал
|
||||
// CardMoves и KV-счётчики не трогаются (Ruling 4, план L275–276). Реального сервиса нет — ok.
|
||||
await learningStore.ClearOutboxAsync(ct);
|
||||
return new MlResetResultDto(Ok: true, Error: null);
|
||||
}
|
||||
@@ -98,13 +75,10 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
|
||||
double delta,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Обучение гарантированно и локально (ml_client.push L40–49): действие пользователя — строка
|
||||
// очереди MlOutbox (отправку в ML-сервис делает воркер этапа 6). Общая логика (trim text/label,
|
||||
// пустые — тихий no-op, text[:6000], id mle_+hex) — в MlOutboxQueue, общем для Local/Grpc-адаптеров.
|
||||
await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct);
|
||||
}
|
||||
|
||||
// Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
|
||||
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
|
||||
|
||||
@@ -4,18 +4,8 @@ using Deal.Contracts.Integrations.Models;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная заглушка <see cref="ITelegramGateway"/> без telegram-service (Ruling 6, план Task 13/14).
|
||||
/// Локальная заглушка <see cref="ITelegramGateway"/> без telegram-service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется как дефолт dev (до появления gRPC-клиента GrpcTelegramClient под флагом
|
||||
/// Services:Telegram:UseLocal=false — Ruling 6): реальный telegram-service в dev не поднят, поэтому гейт
|
||||
/// нейтрален — статус «idle/не подключён» (1:1 форма «сервис недоступен → idle-форма», Ruling 8), команды —
|
||||
/// no-op, выборки пусты. На этапе 6 (Task 14 curl-приёмка) эндпоинты тестируются фейк-реализацией гейта в
|
||||
/// тестах (не этой заглушкой); заглушка гарантирует разрешимость графа DI до подключения сервиса.
|
||||
/// Команды подключения (StartPhone/StartQr/SendCode/SendPassword/Logout) и discovery-операции (Search/Info/
|
||||
/// ReadForEval/Join/Leave) без сервиса не имеют смысла — их ветки эндпоинтов/воркера отдают ошибку
|
||||
/// «Telegram не подключён» по статусу подключения (Ruling 7), сам гейт их не вызывает.
|
||||
/// </remarks>
|
||||
public sealed class LocalTelegramGateway : ITelegramGateway
|
||||
{
|
||||
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
|
||||
@@ -24,7 +14,6 @@ public sealed class LocalTelegramGateway : ITelegramGateway
|
||||
/// <inheritdoc />
|
||||
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
|
||||
{
|
||||
// «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты.
|
||||
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,28 +4,21 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
// Общая запись обучающего сигнала в очередь MlOutbox (ml_client.push L40–49) для адаптеров IMlClient.
|
||||
// Поведение 1:1 с прототипом и с LocalMlClient.PushAsync этапа 3: пустые после trim text/label —
|
||||
// тихий no-op, text обрезается до 6000 символов (без разрыва суррогатной пары), id — mle_ +
|
||||
// 12 случайных hex (store.uid L48). Обучение идёт ВСЕГДА (выключатель mlEnabled его не трогает) —
|
||||
// и в Local-, и в gRPC-режиме сигнал сначала пишется в outbox, отправку в ml-service делает фоновый
|
||||
// MlOutboxFlushScheduler (Ruling 6: PushAsync ВСЕГДА пишет MlOutbox).
|
||||
internal static class MlOutboxQueue
|
||||
{
|
||||
// Максимальная длина текста обучающего примера (ml_client.push L48: text[:6000]).
|
||||
internal const int MaxLearningTextLength = 6000;
|
||||
|
||||
// Случайный хвост id outbox: 6 байт → 12 hex-символов (прототип store.uid — uuid4().hex[:12]).
|
||||
private const int OutboxIdRandomBytes = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Пишет строку очереди обучения: trim text/label (пустые — no-op), text[:6000], id mle_+hex.
|
||||
/// Пишет строку очереди обучения
|
||||
/// </summary>
|
||||
/// <param name="learningStore">Хранилище обучения (таблица MlOutbox схемы тенанта).</param>
|
||||
/// <param name="text">Текст обучающего примера (source_msg карточки или title).</param>
|
||||
/// <param name="label">Метка: id доски (<c>b_...</c>), <c>spam</c> либо <c>t:hire|t:order</c>.</param>
|
||||
/// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Задача завершается после записи строки (отправку делает фоновый флашер).</returns>
|
||||
public static async Task PushAsync(
|
||||
IMlLearningStore learningStore,
|
||||
@@ -49,7 +42,6 @@ internal static class MlOutboxQueue
|
||||
ct);
|
||||
}
|
||||
|
||||
// Генерирует id строки outbox: префикс mle_ + 12 случайных hex-символов (прототип store.uid).
|
||||
// Возвращает: Короткий id записи очереди.
|
||||
private static string NewOutboxId()
|
||||
=> KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant();
|
||||
@@ -57,7 +49,6 @@ internal static class MlOutboxQueue
|
||||
// Обрезает текст до MaxLearningTextLength символов, не разбивая суррогатную пару на конце.
|
||||
// text: Текст (уже trim-нут).
|
||||
// Возвращает: Первые 6000 символов (или весь текст, если короче).
|
||||
// .NET-срез идёт по UTF-16-единицам и может разбить суррогатную пару; Python-срез прототипа
|
||||
// (text[:6000]) режет по code points — хвостовой high-surrogate убираем, чтобы в БД не ушла «битая» пара.
|
||||
private static string TruncateText(string text)
|
||||
{
|
||||
|
||||
@@ -4,18 +4,12 @@ using Deal.Contracts.Integrations.Models;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Кэш статуса ML-сервиса на тенанта (python ml_client L30–31 + refresh_status L127–135; Ruling 6).
|
||||
/// Кэш статуса ML-сервиса на тенанта.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Кэш живёт 15 секунд и хранит последний известный статус + флаг <c>reachable</c>: обновление происходит
|
||||
/// при вызове <c>GrpcMlClient.StatusAsync</c>, когда запись устарела/отсутствует; при сбое сервиса строка
|
||||
/// остаётся со старыми данными и <c>reachable=false</c> (python L132–135). Singleton: кэш переживает scope
|
||||
/// запросов (в /api/ml/status и фоновых циклах тенант один и тот же), ключ — id тенанта (формат N).
|
||||
/// </remarks>
|
||||
public sealed class MlStatusCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Время жизни кэша статуса сервиса — 15 с (refresh_status python L30–31).
|
||||
/// Время жизни кэша статуса сервиса — 15 с.
|
||||
/// </summary>
|
||||
public const int CacheTtlSeconds = 15;
|
||||
|
||||
@@ -33,7 +27,7 @@ public sealed class MlStatusCache
|
||||
private readonly Func<DateTimeOffset> _utcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт кэш с системными часами (DateTimeOffset.UtcNow).
|
||||
/// Создаёт кэш с системными часами
|
||||
/// </summary>
|
||||
public MlStatusCache()
|
||||
: this(() => DateTimeOffset.UtcNow)
|
||||
@@ -41,7 +35,7 @@ public sealed class MlStatusCache
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт кэш с заданными часами (тесты TTL 15 с).
|
||||
/// Создаёт кэш с заданными часами
|
||||
/// </summary>
|
||||
/// <param name="utcNow">Источник текущего времени (UTC).</param>
|
||||
public MlStatusCache(Func<DateTimeOffset> utcNow)
|
||||
@@ -51,7 +45,7 @@ public sealed class MlStatusCache
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает свежую запись кэша (возраст ≤ <see cref="CacheTtlSeconds"/>).
|
||||
/// Возвращает свежую запись кэша
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (формат N).</param>
|
||||
/// <param name="snapshot">Свежая запись (если есть).</param>
|
||||
@@ -73,7 +67,7 @@ public sealed class MlStatusCache
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает последнюю запись независимо от возраста (для «старые данные при сбое», python L134).
|
||||
/// Возвращает последнюю запись независимо от возраста.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (формат N).</param>
|
||||
/// <param name="snapshot">Последняя запись (если есть).</param>
|
||||
@@ -81,7 +75,7 @@ public sealed class MlStatusCache
|
||||
public bool TryGet(string tenantId, out Snapshot snapshot) => _entries.TryGetValue(tenantId, out snapshot!);
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет запись статуса (момент обновления — сейчас).
|
||||
/// Сохраняет запись статуса
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (формат N).</param>
|
||||
/// <param name="service">Статус модели.</param>
|
||||
@@ -95,7 +89,7 @@ public sealed class MlStatusCache
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Помечает запись устаревшей (сброс модели, python reset_model L123 — refresh после сброса).
|
||||
/// Помечает запись устаревшей.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (формат N).</param>
|
||||
public void Invalidate(string tenantId) => _entries.TryRemove(tenantId, out _);
|
||||
|
||||
@@ -7,41 +7,30 @@ using Grpc.Net.Client;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Health-проба grpc.health.v1 автономных сервисов (ml/ai/telegram) для операторского health
|
||||
/// (план Task 10: GET /api/operator/health, Ruling 3/6/9).
|
||||
/// Health-проба grpc.health.v1 автономных сервисов
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Каждый вызов строит свой короткоживущий канал к <c>endpoint</c> сервиса (dev — без TLS, Ruling 2;
|
||||
/// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом и
|
||||
/// проверяет CA сервера — сертификаты передаются <see cref="MtlsCertificates"/> в конструктор) и спрашивает
|
||||
/// Health.Check("") с дедлайном 3 с — health не должен висеть дольше таймаута. Классификация: ответ
|
||||
/// <c>SERVING</c> → Reachable+Serving; ответ с иным статусом → Reachable без
|
||||
/// Serving; таймаут/нет соединения (Unavailable/DeadlineExceeded, HTTP-транспорт) и сервис без health-контракта
|
||||
/// (Unimplemented) → <see cref="ServiceHealthResult.Unreachable"/>.
|
||||
/// </remarks>
|
||||
public sealed class ServiceHealthProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Дедлайн health-RPC, секунд (Ruling 3/9: операторский health отвечает за ~3 с на сервис).
|
||||
/// Дедлайн health-RPC, секунд.
|
||||
/// </summary>
|
||||
public const int HealthTimeoutSeconds = 3;
|
||||
|
||||
private readonly MtlsCertificates? _mtlsCertificates;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт пробу; mTLS-каналы — при переданных сертификатах (иначе plaintext, dev).
|
||||
/// Создаёт пробу; mTLS-каналы — при переданных сертификатах
|
||||
/// </summary>
|
||||
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал.</param>
|
||||
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал.</param>
|
||||
public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null)
|
||||
{
|
||||
_mtlsCertificates = mtlsCertificates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет health-контракт gRPC-сервиса по базовому адресу (grpc.health.v1, сервис "").
|
||||
/// Проверяет health-контракт gRPC-сервиса по базовому адресу
|
||||
/// </summary>
|
||||
/// <param name="endpoint">Базовый адрес сервиса (http://host:port; пустой/пробельный — ошибка аргумента).</param>
|
||||
/// <param name="ct">Токен отмены вызывающего.</param>
|
||||
/// <returns>Результат пробы (см. <see cref="ServiceHealthResult"/>).</returns>
|
||||
public async Task<ServiceHealthResult> ProbeAsync(string endpoint, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -13,36 +13,16 @@ using Deal.SharedKernel.Tenants.Models;
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): успешный RPC ai-service
|
||||
/// (Filter/Classify/GenerateKeywords/EvaluateFit) списывает usage с бюджета тенанта, копит lifetime-сумму
|
||||
/// в tenant-KV и пишет событие в public.token_usage_events; локальный ML-вызов пишет событие (kind=ml).
|
||||
/// Recorder расхода токенов
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Точка вызова — та же, что у этапа 6 (GrpcAiClassifier/GrpcAiTools после успешного RPC; ML — GrpcMlClient/
|
||||
/// LocalMlClient.Predict). Три учёта:
|
||||
/// <list type="number">
|
||||
/// <item><b>Бюджет периода</b> — <c>ITenantLimitStore.AddUsageAsync</c>: инкремент UsedTokens в public.tenant_limits
|
||||
/// (тот же scoped DealDbContext запроса) с ленивым reset периода; источник истины бюджетного гейта Task 9.
|
||||
/// Только для платных AI-вызовов (ML бюджет не расходует).</item>
|
||||
/// <item><b>Lifetime-счётчик</b> — tenant-KV ключ aiTokenUsage ({prompt, completion, total}, существующий формат
|
||||
/// этапа 6): «всего» за всё время. Только для AI (ML — локальный, aiTokenUsage не засоряет).</item>
|
||||
/// <item><b>История событий</b> — <c>TokenUsageEventService.AppendAsync</c> (public.token_usage_events): провайдер,
|
||||
/// модель, вид (ai|ml), токены; основа time-series аналитики оператора (этап 10, T3).</item>
|
||||
/// </list>
|
||||
/// Списание в tenant_limits выполняется только при Total>0 (нулевой usage ответа моделью не заводит строку
|
||||
/// лимита); lifetime-KV пишется всегда, как раньше. Scoped: пишет в KV-хранилище тенанта запроса (ISettingsStore
|
||||
/// → scoped TenantDbContext), в public.tenant_limits/токен-историю — через scoped DealDbContext.
|
||||
/// </remarks>
|
||||
public sealed class TokenUsageRecorder
|
||||
{
|
||||
// Имена полей значения aiTokenUsage (1:1 с Usage ai.proto: prompt/completion/total).
|
||||
private const string PromptField = "prompt";
|
||||
|
||||
private const string CompletionField = "completion";
|
||||
|
||||
private const string TotalField = "total";
|
||||
|
||||
// Оценка токенов по символам, символов на токен (конвенция проекта ai.proto Ruling 5: ≈chars/4).
|
||||
private const int CharsPerToken = 4;
|
||||
|
||||
private readonly ISettingsStore _store;
|
||||
@@ -56,7 +36,7 @@ public sealed class TokenUsageRecorder
|
||||
/// <param name="store">KV-хранилище настроек тенанта (ключ aiTokenUsage, lifetime-счётчик).</param>
|
||||
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits, списание периода).</param>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId списания/события).</param>
|
||||
/// <param name="events">Сервис истории расхода (public.token_usage_events, этап 10).</param>
|
||||
/// <param name="events">Сервис истории расхода.</param>
|
||||
public TokenUsageRecorder(
|
||||
ISettingsStore store,
|
||||
ITenantLimitStore tenantLimits,
|
||||
@@ -74,13 +54,11 @@ public sealed class TokenUsageRecorder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Списывает usage ответа ai-service: (1) инкремент бюджета периода в tenant_limits, (2) lifetime-сумму
|
||||
/// в KV aiTokenUsage, (3) событие истории (kind=ai). usage null — no-op (успешный RPC без оценки токенов).
|
||||
/// Списывает usage ответа ai-service
|
||||
/// </summary>
|
||||
/// <param name="usage">Оценка токенов ответа (Usage ai.proto; reply без usage — нули; null — no-op).</param>
|
||||
/// <param name="provider">Id активного провайдера (deepseek/openai/anthropic/…; событие истории).</param>
|
||||
/// <param name="model">Модель провайдера (событие истории).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public async Task AddAsync(
|
||||
Usage? usage,
|
||||
string provider,
|
||||
@@ -98,7 +76,6 @@ public sealed class TokenUsageRecorder
|
||||
}
|
||||
|
||||
await AddToLifetimeAsync(usage, ct);
|
||||
// Прикладная метрика (этап 12, пакет A): счётчик вызовов/токенов ИИ — та же точка, что и событие
|
||||
// token_usage_events (без tenantId в метках).
|
||||
DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion);
|
||||
await RecordEventAsync(
|
||||
@@ -112,13 +89,11 @@ public sealed class TokenUsageRecorder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие локального ML-вызова (kind=ml) с оценкой токенов по длине входного текста
|
||||
/// (≈chars/4, конвенция ai.proto): бюджет/lifetime aiTokenUsage ML не затрагивает.
|
||||
/// Записывает событие локального ML-вызова
|
||||
/// </summary>
|
||||
/// <param name="text">Входной текст предсказания (оценка токенов запроса; null — 0).</param>
|
||||
/// <param name="provider">Провайдер/источник события (для локальной ML-модели — "local").</param>
|
||||
/// <param name="model">Модель/вид локального ML-вызова (событие истории).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Оценка токенов (для тестов/наблюдаемости).</returns>
|
||||
public async Task<long> AddEstimatedAsync(
|
||||
string? text,
|
||||
@@ -127,7 +102,6 @@ public sealed class TokenUsageRecorder
|
||||
CancellationToken ct)
|
||||
{
|
||||
long promptTokens = EstimateTokens(text);
|
||||
// Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка,
|
||||
// что и событие token_usage_events, kind=ml).
|
||||
DealMetrics.RecordMlUsage(promptTokens);
|
||||
await RecordEventAsync(
|
||||
@@ -142,7 +116,7 @@ public sealed class TokenUsageRecorder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Оценка токенов по символам (≈chars/4; конвенция проекта, ai.proto Ruling 5).
|
||||
/// Оценка токенов по символам.
|
||||
/// </summary>
|
||||
/// <param name="text">Текст (null/пустой — 0).</param>
|
||||
/// <returns>Оценка токенов (неотрицательная).</returns>
|
||||
@@ -196,7 +170,6 @@ public sealed class TokenUsageRecorder
|
||||
return id;
|
||||
}
|
||||
|
||||
// Прибавляет usage к накопленному значению aiTokenUsage (lifetime-счётчик, формат этапа 6).
|
||||
// usage: Оценка токенов ответа.
|
||||
// ct: Токен отмены.
|
||||
private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct)
|
||||
|
||||
Reference in New Issue
Block a user