feat(cv-import): add source-grounded review and explicit corrections
This commit is contained in:
@@ -7,6 +7,60 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public class CvDocumentImportTests
|
||||
{
|
||||
[Fact]
|
||||
public void Grounded_duplicate_skills_keep_evidence_at_their_final_normalized_indices()
|
||||
{
|
||||
var candidate = new StructuredCvProfile { Skills = { "C#", "C#", "SQL" } };
|
||||
var source = Document(("C#", "paragraph"), ("SQL", "paragraph"));
|
||||
var result = StructuredCvProfileJson.DeserializePersisted(StructuredCvProfileJson.SerializePersisted(CvDocumentReview.Ground(candidate, source)));
|
||||
Assert.Equal(new[] { "C#", "SQL" }, result.Skills);
|
||||
Assert.Equal("SQL", result.Metadata.Fields["skills.1"].SourceSnippet);
|
||||
Assert.DoesNotContain("skills.2", result.Metadata.Fields.Keys);
|
||||
}
|
||||
[Fact]
|
||||
public void Appended_bullet_metadata_follows_the_actual_destination_and_preserves_old_evidence()
|
||||
{
|
||||
var current = new StructuredCvProfile { Jobs = { new() { Title = "Engineer", Company = "Example", Start = "2020", End = "2021", Bullets = { "Old" } } } };
|
||||
current.Metadata.Fields["jobs.0.bullets.0"] = new() { SourceSnippet = "Old" };
|
||||
var incoming = new StructuredCvProfile { Jobs = { new() { Title = "Engineer", Company = "Example", Start = "2020", End = "2021", Bullets = { "New" } } } };
|
||||
incoming.Metadata.Fields["jobs.0.bullets.0"] = new() { SourceSnippet = "New" };
|
||||
var result = CvDocumentReview.Apply(current, incoming, new[] { new CvImportDecision("jobs.0.bullets.0", "use") });
|
||||
Assert.Equal(new[] { "Old", "New" }, result.Jobs[0].Bullets);
|
||||
Assert.Equal("Old", result.Metadata.Fields["jobs.0.bullets.0"].SourceSnippet);
|
||||
Assert.Equal("New", result.Metadata.Fields["jobs.0.bullets.1"].SourceSnippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_imported_languages_and_jobs_do_not_create_duplicate_records()
|
||||
{
|
||||
var incoming = new StructuredCvProfile { Languages = { new() { Name = "English", Level = "B2" }, new() { Name = "English", Level = "B2" } }, Jobs = { new() { Title = "Engineer", Company = "A", Start = "2020", End = "2021" }, new() { Title = "Engineer", Company = "A", Start = "2020", End = "2021" }, new() { Title = "Engineer", Company = "A", Start = "2022", End = "2023" } } };
|
||||
var decisions = CvDocumentReview.Build(new(), incoming).SelectMany(g => g.Fields).Select(f => new CvImportDecision(f.Id, "use")).ToArray();
|
||||
var result = CvDocumentReview.Apply(new(), incoming, decisions);
|
||||
Assert.Single(result.Languages);
|
||||
Assert.Equal(2, result.Jobs.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Correcting_a_new_skill_to_an_existing_skill_keeps_metadata_on_a_real_item()
|
||||
{
|
||||
var result = CvDocumentReview.Apply(new() { Skills = { "C#" } }, new() { Skills = { "SQL" } }, new[] { new CvImportDecision("skills.0", "correct", "C#") });
|
||||
var persisted = StructuredCvProfileJson.DeserializePersisted(StructuredCvProfileJson.SerializePersisted(result));
|
||||
Assert.Single(persisted.Skills);
|
||||
Assert.Contains("skills.0", persisted.Metadata.Fields.Keys);
|
||||
Assert.DoesNotContain("skills.1", persisted.Metadata.Fields.Keys);
|
||||
}
|
||||
[Fact]
|
||||
public void Reviewed_metadata_retains_its_own_source_identity_across_later_imports()
|
||||
{
|
||||
var incoming = StructuredCvProfileJson.DeserializePersisted("""{"skills":["C#"],"metadata":{"fields":{"skills.0":{"method":"deterministic-source","sourceBlockId":"b0001","sourceArtifactId":21,"sourceExtractionRunId":7,"sourceFileSha256":"abc"}}}}""");
|
||||
var merged = CvDocumentReview.Apply(new(), incoming, new[] { new CvImportDecision("skills.0", "use") });
|
||||
using var json = System.Text.Json.JsonDocument.Parse(StructuredCvProfileJson.SerializePersisted(merged));
|
||||
var field = json.RootElement.GetProperty("metadata").GetProperty("fields").GetProperty("skills.0");
|
||||
Assert.True(field.TryGetProperty("sourceExtractionRunId", out var run));
|
||||
Assert.Equal(7, run.GetInt32());
|
||||
Assert.Equal(21, field.GetProperty("sourceArtifactId").GetInt32());
|
||||
Assert.Equal("abc", field.GetProperty("sourceFileSha256").GetString());
|
||||
}
|
||||
[Fact]
|
||||
public void Imported_contact_links_append_without_replacing_unrelated_existing_links()
|
||||
{
|
||||
|
||||
@@ -19,6 +19,75 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CvProcessingOperationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Worker_that_lost_its_lease_cannot_overwrite_a_recovered_successful_review()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.SeedUserAsync();
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
fixture.DocumentExtraction = async () => { entered.TrySetResult(); await release.Task; return new("Skills\nC#", false, "application/pdf", 1, 9, "synthetic.pdf", new[] { new CvImportBlock("b1", "Skills\nC#", "paragraph", 1) }, Array.Empty<string>(), "docling-native-pdf-v1"); };
|
||||
await using (var scope = fixture.Provider.CreateAsyncScope())
|
||||
{
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var bytes = Encoding.UTF8.GetBytes("%PDF-1.4\nSynthetic fixture");
|
||||
Assert.IsType<AcceptedResult>(await CreateController(scope.ServiceProvider).Import(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" }));
|
||||
}
|
||||
var worker = fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default);
|
||||
try
|
||||
{
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await using var recovered = fixture.Provider.CreateAsyncScope();
|
||||
var db = recovered.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
await db.UserOperations.IgnoreQueryFilters().ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OperationStatuses.Succeeded).SetProperty(o => o.LeaseToken, (string?)null).SetProperty(o => o.CompletedAtUtc, now));
|
||||
await db.CvExtractionRuns.IgnoreQueryFilters().ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, "pending_review").SetProperty(r => r.StructuredProfileJson, "{\"skills\":[\"Recovered winner\"]}"));
|
||||
}
|
||||
finally { release.TrySetResult(); }
|
||||
Assert.True(await worker.WaitAsync(TimeSpan.FromSeconds(10)));
|
||||
await using var verify = fixture.Provider.CreateAsyncScope();
|
||||
var winner = await verify.ServiceProvider.GetRequiredService<JobTrackerContext>().CvExtractionRuns.IgnoreQueryFilters().SingleAsync();
|
||||
Assert.Equal("pending_review", winner.Status);
|
||||
Assert.Contains("Recovered winner", winner.StructuredProfileJson);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Document_failure_message_is_bounded_and_does_not_crash_the_worker_or_disclose_source()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.SeedUserAsync();
|
||||
fixture.ExtractionFailure = new InvalidOperationException("PRIVATE_SOURCE " + new string('x', 2000));
|
||||
await using (var scope = fixture.Provider.CreateAsyncScope())
|
||||
{
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var bytes = Encoding.UTF8.GetBytes("%PDF-1.4\nSynthetic fixture");
|
||||
Assert.IsType<AcceptedResult>(await CreateController(scope.ServiceProvider).Import(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" }));
|
||||
}
|
||||
Assert.True(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
|
||||
await using var verify = fixture.Provider.CreateAsyncScope();
|
||||
var operation = await verify.ServiceProvider.GetRequiredService<JobTrackerContext>().UserOperations.IgnoreQueryFilters().SingleAsync();
|
||||
Assert.Equal(OperationStatuses.Failed, operation.Status);
|
||||
Assert.InRange(operation.FailureMessage!.Length, 1, 512);
|
||||
Assert.DoesNotContain("PRIVATE_SOURCE", operation.FailureMessage);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Structured_document_worker_publishes_review_under_its_real_database_lease()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.SeedUserAsync();
|
||||
CvProcessingOperationResponse response;
|
||||
await using (var scope = fixture.Provider.CreateAsyncScope())
|
||||
{
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var bytes = Encoding.UTF8.GetBytes("%PDF-1.4\nSynthetic transport fixture");
|
||||
response = Response(await CreateController(scope.ServiceProvider).Import(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" }));
|
||||
}
|
||||
Assert.True(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
|
||||
await using var verify = fixture.Provider.CreateAsyncScope();
|
||||
var db = verify.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
Assert.Equal("pending_review", (await db.CvExtractionRuns.IgnoreQueryFilters().SingleAsync()).Status);
|
||||
Assert.Equal(OperationStatuses.Succeeded, (await db.UserOperations.IgnoreQueryFilters().SingleAsync()).Status);
|
||||
Assert.Empty(await db.CareerProfiles.IgnoreQueryFilters().ToListAsync());
|
||||
}
|
||||
[Fact]
|
||||
public async Task Cancellation_of_an_expired_import_operation_cannot_resurrect_its_review()
|
||||
{
|
||||
@@ -227,6 +296,8 @@ public sealed class CvProcessingOperationTests
|
||||
private readonly string _tempRoot;
|
||||
public ServiceProvider Provider { get; }
|
||||
public AiGenerationException? GenerationFailure { get; set; }
|
||||
public Exception? ExtractionFailure { get; set; }
|
||||
public Func<Task<CvImportDocument>>? DocumentExtraction { get; set; }
|
||||
|
||||
private Fixture(SqliteConnection connection, string tempRoot, ServiceProvider provider)
|
||||
{
|
||||
@@ -251,6 +322,8 @@ public sealed class CvProcessingOperationTests
|
||||
environment.SetupGet(item => item.ContentRootPath).Returns(tempRoot);
|
||||
Fixture? fixture = null;
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer.Setup(item => item.ExtractCvDocumentAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async () => fixture?.ExtractionFailure is not null ? throw fixture.ExtractionFailure : fixture?.DocumentExtraction is not null ? await fixture.DocumentExtraction() : new CvImportDocument("Skills\nC#", false, "application/pdf", 1, 9, "synthetic.pdf", new[] { new CvImportBlock("b1", "Skills", "section_header", 1), new CvImportBlock("b2", "C#", "paragraph", 1) }, Array.Empty<string>(), "docling-native-pdf-v1"));
|
||||
summarizer.Setup(item => item.ExtractTextAsync(
|
||||
It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AiTextExtractionResult(
|
||||
|
||||
@@ -20,6 +20,46 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ProfileCvControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Pending_import_review_remains_reachable_after_ten_newer_completed_runs()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var users = CreateUserManager();
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
await using var db = CreateDb();
|
||||
var pending = new CvExtractionRun { OwnerUserId = user.Id, Status = "pending_review", StartedAtUtc = DateTimeOffset.UtcNow.AddDays(-5), ParserVersion = "docling-v1" };
|
||||
db.CvExtractionRuns.Add(pending);
|
||||
db.CvExtractionRuns.AddRange(Enumerable.Range(0, 12).Select(i => new CvExtractionRun { OwnerUserId = user.Id, Status = "applied", StartedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-i) }));
|
||||
await db.SaveChangesAsync();
|
||||
var result = await CreateController(users.Object, Mock.Of<ISummarizerService>(), db, CreatePaths()).GetRuns();
|
||||
var runs = Assert.IsAssignableFrom<IEnumerable<CvExtractionRunListItem>>(Assert.IsType<OkObjectResult>(result.Result).Value).ToList();
|
||||
Assert.Contains(runs, r => r.Id == pending.Id);
|
||||
Assert.Equal(11, runs.Count);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Document_import_rejects_docx_dtd_before_admission_or_file_storage()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var users = CreateUserManager();
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
await using var db = CreateDb();
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(users.Object, Mock.Of<ISummarizerService>(), db, paths);
|
||||
using var bytes = new MemoryStream();
|
||||
using (var archive = new System.IO.Compression.ZipArchive(bytes, System.IO.Compression.ZipArchiveMode.Create, true))
|
||||
{
|
||||
foreach (var entry in new Dictionary<string, string> { ["[Content_Types].xml"] = "<Types/>", ["word/document.xml"] = "<document/>", ["word/_rels/document.xml.rels"] = "<!DOCTYPE Relationships [<!ENTITY example 'inert'>]><Relationships>&example;</Relationships>" })
|
||||
{
|
||||
await using var writer = new StreamWriter(archive.CreateEntry(entry.Key).Open());
|
||||
await writer.WriteAsync(entry.Value);
|
||||
}
|
||||
}
|
||||
bytes.Position = 0;
|
||||
var file = new FormFile(bytes, 0, bytes.Length, "file", "synthetic.docx") { Headers = new HeaderDictionary(), ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" };
|
||||
Assert.IsType<BadRequestObjectResult>(await controller.Import(file));
|
||||
Assert.Empty(db.CvUploadArtifacts);
|
||||
Assert.Empty(Directory.EnumerateFiles(paths.CvArtifactsRoot, "*", SearchOption.AllDirectories));
|
||||
}
|
||||
[Fact]
|
||||
public async Task Document_import_rejects_spoofed_pdf_without_storing_it()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user