feat(cv-import): ground reviewed facts in revisioned career profiles

This commit is contained in:
cesnimda
2026-10-26 14:30:14 +01:00
parent 3ea8ff9d62
commit e8f76a9dea
24 changed files with 1197 additions and 13 deletions
@@ -0,0 +1,96 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Controllers;
using Xunit;
namespace JobTrackerApi.Tests;
public class CvDocumentImportTests
{
[Fact]
public void Imported_contact_links_append_without_replacing_unrelated_existing_links()
{
var current = new StructuredCvProfile { Contact = new() { Links = { new() { Label = "Blog", Url = "https://blog.example.test" } } } };
var incoming = new StructuredCvProfile { Contact = new() { Links = { new() { Label = "Portfolio", Url = "https://portfolio.example.test" } } } };
var fields = CvDocumentReview.Build(current, incoming).SelectMany(g => g.Fields).ToList();
Assert.All(fields, f => Assert.Null(f.CurrentValue));
var result = CvDocumentReview.Apply(current, incoming, fields.Select(f => new CvImportDecision(f.Id, "use")).ToArray());
Assert.Equal(2, result.Contact.Links.Count);
Assert.Equal("https://blog.example.test", result.Contact.Links[0].Url);
Assert.Equal("https://portfolio.example.test", result.Contact.Links[1].Url);
}
private static CvImportDocument Document(params (string Text, string Kind)[] lines) => new(string.Join("\n", lines.Select(l => l.Text)), false, "application/pdf", 1, 0, "synthetic.pdf", lines.Select((l, i) => new CvImportBlock("b" + i, l.Text, l.Kind, 1)).ToArray(), Array.Empty<string>(), "docling-native-pdf-v1");
[Fact]
public void Structural_parser_preserves_labelled_norwegian_contact_and_current_year_precision()
{
var result = ProfileCvController.ParseImportDocument(Document(("Name: Åse Øvrebø", "paragraph"), ("Location: Tromsø", "paragraph"), ("Arbeidserfaring", "section_header"), ("Utvikler | Eksempel AS | 2020 - nå", "paragraph"), ("Bygget sikre verktøy.", "list_item")));
Assert.Equal("Åse Øvrebø", result.Contact.FullName);
Assert.Equal("Tromsø", result.Contact.Location);
var job = Assert.Single(result.Jobs);
Assert.Equal("Eksempel AS", job.Company);
Assert.Equal("2020", job.Start);
Assert.Equal("nå", job.End);
Assert.True(job.IsCurrent);
Assert.Null(job.StartDate);
Assert.Equal("Bygget sikre verktøy.", Assert.Single(job.Bullets));
}
[Fact]
public void Structural_parser_preserves_custom_sections_and_unknown_proficiency_without_guessing_identity()
{
var result = ProfileCvController.ParseImportDocument(Document(("ase.example@example.test", "paragraph"), ("Languages", "section_header"), ("Norwegian", "paragraph"), ("Courses", "section_header"), ("Database Design, Demo Academy, 2023", "paragraph")));
Assert.Null(result.Contact.FullName);
Assert.Null(result.Contact.Location);
Assert.Null(Assert.Single(result.Languages).Level);
Assert.Equal("Database Design, Demo Academy, 2023", Assert.Single(Assert.Single(result.OtherSections).Items));
}
[Fact]
public void Structural_parser_keeps_unreadable_employment_as_reviewable_source_instead_of_fabricating_a_record()
{
var result = ProfileCvController.ParseImportDocument(Document(("Work Experience", "section_header"), ("Worked with Example Labs; dates and contractual role unavailable.", "paragraph")));
Assert.Empty(result.Jobs);
Assert.Contains(result.OtherSections, section => section.Items.Contains("Worked with Example Labs; dates and contractual role unavailable."));
}
[Fact]
public void Review_only_applies_selected_fields_and_preserves_custom_contact_and_records()
{
var current = new StructuredCvProfile { Contact = new() { GitHub = "https://github.com/existing", Links = { new() { Label = "Portfolio", Url = "https://example.test" } } }, Jobs = { new() { Id = "old", Company = "Acme", Title = "Engineer", Start = "2020", End = "2021", Bullets = { "Existing" } } }, OtherSections = { new() { Title = "Private", Items = { "Preserve" } } } };
var incoming = new StructuredCvProfile { Contact = new() { Email = "new@example.test" }, Skills = { "C#", "SQL" }, Jobs = { new() { Company = "Acme", Title = "Engineer", Start = "2022", End = "2023" } } };
var groups = CvDocumentReview.Build(current, incoming);
var email = groups.SelectMany(g => g.Fields).Single(f => f.Id == "contact.email");
var merged = CvDocumentReview.Apply(current, incoming, new[] { new CvImportDecision(email.Id, "correct", "correct@example.test"), new CvImportDecision("skills.0", "use") });
Assert.Equal("correct@example.test", merged.Contact.Email);
Assert.Equal(current.Contact.GitHub, merged.Contact.GitHub);
Assert.Single(merged.Contact.Links);
Assert.Equal(new[] { "C#" }, merged.Skills);
Assert.Single(merged.Jobs);
Assert.Equal("old", merged.Jobs[0].Id);
Assert.Single(merged.OtherSections);
Assert.Contains(groups, g => g.Category == "jobs" && g.MatchDescription == "New record");
}
[Theory]
[InlineData("metadata.profileVersion", "use", null)]
[InlineData("contact.email", "erase", null)]
[InlineData("contact.email", "correct", null)]
public void Invalid_decisions_are_rejected(string id, string action, string? value)
{
Assert.Throws<ArgumentException>(() => CvDocumentReview.Apply(new(), new() { Contact = new() { Email = "a@example.test" } }, new[] { new CvImportDecision(id, action, value) }));
}
[Fact]
public void Grounding_removes_invented_fields_and_keeps_year_precision()
{
var document = new CvImportDocument("Acme\nEngineer\n2020 - 2021\nC#", false, "application/pdf", 1, 35, "cv.pdf", new[] { new CvImportBlock("b1", "Acme\nEngineer\n2020 - 2021\nC#", "text", 1) }, Array.Empty<string>(), "docling");
var candidate = new StructuredCvProfile { Contact = new() { FullName = "Invented" }, Jobs = { new() { Company = "Acme", Title = "Engineer", Start = "2020", End = "2021", StartDate = "2020-01", Bullets = { "Invented achievement" } } } };
var result = CvDocumentReview.Ground(candidate, document);
Assert.Null(result.Contact.FullName);
Assert.Equal("2020", result.Jobs[0].Start);
Assert.Null(result.Jobs[0].StartDate);
Assert.Empty(result.Jobs[0].Bullets);
Assert.Equal("b1", result.Metadata.Fields["jobs.0.company"].SourceBlockId);
Assert.Null(result.Metadata.Fields["jobs.0.company"].Confidence);
}
}
@@ -0,0 +1,39 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public class CvImportRetentionTests
{
[Theory]
[InlineData("pending_review", 8, true)]
[InlineData("cancelled", 2, true)]
[InlineData("failed", 2, true)]
[InlineData("pending_review", 2, false)]
public async Task Original_sources_have_bounded_retention_and_recent_reviews_survive(string status, int days, bool expires)
{
var root = Path.Combine(Path.GetTempPath(), "cv-import-retention-" + Guid.NewGuid().ToString("N"));
var environment = Mock.Of<IHostEnvironment>(e => e.ContentRootPath == root);
var paths = new AppPaths(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build(), environment);
try
{
await using var db = TestHostFactory.CreateInMemoryDb();
var file = Path.Combine(paths.CvArtifactsRoot, "synthetic.pdf");
await File.WriteAllTextAsync(file, "Synthetic source");
var now = DateTimeOffset.UtcNow;
var run = new CvExtractionRun { OwnerUserId = "user-1", ParserVersion = "docling-v1", Trigger = "document_import", Status = status, StartedAtUtc = now.AddDays(-days), RawExtractedText = "Source", DocumentJson = "{}", StructuredProfileJson = "{}", Artifact = new() { OwnerUserId = "user-1", StoragePath = file } };
db.CvExtractionRuns.Add(run);
await db.SaveChangesAsync();
await CvImportRetentionHostedService.SweepAsync(db, paths, now, default);
Assert.Equal(!expires, File.Exists(file));
Assert.Equal(expires, run.DocumentJson is null);
Assert.Equal(expires && status == "pending_review" ? "expired" : status, run.Status);
}
finally { Directory.Delete(root, true); }
}
}
@@ -20,6 +20,49 @@ namespace JobTrackerApi.Tests;
public sealed class ProfileCvControllerTests
{
[Fact]
public async Task Document_import_rejects_spoofed_pdf_without_storing_it()
{
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 controller = CreateController(users.Object, new Mock<ISummarizerService>().Object, db, CreatePaths());
var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("not a PDF")), 0, 9, "file", "cv.pdf");
Assert.IsType<BadRequestObjectResult>(await controller.Import(file));
Assert.Empty(db.CvUploadArtifacts);
}
[Fact]
public async Task Document_import_requires_explicit_approval_and_repeat_apply_is_idempotent()
{
var user = new ApplicationUser { Id = "user-1" };
var users = CreateUserManager();
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
ConfigureWorkerUser(users, user);
users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var ai = new Mock<ISummarizerService>();
const string source = "# Ada Lovelace\n\n## Skills\nC#, SQL";
ai.Setup(x => x.ExtractCvDocumentAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync(new CvImportDocument(source, false, "application/pdf", 1, source.Length, "cv.pdf", new[] { new CvImportBlock("b1", source, "text", 1) }, Array.Empty<string>(), "docling"));
await using var db = CreateDb();
var controller = CreateController(users.Object, ai.Object, db, CreatePaths());
var bytes = Encoding.UTF8.GetBytes("%PDF-1.7\nsynthetic admission only");
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "cv.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" };
Assert.IsType<AcceptedResult>(await controller.Import(file));
var run = await db.CvExtractionRuns.SingleAsync();
Assert.True((await controller.ProcessQueuedRunAsync(run.Id, default))!.Succeeded);
Assert.Null(user.ProfileCvStructureJson);
Assert.IsType<ConflictObjectResult>(await controller.AcceptRun(run.Id, new(null, 0)));
var review = Assert.IsType<CvImportReview>(Assert.IsType<OkObjectResult>(await controller.GetImportReview(run.Id)).Value);
var field = review.Groups.SelectMany(g => g.Fields).Single(f => f.ExtractedValue == "C#");
var request = new ProfileCvController.ApplyReviewedRequest(0, new[] { new CvImportDecision(field.Id, "use") });
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, request));
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, request));
Assert.Single(db.CareerProfileVersions);
Assert.Equal(new[] { "C#" }, StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Skills);
ai.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Never);
}
[Fact]
public async Task Upload_rejects_unsupported_extension()
{