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);