using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public sealed record CvProcessingOutcome( bool Succeeded, string? FailureCategory = null, string? FailureMessage = null, bool Retryable = false, string? Provider = null, string? Model = null, string? RouteReason = null); public interface ICvProcessingQueue { Task EnqueueAsync(int runId, CancellationToken cancellationToken = default); } /// /// Compatibility name for the CV producer bridge. Durable scheduling and execution are owned by /// the shared AI operation queue; this type does not keep an in-memory CV queue. /// public sealed class CvProcessingQueue(AiOperationAdmission admission) : ICvProcessingQueue { public const string TaskType = "cv.process"; public const string SubjectType = "cv_extraction_run"; public async Task EnqueueAsync(int runId, CancellationToken cancellationToken = default) => await admission.EnqueueAsync( TaskType, $"run:{runId}", SubjectType, runId.ToString(System.Globalization.CultureInfo.InvariantCulture), AiOperationPriorities.UserVisible, cancellationToken); } public sealed class NoOpCvProcessingQueue : ICvProcessingQueue { public static readonly NoOpCvProcessingQueue Instance = new(); public Task EnqueueAsync(int runId, CancellationToken cancellationToken = default) => Task.FromResult(null); } public sealed class CvProcessingOperationHandler : IAiOperationHandler { public string TaskType => CvProcessingQueue.TaskType; public async Task ExecuteAsync( AiOperationExecutionContext context, IServiceProvider services, CancellationToken cancellationToken) { if (!string.Equals(context.Lease.SubjectType, CvProcessingQueue.SubjectType, StringComparison.Ordinal) || !int.TryParse(context.Lease.SubjectId, out var runId) || runId <= 0) { throw new AiOperationFailure("invalid_cv_run", "The CV processing operation has an invalid run reference.", retryable: false); } CvProcessingOutcome? outcome; try { outcome = await services.GetRequiredService() .ProcessQueuedRunAsync(runId, cancellationToken); } catch (OperationCanceledException) { var operation = await services.GetRequiredService() .GetAsync(context.Lease.OperationId, CancellationToken.None); await SetRunStatusAsync( services, runId, operation?.CancellationRequestedAtUtc is null ? "queued" : "cancelled", operation?.CancellationRequestedAtUtc is null ? "CV processing timed out and may be retried." : "CV processing was cancelled."); throw; } if (outcome is null) throw new AiOperationFailure("cv_run_not_found", "The CV processing run is no longer available.", retryable: false); if (!outcome.Succeeded) { if (outcome.Retryable) { var operation = await services.GetRequiredService() .GetAsync(context.Lease.OperationId, cancellationToken); var canRetry = operation is not null && operation.AttemptCount < operation.MaxAttempts && (operation.DeadlineAtUtc is null || operation.DeadlineAtUtc > DateTime.UtcNow); if (!canRetry) await SetRunStatusAsync(services, runId, "failed", outcome.FailureMessage ?? "CV processing failed."); } if (outcome.Provider is not null || outcome.Model is not null || outcome.RouteReason is not null) { throw new AiGenerationException( outcome.FailureCategory ?? "cv_processing_failed", outcome.FailureMessage ?? "CV processing failed.", outcome.Retryable, outcome.Provider, outcome.Model, outcome.RouteReason); } throw new AiOperationFailure( outcome.FailureCategory ?? "cv_processing_failed", outcome.FailureMessage ?? "CV processing failed.", outcome.Retryable); } return new AiOperationExecutionResult( $"/api/profile-cv/runs/{runId}/diff", outcome.Provider, outcome.Model, outcome.RouteReason ?? "cv_pipeline"); } private static Task SetRunStatusAsync(IServiceProvider services, int runId, string status, string message) { var completedAtUtc = status == "failed" || status == "cancelled" ? DateTimeOffset.UtcNow : (DateTimeOffset?)null; return services.GetRequiredService().CvExtractionRuns .Where(run => run.Id == runId) .ExecuteUpdateAsync(setters => setters .SetProperty(run => run.Status, status) .SetProperty(run => run.ErrorMessage, message) .SetProperty(run => run.CompletedAtUtc, completedAtUtc), CancellationToken.None); } }