using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.AspNetCore.Identity; namespace JobTrackerApi.Services; // The shared synchronous provider boundary. Durable operations and the AI workspace reserve // usage before reaching this layer, so their execution scopes explicitly bypass this decorator. public sealed class MeteredSummarizerService( SummarizerService inner, JobTrackerContext db, UserManager users, AiUsageMeter usage, AiOperationExecutionScope operationScope, AiUsageExecutionScope usageScope) : ISummarizerService { public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => MeterAsync("synchronous.summary", text, maxLength, ct => inner.SummarizeAsync(text, maxLength, minLength), CancellationToken.None); public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) => MeterAsync("synchronous.rewrite", instruction + "\n\n" + text, maxLength, ct => inner.SummarizeSectionAsync(instruction, text, maxLength, minLength), CancellationToken.None); public Task GenerateSectionWithMetadataAsync( string instruction, string text, int maxLength = 180, int minLength = 40, CancellationToken cancellationToken = default) => MeterAsync("synchronous.generate", instruction + "\n\n" + text, maxLength, ct => inner.GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength, ct), cancellationToken); public Task ExtractTextAsync( Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => inner.ExtractTextAsync(stream, fileName, contentType, cancellationToken); public Task RunProbeAsync(CancellationToken cancellationToken = default) => inner.RunProbeAsync(cancellationToken); public Task GetMetricsAsync(CancellationToken cancellationToken = default) => inner.GetMetricsAsync(cancellationToken); private async Task MeterAsync( string taskType, string input, int maximumOutputCharacters, Func> generate, CancellationToken cancellationToken) where T : class { if (usageScope.IsSuppressed || operationScope.Current is not null || string.IsNullOrWhiteSpace(db.CurrentUserId)) return await generate(cancellationToken); var ownerUserId = db.CurrentUserId; var user = await users.FindByIdAsync(ownerUserId); var entitlements = user is null ? AccountPlans.ForRoles(null) : AccountPlans.ForRoles(await users.GetRolesAsync(user)); if (user is null || !user.AiEnabled || !entitlements.Ai) throw new AiUsageLimitException("ai_not_available", "AI features require an active Pro account with AI enabled."); var boundedInputCharacters = Math.Min(input.Length, 20_000); var boundedOutputCharacters = Math.Clamp(maximumOutputCharacters, 0, 4_096); var estimatedTokens = Math.Max(1, (boundedInputCharacters + boundedOutputCharacters + 3) / 4); var reservation = await usage.ReserveAsync( ownerUserId, entitlements, "synchronous", Guid.NewGuid().ToString("D"), taskType, boundedInputCharacters, estimatedTokens, cancellationToken); var result = await generate(cancellationToken); switch (result) { case string text: await usage.FinalizeAsync(reservation.Record.Id, boundedInputCharacters, text.Length, cancellationToken); break; case AiGenerationResult generation: await usage.FinalizeAsync(reservation.Record.Id, boundedInputCharacters, generation.Text.Length, cancellationToken); break; } return result; } }