ci / build-test (push) Canceled after 0s
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 зелёные.
449 lines
19 KiB
C#
449 lines
19 KiB
C#
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"
|
||
+ "- 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<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,
|
||
};
|
||
}
|