feat(cv-import): add source-grounded review and explicit corrections

This commit is contained in:
cesnimda
2026-10-26 15:56:54 +01:00
parent 9e1726c61d
commit e941b3ccb7
24 changed files with 943 additions and 48 deletions
@@ -411,9 +411,19 @@ public sealed partial class ProfileCvController : ControllerBase
? 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 guardedDocument = run.ParserVersion == DocumentParserVersion && operationContext is not null;
if (guardedDocument)
{
var lease = operationContext!.Lease;
var claimed = await _db.CvExtractionRuns.Where(r => r.Id == run.Id && r.OwnerUserId == run.OwnerUserId && (r.Status == "queued" || r.Status == "running") && _db.UserOperations.Any(o => o.Id == lease.OperationId && o.LeaseToken == lease.LeaseToken && o.Status == OperationStatuses.Running && o.CancellationRequestedAtUtc == null && o.LeaseExpiresAtUtc > DateTime.UtcNow))
.ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, "running").SetProperty(r => r.ErrorMessage, (string?)null), cancellationToken);
if (claimed != 1) { _db.Entry(run).State = EntityState.Detached; throw new OperationCanceledException("The import worker lease is no longer active."); }
await _db.Entry(run).ReloadAsync(cancellationToken);
}
var user = await _users.FindByIdAsync(run.OwnerUserId);
if (user is null)
{
if (guardedDocument) throw new AiOperationFailure("cv_user_not_found", "The import account is no longer available.", false);
run.Status = "failed";
run.ErrorMessage = "CV processing user was not found.";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
@@ -423,6 +433,7 @@ public sealed partial class ProfileCvController : ControllerBase
if (!user.AiEnabled || !AccountPlans.ForRoles(await _users.GetRolesAsync(user)).Ai)
{
if (guardedDocument) throw new AiOperationFailure("entitlement_changed", "CV import access changed before processing began.", false);
run.Status = "failed";
run.ErrorMessage = user.AiEnabled
? "This AI feature requires Pro."
@@ -432,9 +443,12 @@ public sealed partial class ProfileCvController : ControllerBase
return new CvProcessingOutcome(false, "entitlement_changed", run.ErrorMessage);
}
run.Status = "running";
run.ErrorMessage = null;
await _db.SaveChangesAsync(cancellationToken);
if (!guardedDocument)
{
run.Status = "running";
run.ErrorMessage = null;
await _db.SaveChangesAsync(cancellationToken);
}
try
{
@@ -535,20 +549,36 @@ public sealed partial class ProfileCvController : ControllerBase
}
var generationFailure = ex as AiGenerationException;
var retryable = generationFailure?.Retryable == true;
var failureMessage = generationFailure?.Message
var failureMessage = run.ParserVersion == DocumentParserVersion
? "The document could not be processed safely. Retry or upload a supported PDF or DOCX CV."
: generationFailure?.Message
?? (ex is InvalidOperationException
? ex.Message
: "CV processing failed unexpectedly. Please try again.");
run.Status = retryable ? "queued" : "failed";
run.ErrorMessage = failureMessage;
run.CompletedAtUtc = retryable ? null : DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
if (guardedDocument)
{
var lease = operationContext!.Lease;
var failedAt = retryable ? (DateTimeOffset?)null : DateTimeOffset.UtcNow;
var failedStatus = retryable ? "queued" : "failed";
var updated = await _db.CvExtractionRuns.Where(r => r.Id == run.Id && (r.Status == "queued" || r.Status == "running") && _db.UserOperations.Any(o => o.Id == lease.OperationId && o.LeaseToken == lease.LeaseToken && o.Status == OperationStatuses.Running && o.CancellationRequestedAtUtc == null && o.LeaseExpiresAtUtc > DateTime.UtcNow))
.ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, failedStatus).SetProperty(r => r.ErrorMessage, failureMessage).SetProperty(r => r.CompletedAtUtc, failedAt), cancellationToken);
_db.Entry(run).State = EntityState.Detached;
if (updated != 1) throw new OperationCanceledException("The import worker lease is no longer active.");
}
else
{
run.Status = retryable ? "queued" : "failed";
run.ErrorMessage = failureMessage;
run.CompletedAtUtc = retryable ? null : DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(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);
if (run.ParserVersion == DocumentParserVersion) _logger.LogWarning("CV document processing run {RunId} failed ({Category}).", run.Id, generationFailure?.Category ?? "cv_processing_failed");
else _logger.LogWarning(ex, "CV processing run {RunId} failed for user {UserId}", run.Id, user.Id);
return new CvProcessingOutcome(
false,
generationFailure?.Category ?? "cv_processing_failed",
@@ -564,7 +594,7 @@ public sealed partial class ProfileCvController : ControllerBase
{
run.RawExtractedText = rawText;
run.NormalizedText = normalizedText;
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(structuredCv);
run.StructuredProfileJson = run.ParserVersion == DocumentParserVersion ? StructuredCvProfileJson.SerializePersisted(structuredCv) : StructuredCvProfileJson.Serialize(structuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);