Files
jobtrackingapp/JobTrackerApi/Services/MeteredSummarizerService.cs
T

99 lines
4.8 KiB
C#

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<ApplicationUser> users,
AiUsageMeter usage,
AiOperationExecutionScope operationScope,
AiUsageExecutionScope usageScope) : ISummarizerService
{
public Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30)
=> MeterAsync("synchronous.summary", text, maxLength, ct => inner.SummarizeAsync(text, maxLength, minLength), CancellationToken.None);
public Task<string?> 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<AiGenerationResult?> 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<AiGenerationResult?> EvaluateCvRewriteAsync(string candidateSource, string proposal, string? contextOnly, CancellationToken cancellationToken = default)
=> MeterAsync("synchronous.cv-factual-review", candidateSource + "\n" + proposal + "\n" + contextOnly, 4096,
ct => inner.EvaluateCvRewriteAsync(candidateSource, proposal, contextOnly, ct), cancellationToken);
public Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(string instruction, string text,
AiGenerationOutputMode outputMode, int maxLength = 180, int minLength = 40, CancellationToken cancellationToken = default)
=> MeterAsync("synchronous.generate", instruction + "\n\n" + text, maxLength,
ct => inner.GenerateSectionWithMetadataAsync(instruction, text, outputMode, maxLength, minLength, ct), cancellationToken);
public Task<AiTextExtractionResult?> 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<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)
=> inner.GetMetricsAsync(cancellationToken);
private async Task<T?> MeterAsync<T>(
string taskType,
string input,
int maximumOutputCharacters,
Func<CancellationToken, Task<T?>> 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;
}
}