feat(cv-import): add source-grounded review and explicit corrections
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user