fix(ai): meter synchronous generations
CI and Deploy / test (pull_request) Successful in 5m18s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 20:25:10 +02:00
parent dbf28b97ce
commit 25a6da951e
9 changed files with 360 additions and 21 deletions
@@ -20,14 +20,16 @@ public sealed class AiWorkspaceController : ControllerBase
private readonly IConfiguration _config;
private readonly JobTrackerApi.Data.JobTrackerContext? _db;
private readonly AiUsageMeter? _usage;
private readonly AiUsageExecutionScope? _usageScope;
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null, AiUsageMeter? usage = null)
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null, AiUsageMeter? usage = null, AiUsageExecutionScope? usageScope = null)
{
_users = users;
_workspace = workspace;
_config = config;
_db = db;
_usage = usage;
_usageScope = usageScope;
}
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext);
@@ -71,6 +73,7 @@ public sealed class AiWorkspaceController : ControllerBase
try
{
using var metering = _usageScope?.Suppress();
var interaction = await _workspace.GenerateAsync(
user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user),
new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext), ResolveProvider(), ct);
+4 -1
View File
@@ -39,6 +39,7 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(externalOrigin);
builder.Services.AddSingleton<AiPrivacyPolicy>();
builder.Services.AddSingleton<AiOperationExecutionScope>();
builder.Services.AddSingleton<AiUsageExecutionScope>();
builder.Services.AddTransient<AiPrivacyHeaderHandler>();
builder.Services.AddScoped<CurrentUserService>();
builder.Services.AddScoped<ICurrentUserService>(sp => sp.GetRequiredService<CurrentUserService>());
@@ -149,6 +150,7 @@ builder.Services.AddProblemDetails(options =>
options.CustomizeProblemDetails = context =>
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
});
builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>();
builder.Services.AddOpenApi();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(dataRoot))
@@ -203,7 +205,8 @@ builder.Services.AddHttpClient("ai-service", client =>
builder.Services.AddMemoryCache();
builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<SummarizerService>();
builder.Services.AddScoped<ISummarizerService, MeteredSummarizerService>();
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
@@ -0,0 +1,25 @@
namespace JobTrackerApi.Services;
public sealed class AiUsageExecutionScope
{
private readonly AsyncLocal<int> _suppressionDepth = new();
public bool IsSuppressed => _suppressionDepth.Value > 0;
public IDisposable Suppress()
{
_suppressionDepth.Value++;
return new Restore(this);
}
private sealed class Restore(AiUsageExecutionScope owner) : IDisposable
{
private AiUsageExecutionScope? _owner = owner;
public void Dispose()
{
var current = Interlocked.Exchange(ref _owner, null);
if (current is not null) current._suppressionDepth.Value--;
}
}
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Services;
public sealed class AiUsageLimitExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is not AiUsageLimitException limit) return false;
httpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "AI usage limit reached",
Detail = limit.Message,
Extensions =
{
["code"] = limit.Code,
["traceId"] = httpContext.TraceIdentifier,
},
}, cancellationToken);
return true;
}
}
@@ -0,0 +1,89 @@
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<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;
}
}