Wiring up the AI calls.

This commit is contained in:
Burak Kaan Köse
2026-03-25 00:25:05 +01:00
parent fd81ee31ce
commit 7aad6b0157
6 changed files with 208 additions and 0 deletions
@@ -21,6 +21,9 @@ public interface IWinoAccountApiClient
Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default); Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> SummarizeAsync(string html, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default);
Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default); Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default);
@@ -28,6 +28,9 @@ public interface IWinoAccountProfileService
Task<bool> HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); Task<bool> HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> SummarizeAsync(string html, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default);
Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default); Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default);
@@ -1060,6 +1060,56 @@
"Composer_SmimeSignature": "S/MIME Signature", "Composer_SmimeSignature": "S/MIME Signature",
"Composer_SmimeEncryption": "S/MIME Encryption", "Composer_SmimeEncryption": "S/MIME Encryption",
"Composer_EmailTemplatesPlaceholder": "E-mail templates", "Composer_EmailTemplatesPlaceholder": "E-mail templates",
"Composer_AiSummarize": "Summarize with AI",
"Composer_AiTranslate": "Translate with AI",
"Composer_AiActions": "AI Actions",
"Composer_AiRewrite": "Rewrite with AI",
"Composer_AiRewritePolite": "Make it polite",
"Composer_AiRewritePoliteDescription": "Softens the wording while keeping the same intent.",
"Composer_AiRewriteAngry": "Make it direct",
"Composer_AiRewriteAngryDescription": "Uses a firmer, more forceful tone.",
"Composer_AiRewriteFormal": "Make it formal",
"Composer_AiRewriteFormalDescription": "Makes the message sound more professional and structured.",
"Composer_AiRewriteFriendly": "Make it friendly",
"Composer_AiRewriteFriendlyDescription": "Warms up the message with a more approachable tone.",
"Composer_AiRewriteShorter": "Make it shorter",
"Composer_AiRewriteShorterDescription": "Tightens the text and removes unnecessary detail.",
"Composer_AiRewriteClearer": "Make it clearer",
"Composer_AiRewriteClearerDescription": "Improves readability and makes the message easier to follow.",
"Composer_AiRewriteMode": "Rewrite tone",
"Composer_AiRewriteApply": "Apply rewrite",
"Composer_AiTranslateDialogTitle": "Translate with AI",
"Composer_AiTranslateDialogDescription": "Enter the target language or culture code, such as en-US, tr-TR, de-DE, or fr-FR.",
"Composer_AiTranslateApply": "Translate",
"Composer_AiTranslateLanguage": "Target language",
"Composer_AiTranslateCustomPlaceholder": "Enter culture code",
"Composer_AiTranslateLanguageEnglish": "English (en-US)",
"Composer_AiTranslateLanguageTurkish": "Turkish (tr-TR)",
"Composer_AiTranslateLanguageGerman": "German (de-DE)",
"Composer_AiTranslateLanguageFrench": "French (fr-FR)",
"Composer_AiTranslateLanguageSpanish": "Spanish (es-ES)",
"Composer_AiTranslateLanguageItalian": "Italian (it-IT)",
"Composer_AiTranslateLanguagePortugueseBrazil": "Portuguese (Brazil) (pt-BR)",
"Composer_AiTranslateLanguageDutch": "Dutch (nl-NL)",
"Composer_AiTranslateLanguagePolish": "Polish (pl-PL)",
"Composer_AiTranslateLanguageRussian": "Russian (ru-RU)",
"Composer_AiTranslateLanguageJapanese": "Japanese (ja-JP)",
"Composer_AiTranslateLanguageKorean": "Korean (ko-KR)",
"Composer_AiTranslateLanguageChineseSimplified": "Chinese, Simplified (zh-CN)",
"Composer_AiTranslateLanguageArabic": "Arabic (ar-SA)",
"Composer_AiTranslateLanguageHindi": "Hindi (hi-IN)",
"Composer_AiTranslateLanguageOther": "Other...",
"Composer_AiBusyTitle": "AI is already working",
"Composer_AiBusyMessage": "Please wait for the current AI action to finish.",
"Composer_AiSignInRequired": "Sign in to your Wino Account to use AI features.",
"Composer_AiMissingHtml": "There is no message content to send to Wino AI yet.",
"Composer_AiQuotaUnavailable": "The AI result was applied.",
"Composer_AiAppliedMessage": "The AI result was applied to the composer. Use Undo if you want to revert it.",
"Composer_AiSummarizeSuccessTitle": "AI summary applied",
"Composer_AiTranslateSuccessTitle": "AI translation applied",
"Composer_AiRewriteSuccessTitle": "AI rewrite applied",
"Composer_AiErrorTitle": "AI action failed",
"Reader_AiAppliedMessage": "The AI result is now shown for this message. Reopen the message to view the original content again.",
"SettingsAppPreferences_EmailSyncInterval_Title": "Email sync interval", "SettingsAppPreferences_EmailSyncInterval_Title": "Email sync interval",
"SettingsAppPreferences_EmailSyncInterval_Description": "Automatic email synchronization interval (minutes). This setting will be applied only after restarting Wino Mail.", "SettingsAppPreferences_EmailSyncInterval_Description": "Automatic email synchronization interval (minutes). This setting will be applied only after restarting Wino Mail.",
"ContactsPage_Title": "Contacts", "ContactsPage_Title": "Contacts",
@@ -1272,6 +1322,10 @@
"WinoAccount_Error_ExternalLoginInvalid": "The external sign-in request is invalid.", "WinoAccount_Error_ExternalLoginInvalid": "The external sign-in request is invalid.",
"WinoAccount_Error_ExternalAuthStateInvalid": "The external sign-in state is invalid or expired.", "WinoAccount_Error_ExternalAuthStateInvalid": "The external sign-in state is invalid or expired.",
"WinoAccount_Error_ExternalAuthCodeInvalid": "The external sign-in code is invalid or expired.", "WinoAccount_Error_ExternalAuthCodeInvalid": "The external sign-in code is invalid or expired.",
"WinoAccount_Error_AiQuotaExceeded": "Your AI Pack usage limit has been reached for the current billing period.",
"WinoAccount_Error_AiHtmlEmpty": "There is no email content to process.",
"WinoAccount_Error_AiHtmlTooLarge": "This email is too large to process with Wino AI.",
"WinoAccount_Error_AiUnsupportedLanguage": "That language is not supported. Try a valid culture code such as en-US or tr-TR.",
"WinoAccount_Error_Forbidden": "You do not have permission to perform this action.", "WinoAccount_Error_Forbidden": "You do not have permission to perform this action.",
"WinoAccount_Error_ValidationFailed": "The request is invalid. Please review the entered values.", "WinoAccount_Error_ValidationFailed": "The request is invalid. Please review the entered values.",
"WinoAccount_RegisterSuccessMessage": "Wino Account registration completed for {0}.", "WinoAccount_RegisterSuccessMessage": "Wino Account registration completed for {0}.",
@@ -7,6 +7,7 @@ using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
using Wino.Services; using Wino.Services;
@@ -26,6 +27,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
{ {
_databaseService = new InMemoryDatabaseService(); _databaseService = new InMemoryDatabaseService();
await _databaseService.InitializeAsync(); await _databaseService.InitializeAsync();
await _databaseService.Connection.CreateTableAsync<WinoAccountAddOnCache>();
_service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object); _service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object);
} }
@@ -234,6 +236,43 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
_apiClient.Verify(x => x.GetCurrentUserAsync(default), Times.AtLeastOnce); _apiClient.Verify(x => x.GetCurrentUserAsync(default), Times.AtLeastOnce);
} }
[Fact]
public async Task SummarizeAsync_ShouldPersistQuotaSnapshot()
{
var authResult = CreateAuthResult("first@example.com");
_apiClient
.Setup(x => x.LoginAsync("first@example.com", "pw", default))
.ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(authResult));
_apiClient
.Setup(x => x.SummarizeAsync("<p>Hello</p>", default))
.ReturnsAsync(ApiEnvelope<AiTextResultDto>.Success(
new AiTextResultDto("<p>Summary</p>"),
new QuotaInfoDto(
true,
"Active",
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddDays(29),
1000,
4,
996,
new AiPackProductInfoDto("AI_PACK", 1000, 4.99m, "USD", "month"))));
await _service.LoginAsync("first@example.com", "pw");
var response = await _service.SummarizeAsync("<p>Hello</p>");
response.IsSuccess.Should().BeTrue();
response.Result?.Html.Should().Be("<p>Summary</p>");
var cachedSnapshot = await _service.GetCachedAddOnSnapshotAsync();
cachedSnapshot.Should().NotBeNull();
cachedSnapshot!.HasAiPack.Should().BeTrue();
cachedSnapshot.UsageCount.Should().Be(4);
cachedSnapshot.UsageLimit.Should().Be(1000);
}
private static AuthResultDto CreateAuthResult(string email) private static AuthResultDto CreateAuthResult(string email)
{ {
return new AuthResultDto( return new AuthResultDto(
+65
View File
@@ -110,6 +110,33 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
public Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default) public Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken); => SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken);
public Task<ApiEnvelope<AiTextResultDto>> SummarizeAsync(string html, CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync(
HttpMethod.Post,
"api/v1/ai/summarize",
new SummarizeRequest(html),
WinoAccountApiJsonContext.Default.SummarizeRequest,
WinoAccountApiJsonContext.Default.ApiEnvelopeAiTextResultDto,
cancellationToken);
public Task<ApiEnvelope<AiTextResultDto>> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync(
HttpMethod.Post,
"api/v1/ai/translate",
new TranslateRequest(html, targetLanguage),
WinoAccountApiJsonContext.Default.TranslateRequest,
WinoAccountApiJsonContext.Default.ApiEnvelopeAiTextResultDto,
cancellationToken);
public Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync(
HttpMethod.Post,
"api/v1/ai/rewrite",
new RewriteRequest(html, mode),
WinoAccountApiJsonContext.Default.RewriteRequest,
WinoAccountApiJsonContext.Default.ApiEnvelopeAiTextResultDto,
cancellationToken);
public Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default) public Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
{ {
var endpoint = productId switch var endpoint = productId switch
@@ -350,6 +377,40 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
} }
} }
private async Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TRequest, TResponse>(HttpMethod method,
string endpoint,
TRequest requestBody,
JsonTypeInfo<TRequest> requestTypeInfo,
JsonTypeInfo<ApiEnvelope<TResponse>> responseTypeInfo,
CancellationToken cancellationToken)
{
try
{
using var response = await SendAuthorizedAsync(
() => CreateAuthorizedRequestAsync(
method,
endpoint,
() => JsonContent.Create(requestBody, requestTypeInfo)),
cancellationToken).ConfigureAwait(false);
if (response == null)
{
return ApiEnvelope<TResponse>.Failure("MissingAccessToken");
}
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var envelope = string.IsNullOrWhiteSpace(payload)
? null
: JsonSerializer.Deserialize(payload, responseTypeInfo);
return envelope ?? ApiEnvelope<TResponse>.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim());
}
catch (Exception ex)
{
return ApiEnvelope<TResponse>.Failure(ex.Message);
}
}
private async Task<HttpRequestMessage?> CreateAuthorizedRequestAsync(HttpMethod method, string endpoint, Func<HttpContent>? contentFactory = null) private async Task<HttpRequestMessage?> CreateAuthorizedRequestAsync(HttpMethod method, string endpoint, Func<HttpContent>? contentFactory = null)
{ {
var accessToken = await GetAccessTokenAsync().ConfigureAwait(false); var accessToken = await GetAccessTokenAsync().ConfigureAwait(false);
@@ -478,10 +539,14 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
[JsonSerializable(typeof(LogoutRequest))] [JsonSerializable(typeof(LogoutRequest))]
[JsonSerializable(typeof(ResendEmailConfirmationRequest))] [JsonSerializable(typeof(ResendEmailConfirmationRequest))]
[JsonSerializable(typeof(ForgotPasswordRequest))] [JsonSerializable(typeof(ForgotPasswordRequest))]
[JsonSerializable(typeof(SummarizeRequest))]
[JsonSerializable(typeof(TranslateRequest))]
[JsonSerializable(typeof(RewriteRequest))]
[JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))] [JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<EmailConfirmationResendResultDto>))] [JsonSerializable(typeof(ApiEnvelope<EmailConfirmationResendResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))] [JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))]
[JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))] [JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<AiTextResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<CheckoutSessionResultDto>))] [JsonSerializable(typeof(ApiEnvelope<CheckoutSessionResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<CustomerPortalResultDto>))] [JsonSerializable(typeof(ApiEnvelope<CustomerPortalResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))] [JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
@@ -254,6 +254,15 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return response; return response;
} }
public async Task<ApiEnvelope<AiTextResultDto>> SummarizeAsync(string html, CancellationToken cancellationToken = default)
=> await ExecuteAiOperationAsync(account => _apiClient.SummarizeAsync(html, cancellationToken), "summarize", cancellationToken).ConfigureAwait(false);
public async Task<ApiEnvelope<AiTextResultDto>> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default)
=> await ExecuteAiOperationAsync(account => _apiClient.TranslateAsync(html, targetLanguage, cancellationToken), "translate", cancellationToken).ConfigureAwait(false);
public async Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default)
=> await ExecuteAiOperationAsync(account => _apiClient.RewriteAsync(html, mode, cancellationToken), "rewrite", cancellationToken).ConfigureAwait(false);
public async Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default) public async Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
{ {
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
@@ -409,6 +418,30 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return response.IsSuccess && response.Result?.HasAiPack == true; return response.IsSuccess && response.Result?.HasAiPack == true;
} }
private async Task<ApiEnvelope<AiTextResultDto>> ExecuteAiOperationAsync(Func<WinoAccount, Task<ApiEnvelope<AiTextResultDto>>> executeAsync,
string operationName,
CancellationToken cancellationToken)
{
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
if (account == null)
{
return ApiEnvelope<AiTextResultDto>.Failure("MissingAccessToken");
}
var response = await executeAsync(account).ConfigureAwait(false);
if (!response.IsSuccess)
{
_logger.Warning("Failed to {Operation} HTML with AI for Wino account {Email}. Error code: {ErrorCode}", operationName, account.Email, response.ErrorCode);
}
if (response.Quota != null)
{
await PersistAddOnCacheAsync(account.Id, Map(response.Quota)).ConfigureAwait(false);
}
return response;
}
private async Task<bool> HasUnlimitedAccountsAsync(CancellationToken cancellationToken) private async Task<bool> HasUnlimitedAccountsAsync(CancellationToken cancellationToken)
{ {
var cachedSnapshot = await GetCachedAddOnSnapshotAsync().ConfigureAwait(false); var cachedSnapshot = await GetCachedAddOnSnapshotAsync().ConfigureAwait(false);
@@ -478,6 +511,17 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
cache.HasUnlimitedAccounts, cache.HasUnlimitedAccounts,
new DateTimeOffset(DateTime.SpecifyKind(cache.LastUpdatedUtc, DateTimeKind.Utc))); new DateTimeOffset(DateTime.SpecifyKind(cache.LastUpdatedUtc, DateTimeKind.Utc)));
private static AiStatusResultDto Map(QuotaInfoDto quota)
=> new(
quota.HasAiPack,
quota.EntitlementStatus,
quota.CurrentPeriodStartUtc,
quota.CurrentPeriodEndUtc,
quota.MonthlyLimit,
quota.Used,
quota.Remaining,
quota.Product);
private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right) private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right)
=> left.Id == right.Id && => left.Id == right.Id &&
string.Equals(left.Email, right.Email, StringComparison.Ordinal) && string.Equals(left.Email, right.Email, StringComparison.Ordinal) &&