Wiring up the AI calls.
This commit is contained in:
@@ -21,6 +21,9 @@ public interface IWinoAccountApiClient
|
||||
Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(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<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(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<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(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<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -1060,6 +1060,56 @@
|
||||
"Composer_SmimeSignature": "S/MIME Signature",
|
||||
"Composer_SmimeEncryption": "S/MIME Encryption",
|
||||
"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_Description": "Automatic email synchronization interval (minutes). This setting will be applied only after restarting Wino Mail.",
|
||||
"ContactsPage_Title": "Contacts",
|
||||
@@ -1272,6 +1322,10 @@
|
||||
"WinoAccount_Error_ExternalLoginInvalid": "The external sign-in request is invalid.",
|
||||
"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_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_ValidationFailed": "The request is invalid. Please review the entered values.",
|
||||
"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.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Mail.Api.Contracts.Ai;
|
||||
using Wino.Mail.Api.Contracts.Auth;
|
||||
using Wino.Mail.Api.Contracts.Common;
|
||||
using Wino.Services;
|
||||
@@ -26,6 +27,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
|
||||
{
|
||||
_databaseService = new InMemoryDatabaseService();
|
||||
await _databaseService.InitializeAsync();
|
||||
await _databaseService.Connection.CreateTableAsync<WinoAccountAddOnCache>();
|
||||
_service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object);
|
||||
}
|
||||
|
||||
@@ -234,6 +236,43 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
|
||||
_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)
|
||||
{
|
||||
return new AuthResultDto(
|
||||
|
||||
@@ -110,6 +110,33 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
public Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default)
|
||||
=> 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var accessToken = await GetAccessTokenAsync().ConfigureAwait(false);
|
||||
@@ -478,10 +539,14 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
[JsonSerializable(typeof(LogoutRequest))]
|
||||
[JsonSerializable(typeof(ResendEmailConfirmationRequest))]
|
||||
[JsonSerializable(typeof(ForgotPasswordRequest))]
|
||||
[JsonSerializable(typeof(SummarizeRequest))]
|
||||
[JsonSerializable(typeof(TranslateRequest))]
|
||||
[JsonSerializable(typeof(RewriteRequest))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<EmailConfirmationResendResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AiTextResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<CheckoutSessionResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<CustomerPortalResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
|
||||
|
||||
@@ -254,6 +254,15 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
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)
|
||||
{
|
||||
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -409,6 +418,30 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
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)
|
||||
{
|
||||
var cachedSnapshot = await GetCachedAddOnSnapshotAsync().ConfigureAwait(false);
|
||||
@@ -478,6 +511,17 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
cache.HasUnlimitedAccounts,
|
||||
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)
|
||||
=> left.Id == right.Id &&
|
||||
string.Equals(left.Email, right.Email, StringComparison.Ordinal) &&
|
||||
|
||||
Reference in New Issue
Block a user