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 зелёные.
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Grpc.Ai;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// In-proc gRPC-тесты AiService поверх фейк-провайдера
|
||||
/// </summary>
|
||||
public sealed class AiRpcTests
|
||||
{
|
||||
// Имя провайдера сценариев (для текста ошибки UNAVAILABLE).
|
||||
private const string ProviderId = "deepseek";
|
||||
|
||||
// Usage API-ответа сценариев (проверка проброса в reply).
|
||||
private static readonly ProviderUsage SampleUsage = new(11, 5, 16);
|
||||
|
||||
private const string UnavailableDetail =
|
||||
"ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд";
|
||||
|
||||
/// <summary>
|
||||
/// Filter: модель пропускает сообщение — pass=true с причиной и usage API-ответа.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_ModelPasses_ReturnsPassReasonAndUsage()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"pass": true, "reason": "похоже на заявку"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
FilterReply reply = await client.FilterAsync(
|
||||
new FilterRequest
|
||||
{
|
||||
Prompt = "Фильтр: {domain}",
|
||||
Text = "Ищем разработчика на проект",
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
Assert.True(reply.Pass);
|
||||
Assert.Equal("похоже на заявку", reply.Reason);
|
||||
AssertUsage(reply.Usage, SampleUsage);
|
||||
|
||||
FakeProviderCall call = Assert.Single(fake.Calls);
|
||||
Assert.Equal("Фильтр: {domain}", call.SystemPrompt);
|
||||
Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: модель отклоняет сообщение — pass=false с причиной отказа.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_ModelRejects_ReturnsPassFalse()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"pass": false, "reason": "реклама канала"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
FilterReply reply = await client.FilterAsync(
|
||||
new FilterRequest { Prompt = "Фильтр", Text = "Подпишись на канал", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.False(reply.Pass);
|
||||
Assert.Equal("реклама канала", reply.Reason);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: модель не вернула pass — по умолчанию пропуск
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_MissingPassField_DefaultsToPass()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"note": "ничего не понял"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
FilterReply reply = await client.FilterAsync(
|
||||
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.True(reply.Pass);
|
||||
Assert.False(reply.HasReason);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: провайдер недоступен после ретраев
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail()
|
||||
{
|
||||
FakeProviderClient fake = Failing();
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.FilterAsync(
|
||||
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
|
||||
Assert.Equal(UnavailableDetail, exception.Status.Detail);
|
||||
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_AnswerWithoutJson_ThrowsUnavailable()
|
||||
{
|
||||
FakeProviderClient fake = new((_, _, _) =>
|
||||
Task.FromResult(new ProviderChatResult("какой-то текст без json", Usage: null)));
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.FilterAsync(
|
||||
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson()
|
||||
{
|
||||
const string modelJson =
|
||||
"""{"board": "dev", "title": "Нужен python-разработчик", "budget": null, "contacts": []}""";
|
||||
FakeProviderClient fake = Success(modelJson);
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
ClassifyReply reply = await client.ClassifyAsync(
|
||||
new ClassifyRequest
|
||||
{
|
||||
SystemPrompt = "Система карточки",
|
||||
UserContext = "Доски:\n- dev: разработка\nНовое сообщение: ищем middle",
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
Assert.True(reply.Ok);
|
||||
Assert.True(reply.HasJson);
|
||||
AssertUsage(reply.Usage, SampleUsage);
|
||||
|
||||
JsonObject json = JsonNode.Parse(reply.Json)!.AsObject();
|
||||
Assert.Equal("dev", (string?)json["board"]);
|
||||
Assert.Equal("Нужен python-разработчик", (string?)json["title"]);
|
||||
|
||||
FakeProviderCall call = Assert.Single(fake.Calls);
|
||||
Assert.Equal("Система карточки", call.SystemPrompt);
|
||||
Assert.Equal("Доски:\n- dev: разработка\nНовое сообщение: ищем middle", call.UserText);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка; usage последней попытки в ответе
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage()
|
||||
{
|
||||
const string systemPrompt = "Система карточки";
|
||||
const string userContext = "Доски и сообщение для разбора";
|
||||
const string garbage = "простите, я не умею в json";
|
||||
FakeProviderClient fake = new((_, _, _) =>
|
||||
Task.FromResult(new ProviderChatResult(garbage, Usage: null)));
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
ClassifyReply reply = await client.ClassifyAsync(
|
||||
new ClassifyRequest
|
||||
{
|
||||
SystemPrompt = systemPrompt,
|
||||
UserContext = userContext,
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
Assert.False(reply.Ok);
|
||||
Assert.False(reply.HasJson);
|
||||
Assert.Equal(EstimateTokens(systemPrompt.Length + userContext.Length), reply.Usage.Prompt);
|
||||
Assert.Equal(EstimateTokens(garbage.Length), reply.Usage.Completion);
|
||||
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify: провайдер недоступен — UNAVAILABLE
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_ProviderUnavailable_ThrowsUnavailable()
|
||||
{
|
||||
FakeProviderClient fake = Failing();
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.ClassifyAsync(
|
||||
new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() },
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
|
||||
Assert.Equal(UnavailableDetail, exception.Status.Detail);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"keywords": ["стройка", "ремонт квартир", "подряды"]}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest
|
||||
{
|
||||
Description = "Поиск каналов со стройкой и ремонтом",
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords);
|
||||
AssertUsage(reply.Usage, SampleUsage);
|
||||
|
||||
FakeProviderCall call = Assert.Single(fake.Calls);
|
||||
Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal);
|
||||
Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal);
|
||||
Assert.Equal("Описание ниши/задачи:\nПоиск каналов со стройкой и ремонтом", call.UserText);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_NonStringItems_Skipped()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"keywords": ["php", 42, "c#"]}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.Equal(["php", "c#"], reply.Keywords);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"note": "не нашёл ключей"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.Empty(reply.Keywords);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\n…».
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_ModelFits_ReturnsFitAndReason()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"fit": 1, "reason": "строительная тематика"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest
|
||||
{
|
||||
Text = "Нужна бригада отделочников на объект",
|
||||
Description = "Поиск подрядчиков на отделку",
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
Assert.True(reply.Fit);
|
||||
Assert.Equal("строительная тематика", reply.Reason);
|
||||
AssertUsage(reply.Usage, SampleUsage);
|
||||
|
||||
FakeProviderCall call = Assert.Single(fake.Calls);
|
||||
Assert.Equal("Сообщение:\nНужна бригада отделочников на объект", call.UserText);
|
||||
Assert.Contains("Оцени, относится ли сообщение к сфере/задаче", call.SystemPrompt, StringComparison.Ordinal);
|
||||
Assert.Contains("Описание: Поиск подрядчиков на отделку.", call.SystemPrompt, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_KeywordsJoinedIntoPrompt()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"fit": 0, "reason": "не совпадает"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest
|
||||
{
|
||||
Text = "Текст",
|
||||
Description = "Описание",
|
||||
Keywords = { "стройка", " ремонт ", " ", "подряд" },
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
string systemPrompt = Assert.Single(fake.Calls).SystemPrompt;
|
||||
Assert.Contains("Ключи: стройка, ремонт, подряд.", systemPrompt, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"fit": 0}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.False(reply.Fit);
|
||||
Assert.Equal("не подходит", reply.Reason);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: fit строкой «нет» — false, причина из модели.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_StringFalsyFit_ReturnsFalse()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"fit": "нет", "reason": "другая сфера"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.False(reply.Fit);
|
||||
Assert.Equal("другая сфера", reply.Reason);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: длинная причина модели усекается до 200 символов.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_LongReason_IsTruncatedTo200()
|
||||
{
|
||||
string longReason = new('а', 250);
|
||||
FakeProviderClient fake = Success($$"""{"fit": 1, "reason": "{{longReason}}"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.Equal(200, reply.Reason.Length);
|
||||
Assert.StartsWith("ааааа", reply.Reason, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Конфиг-валидация
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutProviderConfig_IsInvalidArgument()
|
||||
{
|
||||
FakeProviderClient fake = Success("{}");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.ClassifyAsync(
|
||||
new ClassifyRequest { SystemPrompt = "S", UserContext = "U" },
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Конфиг-валидация
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_EmptyModel_IsInvalidArgument()
|
||||
{
|
||||
FakeProviderClient fake = Success("{}");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest
|
||||
{
|
||||
Description = "Описание",
|
||||
ProviderConfig = new ProviderConfig { BaseUrl = "https://api.example.com/v1" },
|
||||
},
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Серверный лимит text
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_TooLongText_IsInvalidArgument()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"pass": true}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.FilterAsync(
|
||||
new FilterRequest
|
||||
{
|
||||
Prompt = "Фильтр",
|
||||
Text = new string('а', 4001),
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Серверный лимит description
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument()
|
||||
{
|
||||
FakeProviderClient fake = Success("{}");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest
|
||||
{
|
||||
Description = new string('а', 4001),
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обязательный tenant-id в metadata
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutTenantId_IsUnauthenticated()
|
||||
{
|
||||
FakeProviderClient fake = Success("{}");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
Metadata metadata = AiTestHost.CallMetadata(AiTestHost.DefaultToken, tenantId: null);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.ClassifyAsync(
|
||||
new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() },
|
||||
AiTestHost.CallOptions(metadata)).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
// Конфиг DI сценария: фейк-провайдер + мгновенные паузы ретраев.
|
||||
// fake: Фейк-провайдер вызовов.
|
||||
private static Action<IServiceCollection> Host(FakeProviderClient fake)
|
||||
=> services =>
|
||||
{
|
||||
AiTestHost.UseFakeProvider(services, fake);
|
||||
AiTestHost.DisableRetryDelays(services);
|
||||
};
|
||||
|
||||
// CallOptions с service-token и tenant-id сценария.
|
||||
private static CallOptions Options()
|
||||
=> AiTestHost.CallOptions(
|
||||
AiTestHost.CallMetadata(AiTestHost.DefaultToken, AiTestHost.DefaultTenantId));
|
||||
|
||||
// Конфиг провайдера запроса (зеркало того, что ядро кладёт в тело запроса).
|
||||
private static ProviderConfig RequestConfig()
|
||||
=> new()
|
||||
{
|
||||
ProviderId = ProviderId,
|
||||
BaseUrl = "https://api.example.com/v1",
|
||||
Model = "deepseek-v4-flash",
|
||||
ApiKey = "test-key",
|
||||
};
|
||||
|
||||
// Фейк, всегда отвечающий заданным JSON-текстом с usage API-ответа.
|
||||
// jsonText: JSON-текст ответа модели.
|
||||
private static FakeProviderClient Success(string jsonText)
|
||||
=> new((_, _, _) => Task.FromResult(new ProviderChatResult(jsonText, SampleUsage)));
|
||||
|
||||
// Фейк, всегда падающий транспортной ошибкой (повод для ретрая).
|
||||
private static FakeProviderClient Failing()
|
||||
=> new((_, _, _) => Task.FromException<ProviderChatResult>(new LlmHttpException("сеть недоступна")));
|
||||
|
||||
// Оценка токенов по символам (зеркало TokenEstimator; для проверки usage в ответе).
|
||||
// charCount: Число символов.
|
||||
private static uint EstimateTokens(int charCount)
|
||||
=> (uint)((charCount + 3) / 4);
|
||||
|
||||
// Проверяет проброс usage провайдера в gRPC-ответ.
|
||||
// usage: Usage ответа RPC.
|
||||
// expected: Ожидаемое значение.
|
||||
private static void AssertUsage(Deal.Grpc.Ai.Usage usage, ProviderUsage expected)
|
||||
{
|
||||
Assert.Equal((uint)expected.PromptTokens, usage.Prompt);
|
||||
Assert.Equal((uint)expected.CompletionTokens, usage.Completion);
|
||||
Assert.Equal((uint)expected.TotalTokens, usage.Total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Grpc.Hosting.Interceptors;
|
||||
using Deal.Grpc.Hosting.Models;
|
||||
using Deal.Grpc.Hosting.Options;
|
||||
using Deal.Grpc.Hosting.Services;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
internal static class AiTestHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ ожидаемого service-token
|
||||
/// </summary>
|
||||
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey;
|
||||
|
||||
public const string TenantIdMetadataKey = AiServiceImpl.TenantIdMetadataKey;
|
||||
|
||||
/// <summary>
|
||||
/// Токен сценариев теста.
|
||||
/// </summary>
|
||||
public const string DefaultToken = "deal-ai-test-token";
|
||||
|
||||
/// <summary>
|
||||
/// Id тенанта сценариев по умолчанию.
|
||||
/// </summary>
|
||||
public const string DefaultTenantId = "tenant-test";
|
||||
|
||||
/// <summary>
|
||||
/// Deadline RPC-вызовов теста
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Прогоняет сценарий против хоста со стандартным токеном и фейк-подменой фасада.
|
||||
/// </summary>
|
||||
/// <param name="configureServices">Настройка DI сценария (фейк-провайдер, мгновенные ретраи).</param>
|
||||
/// <param name="scenario">Сценарий с gRPC-каналом к хосту.</param>
|
||||
public static Task RunAsync(Action<IServiceCollection> configureServices, Func<GrpcChannel, Task> scenario)
|
||||
=> RunAsync(DefaultToken, configureServices, scenario);
|
||||
|
||||
/// <summary>
|
||||
/// Прогоняет сценарий против поднятого хоста с заданным env-токеном и подменами DI.
|
||||
/// </summary>
|
||||
/// <param name="serviceToken">Значение env DEAL_SERVICE_TOKEN (null — убрать переменную).</param>
|
||||
/// <param name="configureServices">Настройка DI сценария (может быть пустой).</param>
|
||||
/// <param name="scenario">Сценарий с gRPC-каналом к хосту.</param>
|
||||
public static async Task RunAsync(
|
||||
string? serviceToken,
|
||||
Action<IServiceCollection> configureServices,
|
||||
Func<GrpcChannel, Task> scenario)
|
||||
{
|
||||
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
|
||||
|
||||
WebApplication? app = null;
|
||||
GrpcChannel? channel = null;
|
||||
try
|
||||
{
|
||||
int port = FreeTcpPort();
|
||||
app = AiServiceHost.Create(port, configureServices: configureServices);
|
||||
await app.StartAsync();
|
||||
|
||||
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
|
||||
await scenario(channel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (channel is not null)
|
||||
{
|
||||
channel.Dispose();
|
||||
}
|
||||
|
||||
if (app is not null)
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подменяет HTTP-фасад вызовов модели фейком сценария
|
||||
/// </summary>
|
||||
/// <param name="fake">Фейк-провайдер сценария.</param>
|
||||
public static void UseFakeProvider(IServiceCollection services, FakeProviderClient fake)
|
||||
=> services.AddSingleton<IProviderClient>(fake);
|
||||
|
||||
/// <summary>
|
||||
/// Делает паузы ретраев мгновенными
|
||||
/// </summary>
|
||||
public static void DisableRetryDelays(IServiceCollection services)
|
||||
=> services.AddSingleton<Func<TimeSpan, CancellationToken, Task>>(static (_, _) => Task.CompletedTask);
|
||||
|
||||
/// <summary>
|
||||
/// Строит metadata вызова
|
||||
/// </summary>
|
||||
/// <param name="serviceToken">Значение заголовка service-token.</param>
|
||||
/// <param name="tenantId">Id тенанта (null — без заголовка tenant-id).</param>
|
||||
public static Metadata CallMetadata(string? serviceToken, string? tenantId = null)
|
||||
{
|
||||
var metadata = new Metadata();
|
||||
if (serviceToken is not null)
|
||||
{
|
||||
metadata.Add(ServiceTokenMetadataKey, serviceToken);
|
||||
}
|
||||
|
||||
if (tenantId is not null)
|
||||
{
|
||||
metadata.Add(TenantIdMetadataKey, tenantId);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CallOptions RPC
|
||||
/// </summary>
|
||||
/// <param name="metadata">Metadata вызова.</param>
|
||||
public static CallOptions CallOptions(Metadata metadata)
|
||||
=> new(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
|
||||
// Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста).
|
||||
private static int FreeTcpPort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
// Зафиксированный вызов фейк-провайдера (для проверки собранных RPC промптов).
|
||||
// SystemPrompt: Системный промпт вызова.
|
||||
// UserText: Пользовательское сообщение вызова.
|
||||
// Config: Конфиг провайдера вызова.
|
||||
internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config);
|
||||
|
||||
internal sealed class FakeProviderClient : IProviderClient
|
||||
{
|
||||
private readonly Func<string, string, LlmConfig, Task<ProviderChatResult>> _handler;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт фейк с поведением сценария.
|
||||
/// </summary>
|
||||
/// <param name="handler">Поведение вызова: (systemPrompt, userText, config) → результат/сбой.</param>
|
||||
public FakeProviderClient(Func<string, string, LlmConfig, Task<ProviderChatResult>> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все вызовы фейка в порядке поступления
|
||||
/// </summary>
|
||||
public List<FakeProviderCall> Calls { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProviderChatResult> ChatAsync(
|
||||
LlmConfig config,
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Calls.Add(new FakeProviderCall(systemPrompt, userText, config));
|
||||
return _handler(systemPrompt, userText, config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Deal.Ai.Llm;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты оркестратора вызовов модели
|
||||
/// </summary>
|
||||
public sealed class ProviderCallerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Успех с первого вызова
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_FirstAttemptSuccess_ReturnsJsonAndUsage()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) => Task.FromResult(
|
||||
new ProviderChatResult("""{"pass": false}""", new ProviderUsage(11, 5, 16))));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None);
|
||||
|
||||
Assert.False((bool)result.Json["pass"]!);
|
||||
Assert.Equal("""{"pass":false}""", result.JsonText);
|
||||
Assert.Equal(16, result.Usage.TotalTokens);
|
||||
Assert.Single(fake.Calls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Марdown-обёртка ответа модели снимается фасадом.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_MarkdownWrappedJson_Extracts()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) => Task.FromResult(
|
||||
new ProviderChatResult("```json\n{\"fit\": 1}\n```", Usage: null)));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, (int)result.Json["fit"]!);
|
||||
Assert.Single(fake.Calls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Без usage API-ответа фасад оценивает токены по символам
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_WithoutProviderUsage_EstimatesTokens()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) => Task.FromResult(
|
||||
new ProviderChatResult("{}", Usage: null)));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallResult result = await caller.ChatJsonAsync(Config(), "abcdefgh", "user", CancellationToken.None);
|
||||
|
||||
// prompt-текст = 8 + 4 = 12 символов → 3 токена; completion = "{}" = 2 символа → 1 токен.
|
||||
Assert.Equal(3, result.Usage.PromptTokens);
|
||||
Assert.Equal(1, result.Usage.CompletionTokens);
|
||||
Assert.Equal(4, result.Usage.TotalTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сбой на первых двух попытках и успех на третьей
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_TwoFailuresThenSuccess_RetriesAndSucceeds()
|
||||
{
|
||||
int attempts = 0;
|
||||
var fake = new FakeProviderClient((_, _, _) =>
|
||||
{
|
||||
attempts++;
|
||||
return attempts < 3
|
||||
? Task.FromException<ProviderChatResult>(new LlmHttpException("таймаут"))
|
||||
: Task.FromResult(new ProviderChatResult("""{"ok": 1}""", Usage: null));
|
||||
});
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, (int)result.Json["ok"]!);
|
||||
Assert.Equal(3, attempts);
|
||||
Assert.Equal(3, fake.Calls.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все попытки — транспортный сбой
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_AllTransportFailures_ThrowsProviderUnavailable()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) =>
|
||||
Task.FromException<ProviderChatResult>(new LlmHttpException("сеть недоступна")));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallException exception = await Assert.ThrowsAsync<LlmCallException>(() =>
|
||||
caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None));
|
||||
|
||||
Assert.Equal(LlmCallFailureKind.ProviderUnavailable, exception.Kind);
|
||||
Assert.Null(exception.Usage);
|
||||
Assert.Equal(
|
||||
"ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд",
|
||||
exception.Message);
|
||||
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Модель отвечала, но ни одна попытка не дала разбираемый JSON
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_AllAnswersNotJson_ThrowsAnswerNotJsonWithUsage()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) =>
|
||||
Task.FromResult(new ProviderChatResult("сожалею, не понимаю", Usage: null)));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallException exception = await Assert.ThrowsAsync<LlmCallException>(() =>
|
||||
caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None));
|
||||
|
||||
Assert.Equal(LlmCallFailureKind.AnswerNotJson, exception.Kind);
|
||||
Assert.NotNull(exception.Usage);
|
||||
Assert.True(exception.Usage!.CompletionTokens > 0);
|
||||
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_Cancelled_PropagatesCancellation()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var fake = new FakeProviderClient(
|
||||
(_, _, _) => Task.FromException<ProviderChatResult>(new OperationCanceledException()));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
caller.ChatJsonAsync(Config(), "system", "user", cancellation.Token));
|
||||
}
|
||||
|
||||
// Создаёт оркестратор с фейком и мгновенными паузами ретраев.
|
||||
// fake: Фейк-провайдер.
|
||||
private static ProviderCaller CreateCaller(FakeProviderClient fake)
|
||||
=> new(fake, static (_, _) => Task.CompletedTask, NullLogger<ProviderCaller>.Instance);
|
||||
|
||||
// Типовой конфиг провайдера сценариев.
|
||||
private static LlmConfig Config()
|
||||
=> new("deepseek", "https://api.example.com/v1", "deepseek-v4-flash", "test-key", null);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты оценки токенов
|
||||
/// </summary>
|
||||
public sealed class TokenEstimatorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Usage провайдера проходит без изменений, включая total ≠ сумме
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_WithProviderUsage_PassesThrough()
|
||||
{
|
||||
var provider = new ProviderUsage(PromptTokens: 11, CompletionTokens: 5, TotalTokens: 16);
|
||||
|
||||
LlmUsage usage = TokenEstimator.Resolve(provider, "system", "text");
|
||||
|
||||
Assert.Equal(11, usage.PromptTokens);
|
||||
Assert.Equal(5, usage.CompletionTokens);
|
||||
Assert.Equal(16, usage.TotalTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нет usage провайдера — оценка по символам
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_WithoutProviderUsage_EstimatesByChars()
|
||||
{
|
||||
// prompt-текст = 8 символов → 2 токена; completion-текст = 13 символов → ceil(13/4) = 4.
|
||||
LlmUsage usage = TokenEstimator.Resolve(null, "12345678", """{"pass": true}""");
|
||||
|
||||
Assert.Equal(2, usage.PromptTokens);
|
||||
Assert.Equal(4, usage.CompletionTokens);
|
||||
Assert.Equal(6, usage.TotalTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Округление вверх
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_WithoutProviderUsage_RoundsUp()
|
||||
{
|
||||
LlmUsage fourChars = TokenEstimator.Resolve(null, "abcd", string.Empty);
|
||||
LlmUsage fiveChars = TokenEstimator.Resolve(null, "abcde", string.Empty);
|
||||
|
||||
Assert.Equal(1, fourChars.PromptTokens);
|
||||
Assert.Equal(2, fiveChars.PromptTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пустые тексты дают нулевую оценку
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_EmptyTexts_ReturnsZeroTokens()
|
||||
{
|
||||
LlmUsage usage = TokenEstimator.Resolve(null, string.Empty, string.Empty);
|
||||
|
||||
Assert.Equal(0, usage.PromptTokens);
|
||||
Assert.Equal(0, usage.CompletionTokens);
|
||||
Assert.Equal(0, usage.TotalTokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// In-proc gRPC-тесты ai-service поднимают реальные Kestrel-хосты и меняют процесс-глобальную
|
||||
// env-переменную DEAL_SERVICE_TOKEN на время сценария (AiTestHost и AiServiceHostTests).
|
||||
// Параллельный прогон классов дал бы гонки на env — тесты сериализованы (тот же шаблон, что
|
||||
// AssemblyInfo тестов ml/telegram-сервисов).
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Deal.Ai.Tests — интеграционные тесты каркаса ai-service (план Task 4, Acceptance):
|
||||
хост (Kestrel HTTP/2) поднимается в процессе теста на эфемерном порту через AiServiceHost.Create
|
||||
(та же сборка, что и Program.cs), проверяются gRPC-health → SERVING и ServiceTokenInterceptor
|
||||
(нет/неверный токен → UNAUTHENTICATED; пустой env DEAL_SERVICE_TOKEN → fail-closed). Стек тестов —
|
||||
как в Deal.Telegram.Tests (Task 2) и Deal.Ml.Tests (Task 3).
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="6.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Клиент gRPC в тестах (транспорт HTTP/2 + типы grpc.health.v1.Health). -->
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.83.0" />
|
||||
<PackageReference Include="Grpc.HealthCheck" Version="2.83.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- WebApplication/Kestrel-типы приходят из общего фреймворка ASP.NET Core (хосты тестов). -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Deal.Ai\Deal.Ai.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Deal.Grpc.Ai;
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Deal.Ai.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Интеграционные тесты хоста ai-service.
|
||||
/// </summary>
|
||||
public sealed class AiServiceHostTests
|
||||
{
|
||||
// Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor).
|
||||
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
// Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey).
|
||||
private const string ServiceTokenMetadataKey = "service-token";
|
||||
|
||||
// Токен сценариев теста.
|
||||
private const string ValidToken = "task4-test-token";
|
||||
|
||||
private const string TenantId = "tenant-test";
|
||||
|
||||
// Deadline RPC-вызовов теста (сек).
|
||||
private const int RpcDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HealthCheck_ReturnsServing()
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
ValidToken,
|
||||
async channel =>
|
||||
{
|
||||
var health = new Health.HealthClient(channel);
|
||||
HealthCheckResponse response = await health.CheckAsync(
|
||||
new HealthCheckRequest(),
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
|
||||
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрос без metadata «service-token» → UNAUTHENTICATED.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutToken_IsUnauthenticated()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: null,
|
||||
expected: StatusCode.Unauthenticated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрос с неверным токеном → UNAUTHENTICATED.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithWrongToken_IsUnauthenticated()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: "wrong-token",
|
||||
expected: StatusCode.Unauthenticated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: ValidToken,
|
||||
expected: StatusCode.InvalidArgument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing()
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
serviceToken: null,
|
||||
async channel =>
|
||||
{
|
||||
var health = new Health.HealthClient(channel);
|
||||
HealthCheckResponse healthResponse = await health.CheckAsync(
|
||||
new HealthCheckRequest(),
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status);
|
||||
|
||||
// Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену).
|
||||
await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated);
|
||||
await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated);
|
||||
});
|
||||
}
|
||||
|
||||
// Прогоняет Classify с заданным заголовком «service-token» (null — без заголовка) и проверяет
|
||||
// ожидаемый код статуса RPC-исключения.
|
||||
// serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN).
|
||||
// tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка).
|
||||
// expected: Ожидаемый StatusCode ответа.
|
||||
private static async Task AssertDealRpcRejectedAsync(
|
||||
string? serviceToken,
|
||||
string? tokenHeader,
|
||||
StatusCode expected)
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
serviceToken,
|
||||
channel => AssertRejectedAsync(channel, tokenHeader, expected));
|
||||
}
|
||||
|
||||
private static async Task AssertRejectedAsync(
|
||||
GrpcChannel channel,
|
||||
string? tokenHeader,
|
||||
StatusCode expected)
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
Metadata metadata = new() { { "tenant-id", TenantId } };
|
||||
if (tokenHeader is not null)
|
||||
{
|
||||
metadata.Add(ServiceTokenMetadataKey, tokenHeader);
|
||||
}
|
||||
|
||||
var callOptions = new CallOptions(
|
||||
metadata,
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
|
||||
AsyncUnaryCall<ClassifyReply> call = client.ClassifyAsync(new ClassifyRequest(), callOptions);
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
|
||||
|
||||
Assert.Equal(expected, exception.StatusCode);
|
||||
}
|
||||
|
||||
// Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно
|
||||
// гасит хост/канал и восстанавливает исходный env.
|
||||
// serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать).
|
||||
// scenario: Сценарий с каналом к поднятому хосту.
|
||||
private static async Task RunHostScenarioAsync(string? serviceToken, Func<GrpcChannel, Task> scenario)
|
||||
{
|
||||
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
|
||||
|
||||
WebApplication? app = null;
|
||||
GrpcChannel? channel = null;
|
||||
try
|
||||
{
|
||||
int port = FreeTcpPort();
|
||||
app = AiServiceHost.Create(port);
|
||||
await app.StartAsync();
|
||||
|
||||
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
|
||||
await scenario(channel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (channel is not null)
|
||||
{
|
||||
channel.Dispose();
|
||||
}
|
||||
|
||||
if (app is not null)
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста).
|
||||
private static int FreeTcpPort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты извлечения JSON из ответа модели
|
||||
/// </summary>
|
||||
public sealed class JsonExtractorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Чистая JSON-строка объекта разбирается как есть.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_PlainObject_Parses()
|
||||
{
|
||||
JsonObject? json = JsonExtractor.TryExtractObject("""{"pass": true, "reason": "ок"}""");
|
||||
|
||||
Assert.NotNull(json);
|
||||
Assert.True((bool)json!["pass"]!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Markdown-обёртка ```json снимается
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_JsonFence_Unwraps()
|
||||
{
|
||||
JsonObject? json = JsonExtractor.TryExtractObject("```json\n{\"fit\": 1}\n```");
|
||||
|
||||
Assert.NotNull(json);
|
||||
Assert.Equal(1, (int)json!["fit"]!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обёртка без метки языка и с прозой вокруг — тоже снимается.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_PlainFenceWithProse_Unwraps()
|
||||
{
|
||||
const string answer = "Вот результат:\n```\n{\"keywords\": [\"php\"]}\n```\nНадеюсь, поможет.";
|
||||
JsonObject? json = JsonExtractor.TryExtractObject(answer);
|
||||
|
||||
Assert.NotNull(json);
|
||||
Assert.Single((JsonArray)json!["keywords"]!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON внутри произвольного текста берётся срезом между первой «{» и последней «}».
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_JsonInsideProse_Slices()
|
||||
{
|
||||
JsonObject? json = JsonExtractor.TryExtractObject("модель решила: {\"pass\": false} конец");
|
||||
|
||||
Assert.NotNull(json);
|
||||
Assert.False((bool)json!["pass"]!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Текст без JSON — null
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_NoJson_ReturnsNull()
|
||||
{
|
||||
Assert.Null(JsonExtractor.TryExtractObject("извините, не могу ответить"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Незакрытая обёртка и битый JSON — null, а не исключение.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_BrokenJson_ReturnsNull()
|
||||
{
|
||||
Assert.Null(JsonExtractor.TryExtractObject("```json\n{\"pass\": tru"));
|
||||
Assert.Null(JsonExtractor.TryExtractObject("{\"pass\": "));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON верхнего уровня не объект
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_NonObjectRoot_ReturnsNull()
|
||||
{
|
||||
Assert.Null(JsonExtractor.TryExtractObject("[\"php\", \"c#\"]"));
|
||||
Assert.Null(JsonExtractor.TryExtractObject("\"просто строка\""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пустой/пробельный текст — null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_Empty_ReturnsNull()
|
||||
{
|
||||
Assert.Null(JsonExtractor.TryExtractObject(string.Empty));
|
||||
Assert.Null(JsonExtractor.TryExtractObject(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler
|
||||
/// </summary>
|
||||
public sealed class LlmHttpClientTests
|
||||
{
|
||||
// Таймауты тестового клиента (короткие — чтобы таймаут-сценарий не ждал 90/60 с).
|
||||
private static readonly TimeSpan TestCallTimeout = TimeSpan.FromMilliseconds(60);
|
||||
|
||||
// Задержка заглушки для таймаут-сценария (должна превышать TestCallTimeout).
|
||||
private static readonly TimeSpan TestHandlerDelay = TimeSpan.FromMilliseconds(300);
|
||||
|
||||
// База OpenAI-совместимого провайдера сценария (как у openai/ollama/lmstudio — с /v1).
|
||||
private const string OpenAiBaseUrl = "https://api.example.com/v1";
|
||||
|
||||
// База Anthropic-провайдера сценария (без /v1 — путь добавляет клиент).
|
||||
private const string AnthropicBaseUrl = "https://api.anthropic.com";
|
||||
|
||||
// Ответ OpenAI-совместимого API с текстом и usage.
|
||||
private const string OpenAiJsonReply =
|
||||
"""
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": { "role": "assistant", "content": "{\"fit\": 1}" }
|
||||
}
|
||||
],
|
||||
"usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 }
|
||||
}
|
||||
""";
|
||||
|
||||
// Ответ Anthropic Messages API: два text-блока и usage input/output.
|
||||
private const string AnthropicJsonReply =
|
||||
"""
|
||||
{
|
||||
"content": [
|
||||
{ "type": "text", "text": "{\"fit\": 1}" },
|
||||
{ "type": "text", "text": " ещё текст" }
|
||||
],
|
||||
"usage": { "input_tokens": 4, "output_tokens": 6 }
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI-совместимый вызов
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
|
||||
IProviderClient client = CreateClient(handler);
|
||||
|
||||
ProviderChatResult result = await client.ChatAsync(
|
||||
OpenAiConfig(apiKey: "secret-key"),
|
||||
"Система",
|
||||
"Сообщение",
|
||||
CancellationToken.None);
|
||||
|
||||
CapturedHttpRequest request = handler.LastRequest;
|
||||
Assert.StartsWith("POST https://api.example.com/v1/chat/completions", request.Url);
|
||||
Assert.Equal("Bearer secret-key", StubHttpMessageHandler.BearerOf(request));
|
||||
|
||||
JsonObject body = StubHttpMessageHandler.BodyOf(request);
|
||||
Assert.Equal("deepseek-v4-flash", (string?)body["model"]);
|
||||
Assert.Equal(0.2, (double?)body["temperature"]);
|
||||
Assert.Equal(8000, (int?)body["max_tokens"]);
|
||||
|
||||
JsonArray messages = (JsonArray)body["messages"]!;
|
||||
Assert.Equal("system", (string?)messages[0]!["role"]);
|
||||
Assert.Equal("Система", (string?)messages[0]!["content"]);
|
||||
Assert.Equal("user", (string?)messages[1]!["role"]);
|
||||
Assert.Equal("Сообщение", (string?)messages[1]!["content"]);
|
||||
|
||||
Assert.Equal("{\"fit\": 1}", result.Text);
|
||||
Assert.Equal(new ProviderUsage(10, 20, 30), result.Usage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Локальный OpenAI-совместимый провайдер без ключа
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
|
||||
IProviderClient client = CreateClient(handler);
|
||||
|
||||
await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None);
|
||||
|
||||
Assert.Null(StubHttpMessageHandler.BearerOf(handler.LastRequest));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Модель вернула только reasoning без ответа — сбой попытки.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_OpenAiReasoningOnly_Throws()
|
||||
{
|
||||
const string reasoningOnlyReply =
|
||||
"""{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }""";
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply));
|
||||
IProviderClient client = CreateClient(handler);
|
||||
|
||||
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||
|
||||
Assert.Contains("reasoning", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic-вызов
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply));
|
||||
IProviderClient client = CreateClient(handler);
|
||||
|
||||
ProviderChatResult result = await client.ChatAsync(
|
||||
AnthropicConfig(),
|
||||
"Система",
|
||||
"Сообщение",
|
||||
CancellationToken.None);
|
||||
|
||||
CapturedHttpRequest request = handler.LastRequest;
|
||||
Assert.StartsWith("POST https://api.anthropic.com/v1/messages", request.Url);
|
||||
Assert.Equal("anthropic-key", request.Headers["x-api-key"]);
|
||||
Assert.Equal("2023-06-01", request.Headers["anthropic-version"]);
|
||||
|
||||
JsonObject body = StubHttpMessageHandler.BodyOf(request);
|
||||
Assert.Equal("claude-sonnet-5", (string?)body["model"]);
|
||||
Assert.Equal(8000, (int?)body["max_tokens"]);
|
||||
Assert.Equal("Система", (string?)body["system"]);
|
||||
Assert.DoesNotContain("temperature", body);
|
||||
|
||||
JsonArray messages = (JsonArray)body["messages"]!;
|
||||
Assert.Single(messages);
|
||||
Assert.Equal("user", (string?)messages[0]!["role"]);
|
||||
Assert.Equal("Сообщение", (string?)messages[0]!["content"]);
|
||||
|
||||
Assert.Equal("{\"fit\": 1} ещё текст", result.Text);
|
||||
Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-ошибка провайдера — сбой попытки с кодом статуса
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_HttpError_Throws()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError));
|
||||
IProviderClient client = CreateClient(handler);
|
||||
|
||||
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||
|
||||
Assert.Contains("500", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Неожиданная форма ответа
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_UnexpectedBody_Throws()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("<html>upstream error</html>", System.Text.Encoding.UTF8, "text/html"),
|
||||
});
|
||||
IProviderClient client = CreateClient(handler);
|
||||
|
||||
await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Таймаут попытки
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_Timeout_Throws()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(
|
||||
StubHttpMessageHandler.JsonOk(OpenAiJsonReply),
|
||||
delay: TestHandlerDelay);
|
||||
IProviderClient client = new LlmHttpClient(
|
||||
TestHttpClient(handler),
|
||||
TestCallTimeout,
|
||||
TestCallTimeout);
|
||||
|
||||
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||
|
||||
Assert.Contains("Таймаут", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// Конфиг OpenAI-совместимого вызова сценария.
|
||||
// apiKey: Ключ API (null — локальный провайдер без ключа).
|
||||
private static LlmConfig OpenAiConfig(string? apiKey = "test-key")
|
||||
=> new("deepseek", OpenAiBaseUrl, "deepseek-v4-flash", apiKey, ApiStyle: null);
|
||||
|
||||
// Конфиг Anthropic-вызова сценария.
|
||||
private static LlmConfig AnthropicConfig()
|
||||
=> new("anthropic", AnthropicBaseUrl, "claude-sonnet-5", "anthropic-key", LlmConfig.AnthropicApiStyle);
|
||||
|
||||
// Клиент сценария над заглушкой (без общего таймаута — управление на попытку).
|
||||
// handler: Заглушка обработчика.
|
||||
private static LlmHttpClient CreateClient(StubHttpMessageHandler handler)
|
||||
=> new(TestHttpClient(handler));
|
||||
|
||||
// HttpClient над заглушкой без общего таймаута (таймаут попытки задаёт клиент).
|
||||
// handler: Заглушка обработчика.
|
||||
private static HttpClient TestHttpClient(StubHttpMessageHandler handler)
|
||||
=> new(handler) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Deal.Ai.Tests.Support;
|
||||
|
||||
// Снимок HTTP-запроса, записанный заглушкой обработчика (для проверок wire-контракта).
|
||||
// Url: Полный URL запроса.
|
||||
// Headers: Заголовки запроса (включая заголовки содержимого).
|
||||
// Body: Тело запроса (JSON-строка) либо null.
|
||||
internal sealed record CapturedHttpRequest(
|
||||
string Url,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
string? Body);
|
||||
|
||||
internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
private readonly TimeSpan? _delay;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт заглушку с ответчиком сценария.
|
||||
/// </summary>
|
||||
/// <param name="responder">Ответчик: request → response.</param>
|
||||
/// <param name="delay">Необязательная задержка ответа (тест таймаута).</param>
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder, TimeSpan? delay = null)
|
||||
{
|
||||
_responder = responder;
|
||||
_delay = delay;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запросы заглушки в порядке поступления.
|
||||
/// </summary>
|
||||
public List<CapturedHttpRequest> Requests { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Последний запрос заглушки.
|
||||
/// </summary>
|
||||
public CapturedHttpRequest LastRequest => Requests[^1];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string? body = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in request.Headers)
|
||||
{
|
||||
headers[header.Key] = string.Join(", ", header.Value);
|
||||
}
|
||||
|
||||
if (request.Content?.Headers is { } contentHeaders)
|
||||
{
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in contentHeaders)
|
||||
{
|
||||
headers[header.Key] = string.Join(", ", header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
Requests.Add(new CapturedHttpRequest(
|
||||
request.Method + " " + (request.RequestUri?.ToString() ?? string.Empty),
|
||||
headers,
|
||||
body));
|
||||
|
||||
if (_delay is { } delay)
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
|
||||
return _responder(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ответчик: JSON-тело с HTTP 200.
|
||||
/// </summary>
|
||||
/// <param name="json">Тело ответа.</param>
|
||||
public static Func<HttpRequestMessage, HttpResponseMessage> JsonOk(string json)
|
||||
=> _ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Ответчик: заданный HTTP-статус без тела.
|
||||
/// </summary>
|
||||
/// <param name="statusCode">Код ответа.</param>
|
||||
public static Func<HttpRequestMessage, HttpResponseMessage> Status(HttpStatusCode statusCode)
|
||||
=> _ => new HttpResponseMessage(statusCode);
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает тело запроса как JSON-объект
|
||||
/// </summary>
|
||||
/// <param name="request">Снимок запроса.</param>
|
||||
public static JsonObject BodyOf(CapturedHttpRequest request)
|
||||
=> JsonNode.Parse(request.Body!)!.AsObject();
|
||||
|
||||
/// <summary>
|
||||
/// Снимает заголовок авторизации Bearer
|
||||
/// </summary>
|
||||
/// <param name="request">Снимок запроса.</param>
|
||||
public static string? BearerOf(CapturedHttpRequest request)
|
||||
=> request.Headers.TryGetValue("Authorization", out string? value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ai", "Deal.Ai\Deal.Ai.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ai.Tests", "Deal.Ai.Tests\Deal.Ai.Tests.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x86.Build.0 = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,57 @@
|
||||
using Deal.Grpc.Hosting.Interceptors;
|
||||
using Deal.Grpc.Hosting.Models;
|
||||
using Deal.Grpc.Hosting.Options;
|
||||
using Deal.Grpc.Hosting.Services;
|
||||
|
||||
namespace Deal.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает WebApplication gRPC-хоста ai-service.
|
||||
/// </summary>
|
||||
public static class AiServiceHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт (не запускает) хост
|
||||
/// </summary>
|
||||
/// <param name="grpcPort">TCP-порт Kestrel.</param>
|
||||
/// <param name="args">Аргументы командной строки (Program.cs); в тестах не нужны.</param>
|
||||
/// <param name="configureServices">Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь, побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests).</param>
|
||||
/// <param name="configureBuilder">Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно.</param>
|
||||
/// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns>
|
||||
public static WebApplication Create(
|
||||
int grpcPort,
|
||||
string[]? args = null,
|
||||
Action<IServiceCollection>? configureServices = null,
|
||||
Action<WebApplicationBuilder>? configureBuilder = null)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []);
|
||||
|
||||
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
|
||||
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
|
||||
builder.Services.AddDealGrpcServer();
|
||||
builder.Services.AddReadyHealthCheck("хост ai-service готов");
|
||||
|
||||
builder.Services.AddHttpClient<Llm.LlmHttpClient>(static httpClient =>
|
||||
httpClient.Timeout = Timeout.InfiniteTimeSpan);
|
||||
builder.Services.AddSingleton<Llm.IProviderClient>(
|
||||
static sp => sp.GetRequiredService<Llm.LlmHttpClient>());
|
||||
builder.Services.AddSingleton<Func<TimeSpan, CancellationToken, Task>>(DefaultRetryDelayAsync);
|
||||
builder.Services.AddSingleton<Llm.ProviderCaller>();
|
||||
|
||||
configureServices?.Invoke(builder.Services);
|
||||
configureBuilder?.Invoke(builder);
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.MapGrpcService<AiServiceImpl>();
|
||||
app.MapGrpcHealthChecksService();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Пауза между ретраями по умолчанию (Task.Delay; тесты подменяют через DI).
|
||||
// delay: Длительность паузы.
|
||||
// cancellationToken: Токен отмены.
|
||||
private static Task DefaultRetryDelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
=> Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// Тесты (Deal.Ai.Tests) проверяют таймауты HTTP-попыток фасада через конструктор LlmHttpClient с
|
||||
// короткими таймаутами (внутренний) — публичный контракт ради тестов не расширяется.
|
||||
[assembly: InternalsVisibleTo("Deal.Ai.Tests")]
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<!--
|
||||
Deal.Ai — gRPC-хост ai-service (план Task 4/7/8; Ruling 1/2/5/12).
|
||||
|
||||
Кодогенерация .proto — в общем проекте src/contracts/Deal.Proto.csproj (Task 1, Ruling 1):
|
||||
сервис подключает его ProjectReference и использует сгенерированную серверную базу
|
||||
Deal.Grpc.Ai.AiService.AiServiceBase (решение по способу подключения — T2, см. task-2-report).
|
||||
Клиентская сторона ai.proto сгенерирована в Deal.Proto (GrpcServices="Both") — понадобится
|
||||
core-адаптерам GrpcAiClassifier/GrpcAiTools (Ruling 6, задачи 15/17).
|
||||
|
||||
Логика LLM-фасада (OpenAI-совместимые + Anthropic; НЕ БД — Ruling 5) реализована в задачах 7–8:
|
||||
фасад в Deal.Ai/Llm (HTTP-клиент провайдеров, ретраи, JSON-извлечение, usage), RPC
|
||||
Filter/Classify/GenerateKeywords/EvaluateFit — в AiServiceImpl поверх фасада.
|
||||
|
||||
Сборка: 0 warnings / 0 errors (TreatWarningsAsErrors, Directory.Build.props каталога сервиса).
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Deal.Ai</AssemblyName>
|
||||
<RootNamespace>Deal.Ai</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Структурированные логи Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл
|
||||
data/logs/deal-ai-*.json; конфигурация — Deal.Ai/DealLogging.cs (Program.cs).
|
||||
Пакет тянет консоль/файл/compact-формат транзитивно. -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- gRPC-сервер ASP.NET Core (Kestrel HTTP/2) + стандартный gRPC-health (Ruling 12). -->
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore.HealthChecks" Version="2.83.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Общая серверная обвязка gRPC-хостов (C31): интерцепторы/mTLS/DealLogging/GrpcServer —
|
||||
единый источник вместо копий в трёх сервисах. -->
|
||||
<ProjectReference Include="..\..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\contracts\Deal.Proto.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
# ai-service: gRPC-хост ИИ-фасада (план Task 4; Ruling 12 — запись в deploy/compose.dev.yml).
|
||||
#
|
||||
# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Ai.csproj ссылается на src/contracts/Deal.Proto.csproj
|
||||
# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из
|
||||
# src/ai-service. Запуск из корня: docker build -f src/ai-service/Deal.Ai/Dockerfile .
|
||||
# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5102.
|
||||
|
||||
# --- Этап сборки: restore + publish ---
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /repo
|
||||
|
||||
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
|
||||
COPY src/contracts/Deal.Proto.csproj src/contracts/
|
||||
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
|
||||
COPY src/ai-service/Directory.Build.props src/ai-service/
|
||||
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
|
||||
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
||||
|
||||
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
|
||||
COPY src/contracts/ src/contracts/
|
||||
COPY src/grpc-hosting/ src/grpc-hosting/
|
||||
COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/
|
||||
RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish
|
||||
|
||||
# --- Runtime-этап ---
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 5102
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token
|
||||
# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata.
|
||||
COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe
|
||||
|
||||
# Volume-ов нет: ai-service без БД и без персистентных файлов (Ruling 5) — конфиг провайдера
|
||||
# (id/base/model/apiKey/api_style) приходит в теле каждого запроса от ядра, ответы не хранятся.
|
||||
|
||||
# Non-root: процесс работает пользователем deal (UID 10001); персистентных файлов нет.
|
||||
RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal
|
||||
ENV HOME=/tmp
|
||||
USER deal
|
||||
|
||||
ENTRYPOINT ["dotnet", "Deal.Ai.dll"]
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Абстракция одного HTTP-вызова LLM-провайдера.
|
||||
/// </summary>
|
||||
public interface IProviderClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Выполняет один вызов модели по системному и пользовательскому сообщениям.
|
||||
/// </summary>
|
||||
/// <param name="config">Конфиг активного провайдера (стиль API выбирается по <c>ApiStyle</c>).</param>
|
||||
/// <param name="systemPrompt">Системный промпт (заполненный ядром либо фиксированный сервиса).</param>
|
||||
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
||||
/// <returns>Текст ответа модели и usage API-ответа (null — провайдер usage не вернул).</returns>
|
||||
public Task<ProviderChatResult> ChatAsync(
|
||||
LlmConfig config,
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Извлечение JSON-объекта из ответа модели
|
||||
/// </summary>
|
||||
public static class JsonExtractor
|
||||
{
|
||||
// Маркер markdown-обёртки кода в ответах моделей.
|
||||
private const string CodeFence = "```";
|
||||
|
||||
// Метка языка после открывающей обёртки (```json).
|
||||
private const string JsonFenceLabel = "json";
|
||||
|
||||
/// <summary>
|
||||
/// Пытается извлечь JSON-объект из текста ответа модели.
|
||||
/// </summary>
|
||||
/// <param name="text">Сырой ответ модели.</param>
|
||||
/// <returns>Объект JSON либо null (нет JSON/не объект/не разбирается).</returns>
|
||||
public static JsonObject? TryExtractObject(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string candidate = UnwrapFence(text.Trim()) ?? text.Trim();
|
||||
|
||||
int openBrace = candidate.IndexOf('{');
|
||||
int closeBrace = candidate.LastIndexOf('}');
|
||||
if (openBrace < 0 || closeBrace <= openBrace)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string slice = candidate[openBrace..(closeBrace + 1)];
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(slice) as JsonObject;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? UnwrapFence(string raw)
|
||||
{
|
||||
int open = raw.IndexOf(CodeFence, StringComparison.Ordinal);
|
||||
if (open < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int contentStart = open + CodeFence.Length;
|
||||
int close = raw.IndexOf(CodeFence, contentStart, StringComparison.Ordinal);
|
||||
if (close < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string inner = raw[contentStart..close].Trim();
|
||||
if (inner.StartsWith(JsonFenceLabel, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inner = inner[JsonFenceLabel.Length..].TrimStart();
|
||||
}
|
||||
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Итоговая ошибка вызова после исчерпания ретраев.
|
||||
/// </summary>
|
||||
public sealed class LlmCallException : Exception
|
||||
{
|
||||
// Шаблон текста ошибки (имя провайдера из конфига).
|
||||
private const string DetailTemplate = "ИИ ({0}) не ответил корректно — повторите попытку через несколько секунд";
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт итоговую ошибку вызова после исчерпания попыток.
|
||||
/// </summary>
|
||||
/// <param name="kind">Причина исчерпания (см. <see cref="LlmCallFailureKind"/>).</param>
|
||||
/// <param name="providerDisplayName">Отображаемое имя провайдера (без ключей).</param>
|
||||
/// <param name="usage">Usage последней ответившей попытки (для Kind=AnswerNotJson; иначе null).</param>
|
||||
public LlmCallException(
|
||||
LlmCallFailureKind kind,
|
||||
string providerDisplayName,
|
||||
LlmUsage? usage = null)
|
||||
: base(string.Format(DetailTemplate, providerDisplayName))
|
||||
{
|
||||
Kind = kind;
|
||||
Usage = usage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Причина исчерпания попыток вызова.
|
||||
/// </summary>
|
||||
public LlmCallFailureKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Usage последней попытки, вернувшей текст модели
|
||||
/// </summary>
|
||||
public LlmUsage? Usage { get; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Причина исчерпания попыток вызова
|
||||
/// </summary>
|
||||
public enum LlmCallFailureKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Провайдер не ответил после ретраев
|
||||
/// </summary>
|
||||
ProviderUnavailable,
|
||||
|
||||
/// <summary>
|
||||
/// Модель отвечала текстом, но JSON не извлечён после ретраев
|
||||
/// </summary>
|
||||
AnswerNotJson,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Успешный результат вызова модели
|
||||
/// </summary>
|
||||
/// <param name="Json">Корневой объект JSON-ответа модели.</param>
|
||||
/// <param name="Usage">Итоговая оценка токенов вызова.</param>
|
||||
public sealed record LlmCallResult(JsonObject Json, LlmUsage Usage)
|
||||
{
|
||||
/// <summary>
|
||||
/// Извлечённый ответ модели компактной json-строкой
|
||||
/// </summary>
|
||||
public string JsonText => Json.ToJsonString();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Эффективный конфиг LLM-провайдера на один вызов
|
||||
/// </summary>
|
||||
public sealed record LlmConfig(
|
||||
string ProviderId,
|
||||
string BaseUrl,
|
||||
string Model,
|
||||
string? ApiKey,
|
||||
string? ApiStyle)
|
||||
{
|
||||
/// <summary>
|
||||
/// Значение api_style для Anthropic Messages API
|
||||
/// </summary>
|
||||
public const string AnthropicApiStyle = "anthropic";
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> KnownProviderNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["deepseek"] = "DeepSeek",
|
||||
["openai"] = "OpenAI",
|
||||
["openrouter"] = "OpenRouter",
|
||||
["anthropic"] = "Anthropic Claude",
|
||||
["ollama"] = "Ollama (локально)",
|
||||
["lmstudio"] = "LM Studio (локально)",
|
||||
["custom"] = "Другой (OpenAI-совместимый)",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Истинно, когда конфиг задаёт Anthropic Messages API
|
||||
/// </summary>
|
||||
public bool IsAnthropic => string.Equals(ApiStyle, AnthropicApiStyle, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Имя провайдера для сообщений об ошибках и логов
|
||||
/// </summary>
|
||||
public string DisplayName => KnownProviderNames.TryGetValue(ProviderId, out string? name)
|
||||
? name
|
||||
: ProviderId;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-реализация <see cref="IProviderClient"/>
|
||||
/// </summary>
|
||||
public sealed class LlmHttpClient : IProviderClient
|
||||
{
|
||||
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
|
||||
private const string OpenAiCompletionsPath = "/chat/completions";
|
||||
|
||||
// Относительный путь Anthropic Messages API.
|
||||
private const string AnthropicMessagesPath = "/v1/messages";
|
||||
|
||||
// Заголовок версии Anthropic API.
|
||||
private const string AnthropicVersionHeader = "anthropic-version";
|
||||
|
||||
private const string AnthropicVersionValue = "2023-06-01";
|
||||
|
||||
// Заголовок ключа Anthropic API.
|
||||
private const string AnthropicApiKeyHeader = "x-api-key";
|
||||
|
||||
private const double Temperature = 0.2;
|
||||
|
||||
private const int MaxResponseTokens = 8000;
|
||||
|
||||
private static readonly TimeSpan OpenAiCallTimeout = TimeSpan.FromSeconds(90);
|
||||
|
||||
private static readonly TimeSpan AnthropicCallTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TimeSpan _openAiCallTimeout;
|
||||
private readonly TimeSpan _anthropicCallTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт HTTP-клиент провайдеров с типовыми таймаутами
|
||||
/// </summary>
|
||||
/// <param name="httpClient">HttpClient (регистрируется в DI; таймаут управляется на попытку).</param>
|
||||
public LlmHttpClient(HttpClient httpClient)
|
||||
: this(httpClient, OpenAiCallTimeout, AnthropicCallTimeout)
|
||||
{
|
||||
}
|
||||
|
||||
// Создаёт HTTP-клиент с таймаутами для тестов (без реальной сети).
|
||||
// httpClient: HttpClient над фейковым обработчиком.
|
||||
// openAiCallTimeout: Таймаут OpenAI-совместимой попытки.
|
||||
// anthropicCallTimeout: Таймаут Anthropic-попытки.
|
||||
internal LlmHttpClient(
|
||||
HttpClient httpClient,
|
||||
TimeSpan openAiCallTimeout,
|
||||
TimeSpan anthropicCallTimeout)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_openAiCallTimeout = openAiCallTimeout;
|
||||
_anthropicCallTimeout = anthropicCallTimeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет один вызов модели по выбранной схеме API.
|
||||
/// </summary>
|
||||
/// <param name="config">Конфиг провайдера (стиль — <c>ApiStyle</c>).</param>
|
||||
/// <param name="systemPrompt">Системный промпт.</param>
|
||||
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
||||
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
|
||||
async Task<ProviderChatResult> IProviderClient.ChatAsync(
|
||||
LlmConfig config,
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
TimeSpan timeout = config.IsAnthropic ? _anthropicCallTimeout : _openAiCallTimeout;
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage request = config.IsAnthropic
|
||||
? BuildAnthropicRequest(config, systemPrompt, userText)
|
||||
: BuildOpenAiRequest(config, systemPrompt, userText);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient
|
||||
.SendAsync(request, timeoutSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await ReadResponseAsync(response, config.IsAnthropic, timeoutSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new LlmHttpException($"Таймаут вызова ИИ-провайдера: {timeout.TotalSeconds:0} с");
|
||||
}
|
||||
catch (HttpRequestException httpError)
|
||||
{
|
||||
throw new LlmHttpException($"Сетевая ошибка вызова ИИ-провайдера: {httpError.Message}", httpError);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpRequestMessage BuildOpenAiRequest(
|
||||
LlmConfig config,
|
||||
string systemPrompt,
|
||||
string userText)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + OpenAiCompletionsPath);
|
||||
if (!string.IsNullOrEmpty(config.ApiKey))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.ApiKey);
|
||||
}
|
||||
|
||||
var body = new JsonObject
|
||||
{
|
||||
["model"] = config.Model,
|
||||
["messages"] = new JsonArray(
|
||||
ChatMessage("system", systemPrompt),
|
||||
ChatMessage("user", userText)),
|
||||
["temperature"] = Temperature,
|
||||
["max_tokens"] = MaxResponseTokens,
|
||||
};
|
||||
|
||||
request.Content = JsonBody(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static HttpRequestMessage BuildAnthropicRequest(
|
||||
LlmConfig config,
|
||||
string systemPrompt,
|
||||
string userText)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + AnthropicMessagesPath);
|
||||
request.Headers.TryAddWithoutValidation(AnthropicApiKeyHeader, config.ApiKey ?? string.Empty);
|
||||
request.Headers.TryAddWithoutValidation(AnthropicVersionHeader, AnthropicVersionValue);
|
||||
|
||||
var body = new JsonObject
|
||||
{
|
||||
["model"] = config.Model,
|
||||
["max_tokens"] = MaxResponseTokens,
|
||||
["system"] = systemPrompt,
|
||||
["messages"] = new JsonArray(ChatMessage("user", userText)),
|
||||
};
|
||||
|
||||
request.Content = JsonBody(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
// Читает ответ HTTP: статус ≠ 2xx или неожиданная форма — LlmHttpException (повод
|
||||
// для ретрая); иначе текст ответа + usage из тела (см. парсеры стилей).
|
||||
// response: Ответ провайдера.
|
||||
// isAnthropic: Стиль ответа (Anthropic vs OpenAI-совместимый).
|
||||
// cancellationToken: Токен отмены попытки.
|
||||
private static async Task<ProviderChatResult> ReadResponseAsync(
|
||||
HttpResponseMessage response,
|
||||
bool isAnthropic,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new LlmHttpException(
|
||||
$"ИИ-провайдер вернул HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||
}
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body);
|
||||
}
|
||||
|
||||
private static ProviderChatResult ReadOpenAiBody(string body)
|
||||
{
|
||||
JsonObject? payload = ParseObjectOrThrow(body);
|
||||
JsonArray? choices = payload["choices"] as JsonArray;
|
||||
if (choices is null || choices.Count == 0)
|
||||
{
|
||||
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
|
||||
throw UnexpectedApiResponse("пустой или отсутствующий список choices");
|
||||
}
|
||||
|
||||
// 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")))
|
||||
{
|
||||
throw new LlmHttpException("Модель вернула только reasoning без ответа");
|
||||
}
|
||||
|
||||
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
|
||||
}
|
||||
|
||||
private static ProviderChatResult ReadAnthropicBody(string body)
|
||||
{
|
||||
JsonObject? payload = ParseObjectOrThrow(body);
|
||||
|
||||
var text = new StringBuilder();
|
||||
if (payload["content"] is JsonArray contentBlocks)
|
||||
{
|
||||
foreach (JsonNode? blockNode in contentBlocks)
|
||||
{
|
||||
if (blockNode is JsonObject block)
|
||||
{
|
||||
text.Append(ReadStringField(block, "text"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"]));
|
||||
}
|
||||
|
||||
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
|
||||
// usageNode: Узел usage ответа.
|
||||
private static ProviderUsage? ReadOpenAiUsage(JsonNode? usageNode)
|
||||
{
|
||||
if (usageNode is not JsonObject usage)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int? promptTokens = ReadIntField(usage, "prompt_tokens");
|
||||
int? completionTokens = ReadIntField(usage, "completion_tokens");
|
||||
int? totalTokens = ReadIntField(usage, "total_tokens");
|
||||
return promptTokens is null || completionTokens is null || totalTokens is null
|
||||
? null
|
||||
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
|
||||
}
|
||||
|
||||
// Возвращает usage Anthropic-ответа (input/output → total = сумма) либо null.
|
||||
// usageNode: Узел usage ответа.
|
||||
private static ProviderUsage? ReadAnthropicUsage(JsonNode? usageNode)
|
||||
{
|
||||
if (usageNode is not JsonObject usage)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int? inputTokens = ReadIntField(usage, "input_tokens");
|
||||
int? outputTokens = ReadIntField(usage, "output_tokens");
|
||||
return inputTokens is null || outputTokens is null
|
||||
? null
|
||||
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
|
||||
}
|
||||
|
||||
// Читает тело как JSON-объект; при не-JSON/не-объекте — исключение (неожиданный ответ API).
|
||||
// body: Тело ответа.
|
||||
private static JsonObject ParseObjectOrThrow(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (JsonNode.Parse(body) is JsonObject payload)
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Ниже — общий текст «неожиданный ответ API».
|
||||
}
|
||||
|
||||
throw UnexpectedApiResponse("тело не является JSON-объектом");
|
||||
}
|
||||
|
||||
private static LlmHttpException UnexpectedApiResponse(string failureKind)
|
||||
=> new($"Неожиданный ответ ИИ-провайдера: {failureKind}");
|
||||
|
||||
// Создаёт сообщение чата {role, content} (формат обоих API).
|
||||
// role: Роль сообщения.
|
||||
// content: Текст сообщения.
|
||||
private static JsonObject ChatMessage(string role, string content)
|
||||
=> new()
|
||||
{
|
||||
["role"] = role,
|
||||
["content"] = content,
|
||||
};
|
||||
|
||||
// Создаёт JSON-содержимое запроса (application/json).
|
||||
// body: Объект тела запроса.
|
||||
private static StringContent JsonBody(JsonObject body)
|
||||
=> new(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
|
||||
// Читает строковое поле объекта (null — поля нет/не строка).
|
||||
// parent: Объект ответа.
|
||||
// fieldName: Имя поля.
|
||||
private static string? ReadStringField(JsonObject? parent, string fieldName)
|
||||
{
|
||||
if (parent is null || parent[fieldName] is not JsonValue value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.TryGetValue<string>(out string? result) ? result : null;
|
||||
}
|
||||
|
||||
// Читает целочисленное поле объекта (null — поля нет/не число).
|
||||
// parent: Объект ответа.
|
||||
// fieldName: Имя поля.
|
||||
private static int? ReadIntField(JsonObject parent, string fieldName)
|
||||
{
|
||||
if (parent[fieldName] is not JsonValue value || !value.TryGetValue<int>(out int result))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка одной HTTP-попытки вызова провайдера
|
||||
/// </summary>
|
||||
public sealed class LlmHttpException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт ошибку HTTP-попытки с текстом причины.
|
||||
/// </summary>
|
||||
/// <param name="message">Краткое описание (без ключей и секретов).</param>
|
||||
public LlmHttpException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт ошибку HTTP-попытки с текстом причины и внутренним исключением.
|
||||
/// </summary>
|
||||
/// <param name="message">Краткое описание (без ключей и секретов).</param>
|
||||
/// <param name="innerException">Исключение-источник (транспортная ошибка).</param>
|
||||
public LlmHttpException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Политика ретраев вызова LLM
|
||||
/// </summary>
|
||||
public static class LlmRetryPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Число дополнительных попыток после первой
|
||||
/// </summary>
|
||||
public const int RetryCount = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Общее число попыток вызова.
|
||||
/// </summary>
|
||||
public const int AttemptCount = RetryCount + 1;
|
||||
|
||||
/// <summary>
|
||||
/// Паузы между попытками
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<TimeSpan> RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(800),
|
||||
TimeSpan.FromSeconds(2),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Итоговая оценка токенов вызова для gRPC-ответа
|
||||
/// </summary>
|
||||
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
|
||||
/// <param name="CompletionTokens">Токены ответа модели.</param>
|
||||
/// <param name="TotalTokens">Суммарно (по данным провайдера — как есть).</param>
|
||||
public sealed record LlmUsage(int PromptTokens, int CompletionTokens, int TotalTokens);
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Оркестратор вызова модели с ретраями и извлечением JSON
|
||||
/// </summary>
|
||||
public sealed class ProviderCaller
|
||||
{
|
||||
private readonly IProviderClient _client;
|
||||
private readonly ILogger<ProviderCaller> _logger;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _retryDelayAsync;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт оркестратор вызовов с заданной функцией паузы между попытками.
|
||||
/// </summary>
|
||||
/// <param name="client">HTTP-клиент одной попытки (в тестах — фейк).</param>
|
||||
/// <param name="retryDelayAsync">Функция паузы между попытками (в тестах — мгновенная).</param>
|
||||
/// <param name="logger">Логгер (ключи и текст промптов в логи не попадают).</param>
|
||||
public ProviderCaller(
|
||||
IProviderClient client,
|
||||
Func<TimeSpan, CancellationToken, Task> retryDelayAsync,
|
||||
ILogger<ProviderCaller> logger)
|
||||
{
|
||||
_client = client;
|
||||
_retryDelayAsync = retryDelayAsync;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Вызывает модель по системному и пользовательскому сообщениям и извлекает JSON-объект ответа.
|
||||
/// </summary>
|
||||
/// <param name="config">Конфиг активного провайдера.</param>
|
||||
/// <param name="systemPrompt">Системный промпт (заполненный ядром или фиксированный сервиса).</param>
|
||||
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
||||
/// <returns>Извлечённый JSON-объект и итоговую оценку токенов.</returns>
|
||||
/// <exception cref="LlmCallException">Все попытки исчерпаны (см. <see cref="LlmCallFailureKind"/>).</exception>
|
||||
public async Task<LlmCallResult> ChatJsonAsync(
|
||||
LlmConfig config,
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string promptText = systemPrompt + userText;
|
||||
string? lastModelText = null;
|
||||
ProviderUsage? lastUsage = null;
|
||||
LlmHttpException? lastHttpError = null;
|
||||
|
||||
for (int attempt = 0; attempt < LlmRetryPolicy.AttemptCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProviderChatResult result = await _client
|
||||
.ChatAsync(config, systemPrompt, userText, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastModelText = result.Text;
|
||||
lastUsage = result.Usage;
|
||||
|
||||
JsonObject? json = JsonExtractor.TryExtractObject(result.Text);
|
||||
if (json is not null)
|
||||
{
|
||||
return new LlmCallResult(json, TokenEstimator.Resolve(result.Usage, promptText, result.Text));
|
||||
}
|
||||
}
|
||||
catch (LlmHttpException httpError)
|
||||
{
|
||||
lastHttpError = httpError;
|
||||
}
|
||||
|
||||
if (attempt < LlmRetryPolicy.RetryCount)
|
||||
{
|
||||
await _retryDelayAsync(LlmRetryPolicy.RetryDelays[attempt], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastModelText is not null)
|
||||
{
|
||||
LlmUsage usage = TokenEstimator.Resolve(lastUsage, promptText, lastModelText);
|
||||
throw new LlmCallException(LlmCallFailureKind.AnswerNotJson, config.DisplayName, usage);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"ИИ ({Provider}) не ответил корректно после {Attempts} попыток: {Error}",
|
||||
config.DisplayName,
|
||||
LlmRetryPolicy.AttemptCount,
|
||||
lastHttpError?.Message ?? "пустой ответ");
|
||||
throw new LlmCallException(LlmCallFailureKind.ProviderUnavailable, config.DisplayName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Результат одной успешной HTTP-попытки вызова модели
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст ответа модели (может быть не-JSON — разбор в <see cref="JsonExtractor"/>).</param>
|
||||
/// <param name="Usage">Usage из API-ответа провайдера; null — провайдер его не вернул (оценка по символам).</param>
|
||||
public sealed record ProviderChatResult(string Text, ProviderUsage? Usage);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Usage токенов из API-ответа провайдера.
|
||||
/// </summary>
|
||||
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
|
||||
/// <param name="CompletionTokens">Токены ответа модели.</param>
|
||||
/// <param name="TotalTokens">Суммарно по данным провайдера (Anthropic: input + output).</param>
|
||||
public sealed record ProviderUsage(int PromptTokens, int CompletionTokens, int TotalTokens);
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Оценка токенов вызова
|
||||
/// </summary>
|
||||
public static class TokenEstimator
|
||||
{
|
||||
private const int EstimatedCharsPerToken = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Сводит usage вызова
|
||||
/// </summary>
|
||||
/// <param name="providerUsage">Usage из API-ответа (null — провайдер его не вернул).</param>
|
||||
/// <param name="promptText">Полный текст запроса (система + пользователь) для оценки.</param>
|
||||
/// <param name="completionText">Текст ответа модели для оценки.</param>
|
||||
public static LlmUsage Resolve(
|
||||
ProviderUsage? providerUsage,
|
||||
string promptText,
|
||||
string completionText)
|
||||
{
|
||||
if (providerUsage is not null)
|
||||
{
|
||||
return new LlmUsage(providerUsage.PromptTokens, providerUsage.CompletionTokens, providerUsage.TotalTokens);
|
||||
}
|
||||
|
||||
int promptTokens = EstimateTokens(promptText.Length);
|
||||
int completionTokens = EstimateTokens(completionText.Length);
|
||||
return new LlmUsage(promptTokens, completionTokens, promptTokens + completionTokens);
|
||||
}
|
||||
|
||||
// Оценка токенов по числу символов: ≈ceil(chars/4).
|
||||
// charCount: Число символов текста.
|
||||
private static int EstimateTokens(int charCount)
|
||||
=> (charCount + EstimatedCharsPerToken - 1) / EstimatedCharsPerToken;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
|
||||
using Deal.Ai;
|
||||
using Deal.Grpc.Hosting.Interceptors;
|
||||
using Deal.Grpc.Hosting.Models;
|
||||
using Deal.Grpc.Hosting.Options;
|
||||
using Deal.Grpc.Hosting.Services;
|
||||
|
||||
const int defaultGrpcPort = 5102;
|
||||
const string aiProcessName = "ai";
|
||||
|
||||
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
|
||||
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
|
||||
|
||||
WebApplication app = AiServiceHost.Create(
|
||||
grpcPort,
|
||||
configureBuilder: builder =>
|
||||
{
|
||||
DealLogging.Configure(builder, aiProcessName);
|
||||
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
||||
});
|
||||
|
||||
DealMetricsHosting.MapDealMetrics(app);
|
||||
|
||||
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration);
|
||||
|
||||
// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать
|
||||
// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше.
|
||||
GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions);
|
||||
|
||||
app.Logger.LogInformation(
|
||||
"ai-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)",
|
||||
mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token",
|
||||
grpcPort);
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user