From cdbe36821962337bb6267128069f5cd3be28f356 Mon Sep 17 00:00:00 2001 From: srebrek Date: Sat, 21 Mar 2026 17:24:10 +0100 Subject: [PATCH 1/3] fix: Vision test fails Model used in the test is a model that does not implement IVision but can read via OCR. LLM service silently packed the images from OCR source to the IVision source and prevented OCR to work as intended. --- MaIN.Core.IntegrationTests/Files/apple.jpg | Bin 0 -> 26262 bytes src/MaIN.Domain/Models/Concrete/LocalModels.cs | 1 + .../Services/LLMService/LLMService.cs | 8 +++++--- 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 MaIN.Core.IntegrationTests/Files/apple.jpg diff --git a/MaIN.Core.IntegrationTests/Files/apple.jpg b/MaIN.Core.IntegrationTests/Files/apple.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a416e60254533c75985b456b557d3fb3c8fb24e2 GIT binary patch literal 26262 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 "mmproj-model-gemma3-4b.gguf"; } diff --git a/src/MaIN.Services/Services/LLMService/LLMService.cs b/src/MaIN.Services/Services/LLMService/LLMService.cs index 791df6b2..de7a9bd3 100644 --- a/src/MaIN.Services/Services/LLMService/LLMService.cs +++ b/src/MaIN.Services/Services/LLMService/LLMService.cs @@ -67,8 +67,12 @@ public LLMService( } var lastMsg = chat.Messages.Last(); + var model = GetLocalModel(chat); - await ChatHelper.ExtractImageFromFiles(lastMsg); + if (model is IVisionModel) + { + await ChatHelper.ExtractImageFromFiles(lastMsg); + } if (ChatHelper.HasFiles(lastMsg)) { @@ -80,8 +84,6 @@ public LLMService( { return await ProcessWithToolsAsync(chat, requestOptions, cancellationToken); } - - var model = GetLocalModel(chat); var tokens = await ProcessChatRequest(chat, model, lastMsg, requestOptions, cancellationToken); lastMsg.MarkProcessed(); return await CreateChatResult(chat, tokens, requestOptions); From 592f972740dd47fb1ca86a2984f8f3e4350b1604 Mon Sep 17 00:00:00 2001 From: srebrek Date: Mon, 23 Mar 2026 19:10:07 +0100 Subject: [PATCH 2/3] tests: change test structure - Previous integration tests are moved to E2E test project as they need real api keys or local inference and their duration is long. - New integration test project holds tests that have mocked HTTP Handlers so we can test integration between modules without time-consuming external dependencies --- .../BackendParamsTests.cs | 5 +- .../ChatTests.cs | 43 +++- .../Files/Books.json | 0 .../Files/Galileo_Galilei.pdf | Bin .../Files/Nicolaus_Copernicus.pdf | Bin .../Files/apple.jpg | Bin .../Files/gamex.jpg | Bin .../Helpers/NetworkHelper.cs | 3 +- MaIN.Core.E2ETests/IntegrationTestBase.cs | 40 ++++ MaIN.Core.E2ETests/MaIN.Core.E2ETests.csproj | 47 ++++ .../AnthropicServiceTests.cs | 169 ++++++++++++++ .../ChatPipelineTests.cs | 194 ++++++++++++++++ .../Fakes/FakeHttpClientFactory.cs | 8 + .../Fakes/FakeHttpMessageHandler.cs | 33 +++ .../Fakes/FakeLLMService.cs | 25 +++ .../Fakes/FakeLLMServiceFactory.cs | 12 + .../IntegrationTestBase.cs | 4 +- .../LLMServiceTestBase.cs | 68 ++++++ .../MaIN.Core.IntegrationTests.csproj | 65 ++---- .../OpenAiServiceTests.cs | 210 ++++++++++++++++++ .../PipelineTestBase.cs | 24 ++ MaIN.sln | 9 +- 22 files changed, 902 insertions(+), 57 deletions(-) rename {MaIN.Core.IntegrationTests => MaIN.Core.E2ETests}/BackendParamsTests.cs (99%) rename {MaIN.Core.IntegrationTests => MaIN.Core.E2ETests}/ChatTests.cs (77%) rename {MaIN.Core.IntegrationTests => MaIN.Core.E2ETests}/Files/Books.json (100%) rename {MaIN.Core.IntegrationTests => MaIN.Core.E2ETests}/Files/Galileo_Galilei.pdf (100%) rename {MaIN.Core.IntegrationTests => MaIN.Core.E2ETests}/Files/Nicolaus_Copernicus.pdf (100%) rename {MaIN.Core.IntegrationTests => MaIN.Core.E2ETests}/Files/apple.jpg (100%) rename {MaIN.Core.IntegrationTests => MaIN.Core.E2ETests}/Files/gamex.jpg (100%) rename {MaIN.Core.IntegrationTests => MaIN.Core.E2ETests}/Helpers/NetworkHelper.cs (90%) create mode 100644 MaIN.Core.E2ETests/IntegrationTestBase.cs create mode 100644 MaIN.Core.E2ETests/MaIN.Core.E2ETests.csproj create mode 100644 MaIN.Core.IntegrationTests/AnthropicServiceTests.cs create mode 100644 MaIN.Core.IntegrationTests/ChatPipelineTests.cs create mode 100644 MaIN.Core.IntegrationTests/Fakes/FakeHttpClientFactory.cs create mode 100644 MaIN.Core.IntegrationTests/Fakes/FakeHttpMessageHandler.cs create mode 100644 MaIN.Core.IntegrationTests/Fakes/FakeLLMService.cs create mode 100644 MaIN.Core.IntegrationTests/Fakes/FakeLLMServiceFactory.cs create mode 100644 MaIN.Core.IntegrationTests/LLMServiceTestBase.cs create mode 100644 MaIN.Core.IntegrationTests/OpenAiServiceTests.cs create mode 100644 MaIN.Core.IntegrationTests/PipelineTestBase.cs diff --git a/MaIN.Core.IntegrationTests/BackendParamsTests.cs b/MaIN.Core.E2ETests/BackendParamsTests.cs similarity index 99% rename from MaIN.Core.IntegrationTests/BackendParamsTests.cs rename to MaIN.Core.E2ETests/BackendParamsTests.cs index cf240ff6..01708fa9 100644 --- a/MaIN.Core.IntegrationTests/BackendParamsTests.cs +++ b/MaIN.Core.E2ETests/BackendParamsTests.cs @@ -1,13 +1,14 @@ using MaIN.Core.Hub; using MaIN.Domain.Configuration; -using MaIN.Domain.Entities; using MaIN.Domain.Configuration.BackendInferenceParams; +using MaIN.Domain.Entities; using MaIN.Domain.Exceptions; using MaIN.Domain.Models; using MaIN.Domain.Models.Concrete; -namespace MaIN.Core.IntegrationTests; +namespace MaIN.Core.E2ETests; +[Collection("E2ETests")] public class BackendParamsTests : IntegrationTestBase { private const string TestQuestion = "What is 2+2? Answer with just the number."; diff --git a/MaIN.Core.IntegrationTests/ChatTests.cs b/MaIN.Core.E2ETests/ChatTests.cs similarity index 77% rename from MaIN.Core.IntegrationTests/ChatTests.cs rename to MaIN.Core.E2ETests/ChatTests.cs index 60d67048..0810220d 100644 --- a/MaIN.Core.IntegrationTests/ChatTests.cs +++ b/MaIN.Core.E2ETests/ChatTests.cs @@ -1,12 +1,13 @@ using FuzzySharp; +using MaIN.Core.E2ETests.Helpers; using MaIN.Core.Hub; -using MaIN.Core.IntegrationTests.Helpers; using MaIN.Domain.Entities; using MaIN.Domain.Models; using MaIN.Domain.Models.Abstract; -namespace MaIN.Core.IntegrationTests; +namespace MaIN.Core.E2ETests; +[Collection("E2ETests")] public class ChatTests : IntegrationTestBase { public ChatTests() : base() @@ -62,13 +63,14 @@ await result.WithMessage("And about physics?") } [Fact] - public async Task Should_AnswerGameFromImage_ChatWithVision() + public async Task Should_AnswerGameFromImage_ChatWithImagesWithText() { List images = ["./Files/gamex.jpg"]; + var expectedAnswer = "call of duty"; var result = await AIHub.Chat() .WithModel(Models.Local.Llama3_2_3b) - .WithMessage("What is the title of the game? Answer only this question.") + .WithMessage("What is the title of the game? Answer in 3 words.") .WithMemoryParams(new MemoryParams { AnswerTokens = 1000 @@ -79,12 +81,41 @@ public async Task Should_AnswerGameFromImage_ChatWithVision() Assert.True(result.Done); Assert.NotNull(result.Message); Assert.NotEmpty(result.Message.Content); - var ratio = Fuzz.PartialRatio("call of duty", result.Message.Content.ToLowerInvariant()); + var ratio = Fuzz.PartialRatio(expectedAnswer, result.Message.Content.ToLowerInvariant()); Assert.True(ratio > 50, $""" Fuzzy match failed! Expected > 50, but got {ratio}. - Expexted: 'call of duty' + Expexted: '{expectedAnswer}' + Actual: '{result.Message.Content}' + """); + } + + [Fact] + public async Task Should_AnswerAppleFromImage_ChatWithImagesWithVision() + { + List images = ["./Files/apple.jpg"]; + var expectedAnswer = "apple"; + + var result = await AIHub.Chat() + .WithModel(Models.Local.Gemma3_4b) + .WithMessage("What is this fruit? Answer in one word.") + .WithMemoryParams(new MemoryParams + { + AnswerTokens = 1000 + }) + .WithFiles(images) + .CompleteAsync(); + + Assert.True(result.Done); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message.Content); + var ratio = Fuzz.PartialRatio(expectedAnswer, result.Message.Content.ToLowerInvariant()); + Assert.True(ratio > 50, + $""" + Fuzzy match failed! + Expected > 50, but got {ratio}. + Expexted: '{expectedAnswer}' Actual: '{result.Message.Content}' """); } diff --git a/MaIN.Core.IntegrationTests/Files/Books.json b/MaIN.Core.E2ETests/Files/Books.json similarity index 100% rename from MaIN.Core.IntegrationTests/Files/Books.json rename to MaIN.Core.E2ETests/Files/Books.json diff --git a/MaIN.Core.IntegrationTests/Files/Galileo_Galilei.pdf b/MaIN.Core.E2ETests/Files/Galileo_Galilei.pdf similarity index 100% rename from MaIN.Core.IntegrationTests/Files/Galileo_Galilei.pdf rename to MaIN.Core.E2ETests/Files/Galileo_Galilei.pdf diff --git a/MaIN.Core.IntegrationTests/Files/Nicolaus_Copernicus.pdf b/MaIN.Core.E2ETests/Files/Nicolaus_Copernicus.pdf similarity index 100% rename from MaIN.Core.IntegrationTests/Files/Nicolaus_Copernicus.pdf rename to MaIN.Core.E2ETests/Files/Nicolaus_Copernicus.pdf diff --git a/MaIN.Core.IntegrationTests/Files/apple.jpg b/MaIN.Core.E2ETests/Files/apple.jpg similarity index 100% rename from MaIN.Core.IntegrationTests/Files/apple.jpg rename to MaIN.Core.E2ETests/Files/apple.jpg diff --git a/MaIN.Core.IntegrationTests/Files/gamex.jpg b/MaIN.Core.E2ETests/Files/gamex.jpg similarity index 100% rename from MaIN.Core.IntegrationTests/Files/gamex.jpg rename to MaIN.Core.E2ETests/Files/gamex.jpg diff --git a/MaIN.Core.IntegrationTests/Helpers/NetworkHelper.cs b/MaIN.Core.E2ETests/Helpers/NetworkHelper.cs similarity index 90% rename from MaIN.Core.IntegrationTests/Helpers/NetworkHelper.cs rename to MaIN.Core.E2ETests/Helpers/NetworkHelper.cs index 1482d4e4..7c9f22fe 100644 --- a/MaIN.Core.IntegrationTests/Helpers/NetworkHelper.cs +++ b/MaIN.Core.E2ETests/Helpers/NetworkHelper.cs @@ -1,7 +1,6 @@ -using System; using System.Net.Sockets; -namespace MaIN.Core.IntegrationTests.Helpers; +namespace MaIN.Core.E2ETests.Helpers; public static class NetworkHelper { diff --git a/MaIN.Core.E2ETests/IntegrationTestBase.cs b/MaIN.Core.E2ETests/IntegrationTestBase.cs new file mode 100644 index 00000000..7efff4f8 --- /dev/null +++ b/MaIN.Core.E2ETests/IntegrationTestBase.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MaIN.Core.E2ETests; + +public class IntegrationTestBase : IDisposable +{ + protected readonly IHost _host; + protected readonly IServiceProvider _services; + + protected IntegrationTestBase() + { + _host = Host.CreateDefaultBuilder() + .ConfigureServices((context, services) => + { + services.AddMaIN(context.Configuration); + ConfigureServices(services); + }) + .Build(); + + _host.Services.UseMaIN(); + _host.Start(); + + _services = _host.Services; + } + + // Allow derived classes to add additional services or override existing ones + protected virtual void ConfigureServices(IServiceCollection services) + { + } + + protected T GetService() where T : notnull => _services.GetRequiredService(); + + public void Dispose() + { + _host.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/MaIN.Core.E2ETests/MaIN.Core.E2ETests.csproj b/MaIN.Core.E2ETests/MaIN.Core.E2ETests.csproj new file mode 100644 index 00000000..5d44937e --- /dev/null +++ b/MaIN.Core.E2ETests/MaIN.Core.E2ETests.csproj @@ -0,0 +1,47 @@ + + + + net8.0;net10.0 + enable + enable + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + Always + + + Always + + + PreserveNewest + + + Always + + + PreserveNewest + + + + diff --git a/MaIN.Core.IntegrationTests/AnthropicServiceTests.cs b/MaIN.Core.IntegrationTests/AnthropicServiceTests.cs new file mode 100644 index 00000000..c00a4639 --- /dev/null +++ b/MaIN.Core.IntegrationTests/AnthropicServiceTests.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using MaIN.Core.Hub; +using MaIN.Domain.Configuration; +using MaIN.Domain.Configuration.BackendInferenceParams; +using MaIN.Domain.Entities.Tools; +using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Abstract; +using MaIN.Services.Services.Models; + +namespace MaIN.Core.IntegrationTests; + +[Collection("IntegrationTests")] +public class AnthropicServiceTests : LLMServiceTestBase +{ + private const string ModelId = "claude-sonnet-4-5"; + + public AnthropicServiceTests() + { + ModelRegistry.RegisterOrReplace(new GenericCloudModel(ModelId, BackendType.Anthropic)); + HttpHandler.ResponseBody = AnthropicResponse("ok"); + } + + [Fact] + public async Task Should_SetMaxTokens_DefaultTo4096_WhenNotSpecified() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .CompleteAsync(); + + JsonElement root = HttpHandler.LastRequestJson!.RootElement; + Assert.Equal(4096, root.GetProperty("max_tokens").GetInt32()); + } + + [Fact] + public async Task Should_MapMaxTokens_FromAnthropicInferenceParams() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .WithInferenceParams(new AnthropicInferenceParams { MaxTokens = 2048 }) + .CompleteAsync(); + + JsonElement root = HttpHandler.LastRequestJson!.RootElement; + Assert.Equal(2048, root.GetProperty("max_tokens").GetInt32()); + } + + [Fact] + public async Task Should_ExtractSystemPrompt_ToTopLevelField() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hello") + .WithSystemPrompt("Be helpful") + .CompleteAsync(); + + JsonElement root = HttpHandler.LastRequestJson!.RootElement; + Assert.True(root.TryGetProperty("system", out JsonElement systemProp)); + Assert.Equal("Be helpful", systemProp.GetString()); + } + + [Fact] + public async Task Should_SendImages_AsBase64_WithMediaType() + { + const string visionModelId = "claude-sonnet-4-5-vision"; + ModelRegistry.RegisterOrReplace(new GenericCloudVisionModel(visionModelId, BackendType.Anthropic)); + + var imageBytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }; // JPEG magic bytes + + await AIHub.Chat() + .WithModel(visionModelId) + .WithMessage("describe this image", imageBytes) + .CompleteAsync(); + + JsonElement root = HttpHandler.LastRequestJson!.RootElement; + JsonElement messages = root.GetProperty("messages"); + JsonElement userMessage = messages.EnumerateArray() + .FirstOrDefault(m => m.GetProperty("role").GetString() == "user"); + + Assert.NotEqual(default, userMessage); + JsonElement content = userMessage.GetProperty("content"); + Assert.Equal(JsonValueKind.Array, content.ValueKind); + JsonElement imagePart = content.EnumerateArray() + .FirstOrDefault(p => p.GetProperty("type").GetString() == "image"); + Assert.NotEqual(default, imagePart); + Assert.Equal("base64", imagePart.GetProperty("source").GetProperty("type").GetString()); + } + + [Fact] + public async Task Should_IncludeXApiKeyHeader() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .CompleteAsync(); + + Assert.True(HttpHandler.LastRequest!.Headers.Contains("x-api-key")); + } + + [Fact] + public async Task Should_IncludeAnthropicVersionHeader() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .CompleteAsync(); + + Assert.True(HttpHandler.LastRequest!.Headers.Contains("anthropic-version")); + } + + [Fact] + public async Task Should_ParseContent_FromNonStreamingResponse() + { + HttpHandler.ResponseBody = AnthropicResponse("hello"); + + ChatResult result = await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .CompleteAsync(); + + Assert.Equal("hello", result.Message.Content); + } + + [Fact] + public async Task Should_UseInputSchema_NotParameters_ForTools() + { + var tools = new ToolsConfiguration + { + Tools = + [ + new ToolDefinition + { + Type = "function", + Function = new FunctionDefinition + { + Name = "get_weather", + Description = "Get current weather", + Parameters = new { type = "object", properties = new { } } + }, + Execute = _ => Task.FromResult("sunny") + } + ] + }; + + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("what's the weather?") + .WithTools(tools) + .CompleteAsync(); + + JsonElement root = HttpHandler.LastRequestJson!.RootElement; + JsonElement toolsArray = root.GetProperty("tools"); + JsonElement tool = toolsArray[0]; + + Assert.True(tool.TryGetProperty("input_schema", out _)); + Assert.False(tool.TryGetProperty("parameters", out _)); + } + + [Fact] + public async Task Should_ThrowInvalidBackendParamsException_WhenWrongParams() + { + await Assert.ThrowsAsync(() => + AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .WithInferenceParams(new OpenAiInferenceParams()) + .CompleteAsync()); + } +} diff --git a/MaIN.Core.IntegrationTests/ChatPipelineTests.cs b/MaIN.Core.IntegrationTests/ChatPipelineTests.cs new file mode 100644 index 00000000..13f099b6 --- /dev/null +++ b/MaIN.Core.IntegrationTests/ChatPipelineTests.cs @@ -0,0 +1,194 @@ +using MaIN.Core.Hub; +using MaIN.Domain.Configuration; +using MaIN.Domain.Configuration.BackendInferenceParams; +using MaIN.Domain.Entities; +using MaIN.Domain.Exceptions.Chats; +using MaIN.Domain.Models.Abstract; +using MaIN.Services.Services.Models; + +namespace MaIN.Core.IntegrationTests; + +[Collection("IntegrationTests")] +public class ChatPipelineTests : PipelineTestBase +{ + private const string TestModelId = "pipeline-test-model"; + + public ChatPipelineTests() + { + ModelRegistry.RegisterOrReplace(new GenericCloudModel(TestModelId, BackendType.OpenAi)); + SetTextResponse("default response"); + } + + [Fact] + public async Task Should_ReturnDone_OnSimpleCompletion() + { + var result = await AIHub.Chat() + .WithModel(TestModelId) + .WithMessage("Hello") + .CompleteAsync(); + + Assert.True(result.Done); + } + + [Fact] + public async Task Should_ReturnConfiguredContent_WhenHandlerSet() + { + SetTextResponse("custom content"); + + var result = await AIHub.Chat() + .WithModel(TestModelId) + .WithMessage("Hello") + .CompleteAsync(); + + Assert.Equal("custom content", result.Message.Content); + } + + [Fact] + public async Task Should_PersistAssistantMessage_AfterCompletion() + { + SetTextResponse("assistant reply"); + + var context = AIHub.Chat().WithModel(TestModelId); + await context + .WithMessage("Hello") + .CompleteAsync(); + + var chatId = context.GetChatId(); + var existing = await AIHub.Chat().FromExisting(chatId); + var history = existing.GetChatHistory(); + + Assert.Equal(2, history.Count); + } + + [Fact] + public async Task Should_AccumulateMessages_AcrossMultipleTurns() + { + SetTextResponse("reply"); + + var context = AIHub.Chat().WithModel(TestModelId); + + await context.WithMessage("Turn 1").CompleteAsync(); + await context.WithMessage("Turn 2").CompleteAsync(); + + var history = context.GetChatHistory(); + Assert.Equal(4, history.Count); + } + + [Fact] + public async Task Should_SendUserMessageToHandler_WithCorrectRole() + { + Chat? captured = null; + FakeFactory.Service.Handler = chat => + { + captured = chat; + return new ChatResult + { + Model = chat.ModelId, + Done = true, + CreatedAt = DateTime.UtcNow, + Message = new Message { Role = "assistant", Content = "ok", Type = MessageType.CloudLLM } + }; + }; + + await AIHub.Chat() + .WithModel(TestModelId) + .WithMessage("Hello from user") + .CompleteAsync(); + + Assert.NotNull(captured); + Assert.Contains(captured!.Messages, m => m.Role == "User"); + } + + [Fact] + public async Task Should_ApplySystemPrompt_AsFirstMessage() + { + Chat? captured = null; + FakeFactory.Service.Handler = chat => + { + captured = chat; + return new ChatResult + { + Model = chat.ModelId, + Done = true, + CreatedAt = DateTime.UtcNow, + Message = new Message { Role = "assistant", Content = "ok", Type = MessageType.CloudLLM } + }; + }; + + await AIHub.Chat() + .WithModel(TestModelId) + .WithMessage("User message") + .WithSystemPrompt("Be concise") + .CompleteAsync(); + + Assert.NotNull(captured); + Assert.Equal("System", captured!.Messages[0].Role); + Assert.Equal("Be concise", captured!.Messages[0].Content); + } + + [Fact] + public async Task Should_ThrowEmptyChatException_WhenNoMessageAdded() + { + var context = AIHub.Chat() + .WithModel(TestModelId) + .WithMessages([]); + + await Assert.ThrowsAsync(() => context.CompleteAsync()); + } + + [Fact] + public async Task Should_UseLastModel_WhenSetTwice() + { + const string secondModel = "pipeline-test-model-2"; + ModelRegistry.RegisterOrReplace(new GenericCloudModel(secondModel, BackendType.OpenAi)); + + Chat? captured = null; + FakeFactory.Service.Handler = chat => + { + captured = chat; + return new ChatResult + { + Model = chat.ModelId, + Done = true, + CreatedAt = DateTime.UtcNow, + Message = new Message { Role = "assistant", Content = "ok", Type = MessageType.CloudLLM } + }; + }; + + var entry = AIHub.Chat(); + entry.WithModel(TestModelId); + await entry + .WithModel(secondModel) + .WithMessage("Hello") + .CompleteAsync(); + + Assert.Equal(secondModel, captured!.ModelId); + } + + [Fact] + public async Task Should_SetBackendParams_WhenInferenceParamsProvided() + { + Chat? captured = null; + FakeFactory.Service.Handler = chat => + { + captured = chat; + return new ChatResult + { + Model = chat.ModelId, + Done = true, + CreatedAt = DateTime.UtcNow, + Message = new Message { Role = "assistant", Content = "ok", Type = MessageType.CloudLLM } + }; + }; + + await AIHub.Chat() + .WithModel(TestModelId) + .WithMessage("Hello") + .WithInferenceParams(new OpenAiInferenceParams { Temperature = 0.42f }) + .CompleteAsync(); + + Assert.NotNull(captured); + var openAiParams = Assert.IsType(captured!.BackendParams); + Assert.Equal(0.42f, openAiParams.Temperature); + } +} diff --git a/MaIN.Core.IntegrationTests/Fakes/FakeHttpClientFactory.cs b/MaIN.Core.IntegrationTests/Fakes/FakeHttpClientFactory.cs new file mode 100644 index 00000000..60156a9e --- /dev/null +++ b/MaIN.Core.IntegrationTests/Fakes/FakeHttpClientFactory.cs @@ -0,0 +1,8 @@ +namespace MaIN.Core.IntegrationTests.Fakes; + +public sealed class FakeHttpClientFactory : IHttpClientFactory +{ + public FakeHttpMessageHandler Handler { get; } = new(); + + public HttpClient CreateClient(string name) => new(Handler, false); +} diff --git a/MaIN.Core.IntegrationTests/Fakes/FakeHttpMessageHandler.cs b/MaIN.Core.IntegrationTests/Fakes/FakeHttpMessageHandler.cs new file mode 100644 index 00000000..70620ffd --- /dev/null +++ b/MaIN.Core.IntegrationTests/Fakes/FakeHttpMessageHandler.cs @@ -0,0 +1,33 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace MaIN.Core.IntegrationTests.Fakes; + +public sealed class FakeHttpMessageHandler : HttpMessageHandler +{ + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestBody { get; private set; } + public JsonDocument? LastRequestJson { get; private set; } + public HttpStatusCode ResponseStatusCode { get; set; } = HttpStatusCode.OK; + public string ResponseBody { get; set; } = string.Empty; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + LastRequest = request; + if (request.Content is not null) + { + LastRequestBody = await request.Content.ReadAsStringAsync(ct); + try + { + LastRequestJson = JsonDocument.Parse(LastRequestBody); + } + catch { } + } + + return new HttpResponseMessage(ResponseStatusCode) + { + Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json") + }; + } +} diff --git a/MaIN.Core.IntegrationTests/Fakes/FakeLLMService.cs b/MaIN.Core.IntegrationTests/Fakes/FakeLLMService.cs new file mode 100644 index 00000000..4d8bdb10 --- /dev/null +++ b/MaIN.Core.IntegrationTests/Fakes/FakeLLMService.cs @@ -0,0 +1,25 @@ +using MaIN.Domain.Entities; +using MaIN.Services.Services.Abstract; +using MaIN.Services.Services.LLMService; +using MaIN.Services.Services.Models; + +namespace MaIN.Core.IntegrationTests.Fakes; + +public sealed class FakeLLMService : ILLMService +{ + public Func? Handler { get; set; } + + public Task Send(Chat chat, ChatRequestOptions options, CancellationToken ct = default) + => Task.FromResult(Handler?.Invoke(chat)); + + public Task AskMemory( + Chat chat, + ChatMemoryOptions memOpts, + ChatRequestOptions reqOpts, + CancellationToken ct = default) + => Task.FromResult(Handler?.Invoke(chat)); + + public Task GetCurrentModels() => Task.FromResult(Array.Empty()); + + public Task CleanSessionCache(string id) => Task.CompletedTask; +} diff --git a/MaIN.Core.IntegrationTests/Fakes/FakeLLMServiceFactory.cs b/MaIN.Core.IntegrationTests/Fakes/FakeLLMServiceFactory.cs new file mode 100644 index 00000000..74693674 --- /dev/null +++ b/MaIN.Core.IntegrationTests/Fakes/FakeLLMServiceFactory.cs @@ -0,0 +1,12 @@ +using MaIN.Domain.Configuration; +using MaIN.Services.Services.Abstract; +using MaIN.Services.Services.LLMService.Factory; + +namespace MaIN.Core.IntegrationTests.Fakes; + +public sealed class FakeLLMServiceFactory : ILLMServiceFactory +{ + public FakeLLMService Service { get; } = new(); + + public ILLMService CreateService(BackendType backendType) => Service; +} diff --git a/MaIN.Core.IntegrationTests/IntegrationTestBase.cs b/MaIN.Core.IntegrationTests/IntegrationTestBase.cs index db53171a..ac948253 100644 --- a/MaIN.Core.IntegrationTests/IntegrationTestBase.cs +++ b/MaIN.Core.IntegrationTests/IntegrationTestBase.cs @@ -1,5 +1,3 @@ -using System.Net.Sockets; -using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -40,4 +38,4 @@ public void Dispose() { _host?.Dispose(); } -} \ No newline at end of file +} diff --git a/MaIN.Core.IntegrationTests/LLMServiceTestBase.cs b/MaIN.Core.IntegrationTests/LLMServiceTestBase.cs new file mode 100644 index 00000000..d4e7852d --- /dev/null +++ b/MaIN.Core.IntegrationTests/LLMServiceTestBase.cs @@ -0,0 +1,68 @@ +using MaIN.Core.IntegrationTests.Fakes; +using MaIN.Domain.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace MaIN.Core.IntegrationTests; + +public class LLMServiceTestBase : IntegrationTestBase +{ + protected readonly FakeHttpClientFactory FakeClientFactory = new(); + protected FakeHttpMessageHandler HttpHandler => FakeClientFactory.Handler; + + protected override void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(FakeClientFactory); + services.AddSingleton(new MaINSettings + { + OpenAiKey = "test-openai-key", + AnthropicKey = "test-anthropic-key", + GeminiKey = "test-gemini-key", + DeepSeekKey = "test-deepseek-key", + GroqCloudKey = "test-groq-key", + XaiKey = "test-xai-key", + }); + } + + protected static string OpenAiResponse(string content, string model = "gpt-4o-mini") => + $$""" + { + "choices": [ + { + "message": { + "role": "assistant", + "content": "{{content}}" + } + } + ], + "model": "{{model}}" + } + """; + + protected static string AnthropicResponse(string content) => + $$""" + { + "content": [ + { + "type": "text", + "text": "{{content}}" + } + ], + "model": "claude-sonnet-4-5", + "id": "msg_test" + } + """; + protected static string OpenAiStreamResponse(string content) => + $$$""" + data: {"choices":[{"delta":{"content":"{{{content}}}"}}]} + data: [DONE] + + """; + protected static string AnthropicStreamResponse(string content) => + $$$""" + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"{{{content}}}"}} + event: message_stop + data: {"type":"message_stop"} + + """; +} diff --git a/MaIN.Core.IntegrationTests/MaIN.Core.IntegrationTests.csproj b/MaIN.Core.IntegrationTests/MaIN.Core.IntegrationTests.csproj index 460c35cf..a7196baf 100644 --- a/MaIN.Core.IntegrationTests/MaIN.Core.IntegrationTests.csproj +++ b/MaIN.Core.IntegrationTests/MaIN.Core.IntegrationTests.csproj @@ -1,44 +1,23 @@ - - - - net8.0;net10.0 - enable - enable - false - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - Always - - - PreserveNewest - - - Always - - - PreserveNewest - - - + + + net8.0;net10.0 + enable + enable + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/MaIN.Core.IntegrationTests/OpenAiServiceTests.cs b/MaIN.Core.IntegrationTests/OpenAiServiceTests.cs new file mode 100644 index 00000000..60f94fa0 --- /dev/null +++ b/MaIN.Core.IntegrationTests/OpenAiServiceTests.cs @@ -0,0 +1,210 @@ +using System.Text.Json; +using MaIN.Core.Hub; +using MaIN.Domain.Configuration; +using MaIN.Domain.Configuration.BackendInferenceParams; +using MaIN.Domain.Entities.Tools; +using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Abstract; + +namespace MaIN.Core.IntegrationTests; + +[Collection("IntegrationTests")] +public class OpenAiServiceTests : LLMServiceTestBase +{ + private const string ModelId = "gpt-4o-mini"; + + public OpenAiServiceTests() + { + ModelRegistry.RegisterOrReplace(new GenericCloudModel(ModelId, BackendType.OpenAi)); + HttpHandler.ResponseBody = OpenAiResponse("ok"); + } + + [Fact] + public async Task Should_SendModelId_InRequestBody() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .CompleteAsync(); + + var root = HttpHandler.LastRequestJson!.RootElement; + Assert.Equal(ModelId, root.GetProperty("model").GetString()); + } + + [Fact] + public async Task Should_SendUserMessage_InMessagesArray() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hello world") + .CompleteAsync(); + + var root = HttpHandler.LastRequestJson!.RootElement; + var messages = root.GetProperty("messages"); + var userMessage = messages.EnumerateArray() + .FirstOrDefault(m => m.GetProperty("role").GetString() == "user"); + + Assert.NotEqual(default, userMessage); + Assert.Equal("hello world", userMessage.GetProperty("content").GetString()); + } + + [Fact] + public async Task Should_SendStreamFalse_ForNonStreaming() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .CompleteAsync(); + + var root = HttpHandler.LastRequestJson!.RootElement; + Assert.False(root.GetProperty("stream").GetBoolean()); + } + + [Fact] + public async Task Should_SendStreamTrue_ForStreaming() + { + HttpHandler.ResponseBody = OpenAiStreamResponse("hello"); + + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .CompleteAsync(interactive: true); + + var root = HttpHandler.LastRequestJson!.RootElement; + Assert.True(root.GetProperty("stream").GetBoolean()); + } + + [Fact] + public async Task Should_MapTemperature_FromOpenAiInferenceParams() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .WithInferenceParams(new OpenAiInferenceParams { Temperature = 0.7f }) + .CompleteAsync(); + + var root = HttpHandler.LastRequestJson!.RootElement; + Assert.Equal(0.7f, root.GetProperty("temperature").GetSingle()); + } + + [Fact] + public async Task Should_MapMaxTokens_FromOpenAiInferenceParams() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .WithInferenceParams(new OpenAiInferenceParams { MaxTokens = 512 }) + .CompleteAsync(); + + var root = HttpHandler.LastRequestJson!.RootElement; + Assert.Equal(512, root.GetProperty("max_tokens").GetInt32()); + } + + [Fact] + public async Task Should_MapTopP_FromOpenAiInferenceParams() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .WithInferenceParams(new OpenAiInferenceParams { TopP = 0.9f }) + .CompleteAsync(); + + var root = HttpHandler.LastRequestJson!.RootElement; + Assert.Equal(0.9f, root.GetProperty("top_p").GetSingle()); + } + + [Fact] + public async Task Should_ParseContent_FromNonStreamingResponse() + { + HttpHandler.ResponseBody = OpenAiResponse("hello"); + + var result = await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .CompleteAsync(); + + Assert.Equal("hello", result.Message.Content); + } + + [Fact] + public async Task Should_SendAuthorizationHeader_WithBearerToken() + { + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .CompleteAsync(); + + Assert.NotNull(HttpHandler.LastRequest!.Headers.Authorization); + Assert.Equal("Bearer", HttpHandler.LastRequest!.Headers.Authorization!.Scheme); + } + + [Fact] + public async Task Should_IncludeVisionContent_WhenModelIsVision() + { + const string visionModelId = "gpt-4o-vision"; + ModelRegistry.RegisterOrReplace(new GenericCloudVisionModel(visionModelId, BackendType.OpenAi)); + + var imageBytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }; // JPEG magic bytes + + await AIHub.Chat() + .WithModel(visionModelId) + .WithMessage("describe this image", imageBytes) + .CompleteAsync(); + + var root = HttpHandler.LastRequestJson!.RootElement; + var messages = root.GetProperty("messages"); + var userMessage = messages.EnumerateArray() + .FirstOrDefault(m => m.GetProperty("role").GetString() == "user"); + + Assert.NotEqual(default, userMessage); + var content = userMessage.GetProperty("content"); + Assert.Equal(JsonValueKind.Array, content.ValueKind); + Assert.Contains(content.EnumerateArray() +, part => part.GetProperty("type").GetString() == "image_url"); + } + + [Fact] + public async Task Should_IncludeToolsArray_WhenToolsConfigured() + { + var tools = new ToolsConfiguration + { + Tools = + [ + new ToolDefinition + { + Type = "function", + Function = new FunctionDefinition + { + Name = "get_weather", + Description = "Get current weather", + Parameters = new { type = "object", properties = new { } } + }, + Execute = _ => Task.FromResult("sunny") + } + ] + }; + + await AIHub.Chat() + .WithModel(ModelId) + .WithMessage("what's the weather?") + .WithTools(tools) + .CompleteAsync(); + + var root = HttpHandler.LastRequestJson!.RootElement; + var toolsArray = root.GetProperty("tools"); + Assert.Equal(JsonValueKind.Array, toolsArray.ValueKind); + Assert.Equal("get_weather", + toolsArray[0].GetProperty("function").GetProperty("name").GetString()); + } + + [Fact] + public async Task Should_ThrowInvalidBackendParamsException_WhenWrongParams() + { + await Assert.ThrowsAsync(() => + AIHub.Chat() + .WithModel(ModelId) + .WithMessage("hi") + .WithInferenceParams(new AnthropicInferenceParams()) + .CompleteAsync()); + } +} diff --git a/MaIN.Core.IntegrationTests/PipelineTestBase.cs b/MaIN.Core.IntegrationTests/PipelineTestBase.cs new file mode 100644 index 00000000..f41010dd --- /dev/null +++ b/MaIN.Core.IntegrationTests/PipelineTestBase.cs @@ -0,0 +1,24 @@ +using MaIN.Core.IntegrationTests.Fakes; +using MaIN.Domain.Entities; +using MaIN.Services.Services.LLMService.Factory; +using MaIN.Services.Services.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace MaIN.Core.IntegrationTests; + +public class PipelineTestBase : IntegrationTestBase +{ + protected readonly FakeLLMServiceFactory FakeFactory = new(); + + protected override void ConfigureServices(IServiceCollection services) + => services.AddSingleton(FakeFactory); + + protected void SetTextResponse(string content) => + FakeFactory.Service.Handler = chat => new ChatResult + { + Model = chat.ModelId ?? "fake", + Done = true, + CreatedAt = DateTime.UtcNow, + Message = new Message { Role = "assistant", Content = content, Type = MessageType.CloudLLM } + }; +} diff --git a/MaIN.sln b/MaIN.sln index 4417e84c..752ecbea 100644 --- a/MaIN.sln +++ b/MaIN.sln @@ -23,7 +23,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.SimpleConsole", "E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.InferPage", "src\MaIN.InferPage\MaIN.InferPage.csproj", "{B691188A-1170-489D-8729-A13108C12C57}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.Core.IntegrationTests", "MaIN.Core.IntegrationTests\MaIN.Core.IntegrationTests.csproj", "{2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.Core.IntegrationTests", "MaIN.Core.IntegrationTests\MaIN.Core.IntegrationTests.csproj", "{C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.Core.E2ETests", "MaIN.Core.E2ETests\MaIN.Core.E2ETests.csproj", "{2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -63,6 +65,10 @@ Global {B691188A-1170-489D-8729-A13108C12C57}.Debug|Any CPU.Build.0 = Debug|Any CPU {B691188A-1170-489D-8729-A13108C12C57}.Release|Any CPU.ActiveCfg = Release|Any CPU {B691188A-1170-489D-8729-A13108C12C57}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}.Release|Any CPU.Build.0 = Release|Any CPU {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -75,6 +81,7 @@ Global {781BDD20-65BA-4C5D-815B-D8A15931570A} = {28851935-517F-438D-BF7C-02FEB1A37A68} {46E6416B-1736-478C-B697-B37BB8E6A23E} = {53D24B04-279D-4D18-8829-EA0F57AE69F3} {75DEBB8A-75CD-44BA-9369-3916950428EF} = {28851935-517F-438D-BF7C-02FEB1A37A68} + {C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012} = {53D24B04-279D-4D18-8829-EA0F57AE69F3} {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3} = {53D24B04-279D-4D18-8829-EA0F57AE69F3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution From fbfd2c12b2d1a9e1bde248d642f3d2a54caae665 Mon Sep 17 00:00:00 2001 From: srebrek Date: Mon, 23 Mar 2026 23:38:47 +0100 Subject: [PATCH 3/3] tests: add result asserts and limit answer tokens to reduce model looping. --- MaIN.Core.E2ETests/ChatTests.cs | 51 +++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/MaIN.Core.E2ETests/ChatTests.cs b/MaIN.Core.E2ETests/ChatTests.cs index 0810220d..5917ffbc 100644 --- a/MaIN.Core.E2ETests/ChatTests.cs +++ b/MaIN.Core.E2ETests/ChatTests.cs @@ -17,7 +17,7 @@ public ChatTests() : base() [Fact] public async Task Should_AnswerQuestion_BasicChat() { - var context = AIHub.Chat().WithModel(Models.Local.Gemma2_2b); + var context = AIHub.Chat().WithModel(Models.Local.Qwen2_5_0_5b); var result = await context .WithMessage("Where the hedgehog goes at night?") @@ -29,28 +29,38 @@ public async Task Should_AnswerQuestion_BasicChat() } [Fact] - public async Task Should_AnswerDifferences_BetweenDocuments_ChatWithFiles() + public async Task Should_AnswerFileSubject_ChatWithFiles() { - List files = ["./Files/Nicolaus_Copernicus.pdf", "./Files/Galileo_Galilei.pdf"]; + List files = ["./Files/Nicolaus_Copernicus.pdf"]; var result = await AIHub.Chat() - .WithModel(Models.Local.Gemma2_2b) - .WithMessage("You have 2 documents in memory. Whats the difference of work between Galileo and Copernicus?. Give answer based on the documents.") + .WithModel(Models.Local.Qwen2_5_0_5b) + .WithMessage("Who is described in the file? Reply with ONLY their full name. No explanation, no punctuation. Example: Isaak Newton") + .WithMemoryParams(new MemoryParams { AnswerTokens = 10 }) .WithFiles(files) .CompleteAsync(); Assert.True(result.Done); Assert.NotNull(result.Message); Assert.NotEmpty(result.Message.Content); + var ratio = Fuzz.PartialRatio("nicolaus copernicus", result.Message.Content.ToLowerInvariant()); + Assert.True(ratio > 50, + $""" + Fuzzy match failed! + Expected > 50, but got {ratio}. + Expected: 'nicolaus copernicus' + Actual: '{result.Message.Content}' + """); } [Fact] public async Task Should_AnswerQuestion_FromExistingChat() { var result = AIHub.Chat() - .WithModel(Models.Local.Gemma2_2b); + .WithModel(Models.Local.Qwen2_5_0_5b); await result.WithMessage("What do you think about math theories?") + .WithMemoryParams(new MemoryParams { AnswerTokens = 10 }) .CompleteAsync(); await result.WithMessage("And about physics?") @@ -71,10 +81,7 @@ public async Task Should_AnswerGameFromImage_ChatWithImagesWithText() var result = await AIHub.Chat() .WithModel(Models.Local.Llama3_2_3b) .WithMessage("What is the title of the game? Answer in 3 words.") - .WithMemoryParams(new MemoryParams - { - AnswerTokens = 1000 - }) + .WithMemoryParams(new MemoryParams { AnswerTokens = 10 }) .WithFiles(images) .CompleteAsync(); @@ -100,10 +107,7 @@ public async Task Should_AnswerAppleFromImage_ChatWithImagesWithVision() var result = await AIHub.Chat() .WithModel(Models.Local.Gemma3_4b) .WithMessage("What is this fruit? Answer in one word.") - .WithMemoryParams(new MemoryParams - { - AnswerTokens = 1000 - }) + .WithMemoryParams(new MemoryParams { AnswerTokens = 10 }) .WithFiles(images) .CompleteAsync(); @@ -144,9 +148,9 @@ public async Task Should_GenerateImage_BasedOnPrompt() } [Fact] - public async Task Should_AnswerDifferences_BetweenDocuments_ChatWithFiles_UsingStreams() + public async Task Should_AnswerFileSubject_ChatWithFiles_UsingStreams() { - List files = ["./Files/Nicolaus_Copernicus.pdf", "./Files/Galileo_Galilei.pdf"]; + List files = ["./Files/Nicolaus_Copernicus.pdf"]; var fileStreams = new List(); @@ -166,14 +170,25 @@ public async Task Should_AnswerDifferences_BetweenDocuments_ChatWithFiles_UsingS fileStreams.Add(fs); } + var expectedAnswer = "nicolaus copernicus"; + var result = await AIHub.Chat() - .WithModel(Models.Local.Gemma2_2b) - .WithMessage("You have 2 documents in memory. Whats the difference of work between Galileo and Copernicus?. Give answer based on the documents.") + .WithModel(Models.Local.Qwen2_5_0_5b) + .WithMessage("Who is described in the file? Reply with ONLY their full name. No explanation, no punctuation. Example: Isaak Newton") + .WithMemoryParams(new MemoryParams { AnswerTokens = 10 }) .WithFiles(fileStreams) .CompleteAsync(); Assert.True(result.Done); Assert.NotNull(result.Message); Assert.NotEmpty(result.Message.Content); + var ratio = Fuzz.PartialRatio(expectedAnswer, result.Message.Content.ToLowerInvariant()); + Assert.True(ratio > 50, + $""" + Fuzzy match failed! + Expected > 50, but got {ratio}. + Expected: '{expectedAnswer}' + Actual: '{result.Message.Content}' + """); } }