using System.Globalization; using System.Text.Json.Nodes; using Deal.Ai.Llm; using Deal.Grpc.Ai; using Grpc.Core; namespace Deal.Ai; /// /// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service. /// public sealed class AiServiceImpl : AiService.AiServiceBase { public const string TenantIdMetadataKey = "tenant-id"; // Деталь отказа: tenant-id отсутствует в metadata (UNAUTHENTICATED, шаблон MlServiceImpl). private const string TenantIdMissingDetail = "tenant-id отсутствует в metadata"; // Деталь отказа: не задан конфиг ИИ-провайдера (INVALID_ARGUMENT). private const string ProviderConfigMissingDetail = "Не задан конфиг ИИ-провайдера (provider_config)"; // Деталь отказа: пустой base_url конфига (INVALID_ARGUMENT). private const string ProviderBaseUrlEmptyDetail = "Конфиг ИИ-провайдера: пустой base_url"; // Деталь отказа: пустая model конфига (INVALID_ARGUMENT). private const string ProviderModelEmptyDetail = "Конфиг ИИ-провайдера: пустая model"; // Деталь отказа: текст сообщения длиннее контрактного лимита (INVALID_ARGUMENT). private const string MessageTextTooLongDetail = "Слишком длинный текст сообщения"; // Деталь отказа: промпт длиннее защитного лимита (INVALID_ARGUMENT). private const string PromptTooLongDetail = "Слишком длинный промпт"; // Деталь отказа: описание ниши/задачи длиннее контрактного лимита (INVALID_ARGUMENT). private const string DescriptionTooLongDetail = "Слишком длинное описание ниши/задачи"; // Деталь отказа: контекст Classify длиннее защитного лимита (INVALID_ARGUMENT). private const string UserContextTooLongDetail = "Слишком длинный контекст разбора"; // Деталь отказа: слишком много ключей задачи в EvaluateFit (INVALID_ARGUMENT). private const string TooManyKeywordsDetail = "Слишком много ключей задачи"; // Деталь отказа: ключ задачи длиннее лимита (INVALID_ARGUMENT). private const string KeywordTooLongDetail = "Слишком длинный ключ задачи"; private const int MaxTextLength = 4000; private const int MaxDescriptionLength = 4000; // Защитный потолок длины промпта (Filter.prompt/Classify.system_prompt; лимит не декларирован). private const int MaxPromptLength = 20000; // Защитный потолок длины user-контекста Classify (лимит не декларирован). private const int MaxUserContextLength = 20000; private const int MaxKeywordsCount = 200; private const int MaxKeywordLength = 200; private const string FilterUserPrefix = "Сообщение:\n"; private const string KeywordsUserPrefix = "Описание ниши/задачи:\n"; private const string GenerateKeywordsSystemPrompt = "Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи " + "составь поисковые ключевые слова, по которым в глобальном поиске Telegram " + "находят подходящие источники. Верни строго JSON вида " + "{\"keywords\": [\"...\", \"...\"]}. Требования к списку:\n" + "- 10–16 ключей;\n" + "- примерно поровну русских и английских (английские — популярные в нише термины);\n" + "- короткие фразы 1–4 слова;\n" + "- без #, @, кавычек и лишней пунктуации;\n" + "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n" + "- без дублей и близких по смыслу повторов."; private const string EvaluateFitSystemPromptTemplate = "Оцени, относится ли сообщение к сфере/задаче. Описание: {0}. Ключи: {1}. " + "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."; private const int MaxEvalReasonLength = 200; private const string FitReasonDefault = "подходит"; private const string NotFitReasonDefault = "не подходит"; private const string PassFieldName = "pass"; // Имя поля причины в JSON-ответе модели. private const string ReasonFieldName = "reason"; private const string FitFieldName = "fit"; // Имя поля списка ключевых слов в JSON-ответе модели. private const string KeywordsFieldName = "keywords"; private static readonly IReadOnlySet FalsyAnswerValues = new HashSet(StringComparer.OrdinalIgnoreCase) { "0", "false", "no", "нет", "null", "none", }; private readonly ProviderCaller _caller; private readonly ILogger _logger; /// /// Создаёт gRPC-сервис команд ядра поверх LLM-фасада. /// /// Оркестратор вызовов модели (ретраи + извлечение JSON + usage). /// Логгер аудита. public AiServiceImpl(ProviderCaller caller, ILogger logger) { _caller = caller; _logger = logger; } /// /// Filter — ИИ-фильтр входящих сообщений /// public override async Task Filter(FilterRequest request, ServerCallContext context) { string tenantId = RequireTenantId(context); EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail); EnsureLengthAtMost(request.Prompt, MaxPromptLength, PromptTooLongDetail); LlmConfig config = ResolveConfig(request.ProviderConfig); try { LlmCallResult result = await _caller .ChatJsonAsync(config, request.Prompt, FilterUserPrefix + request.Text, context.CancellationToken) .ConfigureAwait(false); bool pass = ReadBoolField(result.Json, PassFieldName, defaultValue: true); string? reason = ReadStringField(result.Json, ReasonFieldName); var reply = new FilterReply { Pass = pass, Usage = ToUsage(result.Usage) }; if (reason is not null) { reply.Reason = reason; } _logger.LogInformation( "Аудит: tenant {TenantId} filter → pass={Pass}, usage={TotalTokens}", tenantId, pass, result.Usage.TotalTokens); return reply; } catch (LlmCallException callError) { LogAiUnavailable("filter", tenantId, config, callError); throw ToUnavailable(callError); } } /// /// Classify — полный разбор лида /// public override async Task Classify(ClassifyRequest request, ServerCallContext context) { string tenantId = RequireTenantId(context); EnsureLengthAtMost(request.SystemPrompt, MaxPromptLength, PromptTooLongDetail); EnsureLengthAtMost(request.UserContext, MaxUserContextLength, UserContextTooLongDetail); LlmConfig config = ResolveConfig(request.ProviderConfig); try { LlmCallResult result = await _caller .ChatJsonAsync(config, request.SystemPrompt, request.UserContext, context.CancellationToken) .ConfigureAwait(false); var reply = new ClassifyReply { Ok = true, Json = result.JsonText, Usage = ToUsage(result.Usage) }; _logger.LogInformation( "Аудит: tenant {TenantId} classify → ok=true, usage={TotalTokens}", tenantId, result.Usage.TotalTokens); return reply; } catch (LlmCallException callError) when (callError.Kind == LlmCallFailureKind.AnswerNotJson) { // «Ответ без разбираемого JSON» — контрактная форма ClassifyReply.ok=false (README ai.proto). _logger.LogWarning( "Аудит: tenant {TenantId} classify → ok=false ({Provider}): ответ модели без JSON", tenantId, config.DisplayName); var reply = new ClassifyReply { Ok = false, Usage = ToUsage(callError.Usage) }; return reply; } catch (LlmCallException callError) { LogAiUnavailable("classify", tenantId, config, callError); throw ToUnavailable(callError); } } /// /// GenerateKeywords — ключевые слова discovery-задачи по описанию /// public override async Task GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context) { string tenantId = RequireTenantId(context); EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail); LlmConfig config = ResolveConfig(request.ProviderConfig); try { LlmCallResult result = await _caller .ChatJsonAsync( config, GenerateKeywordsSystemPrompt, KeywordsUserPrefix + request.Description, context.CancellationToken) .ConfigureAwait(false); var reply = new GenerateKeywordsReply { Usage = ToUsage(result.Usage) }; reply.Keywords.AddRange(ReadKeywords(result.Json)); _logger.LogInformation( "Аудит: tenant {TenantId} generate_keywords → keywords={Count}, usage={TotalTokens}", tenantId, reply.Keywords.Count, result.Usage.TotalTokens); return reply; } catch (LlmCallException callError) { LogAiUnavailable("generate_keywords", tenantId, config, callError); throw ToUnavailable(callError); } } /// /// EvaluateFit — оценка соответствия сообщения задаче поиска /// public override async Task EvaluateFit(EvaluateFitRequest request, ServerCallContext context) { string tenantId = RequireTenantId(context); EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail); EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail); EnsureKeywordsWithinBounds(request.Keywords); LlmConfig config = ResolveConfig(request.ProviderConfig); try { string systemPrompt = string.Format( CultureInfo.InvariantCulture, EvaluateFitSystemPromptTemplate, request.Description, JoinKeywords(request.Keywords)); LlmCallResult result = await _caller .ChatJsonAsync(config, systemPrompt, FilterUserPrefix + request.Text, context.CancellationToken) .ConfigureAwait(false); bool fit = ReadBoolField(result.Json, FitFieldName, defaultValue: false); string reason = ReadFitReason(result.Json, fit); var reply = new EvaluateFitReply { Fit = fit, Reason = reason, Usage = ToUsage(result.Usage) }; _logger.LogInformation( "Аудит: tenant {TenantId} evaluate_fit → fit={Fit}, usage={TotalTokens}", tenantId, fit, result.Usage.TotalTokens); return reply; } catch (LlmCallException callError) { LogAiUnavailable("evaluate_fit", tenantId, config, callError); throw ToUnavailable(callError); } } private static string RequireTenantId(ServerCallContext context) { string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); if (string.IsNullOrWhiteSpace(tenantId)) { throw new RpcException(new Status(StatusCode.Unauthenticated, TenantIdMissingDetail)); } return tenantId; } // INVALID_ARGUMENT при превышении лимита длины текстового поля (серверный enforcement ai.proto). // value: Значение поля запроса (в proto строка не бывает null). // maxLength: Допустимый максимум символов. // detail: Текст отказа (detail RPC). private static void EnsureLengthAtMost( string value, int maxLength, string detail) { if (value.Length > maxLength) { throw new RpcException(new Status(StatusCode.InvalidArgument, detail)); } } // Проверяет число и длины ключей задачи EvaluateFit (защита промпта от раздувания). // keywords: Ключи задачи из запроса. private static void EnsureKeywordsWithinBounds(IEnumerable keywords) { int count = 0; foreach (string keyword in keywords) { if (++count > MaxKeywordsCount) { throw new RpcException(new Status(StatusCode.InvalidArgument, TooManyKeywordsDetail)); } if (keyword.Length > MaxKeywordLength) { throw new RpcException(new Status(StatusCode.InvalidArgument, KeywordTooLongDetail)); } } } // Проверяет конфиг провайдера и строит рабочий конфиг вызова. Конфиг невалиден (пустые // base_url/model) — INVALID_ARGUMENT: без конфига вызов модели невозможен. // providerConfig: Конфиг из тела запроса (ядро всегда заполняет). private static LlmConfig ResolveConfig(ProviderConfig? providerConfig) { if (providerConfig is null) { throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderConfigMissingDetail)); } if (string.IsNullOrWhiteSpace(providerConfig.BaseUrl)) { throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderBaseUrlEmptyDetail)); } if (string.IsNullOrWhiteSpace(providerConfig.Model)) { throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderModelEmptyDetail)); } return new LlmConfig( providerConfig.ProviderId, providerConfig.BaseUrl, providerConfig.Model, providerConfig.ApiKey, providerConfig.ApiStyle); } private void LogAiUnavailable( string method, string tenantId, LlmConfig config, LlmCallException callError) => _logger.LogWarning( "Аудит: tenant {TenantId} {Method} → ИИ ({Provider}) недоступен ({Kind})", tenantId, method, config.DisplayName, callError.Kind); private static RpcException ToUnavailable(LlmCallException callError) => new(new Status(StatusCode.Unavailable, callError.Message)); private static bool ReadBoolField( JsonObject json, string fieldName, bool defaultValue) { if (json[fieldName] is not JsonValue value) { return defaultValue; } if (value.TryGetValue(out bool flag)) { return flag; } if (value.TryGetValue(out string? raw) && raw is not null) { string text = raw.Trim(); return text.Length == 0 ? defaultValue : !FalsyAnswerValues.Contains(text); } if (value.TryGetValue(out int number)) { return number != 0; } return value.TryGetValue(out double fractional) && fractional != 0; } // Читает строковое поле ответа модели (null — поля нет/не строка/пусто). // json: Корневой объект ответа модели. // fieldName: Имя поля. private static string? ReadStringField(JsonObject json, string fieldName) { if (json[fieldName] is not JsonValue value || !value.TryGetValue(out string? text)) { return null; } string trimmed = text.Trim(); return trimmed.Length == 0 ? null : trimmed; } private static string ReadFitReason(JsonObject json, bool fit) { string reason = ReadStringField(json, ReasonFieldName) ?? (fit ? FitReasonDefault : NotFitReasonDefault); return reason.Length <= MaxEvalReasonLength ? reason : reason[..MaxEvalReasonLength]; } // Читает список ключевых слов из ответа модели (не-строки пропускаются; чистку — ядро). // json: Корневой объект ответа модели. private static IEnumerable ReadKeywords(JsonObject json) { if (json[KeywordsFieldName] is not JsonArray keywords) { yield break; } foreach (JsonNode? keywordNode in keywords) { if (keywordNode is JsonValue value && value.TryGetValue(out string? keyword)) { string trimmed = keyword.Trim(); if (trimmed.Length > 0) { yield return trimmed; } } } } private static string JoinKeywords(IEnumerable keywords) => string.Join( ", ", keywords.Where(keyword => !string.IsNullOrWhiteSpace(keyword)).Select(keyword => keyword.Trim())); // Маппит итоговую оценку токенов в gRPC-usage (null → нули — контрактный ответ без usage). // usage: Оценка токенов вызова. private static Usage ToUsage(LlmUsage? usage) => new() { Prompt = usage is null ? 0U : (uint)usage.PromptTokens, Completion = usage is null ? 0U : (uint)usage.CompletionTokens, Total = usage is null ? 0U : (uint)usage.TotalTokens, }; }