diff --git a/src/Orbit.Api/Controllers/TagsController.cs b/src/Orbit.Api/Controllers/TagsController.cs index c1ae1419..0378e18e 100644 --- a/src/Orbit.Api/Controllers/TagsController.cs +++ b/src/Orbit.Api/Controllers/TagsController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; +using Orbit.Api.RateLimiting; using Orbit.Application.Tags.Commands; using Orbit.Application.Tags.Queries; @@ -17,6 +18,7 @@ public partial class TagsController(IMediator mediator, ILogger public record CreateTagRequest(string Name, string Color); public record UpdateTagRequest(string Name, string Color); public record AssignTagsRequest(IReadOnlyList TagIds); + public record SuggestTagsRequest(string Title, string? Description, string? Language); [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] @@ -100,6 +102,26 @@ public async Task AssignTags( return result.ToPayGateAwareResult(() => NoContent()); } + [HttpPost("suggest")] + [DistributedRateLimit("tag-suggest")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task SuggestTags( + [FromBody] SuggestTagsRequest request, + CancellationToken cancellationToken) + { + var query = new SuggestTagsQuery( + HttpContext.GetUserId(), + request.Title, + request.Description, + request.Language ?? "en"); + var result = await mediator.Send(query, cancellationToken); + + return result.ToPayGateAwareResult(v => Ok(v)); + } + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Tag created {TagId} by user {UserId}")] private static partial void LogTagCreated(ILogger logger, Guid tagId, Guid userId); diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs index d5ebb6f1..cc5c6fad 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs @@ -27,6 +27,7 @@ private static void AddAiPlatformServices(WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Orbit.Application/Common/ErrorMessages.cs b/src/Orbit.Application/Common/ErrorMessages.cs index b3aaf0e0..e938397d 100644 --- a/src/Orbit.Application/Common/ErrorMessages.cs +++ b/src/Orbit.Application/Common/ErrorMessages.cs @@ -85,6 +85,7 @@ public static class ErrorMessages public static readonly AppError AiUnavailable = new(ErrorCodes.AiUnavailable, "AI service temporarily unavailable"); public static readonly AppError AiRetrospectiveUnavailable = new(ErrorCodes.AiUnavailable, "AI retrospective temporarily unavailable"); public static readonly AppError AiGoalReviewUnavailable = new(ErrorCodes.AiUnavailable, "AI goal review temporarily unavailable"); + public static readonly AppError AiTagSuggestionUnavailable = new(ErrorCodes.AiUnavailable, "AI tag suggestion temporarily unavailable"); public static readonly AppError AiSummaryUnavailable = new(ErrorCodes.AiUnavailable, "AI summary temporarily unavailable"); public static readonly AppError AiRescheduleUnavailable = new(ErrorCodes.AiUnavailable, "AI reschedule temporarily unavailable"); public static readonly AppError HabitNotOverdue = new(ErrorCodes.HabitNotOverdue, "Habit is not overdue."); diff --git a/src/Orbit.Application/Tags/Queries/SuggestTagsQuery.cs b/src/Orbit.Application/Tags/Queries/SuggestTagsQuery.cs new file mode 100644 index 00000000..8a9e2b97 --- /dev/null +++ b/src/Orbit.Application/Tags/Queries/SuggestTagsQuery.cs @@ -0,0 +1,98 @@ +using MediatR; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tags.Queries; + +public record SuggestedTag(string Name, string Color, bool IsExisting, Guid? Id); + +public record SuggestTagsResponse(IReadOnlyList Tags); + +public record SuggestTagsQuery(Guid UserId, string Title, string? Description, string Language) + : IRequest>; + +public class SuggestTagsQueryHandler( + IPayGateService payGate, + ITagSuggestionService tagSuggestionService, + IGenericRepository tagRepository, + IGenericRepository userRepository, + IUnitOfWork unitOfWork) : IRequestHandler> +{ + private const string NewTagColor = "#7c3aed"; + + public async Task> Handle( + SuggestTagsQuery request, + CancellationToken cancellationToken) + { + var gateCheck = await payGate.CanSendAiMessage(request.UserId, cancellationToken); + if (gateCheck.IsFailure) + return gateCheck.PropagateError(); + + var existingTags = await tagRepository.FindAsync( + tag => tag.UserId == request.UserId, + cancellationToken); + + var existingNames = existingTags.Select(tag => tag.Name).ToList(); + + var suggestionResult = await tagSuggestionService.SuggestTagsAsync( + request.Title, + request.Description, + existingNames, + request.Language, + cancellationToken); + + if (suggestionResult.IsFailure) + return suggestionResult.PropagateError(); + + var suggestions = MapSuggestions(suggestionResult.Value, existingTags); + + await MeterAiMessageAsync(request.UserId, cancellationToken); + + return Result.Success(new SuggestTagsResponse(suggestions)); + } + + private static IReadOnlyList MapSuggestions( + IReadOnlyList suggestedNames, + IReadOnlyList existingTags) + { + var existingByName = existingTags + .GroupBy(tag => tag.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var mapped = new List(); + + foreach (var rawName in suggestedNames) + { + var capitalized = Capitalize(rawName.Trim()); + if (string.IsNullOrEmpty(capitalized) || !seen.Add(capitalized)) + continue; + + mapped.Add(existingByName.TryGetValue(capitalized, out var existing) + ? new SuggestedTag(existing.Name, existing.Color, IsExisting: true, existing.Id) + : new SuggestedTag(capitalized, NewTagColor, IsExisting: false, Id: null)); + + if (mapped.Count >= AppConstants.MaxTagsPerHabit) + break; + } + + return mapped; + } + + private async Task MeterAiMessageAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userRepository.FindOneTrackedAsync( + candidate => candidate.Id == userId, + cancellationToken: cancellationToken); + if (user is null) + return; + + user.IncrementAiMessageCount(); + await unitOfWork.SaveChangesAsync(cancellationToken); + } + + private static string Capitalize(string value) => + string.IsNullOrEmpty(value) ? value : char.ToUpper(value[0]) + value[1..].ToLower(); +} diff --git a/src/Orbit.Application/Tags/Validators/SuggestTagsQueryValidator.cs b/src/Orbit.Application/Tags/Validators/SuggestTagsQueryValidator.cs new file mode 100644 index 00000000..e0cf416c --- /dev/null +++ b/src/Orbit.Application/Tags/Validators/SuggestTagsQueryValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Orbit.Application.Common; +using Orbit.Application.Tags.Queries; + +namespace Orbit.Application.Tags.Validators; + +public class SuggestTagsQueryValidator : AbstractValidator +{ + public SuggestTagsQueryValidator() + { + RuleFor(x => x.UserId) + .NotEmpty(); + + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(AppConstants.MaxHabitTitleLength); + + RuleFor(x => x.Description) + .MaximumLength(AppConstants.MaxHabitDescriptionLength); + + RuleFor(x => x.Language) + .NotEmpty() + .MaximumLength(AppConstants.MaxLanguageLength) + .Must(lang => AppConstants.SupportedLanguages.Contains(lang)) + .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); + } +} diff --git a/src/Orbit.Domain/Interfaces/ITagSuggestionService.cs b/src/Orbit.Domain/Interfaces/ITagSuggestionService.cs new file mode 100644 index 00000000..a7d75aee --- /dev/null +++ b/src/Orbit.Domain/Interfaces/ITagSuggestionService.cs @@ -0,0 +1,13 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Interfaces; + +public interface ITagSuggestionService +{ + Task>> SuggestTagsAsync( + string title, + string? description, + IReadOnlyList existingTagNames, + string language, + CancellationToken cancellationToken = default); +} diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs index dbb8c227..d0379ecd 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs @@ -401,7 +401,7 @@ private static AgentCapability[] TagCapabilities() AgentConfirmationRequirement.None, chatTools: ["assign_tags", "create_tag", "update_tag"], mcpTools: ["create_tag", "update_tag", "assign_tags"], - controllerActions: ["TagsController.CreateTag", "TagsController.UpdateTag", "TagsController.AssignTags"]), + controllerActions: ["TagsController.CreateTag", "TagsController.UpdateTag", "TagsController.AssignTags", "TagsController.SuggestTags"]), CreateCapability( AgentCapabilityIds.TagsDelete, diff --git a/src/Orbit.Infrastructure/Services/AiTagSuggestionService.cs b/src/Orbit.Infrastructure/Services/AiTagSuggestionService.cs new file mode 100644 index 00000000..0b6b034c --- /dev/null +++ b/src/Orbit.Infrastructure/Services/AiTagSuggestionService.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.AI; + +namespace Orbit.Infrastructure.Services; + +public sealed partial class AiTagSuggestionService( + AiCompletionClient aiClient, + ILogger logger) : ITagSuggestionService +{ + private sealed record TagSuggestionResult(List Tags); + + public async Task>> SuggestTagsAsync( + string title, + string? description, + IReadOnlyList existingTagNames, + string language, + CancellationToken cancellationToken = default) + { + var prompt = BuildPrompt(title, description, existingTagNames, language); + + if (logger.IsEnabled(LogLevel.Information)) + LogGeneratingTagSuggestions(logger, language); + + try + { + var completion = await aiClient.CompleteJsonAsync( + "You suggest tags for a habit and reply with a single JSON object, nothing else.", + prompt, + cancellationToken: cancellationToken, + purpose: "tag_suggestion", + tier: AiModelTier.SubTask); + + var cleaned = completion?.Tags? + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag.Trim()) + .ToList(); + + if (cleaned is null || cleaned.Count == 0) + return Result.Failure>(ErrorMessages.AiEmptyResponse); + + if (logger.IsEnabled(LogLevel.Information)) + LogTagSuggestionsGenerated(logger, cleaned.Count); + + return Result.Success>(cleaned); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogTagSuggestionFailed(logger, ex); + return Result.Failure>(ErrorMessages.AiTagSuggestionUnavailable); + } + } + + internal static string BuildPrompt( + string title, + string? description, + IReadOnlyList existingTagNames, + string language) + { + var languageName = LocaleHelper.GetAiLanguageName(language); + var existingTagsBlock = existingTagNames.Count > 0 + ? string.Join(", ", existingTagNames) + : "(none yet)"; + var descriptionLine = string.IsNullOrWhiteSpace(description) + ? "(no description)" + : description.Trim(); + + return $$""" + HABIT + Title: {{title}} + Description: {{descriptionLine}} + + EXISTING TAGS (reuse one of these verbatim whenever it fits instead of inventing a near-duplicate): + {{existingTagsBlock}} + + RULES + - Suggest 1 to 5 short tags that categorize this habit. + - Each tag is 1-2 words written in {{languageName}}. + - Strongly prefer an existing tag over a new near-duplicate. + - No '#' prefix, no punctuation, no duplicates. + - Respond only with JSON in this exact shape: { "tags": ["tag1", "tag2"] } + """; + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Generating tag suggestions (language: {Language})...")] + private static partial void LogGeneratingTagSuggestions(ILogger logger, string language); + + [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Tag suggestions generated ({Count} tags)")] + private static partial void LogTagSuggestionsGenerated(ILogger logger, int count); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "AI API call failed for tag suggestion")] + private static partial void LogTagSuggestionFailed(ILogger logger, Exception ex); +} diff --git a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs index 5e58a5ac..447e2414 100644 --- a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs +++ b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs @@ -18,7 +18,8 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider ["ai-resolve"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4), ["habit-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4), ["support"] = new(TimeSpan.FromHours(1), PermitLimit: 3, SegmentCount: 1), - ["uploads"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4) + ["uploads"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4), + ["tag-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4) }; public async Task TryAcquireAsync( diff --git a/tests/Orbit.Application.Tests/Queries/Tags/SuggestTagsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Tags/SuggestTagsQueryHandlerTests.cs new file mode 100644 index 00000000..cb4905a4 --- /dev/null +++ b/tests/Orbit.Application.Tests/Queries/Tags/SuggestTagsQueryHandlerTests.cs @@ -0,0 +1,149 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Tags.Queries; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Queries.Tags; + +public class SuggestTagsQueryHandlerTests +{ + private readonly IPayGateService _payGate = Substitute.For(); + private readonly ITagSuggestionService _suggestionService = Substitute.For(); + private readonly IGenericRepository _tagRepo = Substitute.For>(); + private readonly IGenericRepository _userRepo = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly SuggestTagsQueryHandler _handler; + + private static readonly Guid UserId = Guid.NewGuid(); + + public SuggestTagsQueryHandlerTests() + { + _handler = new SuggestTagsQueryHandler(_payGate, _suggestionService, _tagRepo, _userRepo, _unitOfWork); + } + + private void GivenPayGateAllows() => + _payGate.CanSendAiMessage(UserId, Arg.Any()).Returns(Result.Success()); + + private void GivenExistingTags(params Tag[] tags) => + _tagRepo.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(tags.ToList().AsReadOnly()); + + private void GivenAiSuggests(params string[] names) => + _suggestionService.SuggestTagsAsync( + Arg.Any(), Arg.Any(), Arg.Any>(), + Arg.Any(), Arg.Any()) + .Returns(Result.Success>(names.ToList())); + + private User GivenTrackedUser() + { + var user = User.Create("Test User", "test@example.com").Value; + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(user); + return user; + } + + private static SuggestTagsQuery Query() => new(UserId, "Morning run", "Jog around the park", "en"); + + [Fact] + public async Task Handle_PayGateFails_ReturnsFailureWithoutCallingAi() + { + _payGate.CanSendAiMessage(UserId, Arg.Any()) + .Returns(Result.PayGateFailure("You've reached your monthly AI message limit (20).")); + + var result = await _handler.Handle(Query(), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(Result.PayGateErrorCode); + await _suggestionService.DidNotReceive().SuggestTagsAsync( + Arg.Any(), Arg.Any(), Arg.Any>(), + Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_AiServiceFails_ReturnsFailureWithoutMetering() + { + GivenPayGateAllows(); + GivenExistingTags(); + _suggestionService.SuggestTagsAsync( + Arg.Any(), Arg.Any(), Arg.Any>(), + Arg.Any(), Arg.Any()) + .Returns(Result.Failure>("AI tag suggestion temporarily unavailable")); + + var result = await _handler.Handle(Query(), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_NewSuggestion_ReturnsNewTagAndMetersOneMessage() + { + GivenPayGateAllows(); + GivenExistingTags(); + GivenAiSuggests("fitness"); + var user = GivenTrackedUser(); + + var result = await _handler.Handle(Query(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Tags.Should().ContainSingle(); + var suggestion = result.Value.Tags[0]; + suggestion.Name.Should().Be("Fitness"); + suggestion.Color.Should().Be("#7c3aed"); + suggestion.IsExisting.Should().BeFalse(); + suggestion.Id.Should().BeNull(); + + user.AiMessagesUsedThisMonth.Should().Be(1); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_ExistingNameMatch_ReusesExistingTagOverCreatingDuplicate() + { + GivenPayGateAllows(); + var existing = Tag.Create(UserId, "Health", "#10b981").Value; + GivenExistingTags(existing); + GivenAiSuggests("health", "reading"); + GivenTrackedUser(); + + var result = await _handler.Handle(Query(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Tags.Should().HaveCount(2); + + var reused = result.Value.Tags[0]; + reused.IsExisting.Should().BeTrue(); + reused.Id.Should().Be(existing.Id); + reused.Name.Should().Be("Health"); + reused.Color.Should().Be("#10b981"); + + var created = result.Value.Tags[1]; + created.IsExisting.Should().BeFalse(); + created.Id.Should().BeNull(); + created.Name.Should().Be("Reading"); + created.Color.Should().Be("#7c3aed"); + } + + [Fact] + public async Task Handle_DedupesCaseInsensitiveAndCapsAtMaxTags() + { + GivenPayGateAllows(); + GivenExistingTags(); + GivenAiSuggests("Fit", "fit", "FIT", "Run", "Walk", "Swim", "Bike", "Yoga"); + GivenTrackedUser(); + + var result = await _handler.Handle(Query(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Tags.Should().HaveCount(5); + result.Value.Tags.Select(t => t.Name).Should().OnlyHaveUniqueItems(); + result.Value.Tags.Select(t => t.Name).Should().ContainSingle(name => name == "Fit"); + } +} diff --git a/tests/Orbit.Application.Tests/Validators/SuggestTagsQueryValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/SuggestTagsQueryValidatorTests.cs new file mode 100644 index 00000000..7b513bfd --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/SuggestTagsQueryValidatorTests.cs @@ -0,0 +1,45 @@ +using FluentValidation.TestHelper; +using Orbit.Application.Tags.Queries; +using Orbit.Application.Tags.Validators; + +namespace Orbit.Application.Tests.Validators; + +public class SuggestTagsQueryValidatorTests +{ + private readonly SuggestTagsQueryValidator _validator = new(); + + private static SuggestTagsQuery ValidQuery() => + new(Guid.NewGuid(), "Morning run", null, "en"); + + [Fact] + public void Validate_Valid_NoErrors() + { + var result = _validator.TestValidate(ValidQuery()); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyTitle_HasError() + { + var result = _validator.TestValidate(ValidQuery() with { Title = "" }); + + result.ShouldHaveValidationErrorFor(x => x.Title); + } + + [Fact] + public void Validate_UnsupportedLanguage_HasError() + { + var result = _validator.TestValidate(ValidQuery() with { Language = "xx" }); + + result.ShouldHaveValidationErrorFor(x => x.Language); + } + + [Fact] + public void Validate_SupportedNonDefaultLanguage_NoLanguageError() + { + var result = _validator.TestValidate(ValidQuery() with { Language = "pt-BR" }); + + result.ShouldNotHaveValidationErrorFor(x => x.Language); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiTagSuggestionServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiTagSuggestionServiceTests.cs new file mode 100644 index 00000000..378786a0 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AiTagSuggestionServiceTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AiTagSuggestionServiceTests +{ + [Fact] + public void BuildPrompt_IncludesTitleDescriptionAndExistingTags() + { + var prompt = AiTagSuggestionService.BuildPrompt( + "Morning run", + "Jog around the park", + new[] { "Health", "Fitness" }, + "en"); + + prompt.Should().Contain("Morning run"); + prompt.Should().Contain("Jog around the park"); + prompt.Should().Contain("Health"); + prompt.Should().Contain("Fitness"); + prompt.Should().Contain("English"); + prompt.Should().Contain("\"tags\""); + } + + [Fact] + public void BuildPrompt_NoExistingTags_RendersPlaceholder() + { + var prompt = AiTagSuggestionService.BuildPrompt("Read a book", "x", Array.Empty(), "en"); + + prompt.Should().Contain("(none yet)"); + } + + [Fact] + public void BuildPrompt_NullDescription_RendersPlaceholder() + { + var prompt = AiTagSuggestionService.BuildPrompt("Read a book", null, new[] { "Learning" }, "en"); + + prompt.Should().Contain("(no description)"); + } + + [Fact] + public void BuildPrompt_PortugueseLanguage_RequestsPortugueseOutput() + { + var prompt = AiTagSuggestionService.BuildPrompt("Correr", "Corrida matinal", Array.Empty(), "pt-BR"); + + prompt.Should().Contain("Brazilian Portuguese"); + } +}