Files
jobtrackingapp/JobTrackerApi/Controllers/ProfileCvController.Import.cs
T

248 lines
19 KiB
C#

using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
public sealed partial class ProfileCvController
{
public const string DocumentParserVersion = "docling-v1";
public sealed record ApplyReviewedRequest(int? ExpectedVersion, IReadOnlyList<CvImportDecision>? Decisions, IReadOnlyList<CvImportRecordMapping>? Mappings = null);
private sealed record AppliedReview(string Hash, int Revision, StructuredCvProfile StructuredCv, IReadOnlyList<CvImportDecision>? Decisions = null, IReadOnlyList<CvImportRecordMapping>? Mappings = null);
private async Task ProcessDocumentImportAsync(CvExtractionRun run, AiOperationExecutionContext? context, CancellationToken cancellationToken)
{
async Task Stage(string stage)
{
cancellationToken.ThrowIfCancellationRequested();
if (context is null) return;
var lease = context.Lease;
var updated = await new UserOperationStore(_db, TimeProvider.System)
.ReportStageAsync(lease.OperationId, lease.LeaseToken, stage, cancellationToken);
if (updated != 1) throw new OperationCanceledException("The document processing lease is no longer active.", cancellationToken);
}
await Stage("cv_validating_file");
var artifact = await _db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(a => a.Id == run.ArtifactId && a.OwnerUserId == run.OwnerUserId, cancellationToken);
if (artifact is null || !System.IO.File.Exists(artifact.StoragePath)) throw new InvalidOperationException("The stored source document is unavailable.");
await using var stream = System.IO.File.OpenRead(artifact.StoragePath);
if (!string.Equals(Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)), artifact.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("The source document changed. Upload it again.");
stream.Position = 0;
await Stage("cv_extracting");
var document = await _aiService.ExtractCvDocumentAsync(stream, artifact.OriginalFileName, artifact.MimeType, cancellationToken)
?? throw new InvalidOperationException("Document extraction is unavailable. The source was retained for retry.");
await Stage("cv_identifying_sections");
var blocks = document.Blocks;
await Stage("cv_analysing");
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.SerializePersisted(profile);
if (context is null)
{
run.DocumentJson = documentJson;
await CompleteQueuedRunForReviewAsync(run, document.Text, document.Text, profile, cancellationToken);
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, 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);
}
[HttpPost("import")]
[Authorize(Policy = ProEntitlement.Policy)]
[RequestSizeLimit(MaxFileSizeBytes + 65536)]
public async Task<IActionResult> Import([FromForm] IFormFile file)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (file is null || file.Length is <= 0 or > MaxFileSizeBytes) return BadRequest("Select a PDF or DOCX CV under 5 MB.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (extension is not (".pdf" or ".docx")) return BadRequest("Select a PDF or DOCX CV.");
try { await ValidateImportAsync(file, extension, HttpContext.RequestAborted); }
catch (InvalidDataException) { return BadRequest("The document signature or archive is invalid or unsupported."); }
if (await _db.CvExtractionRuns.CountAsync(r => r.OwnerUserId == user.Id && r.Status == "pending_review", HttpContext.RequestAborted) >= ExtractionRunRetentionCount) return Conflict("Review or discard an existing import before uploading another document.");
if (await _db.CvUploadArtifacts.CountAsync(a => a.OwnerUserId == user.Id && a.StoragePath != "", HttpContext.RequestAborted) >= 100) return Conflict("Remove or finish existing imports before uploading more documents.");
var artifact = await SaveUploadArtifactAsync(user, file, HttpContext.RequestAborted);
artifact.OriginalFileName = Path.GetFileName(file.FileName.Replace('\\', '/'));
artifact.MimeType = extension == ".pdf" ? "application/pdf" : "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
var stored = false;
try
{
var active = await FindActiveRunAsync(user.Id, "document_import", null, artifact.Sha256, HttpContext.RequestAborted);
if (active is not null) { TryDeleteCvArtifactFile(artifact.StoragePath); return await EnqueueRunAsync(active, HttpContext.RequestAborted); }
var run = new CvExtractionRun { OwnerUserId = user.Id, Artifact = artifact, Trigger = "document_import", ParserVersion = DocumentParserVersion, NormalizerVersion = "source-grounded-v1", LlmPromptVersion = "none", Status = "queued" };
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(HttpContext.RequestAborted);
stored = true;
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
}
catch { if (!stored) TryDeleteCvArtifactFile(artifact.StoragePath); throw; }
}
private static async Task ValidateImportAsync(IFormFile file, string extension, CancellationToken cancellationToken)
{
await using var source = file.OpenReadStream();
using var bytes = new MemoryStream();
var buffer = new byte[8192];
int read;
while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0)
{
if (bytes.Length + read > MaxFileSizeBytes) throw new InvalidDataException();
bytes.Write(buffer, 0, read);
}
var data = bytes.ToArray();
if (extension == ".pdf")
{
if (data.Length < 8 || !data.AsSpan(0, 5).SequenceEqual("%PDF-"u8)) throw new InvalidDataException();
var tokens = Encoding.Latin1.GetString(data);
if (new[] { "/JavaScript", "/JS", "/Launch", "/OpenAction", "/EmbeddedFile", "/Encrypt" }.Any(tokens.Contains)) throw new InvalidDataException();
return;
}
bytes.Position = 0;
try
{
using var archive = new ZipArchive(bytes, ZipArchiveMode.Read, true);
if (archive.Entries.Count is 0 or > 2048 || archive.GetEntry("word/document.xml") is null || archive.GetEntry("[Content_Types].xml") is null) throw new InvalidDataException();
long expanded = 0;
foreach (var entry in archive.Entries)
{
expanded += entry.Length;
if (entry.FullName.Contains("..") || entry.FullName.StartsWith('/') || entry.FullName.Contains('\\') || entry.Length > 10 * 1024 * 1024 || expanded > 25 * 1024 * 1024 || entry.Length > 200 * Math.Max(1, entry.CompressedLength) || entry.FullName.EndsWith("vbaProject.bin", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException();
if (entry.FullName.EndsWith(".rels", StringComparison.OrdinalIgnoreCase))
{
using var reader = new StreamReader(entry.Open());
var relationships = await reader.ReadToEndAsync(cancellationToken);
// Hyperlinks remain inert text; linked templates/OLE/media are refused.
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();
}
}
}
catch (Exception ex) when (ex is IOException or System.Xml.XmlException or NotSupportedException) { throw new InvalidDataException("Invalid document archive.", ex); }
}
[NonAction]
public static StructuredCvProfile ParseImportDocument(CvImportDocument document)
{
var candidate = CvDocumentInterpreter.Parse(document.Blocks);
return CvDocumentReview.Ground(candidate, document);
}
[HttpGet("runs/{id:int}/review")]
public async Task<IActionResult> GetImportReview(int id)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.AsNoTracking().Include(r => r.Artifact).FirstOrDefaultAsync(r => r.Id == id && r.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
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.DeserializePersisted(run.StructuredProfileJson)), document?.Blocks ?? Array.Empty<CvImportBlock>(), CvImportMapping.Catalog(snapshot.Profile), CvImportMapping.Targets(snapshot.Profile)));
}
[HttpGet("runs/{id:int}/source")]
public async Task<IActionResult> GetImportSource(int id)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.AsNoTracking().Include(r => r.Artifact).FirstOrDefaultAsync(r => r.Id == id && r.OwnerUserId == user.Id, HttpContext.RequestAborted);
var artifact = run?.Artifact;
if (artifact is null || artifact.OwnerUserId != user.Id || !System.IO.File.Exists(artifact.StoragePath)) return NotFound();
Response.Headers.CacheControl = "private, no-store";
Response.Headers["X-Content-Type-Options"] = "nosniff";
return PhysicalFile(artifact.StoragePath, artifact.MimeType, artifact.OriginalFileName);
}
[HttpPost("runs/{id:int}/apply-reviewed")]
public async Task<IActionResult> ApplyReviewed(int id, [FromBody] ApplyReviewedRequest? request)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (request?.ExpectedVersion is null or < 0 || request.Decisions is null || request.Decisions.Any(d => d is null || d.Id is null || d.Action is null)) return BadRequest("expectedVersion and review decisions are required.");
var mappings = request.Mappings ?? Array.Empty<CvImportRecordMapping>();
if (mappings.Count > 25 || mappings.Any(m => m is null)) return BadRequest("Invalid or excessive record mappings.");
// Preserve hashes for old requests and already-applied historical reviews.
var hashBytes = mappings.Count == 0 ? JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions.OrderBy(d => d.Id, StringComparer.Ordinal) })
: JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions.OrderBy(d => d.Id, StringComparer.Ordinal), Mappings = mappings });
var hash = Convert.ToHexString(SHA256.HashData(hashBytes));
try
{
return await _careerProfileService.ExecuteAtomicAsync<IActionResult>(async () =>
{
var run = await _db.CvExtractionRuns.Include(r => r.Artifact).FirstOrDefaultAsync(r => r.Id == id && r.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (run.ParserVersion != DocumentParserVersion) return Conflict("Use legacy review for this run.");
if (run.Status == "applied")
{
var prior = JsonSerializer.Deserialize<AppliedReview>(run.ReviewJson ?? "null");
var sameMappings = mappings.Count == 0 || JsonSerializer.Serialize(prior?.Mappings) == JsonSerializer.Serialize(mappings);
var identical = prior?.Hash == hash && sameMappings;
// Old mapping receipts sorted IDs; accept their exact original order only.
if (!identical && prior is not null && mappings.Count > 0 && sameMappings)
{
var legacyHash = Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions.OrderBy(d => d.Id, StringComparer.Ordinal), Mappings = mappings.OrderBy(m => m.ClientId, StringComparer.Ordinal) })));
identical = prior.Hash == legacyHash;
}
return identical ? Ok(new { runId = id, status = "applied", revision = prior!.Revision, structuredCv = prior.StructuredCv }) : Conflict("This import has already been applied with different decisions.");
}
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();
CvImportMapping.ValidateTargets(snapshot.Profile, mappings);
var merged = CvDocumentReview.Apply(snapshot.Profile, StructuredCvProfileJson.DeserializePersisted(run.StructuredProfileJson), request.Decisions);
if (mappings.Count > 0)
{
var document = JsonSerializer.Deserialize<CvImportDocument>(run.DocumentJson ?? "null");
if (document is null || (run.Artifact is not null && run.Artifact.OwnerUserId != user.Id)) return BadRequest("The source evidence is unavailable.");
merged = CvImportMapping.Apply(merged, mappings, document, run.ArtifactId, run.Id, run.Artifact?.Sha256);
}
if (!request.Decisions.Any(d => d.Action != "keep") && mappings.Count == 0) 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 = 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);
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged);
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
user.CurrentCvUploadArtifactId = run.ArtifactId;
run.Status = "applied";
run.AppliedAtUtc = DateTimeOffset.UtcNow;
run.ReviewJson = JsonSerializer.Serialize(new AppliedReview(hash, snapshot.Revision + 1, merged, request.Decisions, request.Mappings));
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description)));
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new { runId = id, run.Status, revision = snapshot.Revision + 1, structuredCv = merged });
}, HttpContext.RequestAborted);
}
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Refresh the comparison and review your decisions." }); }
// MariaDB can report a changed run as a snapshot conflict before EF sees zero affected rows.
catch (DbUpdateException ex) when (ex.InnerException is MySqlConnector.MySqlException { Number: 1020 }) { return Conflict(new { code = "revision_conflict", message = "The import changed. Refresh the comparison and review your decisions." }); }
catch (ArgumentException ex) { return BadRequest(ex.Message); }
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
}
}