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
@@ -42,11 +42,17 @@ public sealed partial class ProfileCvController
var candidate = CvDocumentInterpreter.Parse(blocks);
await Stage("cv_validating_details");
var profile = CvDocumentReview.Ground(candidate, document);
foreach (var field in profile.Metadata.Fields.Values)
{
field.SourceArtifactId = artifact.Id;
field.SourceExtractionRunId = run.Id;
field.SourceFileSha256 = artifact.Sha256;
}
await Stage("cv_preparing_review");
await Stage("cv_review_ready");
cancellationToken.ThrowIfCancellationRequested();
var documentJson = JsonSerializer.Serialize(document);
var profileJson = StructuredCvProfileJson.Serialize(profile);
var profileJson = StructuredCvProfileJson.SerializePersisted(profile);
if (context is null)
{
run.DocumentJson = documentJson;
@@ -54,8 +60,9 @@ public sealed partial class ProfileCvController
return;
}
var currentLease = context.Lease;
var completedAt = DateTimeOffset.UtcNow;
var published = await _db.CvExtractionRuns.Where(r => r.Id == run.Id && r.OwnerUserId == run.OwnerUserId && r.Status == "running" && _db.UserOperations.Any(o => o.Id == currentLease.OperationId && o.LeaseToken == currentLease.LeaseToken && o.Status == OperationStatuses.Running && o.CancellationRequestedAtUtc == null && o.LeaseExpiresAtUtc > DateTime.UtcNow))
.ExecuteUpdateAsync(s => s.SetProperty(r => r.DocumentJson, documentJson).SetProperty(r => r.RawExtractedText, document.Text).SetProperty(r => r.NormalizedText, document.Text).SetProperty(r => r.StructuredProfileJson, profileJson).SetProperty(r => r.Status, "pending_review").SetProperty(r => r.CompletedAtUtc, DateTimeOffset.UtcNow), cancellationToken);
.ExecuteUpdateAsync(s => s.SetProperty(r => r.DocumentJson, documentJson).SetProperty(r => r.RawExtractedText, document.Text).SetProperty(r => r.NormalizedText, document.Text).SetProperty(r => r.StructuredProfileJson, profileJson).SetProperty(r => r.Status, "pending_review").SetProperty(r => r.CompletedAtUtc, completedAt), cancellationToken);
if (published != 1) throw new OperationCanceledException("The document processing lease was lost before publication.", cancellationToken);
await _db.Entry(run).ReloadAsync(cancellationToken);
await PruneExtractionRunsAsync(run.OwnerUserId, cancellationToken);
@@ -126,7 +133,8 @@ public sealed partial class ProfileCvController
using var reader = new StreamReader(entry.Open());
var relationships = await reader.ReadToEndAsync(cancellationToken);
// Hyperlinks remain inert text; linked templates/OLE/media are refused.
var xml = System.Xml.Linq.XDocument.Parse(relationships);
using var xmlReader = System.Xml.XmlReader.Create(new StringReader(relationships), new System.Xml.XmlReaderSettings { DtdProcessing = System.Xml.DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 2 * 1024 * 1024 });
var xml = System.Xml.Linq.XDocument.Load(xmlReader);
if (xml.Descendants().Any(e => string.Equals((string?)e.Attribute("TargetMode"), "External", StringComparison.OrdinalIgnoreCase) && !((string?)e.Attribute("Type") ?? "").EndsWith("/hyperlink", StringComparison.Ordinal))) throw new InvalidDataException();
}
}
@@ -151,7 +159,7 @@ public sealed partial class ProfileCvController
if (run.ParserVersion != DocumentParserVersion || string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This import is not ready for review.");
var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
var document = JsonSerializer.Deserialize<CvImportDocument>(run.DocumentJson ?? "null");
return Ok(new CvImportReview(run.Id, run.Status, snapshot.Revision, run.Artifact?.OriginalFileName, $"/api/profile-cv/runs/{run.Id}/source", run.RawExtractedText, document?.Warnings ?? Array.Empty<string>(), CvDocumentReview.Build(snapshot.Profile, StructuredCvProfileJson.Deserialize(run.StructuredProfileJson))));
return Ok(new CvImportReview(run.Id, run.Status, snapshot.Revision, run.Artifact?.OriginalFileName, $"/api/profile-cv/runs/{run.Id}/source", run.RawExtractedText, document?.Warnings ?? Array.Empty<string>(), CvDocumentReview.Build(snapshot.Profile, StructuredCvProfileJson.DeserializePersisted(run.StructuredProfileJson))));
}
[HttpGet("runs/{id:int}/source")]
@@ -189,11 +197,11 @@ public sealed partial class ProfileCvController
if (run.Status != "pending_review" || string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This import is not awaiting review.");
var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
if (snapshot.Revision != request.ExpectedVersion) throw new DbUpdateConcurrencyException();
var merged = CvDocumentReview.Apply(snapshot.Profile, StructuredCvProfileJson.Deserialize(run.StructuredProfileJson), request.Decisions);
var merged = CvDocumentReview.Apply(snapshot.Profile, StructuredCvProfileJson.DeserializePersisted(run.StructuredProfileJson), request.Decisions);
if (!request.Decisions.Any(d => d.Action != "keep")) return BadRequest("No imported fields were selected. Discard this import to keep your current profile.");
var validation = CareerProfileValidator.Validate(merged);
if (validation is not null || StructuredCvProfileJson.SerializePersisted(merged).Length > 500000) return BadRequest(validation ?? "The reviewed profile exceeds its size limit.");
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
merged.Metadata.ProfileVersion = snapshot.Revision + 1;
merged.Metadata.AppliedExtractionRunId = run.Id;
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, merged, "document_import:reviewed", request.ExpectedVersion.Value, HttpContext.RequestAborted);
@@ -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);
@@ -219,7 +219,8 @@ public sealed partial class ProfileCvController : ControllerBase
.Where(x => x.OwnerUserId == user.Id);
var isSqlite = _db.Database.IsSqlite();
if (!isSqlite)
runsQuery = runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10);
runsQuery = runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10)
.Union(_db.CvExtractionRuns.AsNoTracking().Where(x => x.OwnerUserId == user.Id && x.Status == "pending_review"));
var runs = await runsQuery.Select(x => new CvExtractionRunListItem(
x.Id,
x.Trigger,
@@ -233,8 +234,8 @@ public sealed partial class ProfileCvController : ControllerBase
x.LlmPromptVersion,
x.ErrorMessage,
null)).ToListAsync(HttpContext.RequestAborted);
if (isSqlite)
runs = runs.OrderByDescending(x => x.StartedAtUtc).Take(10).ToList();
runs = runs.OrderByDescending(x => x.StartedAtUtc).Take(10).Concat(runs.Where(x => x.Status == "pending_review"))
.DistinctBy(x => x.Id).OrderByDescending(x => x.StartedAtUtc).ToList();
var runIds = runs.Select(run => run.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToList();
var operations = await _db.UserOperations.AsNoTracking()