feat(cv)!: queue durable processing
CV upload now returns 202 with an owner-scoped operation instead of holding the request through parsing. Existing review approval remains required. BREAKING CHANGE: profile-cv upload responses use the durable operation contract.
This commit is contained in:
@@ -293,6 +293,9 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
|
||||
private async Task<CvExtractionRun> CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken)
|
||||
{
|
||||
var active = await FindActiveRunAsync(ownerUserId, trigger, artifactId, null, cancellationToken);
|
||||
if (active is not null) return active;
|
||||
|
||||
var run = new CvExtractionRun
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
@@ -309,14 +312,90 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
return run;
|
||||
}
|
||||
|
||||
// Invoked by CvProcessingHostedService (this controller is also registered as a
|
||||
private async Task<CvExtractionRun?> FindActiveRunAsync(
|
||||
string ownerUserId,
|
||||
string trigger,
|
||||
int? artifactId,
|
||||
string? artifactSha256,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var activeStatuses = new[]
|
||||
{
|
||||
OperationStatuses.Queued,
|
||||
OperationStatuses.Running,
|
||||
OperationStatuses.WaitingForRetry,
|
||||
OperationStatuses.WaitingForExternalFallback,
|
||||
};
|
||||
var subjectIds = await _db.UserOperations.AsNoTracking()
|
||||
.Where(operation => operation.TaskType == CvProcessingQueue.TaskType && activeStatuses.Contains(operation.Status))
|
||||
.Select(operation => operation.SubjectId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var runIds = subjectIds
|
||||
.Select(value => int.TryParse(value, out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.ToList();
|
||||
if (runIds.Count == 0) return null;
|
||||
|
||||
var candidates = await _db.CvExtractionRuns
|
||||
.Include(run => run.Artifact)
|
||||
.Where(run => run.OwnerUserId == ownerUserId && run.Trigger == trigger && runIds.Contains(run.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
return candidates
|
||||
.Where(run => artifactSha256 is not null
|
||||
? string.Equals(run.Artifact?.Sha256, artifactSha256, StringComparison.OrdinalIgnoreCase)
|
||||
: run.ArtifactId == artifactId)
|
||||
.MaxBy(run => run.StartedAtUtc);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> EnqueueRunAsync(CvExtractionRun run, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var admission = await _cvProcessingQueue.EnqueueAsync(run.Id, cancellationToken);
|
||||
return Accepted(
|
||||
admission?.StatusUrl,
|
||||
new CvProcessingOperationResponse(
|
||||
true,
|
||||
run.Id,
|
||||
run.Status,
|
||||
admission is null ? null : OperationDto.From(admission.Operation),
|
||||
admission?.StatusUrl,
|
||||
admission?.Created ?? false));
|
||||
}
|
||||
catch (AiOperationAdmissionException exception)
|
||||
{
|
||||
run.Status = "failed";
|
||||
run.ErrorMessage = exception.Message;
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (exception.RetryAfterSeconds is int seconds) Response.Headers.RetryAfter = seconds.ToString();
|
||||
return StatusCode(exception.StatusCode, new { code = exception.Code, message = exception.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteCvArtifactFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(path)) System.IO.File.Delete(path);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Could not remove duplicate CV upload artifact {ArtifactPath}", path);
|
||||
}
|
||||
}
|
||||
|
||||
// Invoked by CvProcessingOperationHandler (this controller is also registered as a
|
||||
// transient service). NonAction keeps it off the HTTP surface: without it the
|
||||
// controller-level [Route] exposes it as an any-verb endpoint.
|
||||
[NonAction]
|
||||
public async Task ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken)
|
||||
public async Task<CvProcessingOutcome?> ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await _db.CvExtractionRuns.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == runId, cancellationToken);
|
||||
if (run is null) return;
|
||||
var ownerUserId = _db.CurrentUserId;
|
||||
var run = ownerUserId is null
|
||||
? await _db.CvExtractionRuns.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == runId, cancellationToken)
|
||||
: await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == runId && x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (run is null) return null;
|
||||
var user = await _users.FindByIdAsync(run.OwnerUserId);
|
||||
if (user is null)
|
||||
{
|
||||
@@ -324,7 +403,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
run.ErrorMessage = "CV processing user was not found.";
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
return new CvProcessingOutcome(false, "cv_user_not_found", run.ErrorMessage);
|
||||
}
|
||||
|
||||
if (!user.AiEnabled || !AccountPlans.ForRoles(await _users.GetRolesAsync(user)).Ai)
|
||||
@@ -335,7 +414,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
: "AI is disabled in your privacy settings.";
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
return new CvProcessingOutcome(false, "entitlement_changed", run.ErrorMessage);
|
||||
}
|
||||
|
||||
run.Status = "running";
|
||||
@@ -344,16 +423,19 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
AiGenerationResult? generation = null;
|
||||
switch (run.Trigger)
|
||||
{
|
||||
case "rebuild":
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) throw new InvalidOperationException("Add or import CV text before rebuilding it.");
|
||||
var rebuilt = await _aiService.SummarizeSectionAsync(
|
||||
generation = await _aiService.GenerateSectionWithMetadataAsync(
|
||||
"Rewrite this CV into a stronger master CV with clear sections such as Professional Summary, Core Skills, Experience Highlights, and Selected Achievements. Preserve only factual claims, avoid inventing employers or metrics, and make the output clean and ready for tailoring to job applications. Return only the rebuilt CV text.",
|
||||
user.ProfileCvText,
|
||||
2200,
|
||||
700);
|
||||
700,
|
||||
cancellationToken);
|
||||
var rebuilt = generation?.Text;
|
||||
if (string.IsNullOrWhiteSpace(rebuilt)) throw new InvalidOperationException("The AI service could not rebuild your CV text right now.");
|
||||
|
||||
var normalizedText = rebuilt.Trim();
|
||||
@@ -364,11 +446,13 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
case "improve":
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) throw new InvalidOperationException("Add or import CV text before improving it.");
|
||||
var improved = await _aiService.SummarizeSectionAsync(
|
||||
generation = await _aiService.GenerateSectionWithMetadataAsync(
|
||||
"Rewrite this CV into a cleaner, better-structured master CV profile. Preserve factual claims, employers, skills, and measurable results. Improve clarity, tighten wording, use strong bullet-style phrasing, and keep it ready for further tailoring to specific roles. Return only the improved CV text.",
|
||||
user.ProfileCvText,
|
||||
1800,
|
||||
500);
|
||||
500,
|
||||
cancellationToken);
|
||||
var improved = generation?.Text;
|
||||
if (string.IsNullOrWhiteSpace(improved)) throw new InvalidOperationException("The AI service could not improve your CV text right now.");
|
||||
|
||||
var normalizedText = improved.Trim();
|
||||
@@ -376,6 +460,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
|
||||
break;
|
||||
}
|
||||
case "upload":
|
||||
case "reprocess":
|
||||
{
|
||||
var artifact = await _db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken);
|
||||
@@ -401,16 +486,42 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
await SendRunCompletionEmailAsync(user, run, true, cancellationToken);
|
||||
return new CvProcessingOutcome(
|
||||
true,
|
||||
Provider: generation?.Provider,
|
||||
Model: generation?.Model,
|
||||
RouteReason: generation?.RouteReason);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
run.Status = "queued";
|
||||
run.ErrorMessage = "CV processing was interrupted before completion.";
|
||||
run.CompletedAtUtc = null;
|
||||
await _db.SaveChangesAsync(CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
run.Status = "failed";
|
||||
var generationFailure = ex as AiGenerationException;
|
||||
var retryable = generationFailure?.Retryable == true;
|
||||
run.Status = retryable ? "queued" : "failed";
|
||||
run.ErrorMessage = ex.Message;
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
run.CompletedAtUtc = retryable ? null : DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await PruneExtractionRunsAsync(user.Id, cancellationToken);
|
||||
await SendRunCompletionEmailAsync(user, run, false, cancellationToken);
|
||||
if (!retryable)
|
||||
{
|
||||
await PruneExtractionRunsAsync(user.Id, cancellationToken);
|
||||
await SendRunCompletionEmailAsync(user, run, false, cancellationToken);
|
||||
}
|
||||
_logger.LogWarning(ex, "CV processing run {RunId} failed for user {UserId}", run.Id, user.Id);
|
||||
return new CvProcessingOutcome(
|
||||
false,
|
||||
generationFailure?.Category ?? "cv_processing_failed",
|
||||
ex.Message,
|
||||
retryable,
|
||||
generationFailure?.Provider,
|
||||
generationFailure?.Model,
|
||||
generationFailure?.RouteReason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,12 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
var artifact = await SaveUploadArtifactAsync(user, file, HttpContext.RequestAborted);
|
||||
var activeRun = await FindActiveRunAsync(user.Id, "upload", null, artifact.Sha256, HttpContext.RequestAborted);
|
||||
if (activeRun is not null)
|
||||
{
|
||||
TryDeleteCvArtifactFile(artifact.StoragePath);
|
||||
return await EnqueueRunAsync(activeRun, HttpContext.RequestAborted);
|
||||
}
|
||||
_db.CvUploadArtifacts.Add(artifact);
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
|
||||
@@ -163,42 +169,12 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
ParserVersion = ParserVersion,
|
||||
NormalizerVersion = NormalizerVersion,
|
||||
LlmPromptVersion = LlmPromptVersion,
|
||||
Status = "running",
|
||||
Status = "queued",
|
||||
StartedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CvExtractionRuns.Add(run);
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted);
|
||||
run.RawExtractedText = result.RawText;
|
||||
run.NormalizedText = result.NormalizedText;
|
||||
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
|
||||
run.Status = "pending_review";
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
await PruneExtractionRunsAsync(user.Id, HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
imported = false,
|
||||
pendingReview = true,
|
||||
characters = result.NormalizedText.Length,
|
||||
artifactId = artifact.Id,
|
||||
extractionRunId = run.Id,
|
||||
status = run.Status,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
run.Status = "failed";
|
||||
run.ErrorMessage = ex.Message;
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
await PruneExtractionRunsAsync(user.Id, HttpContext.RequestAborted);
|
||||
throw;
|
||||
}
|
||||
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
[HttpGet("runs")]
|
||||
@@ -221,11 +197,23 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
x.ParserVersion,
|
||||
x.NormalizerVersion,
|
||||
x.LlmPromptVersion,
|
||||
x.ErrorMessage));
|
||||
x.ErrorMessage,
|
||||
null));
|
||||
var runs = _db.Database.IsSqlite()
|
||||
? (await runsQuery.ToListAsync(HttpContext.RequestAborted)).OrderByDescending(x => x.StartedAtUtc).Take(10).ToList()
|
||||
: await runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10).ToListAsync(HttpContext.RequestAborted);
|
||||
|
||||
var runIds = runs.Select(run => run.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToList();
|
||||
var operations = await _db.UserOperations.AsNoTracking()
|
||||
.Where(operation => operation.TaskType == CvProcessingQueue.TaskType && operation.SubjectId != null && runIds.Contains(operation.SubjectId))
|
||||
.ToListAsync(HttpContext.RequestAborted);
|
||||
var latestOperations = operations
|
||||
.GroupBy(operation => operation.SubjectId!, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.MaxBy(operation => operation.CreatedAtUtc)!);
|
||||
runs = runs.Select(run => latestOperations.TryGetValue(run.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), out var operation)
|
||||
? run with { Operation = OperationDto.From(operation) }
|
||||
: run).ToList();
|
||||
|
||||
return Ok(runs);
|
||||
}
|
||||
|
||||
@@ -313,8 +301,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
var run = await CreateQueuedRunAsync(user.Id, artifact.Id, "reprocess", HttpContext.RequestAborted);
|
||||
await _cvProcessingQueue.EnqueueAsync(run.Id, HttpContext.RequestAborted);
|
||||
return Accepted(new { queued = true, extractionRunId = run.Id, status = run.Status });
|
||||
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
[HttpPost("rebuild")]
|
||||
@@ -326,8 +313,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before rebuilding it.");
|
||||
|
||||
var run = await CreateQueuedRunAsync(user.Id, user.CurrentCvUploadArtifactId, "rebuild", HttpContext.RequestAborted);
|
||||
await _cvProcessingQueue.EnqueueAsync(run.Id, HttpContext.RequestAborted);
|
||||
return Accepted(new { queued = true, extractionRunId = run.Id, status = run.Status });
|
||||
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
[HttpPost("rewrite-section")]
|
||||
@@ -527,8 +513,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before improving it.");
|
||||
|
||||
var run = await CreateQueuedRunAsync(user.Id, user.CurrentCvUploadArtifactId, "improve", HttpContext.RequestAborted);
|
||||
await _cvProcessingQueue.EnqueueAsync(run.Id, HttpContext.RequestAborted);
|
||||
return Accepted(new { queued = true, extractionRunId = run.Id, status = run.Status });
|
||||
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
private static string BuildRewriteSourceText(string? sectionName, string? sourceText, StructuredCvProfile structuredCv)
|
||||
|
||||
@@ -17,4 +17,12 @@ public sealed record CvExtractionRunListItem(
|
||||
string ParserVersion,
|
||||
string NormalizerVersion,
|
||||
string LlmPromptVersion,
|
||||
string? ErrorMessage);
|
||||
string? ErrorMessage,
|
||||
OperationDto? Operation);
|
||||
public sealed record CvProcessingOperationResponse(
|
||||
bool Queued,
|
||||
int ExtractionRunId,
|
||||
string Status,
|
||||
OperationDto? Operation,
|
||||
string? StatusUrl,
|
||||
bool Created);
|
||||
|
||||
Reference in New Issue
Block a user