Вынести wire-ключи и операции ИИ-сервиса в каталоги

This commit is contained in:
2026-09-13 20:51:42 +03:00
parent 1fbe29a838
commit 04d7a0f6fb
5 changed files with 165 additions and 29 deletions
+27
View File
@@ -0,0 +1,27 @@
namespace Deal.Ai;
/// <summary>
/// Имена операций AiService для аудита
/// </summary>
public static class AiOperations
{
/// <summary>
/// Операция ИИ-фильтра входящих сообщений.
/// </summary>
public const string Filter = "filter";
/// <summary>
/// Операция полного разбора лида.
/// </summary>
public const string Classify = "classify";
/// <summary>
/// Операция генерации ключевых слов discovery-задачи.
/// </summary>
public const string GenerateKeywords = "generate_keywords";
/// <summary>
/// Операция оценки соответствия сообщения задаче поиска.
/// </summary>
public const string EvaluateFit = "evaluate_fit";
}
+4 -4
View File
@@ -150,7 +150,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("filter", tenantId, config, callError);
LogAiUnavailable(AiOperations.Filter, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -191,7 +191,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("classify", tenantId, config, callError);
LogAiUnavailable(AiOperations.Classify, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -226,7 +226,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("generate_keywords", tenantId, config, callError);
LogAiUnavailable(AiOperations.GenerateKeywords, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -267,7 +267,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("evaluate_fit", tenantId, config, callError);
LogAiUnavailable(AiOperations.EvaluateFit, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -0,0 +1,17 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Роли сообщений чата в wire-формате ИИ-провайдеров
/// </summary>
public static class LlmChatRoles
{
/// <summary>
/// Роль системного сообщения.
/// </summary>
public const string System = "system";
/// <summary>
/// Роль пользовательского сообщения.
/// </summary>
public const string User = "user";
}
+25 -25
View File
@@ -100,12 +100,12 @@ public sealed class LlmHttpClient : IProviderClient
var body = new JsonObject
{
["model"] = config.Model,
["messages"] = new JsonArray(
ChatMessage("system", systemPrompt),
ChatMessage("user", userText)),
["temperature"] = Temperature,
["max_tokens"] = MaxResponseTokens,
[LlmWireKeys.Model] = config.Model,
[LlmWireKeys.Messages] = new JsonArray(
ChatMessage(LlmChatRoles.System, systemPrompt),
ChatMessage(LlmChatRoles.User, userText)),
[LlmWireKeys.Temperature] = Temperature,
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
};
request.Content = JsonBody(body);
@@ -123,10 +123,10 @@ public sealed class LlmHttpClient : IProviderClient
var body = new JsonObject
{
["model"] = config.Model,
["max_tokens"] = MaxResponseTokens,
["system"] = systemPrompt,
["messages"] = new JsonArray(ChatMessage("user", userText)),
[LlmWireKeys.Model] = config.Model,
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
[LlmWireKeys.System] = systemPrompt,
[LlmWireKeys.Messages] = new JsonArray(ChatMessage(LlmChatRoles.User, userText)),
};
request.Content = JsonBody(body);
@@ -156,7 +156,7 @@ public sealed class LlmHttpClient : IProviderClient
private static ProviderChatResult ReadOpenAiBody(string body)
{
JsonObject? payload = ParseObjectOrThrow(body);
JsonArray? choices = payload["choices"] as JsonArray;
JsonArray? choices = payload[LlmWireKeys.Choices] as JsonArray;
if (choices is null || choices.Count == 0)
{
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
@@ -164,14 +164,14 @@ public sealed class LlmHttpClient : IProviderClient
}
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject;
string? content = ReadStringField(message, "content");
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content")))
JsonObject? message = (choices[0] as JsonObject)?[LlmWireKeys.Message] as JsonObject;
string? content = ReadStringField(message, LlmWireKeys.Content);
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, LlmWireKeys.ReasoningContent)))
{
throw new LlmHttpException("Модель вернула только reasoning без ответа");
}
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload[LlmWireKeys.Usage]));
}
private static ProviderChatResult ReadAnthropicBody(string body)
@@ -179,18 +179,18 @@ public sealed class LlmHttpClient : IProviderClient
JsonObject? payload = ParseObjectOrThrow(body);
var text = new StringBuilder();
if (payload["content"] is JsonArray contentBlocks)
if (payload[LlmWireKeys.Content] is JsonArray contentBlocks)
{
foreach (JsonNode? blockNode in contentBlocks)
{
if (blockNode is JsonObject block)
{
text.Append(ReadStringField(block, "text"));
text.Append(ReadStringField(block, LlmWireKeys.Text));
}
}
}
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"]));
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload[LlmWireKeys.Usage]));
}
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
@@ -202,9 +202,9 @@ public sealed class LlmHttpClient : IProviderClient
return null;
}
int? promptTokens = ReadIntField(usage, "prompt_tokens");
int? completionTokens = ReadIntField(usage, "completion_tokens");
int? totalTokens = ReadIntField(usage, "total_tokens");
int? promptTokens = ReadIntField(usage, LlmWireKeys.PromptTokens);
int? completionTokens = ReadIntField(usage, LlmWireKeys.CompletionTokens);
int? totalTokens = ReadIntField(usage, LlmWireKeys.TotalTokens);
return promptTokens is null || completionTokens is null || totalTokens is null
? null
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
@@ -219,8 +219,8 @@ public sealed class LlmHttpClient : IProviderClient
return null;
}
int? inputTokens = ReadIntField(usage, "input_tokens");
int? outputTokens = ReadIntField(usage, "output_tokens");
int? inputTokens = ReadIntField(usage, LlmWireKeys.InputTokens);
int? outputTokens = ReadIntField(usage, LlmWireKeys.OutputTokens);
return inputTokens is null || outputTokens is null
? null
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
@@ -254,8 +254,8 @@ public sealed class LlmHttpClient : IProviderClient
private static JsonObject ChatMessage(string role, string content)
=> new()
{
["role"] = role,
["content"] = content,
[LlmWireKeys.Role] = role,
[LlmWireKeys.Content] = content,
};
// Создаёт JSON-содержимое запроса (application/json).
+92
View File
@@ -0,0 +1,92 @@
namespace Deal.Ai.Llm;
/// <summary>
/// JSON-ключи wire-формата запросов и ответов ИИ-провайдеров
/// </summary>
public static class LlmWireKeys
{
/// <summary>
/// Ключ имени модели.
/// </summary>
public const string Model = "model";
/// <summary>
/// Ключ списка сообщений чата.
/// </summary>
public const string Messages = "messages";
/// <summary>
/// Ключ системного промпта (Anthropic — отдельным полем).
/// </summary>
public const string System = "system";
/// <summary>
/// Ключ температуры генерации (OpenAI-совместимый формат).
/// </summary>
public const string Temperature = "temperature";
/// <summary>
/// Ключ лимита токенов ответа.
/// </summary>
public const string MaxTokens = "max_tokens";
/// <summary>
/// Ключ списка вариантов ответа (OpenAI-совместимый формат).
/// </summary>
public const string Choices = "choices";
/// <summary>
/// Ключ выбранного варианта ответа (OpenAI-совместимый формат).
/// </summary>
public const string Message = "message";
/// <summary>
/// Ключ текстового содержимого.
/// </summary>
public const string Content = "content";
/// <summary>
/// Ключ текста рассуждений модели (reasoning).
/// </summary>
public const string ReasoningContent = "reasoning_content";
/// <summary>
/// Ключ оценки токенов вызова.
/// </summary>
public const string Usage = "usage";
/// <summary>
/// Ключ текста блока контента (Anthropic).
/// </summary>
public const string Text = "text";
/// <summary>
/// Ключ числа токенов запроса (OpenAI-совместимый формат).
/// </summary>
public const string PromptTokens = "prompt_tokens";
/// <summary>
/// Ключ числа токенов ответа (OpenAI-совместимый формат).
/// </summary>
public const string CompletionTokens = "completion_tokens";
/// <summary>
/// Ключ суммарного числа токенов (OpenAI-совместимый формат).
/// </summary>
public const string TotalTokens = "total_tokens";
/// <summary>
/// Ключ числа входных токенов (Anthropic).
/// </summary>
public const string InputTokens = "input_tokens";
/// <summary>
/// Ключ числа выходных токенов (Anthropic).
/// </summary>
public const string OutputTokens = "output_tokens";
/// <summary>
/// Ключ роли сообщения чата.
/// </summary>
public const string Role = "role";
}