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,
};
}