Files
Deal/src/ai-service/Deal.Ai/AiServiceImpl.cs
T
Rustam Khalimov 27c7831910
ci / build-test (push) Canceled after 0s
Deal — единая кодовая база
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 23:56:47 +03:00

449 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Text.Json.Nodes;
using Deal.Ai.Llm;
using Deal.Grpc.Ai;
using Grpc.Core;
namespace Deal.Ai;
/// <summary>
/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service.
/// </summary>
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"
+ "- 1016 ключей;\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<string> FalsyAnswerValues = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"0",
"false",
"no",
"нет",
"null",
"none",
};
private readonly ProviderCaller _caller;
private readonly ILogger<AiServiceImpl> _logger;
/// <summary>
/// Создаёт gRPC-сервис команд ядра поверх LLM-фасада.
/// </summary>
/// <param name="caller">Оркестратор вызовов модели (ретраи + извлечение JSON + usage).</param>
/// <param name="logger">Логгер аудита.</param>
public AiServiceImpl(ProviderCaller caller, ILogger<AiServiceImpl> logger)
{
_caller = caller;
_logger = logger;
}
/// <summary>
/// Filter — ИИ-фильтр входящих сообщений
/// </summary>
public override async Task<FilterReply> 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);
}
}
/// <summary>
/// Classify — полный разбор лида
/// </summary>
public override async Task<ClassifyReply> 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);
}
}
/// <summary>
/// GenerateKeywords — ключевые слова discovery-задачи по описанию
/// </summary>
public override async Task<GenerateKeywordsReply> 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);
}
}
/// <summary>
/// EvaluateFit — оценка соответствия сообщения задаче поиска
/// </summary>
public override async Task<EvaluateFitReply> 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<string> 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<bool>(out bool flag))
{
return flag;
}
if (value.TryGetValue<string>(out string? raw) && raw is not null)
{
string text = raw.Trim();
return text.Length == 0 ? defaultValue : !FalsyAnswerValues.Contains(text);
}
if (value.TryGetValue<int>(out int number))
{
return number != 0;
}
return value.TryGetValue<double>(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<string>(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<string> ReadKeywords(JsonObject json)
{
if (json[KeywordsFieldName] is not JsonArray keywords)
{
yield break;
}
foreach (JsonNode? keywordNode in keywords)
{
if (keywordNode is JsonValue value && value.TryGetValue<string>(out string? keyword))
{
string trimmed = keyword.Trim();
if (trimmed.Length > 0)
{
yield return trimmed;
}
}
}
}
private static string JoinKeywords(IEnumerable<string> 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,
};
}