feat(cv-qa): map missing records from reviewed source evidence

This commit is contained in:
cesnimda
2026-10-26 19:43:30 +01:00
parent 249d06848a
commit 5e68848cf7
13 changed files with 778 additions and 14 deletions
@@ -72,6 +72,85 @@ public class CvImportReviewedPersistenceTests
return run;
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Source_mapped_new_records_fill_empty_profile_or_roll_back_without_rejected_proposals(bool succeeds)
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
var run = await Seed(db);
var blocks = new List<CvImportBlock>();
var sequence = 0;
CvImportRecordMapping Map(string category, params (string Name, string Value)[] values)
{
var fields = values.Select(value =>
{
var block = new CvImportBlock("b" + ++sequence, value.Value, "paragraph", 1);
blocks.Add(block);
return new CvImportMappedField(value.Name, value.Value, block.Id);
}).ToArray();
return new("record-" + sequence, category, null, fields);
}
var mappings = new[]
{
Map("contact", ("fullName", "Åse Synthetic"), ("email", "ase@example.test"), ("phone", "+47 400 00 001"), ("headline", "Engineer"), ("location", "Oslo"), ("website", "https://example.test"), ("linkedIn", "https://linkedin.com/in/synthetic"), ("gitHub", "https://github.com/synthetic"), ("linkLabel", "Portfolio"), ("linkUrl", "https://portfolio.example.test")),
Map("jobs", ("title", "Engineer"), ("company", "First AS"), ("location", "Oslo"), ("start", "2018"), ("end", "2020"), ("bullets", "Built tools."), ("skills", "Rust")),
Map("jobs", ("title", "Lead"), ("company", "Second AS"), ("start", "2021"), ("end", "Present"), ("isCurrent", "true"), ("bullets", "Led delivery.")),
Map("education", ("qualification", "Bachelor"), ("qualificationLevel", "Bachelor"), ("institution", "First University"), ("location", "Bergen"), ("start", "2010"), ("end", "2013"), ("details", "Databases")),
Map("education", ("qualification", "Master"), ("institution", "Second University"), ("start", "2013"), ("end", "2015"), ("details", "Distributed systems")),
Map("certifications", ("name", "Synthetic certificate"), ("issuer", "Example Institute"), ("date", "2022"), ("location", "Online"), ("details", "Recorded assessment")),
Map("projects", ("name", "Atlas"), ("role", "Developer"), ("location", "Remote"), ("start", "2020"), ("end", "2021"), ("bullets", "Built search."), ("skills", "React")),
Map("languages", ("name", "Norsk"), ("level", "Morsmål"), ("notes", "Stated level")), Map("languages", ("name", "English")),
Map("skills", ("value", "Rust"), ("value", "Python")), Map("summary", ("value", "Source-backed professional summary.")),
Map("otherSections", ("title", "Courses"), ("items", "Systems course")), Map("awards", ("value", "Synthetic award")),
Map("publications", ("value", "Synthetic publication")), Map("organisations", ("value", "Synthetic organisation")),
Map("interests", ("value", "Hiking")), Map("references", ("value", "Available on request")),
};
run.RawExtractedText = string.Join('\n', blocks.Select(b => b.Text));
run.DocumentJson = JsonSerializer.Serialize(new CvImportDocument(run.RawExtractedText, false, "application/pdf", 1, run.RawExtractedText.Length, "synthetic.pdf", blocks, Array.Empty<string>(), "synthetic-test-document"));
run.Artifact = new() { OwnerUserId = Owner, OriginalFileName = "synthetic.pdf", StoredFileName = "synthetic.pdf", MimeType = "application/pdf", Sha256 = new string('a', 64) };
await db.SaveChangesAsync();
var artifactId = run.ArtifactId;
var request = new ProfileCvController.ApplyReviewedRequest(0, new[] { new CvImportDecision("skills.0", "keep"), new CvImportDecision("skills.1", "keep") }, mappings);
var result = await Controller(db, succeeds).ApplyReviewed(run.Id, request);
if (succeeds)
{
Assert.IsType<OkObjectResult>(result);
Assert.IsType<OkObjectResult>(await Controller(db).ApplyReviewed(run.Id, request));
}
else Assert.IsType<BadRequestObjectResult>(result);
await db.SaveChangesAsync();
await using var verify = database.Context();
var snapshot = await new CareerProfileService(verify).LoadSnapshotAsync(Owner, default);
Assert.Equal(succeeds ? 1 : 0, snapshot.Revision);
Assert.Equal(succeeds ? 2 : 0, await verify.CareerExperiences.CountAsync());
Assert.Equal(succeeds ? 2 : 0, await verify.CareerEducations.CountAsync());
Assert.Equal(succeeds ? 2 : 0, await verify.CareerLanguages.CountAsync());
Assert.Equal(succeeds ? 1 : 0, await verify.CareerProjects.CountAsync());
Assert.Equal(succeeds ? 1 : 0, await verify.CareerCertifications.CountAsync());
Assert.Equal(succeeds ? new[] { "Python", "Rust" } : Array.Empty<string>(), await verify.CareerSkills.OrderBy(s => s.Name).Select(s => s.Name!).ToArrayAsync());
Assert.Equal(succeeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
Assert.Equal(succeeds ? "applied" : "pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status);
if (!succeeds) return;
var profile = snapshot.Profile;
Assert.Equal("Åse Synthetic", profile.Contact.FullName);
Assert.Equal("https://portfolio.example.test", Assert.Single(profile.Contact.Links).Url);
Assert.Equal("2018", profile.Jobs[0].Start);
Assert.Equal("Oslo", profile.Jobs[0].Location);
Assert.True(profile.Jobs[1].IsCurrent);
Assert.Null(profile.Languages.Single(l => l.Name == "English").Level);
Assert.Equal("Databases", Assert.Single(profile.Education[0].Details));
Assert.Equal("React", Assert.Single(profile.Projects[0].Skills));
Assert.Equal("Courses", Assert.Single(profile.OtherSections).Title);
Assert.Single(profile.Awards); Assert.Single(profile.Publications); Assert.Single(profile.Organisations); Assert.Single(profile.Interests); Assert.Single(profile.References); Assert.Single(profile.Summary);
Assert.DoesNotContain("SQL", StructuredCvProfileJson.SerializePersisted(profile));
Assert.Equal(artifactId, profile.Metadata.Fields["jobs.0.title"].SourceArtifactId);
Assert.Equal(run.Id, profile.Metadata.Fields["jobs.0.title"].SourceExtractionRunId);
Assert.Equal("user-source-mapping", profile.Metadata.Fields["jobs.0.title"].Method);
Assert.Contains("Mappings", (await verify.CvExtractionRuns.SingleAsync()).ReviewJson);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
@@ -0,0 +1,47 @@
using System.Security.Claims;
using System.Text.Json;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public class CvImportMappingControllerTests
{
[Fact]
public async Task Mapping_only_apply_is_revision_bound_audited_and_idempotent()
{
await using var db = TestHostFactory.CreateInMemoryDb("user-1");
var user = new ApplicationUser { Id = "user-1", UserName = "mapping-user" };
db.Users.Add(user);
var document = new CvImportDocument("Engineer Example 2020", false, "application/pdf", 1, 21, "synthetic.pdf", new[] { new CvImportBlock("b1", "Engineer Example 2020", "paragraph", 1) }, Array.Empty<string>(), "docling");
var run = new CvExtractionRun { OwnerUserId = user.Id, ParserVersion = ProfileCvController.DocumentParserVersion, Status = "pending_review", DocumentJson = JsonSerializer.Serialize(document), StructuredProfileJson = StructuredCvProfileJson.SerializePersisted(new()) };
db.CvExtractionRuns.Add(run);
await db.SaveChangesAsync();
var users = TestHostFactory.CreateUserManager();
users.Setup(u => u.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(u => u.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var controller = new ProfileCvController(users.Object, Mock.Of<ISummarizerService>(), db, null!) { ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
var review = Assert.IsType<CvImportReview>(Assert.IsType<OkObjectResult>(await controller.GetImportReview(run.Id)).Value);
Assert.Single(review.SourceBlocks);
Assert.Contains(review.MappingCategories, c => c.Category == "jobs" && c.Fields.Any(f => f.Name == "title"));
var mappings = new[] { new CvImportRecordMapping("job-1", "jobs", null, new[] { new CvImportMappedField("title", "Engineer", "b1"), new CvImportMappedField("company", "Example", "b1"), new CvImportMappedField("start", "2020", "b1") }) };
var request = new ProfileCvController.ApplyReviewedRequest(0, Array.Empty<CvImportDecision>(), mappings);
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, request));
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, request));
Assert.Single((await new CareerProfileService(db).LoadStructuredAsync(user.Id, default)).Jobs);
Assert.Single(await db.CareerProfileVersions.ToListAsync());
Assert.Contains("job-1", run.ReviewJson);
Assert.IsType<ConflictObjectResult>(await controller.ApplyReviewed(run.Id, request with { Mappings = new[] { mappings[0] with { ClientId = "changed" } } }));
var updatedReview = Assert.IsType<CvImportReview>(Assert.IsType<OkObjectResult>(await controller.GetImportReview(run.Id)).Value);
Assert.Contains(updatedReview.Targets, t => t.Category == "jobs" && t.Index == 0);
Assert.Equal("Engineer", updatedReview.Targets.Single(t => t.Category == "jobs" && t.Index == 0).Values!["title"]);
}
}
+155
View File
@@ -0,0 +1,155 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public class CvImportMappingTests
{
private static readonly CvImportDocument Source = new("Engineer Example 2020\nDesigner Other 2022\nPortfolio https://example.test\nSQL", false, "application/pdf", 2, 100, "synthetic.pdf",
new[] { new CvImportBlock("b1", "Engineer Example 2020", "paragraph", 1), new CvImportBlock("b2", "Designer Other 2022", "paragraph", 2), new CvImportBlock("b3", "Portfolio https://example.test", "paragraph", 2), new CvImportBlock("b4", "SQL", "paragraph", 2) }, Array.Empty<string>(), "docling");
private static CvImportMappedField Field(string name, string value, string block = "b1", bool corrected = false) => new(name, value, block, corrected);
private static StructuredCvProfile Apply(StructuredCvProfile current, params CvImportRecordMapping[] mappings) => CvImportMapping.Apply(current, mappings, Source, 21, 7, "abc");
[Fact]
public void Mapping_only_authors_multiple_missing_records_with_real_per_field_origin_and_date_precision()
{
var result = Apply(new(), new("first", "jobs", null, new[] { Field("title", "Engineer"), Field("company", "Example"), Field("start", "2020") }),
new("second", "jobs", null, new[] { Field("title", "Designer", "b2"), Field("company", "Other", "b2"), Field("start", "2022", "b2") }));
Assert.Equal(2, result.Jobs.Count);
Assert.Equal("2020", result.Jobs[0].Start);
Assert.Null(result.Jobs[0].End);
Assert.Null(result.Jobs[0].StartDate);
var evidence = result.Metadata.Fields["jobs.1.company"];
Assert.Equal("b2", evidence.SourceBlockId);
Assert.Equal(2, evidence.SourcePage);
Assert.Equal(21, evidence.SourceArtifactId);
Assert.Equal(7, evidence.SourceExtractionRunId);
Assert.Equal("abc", evidence.SourceFileSha256);
Assert.Equal("user-source-mapping", evidence.Method);
}
[Fact]
public void Explicit_target_updates_only_approved_leaves_and_lists_append_with_destination_evidence()
{
var current = new StructuredCvProfile { Jobs = { new() { Id = "stable", Title = "Original", Company = "Keep company", Bullets = { "Old" } } }, Skills = { "C#" } };
current.Metadata.Fields["jobs.0.bullets.0"] = new() { SourceSnippet = "Old" };
var result = Apply(current, new("target", "jobs", 0, new[] { Field("title", "Engineer"), Field("bullets", "Reviewed achievement", corrected: true) }),
new("skill", "skills", null, new[] { Field("value", "SQL", "b4") }));
var job = Assert.Single(result.Jobs);
Assert.Equal("stable", job.Id);
Assert.Equal("Keep company", job.Company);
Assert.Equal("Engineer", job.Title);
Assert.Equal(new[] { "Old", "Reviewed achievement" }, job.Bullets);
Assert.Equal("Old", result.Metadata.Fields["jobs.0.bullets.0"].SourceSnippet);
Assert.Equal("user-correction", result.Metadata.Fields["jobs.0.bullets.1"].Method);
Assert.Equal(new[] { "C#", "SQL" }, result.Skills);
Assert.Equal("Original", current.Jobs[0].Title);
}
[Fact]
public void Contact_mapping_authors_labelled_url_without_replacing_saved_links()
{
var result = Apply(new() { Contact = new() { GitHub = "https://github.com/saved", Links = { new() { Label = "Blog", Url = "https://blog.test" } } } },
new CvImportRecordMapping("link", "contact", null, new[] { Field("linkLabel", "Portfolio", "b3"), Field("linkUrl", "https://example.test", "b3") }));
Assert.Equal("https://github.com/saved", result.Contact.GitHub);
Assert.Equal(2, result.Contact.Links.Count);
Assert.Equal("Portfolio", result.Contact.Links[1].Label);
Assert.Equal("b3", result.Metadata.Fields["contact.links.1.url"].SourceBlockId);
}
[Theory]
[InlineData("metadata", "value", "Engineer", "b1", null)]
[InlineData("jobs", "id", "Engineer", "b1", null)]
[InlineData("jobs", "startDate", "2020", "b1", null)]
[InlineData("jobs", "title.extra", "Engineer", "b1", null)]
[InlineData("skills", "value.sourceBlockId", "SQL", "b4", null)]
[InlineData("skills", "sourceBlockId", "SQL", "b4", null)]
[InlineData("jobs", "title", "Engineer", "foreign-block", null)]
[InlineData("jobs", "title", "Not in source", "b1", null)]
[InlineData("jobs", "isCurrent", "yes", "b1", null)]
[InlineData("jobs", "title", "Engineer", "b1", 0)]
[InlineData("jobs", "title", "Engineer", "b1", -1)]
[InlineData("skills", "value", "SQL", "b4", 0)]
public void Invalid_or_unowned_mappings_fail_closed(string category, string name, string value, string block, int? index)
=> Assert.Throws<ArgumentException>(() => Apply(new(), new CvImportRecordMapping("invalid", category, index, new[] { Field(name, value, block) })));
[Fact]
public void Empty_identity_duplicate_ids_fields_and_oversized_requests_are_rejected()
{
Assert.Throws<ArgumentException>(() => Apply(new(), new CvImportRecordMapping("empty", "jobs", null, Array.Empty<CvImportMappedField>())));
Assert.Throws<ArgumentException>(() => Apply(new(), new CvImportRecordMapping("identity", "jobs", null, new[] { Field("start", "2020") })));
var mapping = new CvImportRecordMapping("same", "jobs", null, new[] { Field("title", "Engineer") });
Assert.Throws<ArgumentException>(() => Apply(new(), mapping, mapping));
Assert.Throws<ArgumentException>(() => Apply(new(), mapping with { Fields = new[] { Field("title", "Engineer"), Field("title", "Engineer") } }));
Assert.Throws<ArgumentException>(() => Apply(new(), mapping with { Fields = new[] { Field("title", new string('x', 8001), corrected: true) } }));
Assert.Throws<ArgumentException>(() => Apply(new(), Enumerable.Range(0, 26).Select(i => mapping with { ClientId = i.ToString() }).ToArray()));
Assert.Throws<ArgumentException>(() => Apply(new(), mapping with { Fields = Enumerable.Repeat(Field("bullets", "Engineer"), 201).ToArray() }));
}
[Fact]
public void Grounding_respects_valid_selected_occurrence_and_rejects_invalid_selected_evidence()
{
var document = Source with { Blocks = new[] { new CvImportBlock("first", "Engineer", "paragraph", 1), new CvImportBlock("second", "Engineer Example", "paragraph", 2) } };
var candidate = new StructuredCvProfile { Jobs = { new() { Title = "Engineer", Company = "Example" } } };
candidate.Metadata.Fields["jobs.0.title"] = new() { SourceBlockId = "second", SourceSnippet = "Engineer", SourcePage = 2 };
candidate.Metadata.Fields["jobs.0.company"] = new() { SourceBlockId = "first", SourceSnippet = "Example" };
var result = CvDocumentReview.Ground(candidate, document);
Assert.Equal("second", result.Metadata.Fields["jobs.0.title"].SourceBlockId);
Assert.Null(result.Jobs[0].Company);
}
[Fact]
public void Selected_evidence_survives_scalar_list_deduplication_without_changing_occurrence()
{
var source = Source with { Blocks = new[] { new CvImportBlock("first", "SQL", "paragraph", 1), new CvImportBlock("second", "SQL Engineer", "paragraph", 2) } };
var candidate = new StructuredCvProfile { Skills = { "SQL", "SQL", "Engineer" } };
candidate.Metadata.Fields["skills.2"] = new() { SourceBlockId = "second", SourceSnippet = "Engineer" };
var result = CvDocumentReview.Ground(candidate, source);
Assert.Equal(new[] { "SQL", "Engineer" }, result.Skills);
Assert.Equal("second", result.Metadata.Fields["skills.1"].SourceBlockId);
Assert.DoesNotContain("skills.2", result.Metadata.Fields.Keys);
}
[Fact]
public void Mapping_comparison_catalog_exposes_only_allowlisted_current_values_for_the_selected_target()
{
var current = new StructuredCvProfile
{
Contact = new() { FullName = "Saved name", GitHub = "https://github.com/saved" }, Skills = { "C#", "SQL" },
Jobs = { new() { Id = "private-id", Title = "Saved title", Company = "Saved company", StartDate = "2020-01", Bullets = { "First", "Second" } }, new() { Title = "Different title" } },
};
current.Metadata.Fields["jobs.0.title"] = new() { SourceSnippet = "private evidence" };
var targets = CvImportMapping.Targets(current);
Assert.DoesNotContain(targets, t => t.Category == "contact");
var target = Assert.Single(targets, t => t.Category == "jobs" && t.Index == 0);
Assert.Equal("Saved title", target.Values!["title"]);
Assert.Equal("First\nSecond", target.Values["bullets"]);
Assert.DoesNotContain("id", target.Values.Keys);
Assert.DoesNotContain("metadata", target.Values.Keys);
Assert.DoesNotContain("startDate", target.Values.Keys);
Assert.Equal("Different title", targets.Single(t => t.Category == "jobs" && t.Index == 1).Values!["title"]);
var catalog = CvImportMapping.Catalog(current);
Assert.Equal("Saved name", catalog.Single(c => c.Category == "contact").Fields.Single(f => f.Name == "fullName").CurrentValue);
Assert.Equal("C#\nSQL", catalog.Single(c => c.Category == "skills").Fields.Single(f => f.Name == "value").CurrentValue);
Assert.All(catalog.Single(c => c.Category == "jobs").Fields, f => Assert.Null(f.CurrentValue));
}
[Theory]
[InlineData("Py", "Python", false)]
[InlineData("2020", "12020", false)]
[InlineData("SQL", "NoSQL", false)]
[InlineData("C#", "Used C# and SQL.", true)]
[InlineData(".NET", "Built .NET services.", true)]
[InlineData("2020", "2020 - 2021", true)]
[InlineData("Engineer", "Engineer.", true)]
public void Source_mode_mapping_requires_honest_token_boundaries(string value, string source, bool supported)
{
var document = Source with { Blocks = new[] { new CvImportBlock("token", source, "paragraph", 1) } };
var mapping = new CvImportRecordMapping("token", "skills", null, new[] { Field("value", value, "token") });
if (supported) Assert.Equal(value, Assert.Single(CvImportMapping.Apply(new(), new[] { mapping }, document, 1, 1, "hash").Skills));
else Assert.Throws<ArgumentException>(() => CvImportMapping.Apply(new(), new[] { mapping }, document, 1, 1, "hash"));
var correction = mapping with { Fields = new[] { Field("value", value, "token", corrected: true) } };
Assert.Equal(value, Assert.Single(CvImportMapping.Apply(new(), new[] { correction }, document, 1, 1, "hash").Skills));
}
}
@@ -13,8 +13,8 @@ namespace JobTrackerApi.Controllers;
public sealed partial class ProfileCvController
{
public const string DocumentParserVersion = "docling-v1";
public sealed record ApplyReviewedRequest(int? ExpectedVersion, IReadOnlyList<CvImportDecision>? Decisions);
private sealed record AppliedReview(string Hash, int Revision, StructuredCvProfile StructuredCv, IReadOnlyList<CvImportDecision>? Decisions = null);
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)
{
@@ -159,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.DeserializePersisted(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)), document?.Blocks ?? Array.Empty<CvImportBlock>(), CvImportMapping.Catalog(snapshot.Profile), CvImportMapping.Targets(snapshot.Profile)));
}
[HttpGet("runs/{id:int}/source")]
@@ -181,12 +181,17 @@ public sealed partial class ProfileCvController
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 hash = Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions.OrderBy(d => d.Id, StringComparer.Ordinal) })));
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.OrderBy(m => m.ClientId, StringComparer.Ordinal) });
var hash = Convert.ToHexString(SHA256.HashData(hashBytes));
try
{
return await _careerProfileService.ExecuteAtomicAsync<IActionResult>(async () =>
{
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(r => r.Id == id && r.OwnerUserId == user.Id, HttpContext.RequestAborted);
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")
@@ -197,8 +202,15 @@ 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();
CvImportMapping.ValidateTargets(snapshot.Profile, mappings);
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.");
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;
@@ -211,7 +223,7 @@ public sealed partial class ProfileCvController
user.CurrentCvUploadArtifactId = run.ArtifactId;
run.Status = "applied";
run.AppliedAtUtc = DateTimeOffset.UtcNow;
run.ReviewJson = JsonSerializer.Serialize(new AppliedReview(hash, snapshot.Revision + 1, merged, request.Decisions));
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);
+48 -4
View File
@@ -10,7 +10,7 @@ public sealed record CvImportDocument(string Text, bool OcrUsed, string? Content
public sealed record CvImportDecision(string Id, string Action, string? Value = null);
public sealed record CvImportField(string Id, string Label, string? CurrentValue, string ExtractedValue, string? SourceSnippet, int? SourcePage, string Method, string ReviewState);
public sealed record CvImportGroup(string Id, string Category, string Label, string? MatchDescription, IReadOnlyList<CvImportField> Fields);
public sealed record CvImportReview(int RunId, string Status, int Revision, string? FileName, string SourceUrl, string? SourceText, IReadOnlyList<string> Warnings, IReadOnlyList<CvImportGroup> Groups);
public sealed record CvImportReview(int RunId, string Status, int Revision, string? FileName, string SourceUrl, string? SourceText, IReadOnlyList<string> Warnings, IReadOnlyList<CvImportGroup> Groups, IReadOnlyList<CvImportBlock> SourceBlocks = null!, IReadOnlyList<CvImportMappingCategory> MappingCategories = null!, IReadOnlyList<CvImportMappingTarget> Targets = null!);
// Paths are generated from the canonical typed schema, never accepted as arbitrary JSON patches.
public static class CvDocumentReview
@@ -22,9 +22,49 @@ public static class CvDocumentReview
public static StructuredCvProfile Ground(StructuredCvProfile candidate, CvImportDocument document)
{
var original = Node(candidate);
var supplied = candidate.Metadata.Fields.ToDictionary(f => f.Key, f => f.Value, StringComparer.Ordinal);
var node = Node(StructuredCvProfileJson.NormalizeForPersistence(candidate));
node["sections"] = new JsonArray();
var selected = new Dictionary<string, StructuredCvFieldMetadata>(StringComparer.Ordinal);
Reindex(original, node, "", "");
var metadata = new Dictionary<string, StructuredCvFieldMetadata>();
// Persistence trims/deduplicates lists; carry selected evidence to the retained leaf's index.
void Reindex(JsonNode? before, JsonNode? after, string oldPath, string newPath)
{
if (after is JsonObject obj)
{
foreach (var entry in obj.Where(e => !Excluded.Contains(e.Key))) Reindex((before as JsonObject)?[entry.Key], entry.Value, oldPath.Length == 0 ? entry.Key : oldPath + "." + entry.Key, newPath.Length == 0 ? entry.Key : newPath + "." + entry.Key);
}
else if (after is JsonArray list && before is JsonArray old)
{
var used = new HashSet<int>();
for (var i = 0; i < list.Count; i++)
{
var index = Enumerable.Range(0, old.Count).FirstOrDefault(j => !used.Contains(j) && Equivalent(old[j], list[i]), -1);
if (index < 0) continue;
used.Add(index);
Reindex(old[index], list[i], oldPath + "." + index, newPath + "." + i);
}
}
else if (supplied.TryGetValue(oldPath, out var evidence)) selected[newPath] = evidence;
}
bool Equivalent(JsonNode? before, JsonNode? after)
{
if (after is JsonObject obj) return obj.Where(e => !Excluded.Contains(e.Key)).All(e => Equivalent((before as JsonObject)?[e.Key], e.Value));
if (after is JsonArray array) return before is JsonArray old && array.All(item => old.Any(prior => Equivalent(prior, item)));
return string.Equals(Clean(before?.ToString() ?? ""), Clean(after?.ToString() ?? ""), StringComparison.OrdinalIgnoreCase);
}
CvImportBlock? Evidence(string path, string value)
{
if (!selected.TryGetValue(path, out var evidence)) return document.Blocks.FirstOrDefault(b => Clean(b.Text).Contains(Clean(value), StringComparison.OrdinalIgnoreCase));
var block = document.Blocks.FirstOrDefault(b => b.Id == evidence.SourceBlockId);
return block is not null && !string.IsNullOrWhiteSpace(evidence.SourceSnippet)
&& Clean(block.Text).Contains(Clean(value), StringComparison.OrdinalIgnoreCase)
&& Clean(block.Text).Contains(Clean(evidence.SourceSnippet), StringComparison.OrdinalIgnoreCase)
&& Clean(evidence.SourceSnippet).Contains(Clean(value), StringComparison.OrdinalIgnoreCase) ? block : null;
}
void Walk(JsonNode? value, string path)
{
if (value is JsonObject obj)
@@ -37,10 +77,14 @@ public static class CvDocumentReview
}
else if (value is JsonArray array)
{
var retained = new List<StructuredCvFieldMetadata?>();
for (var i = 0; i < array.Count; i++) { selected.TryGetValue(path + "." + i, out var evidence); retained.Add(evidence); }
for (var i = array.Count - 1; i >= 0; i--)
{
if (array[i] is JsonValue scalar && scalar.TryGetValue<string>(out var text) && !document.Blocks.Any(b => Clean(b.Text).Contains(Clean(text), StringComparison.OrdinalIgnoreCase))) array.RemoveAt(i);
if (array[i] is JsonValue scalar && scalar.TryGetValue<string>(out var text) && Evidence(path + "." + i, text) is null) { array.RemoveAt(i); retained.RemoveAt(i); }
}
for (var i = 0; i < retained.Count; i++)
if (array[i] is JsonValue) { selected.Remove(path + "." + i); if (retained[i] is { } evidence) selected[path + "." + i] = evidence; }
for (var i = 0; i < array.Count; i++) Walk(array[i], path + "." + i);
}
else if (value is JsonValue scalar)
@@ -51,14 +95,14 @@ public static class CvDocumentReview
{
var end = record["end"]?.ToString();
var marker = end is not null && Regex.IsMatch(end, @"^(present|current|now|nå|dags dato|pågående)$", RegexOptions.IgnoreCase);
var evidence = marker ? document.Blocks.FirstOrDefault(b => Clean(b.Text).Contains(Clean(end!), StringComparison.OrdinalIgnoreCase)) : null;
var evidence = marker ? Evidence(path, end!) : null;
if (evidence is null) record[path.Split('.').Last()] = false;
else metadata[path] = new() { Method = "normalized-current-marker", SourceBlockId = evidence.Id, SourcePage = evidence.Page, SourceSnippet = end, ReviewState = "suggested" };
}
return;
}
if (!scalar.TryGetValue<string>(out var text) || string.IsNullOrWhiteSpace(text)) return;
var block = document.Blocks.FirstOrDefault(b => Clean(b.Text).Contains(Clean(text), StringComparison.OrdinalIgnoreCase));
var block = Evidence(path, text);
if (block is null) { if (value.Parent is JsonObject parent) parent[path.Split('.').Last()] = null; return; }
metadata[path] = new() { Method = "deterministic-source", SourceBlockId = block.Id, SourcePage = block.Page, SourceSnippet = text.Length > 500 ? text[..500] : text, ReviewState = "suggested" };
}
+164
View File
@@ -0,0 +1,164 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
public sealed record CvImportMappedField(string Name, string Value, string SourceBlockId, bool Corrected = false);
public sealed record CvImportRecordMapping(string ClientId, string Category, int? TargetIndex, IReadOnlyList<CvImportMappedField> Fields);
public sealed record CvImportMappingField(string Name, bool Multiple, string? CurrentValue = null);
public sealed record CvImportMappingCategory(string Category, IReadOnlyList<CvImportMappingField> Fields);
public sealed record CvImportMappingTarget(string Category, int Index, string Label, IReadOnlyDictionary<string, string?>? Values = null);
// Only these canonical leaves can be authored; source identity and destination paths stay server-owned.
public static class CvImportMapping
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private static CvImportMappingCategory Category(string name, string scalars, string lists = "") => new(name,
scalars.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(f => new CvImportMappingField(f, false))
.Concat(lists.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(f => new CvImportMappingField(f, true))).ToArray());
public static IReadOnlyList<CvImportMappingCategory> Categories { get; } = new[]
{
Category("contact", "fullName headline email phone location website linkedIn gitHub linkLabel linkUrl"),
Category("jobs", "title company location start end isCurrent", "bullets skills"),
Category("education", "qualification qualificationLevel institution location start end", "details"),
Category("certifications", "name issuer location date", "details"),
Category("projects", "name role location start end", "bullets skills"),
Category("languages", "name level notes"), Category("otherSections", "title", "items"),
Category("summary", "", "value"), Category("skills", "", "value"), Category("interests", "", "value"),
Category("awards", "", "value"), Category("publications", "", "value"), Category("organisations", "", "value"), Category("references", "", "value"),
};
private static readonly HashSet<string> ScalarLists = new(StringComparer.Ordinal) { "summary", "skills", "interests", "awards", "publications", "organisations", "references" };
private static string Clean(string text) => Regex.Replace(text, @"\s+", " ").Trim();
private static JsonObject Node(StructuredCvProfile profile) => JsonSerializer.SerializeToNode(profile, Json)!.AsObject();
public static IReadOnlyList<CvImportMappingCategory> Catalog(StructuredCvProfile profile)
{
var node = Node(profile);
return Categories.Select(c => c with { Fields = c.Fields.Select(f => f with
{
CurrentValue = c.Category == "contact" ? f.Name switch
{
"linkLabel" => string.Join("\n", profile.Contact.Links.Select(l => l.Label)),
"linkUrl" => string.Join("\n", profile.Contact.Links.Select(l => l.Url)),
_ => node["contact"]?[f.Name]?.ToString(),
} : ScalarLists.Contains(c.Category) ? string.Join("\n", node[c.Category]!.AsArray().Select(v => v?.ToString())) : null,
}).ToArray() }).ToArray();
}
public static IReadOnlyList<CvImportMappingTarget> Targets(StructuredCvProfile profile)
{
var node = Node(profile);
return Categories.Where(c => c.Category != "contact" && !ScalarLists.Contains(c.Category)).SelectMany(c =>
node[c.Category]!.AsArray().Select((item, index) => new CvImportMappingTarget(c.Category, index,
string.Join(" · ", new[] { "title", "company", "qualification", "institution", "name", "role", "issuer", "start", "end", "date" }
.Select(k => item?[k]?.ToString()).Where(v => !string.IsNullOrWhiteSpace(v))),
c.Fields.ToDictionary(f => f.Name, f => f.Multiple ? string.Join("\n", (item?[f.Name] as JsonArray ?? new JsonArray()).Select(v => v?.ToString())) : item?[f.Name]?.ToString(), StringComparer.Ordinal)))).ToArray();
}
public static void ValidateTargets(StructuredCvProfile current, IReadOnlyList<CvImportRecordMapping> mappings)
{
var node = Node(current);
if (mappings.Count > 25 || mappings.Any(m => m is null || string.IsNullOrWhiteSpace(m.ClientId) || m.ClientId.Length > 100 || m.ClientId.Any(char.IsControl))
|| mappings.Select(m => m.ClientId).Distinct(StringComparer.Ordinal).Count() != mappings.Count) throw new ArgumentException("Invalid or excessive record mappings.");
foreach (var mapping in mappings)
{
if (!Categories.Any(c => c.Category == mapping.Category)) throw new ArgumentException("Unsupported mapping category.");
if (mapping.TargetIndex is int index && (mapping.Category == "contact" || ScalarLists.Contains(mapping.Category) || index < 0 || index >= node[mapping.Category]!.AsArray().Count))
throw new ArgumentException("The selected mapping target is unavailable. Refresh the comparison.");
}
}
public static StructuredCvProfile Apply(StructuredCvProfile current, IReadOnlyList<CvImportRecordMapping> mappings, CvImportDocument document, int? artifactId, int extractionRunId, string? sha256)
{
ValidateTargets(current, mappings);
if (mappings.Any(m => m.Fields is null) || mappings.Sum(m => (long)m.Fields.Count) > 200) throw new ArgumentException("Too many mapped fields.");
var result = Node(current);
var metadata = result["metadata"]!["fields"]!.AsObject();
var scalarWrites = new HashSet<string>(StringComparer.Ordinal);
foreach (var mapping in mappings.OrderBy(m => m.ClientId, StringComparer.Ordinal))
{
var catalog = Categories.Single(c => c.Category == mapping.Category);
if (mapping.Fields.Count == 0) throw new ArgumentException("A record mapping must contain reviewed fields.");
foreach (var field in mapping.Fields)
{
if (field is null || !catalog.Fields.Any(f => f.Name == field.Name) || string.IsNullOrWhiteSpace(field.Value) || field.Value.Length > 8000
|| field.Value.Any(c => char.IsControl(c) && c is not '\n' and not '\r' and not '\t') || string.IsNullOrWhiteSpace(field.SourceBlockId)) throw new ArgumentException("Invalid mapped field.");
var block = document.Blocks.SingleOrDefault(b => b.Id == field.SourceBlockId) ?? throw new ArgumentException("Mapped evidence is not part of this source document.");
if (field.Name == "isCurrent" && !bool.TryParse(field.Value, out _)) throw new ArgumentException("isCurrent must be true or false.");
if (!field.Corrected && !Regex.IsMatch(Clean(block.Text), @"(?<![\p{L}\p{N}\p{M}_])" + Regex.Escape(Clean(field.Value)) + @"(?![\p{L}\p{N}\p{M}_])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) throw new ArgumentException("A mapped value must appear as complete tokens in its selected evidence block or be explicitly corrected.");
if (!catalog.Fields.Single(f => f.Name == field.Name).Multiple && mapping.Fields.Count(f => f.Name == field.Name) != 1) throw new ArgumentException("Duplicate mapped scalar fields.");
}
var scalarList = ScalarLists.Contains(mapping.Category);
if (mapping.TargetIndex is null && mapping.Category != "contact" && !scalarList)
{
var identity = mapping.Category switch { "jobs" => new[] { "title", "company" }, "education" => new[] { "qualification", "institution" }, "otherSections" => new[] { "title" }, _ => new[] { "name" } };
if (!mapping.Fields.Any(f => identity.Contains(f.Name))) throw new ArgumentException("A new mapped record needs an identifying name, title, company or institution.");
}
JsonNode target;
string prefix;
if (mapping.Category == "contact") { target = result["contact"]!; prefix = "contact"; }
else if (scalarList) { target = result[mapping.Category]!; prefix = mapping.Category; }
else
{
var records = result[mapping.Category]!.AsArray();
var index = mapping.TargetIndex ?? records.Count;
if (index == records.Count) records.Add(new JsonObject());
target = records[index]!;
prefix = mapping.Category + "." + index;
}
if (mapping.Category == "contact" && mapping.Fields.Any(f => f.Name is "linkLabel" or "linkUrl"))
{
var label = mapping.Fields.SingleOrDefault(f => f.Name == "linkLabel");
var url = mapping.Fields.SingleOrDefault(f => f.Name == "linkUrl");
if (label is null || url is null || !Uri.TryCreate(url.Value.Trim(), UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) throw new ArgumentException("A mapped contact link needs a label and an HTTP(S) URL.");
var links = target["links"]!.AsArray();
if (links.Any(l => string.Equals(l?["url"]?.ToString(), url.Value.Trim(), StringComparison.OrdinalIgnoreCase)) || links.Count >= 12) throw new ArgumentException("The mapped contact link is duplicated or exceeds the supported limit.");
var linkIndex = links.Count;
links.Add(new JsonObject { ["label"] = label.Value.Trim(), ["url"] = url.Value.Trim() });
Evidence($"contact.links.{linkIndex}.label", label);
Evidence($"contact.links.{linkIndex}.url", url);
}
foreach (var field in mapping.Fields.Where(f => f.Name is not ("linkLabel" or "linkUrl")))
{
var multiple = catalog.Fields.Single(f => f.Name == field.Name).Multiple;
var path = prefix + (scalarList ? "" : "." + field.Name);
if (multiple)
{
JsonArray list;
if (scalarList) list = target.AsArray();
else { target[field.Name] ??= new JsonArray(); list = target[field.Name]!.AsArray(); }
var index = Enumerable.Range(0, list.Count).FirstOrDefault(i => string.Equals(list[i]?.ToString(), field.Value.Trim(), StringComparison.OrdinalIgnoreCase), -1);
// An already present list value keeps its original evidence.
if (index >= 0) continue;
index = list.Count;
list.Add(field.Value.Trim());
path += "." + index;
}
else
{
if (!scalarWrites.Add(path)) throw new ArgumentException("Multiple mappings cannot overwrite the same scalar field.");
target[field.Name] = field.Name == "isCurrent" ? JsonValue.Create(bool.Parse(field.Value)) : JsonValue.Create(field.Value.Trim());
}
Evidence(path, field);
}
}
var profile = result.Deserialize<StructuredCvProfile>(Json)!;
var linkCount = profile.Contact.Links.Count;
profile = StructuredCvProfileJson.NormalizeForPersistence(profile);
if (profile.Contact.Links.Count != linkCount) throw new ArgumentException("A mapped contact link is invalid.");
return profile;
void Evidence(string path, CvImportMappedField field)
{
var block = document.Blocks.Single(b => b.Id == field.SourceBlockId);
metadata[path] = JsonSerializer.SerializeToNode(new StructuredCvFieldMetadata
{
Method = field.Corrected ? "user-correction" : "user-source-mapping", ReviewState = field.Corrected ? "corrected" : "accepted",
SourceSnippet = field.Corrected ? block.Text[..Math.Min(500, block.Text.Length)] : field.Value.Trim()[..Math.Min(500, field.Value.Trim().Length)],
SourceBlockId = block.Id, SourcePage = block.Page, SourceArtifactId = artifactId, SourceExtractionRunId = extractionRunId, SourceFileSha256 = sha256, LastUpdatedAtUtc = DateTimeOffset.UtcNow,
}, Json);
}
}
}
@@ -0,0 +1,24 @@
# CV-QA-01 source-backed mapping and new records
26 October 2026. Entry gap: the old review could only accept/correct fields emitted by extraction. An unmatched extracted record could already be added; a missing record/field could not. This distinction is independently tested rather than treating updates as creation proof.
## Implemented contract
Owned review now includes source blocks, an allowlisted field catalog with current contact/list values, and existing-record targets with their actual affected values. The user can create a missing record, select its section/destination, assign literal source values, explicitly mark corrections, add list fields and approve the mapped record. Selecting evidence proposes its literal text only into an empty value; it never overwrites a correction or approves anything. Changing fields/target clears approval. Narrow screens reuse the existing MUI review layout, labels and EN/Bokmål translations.
`apply-reviewed` accepts optional mappings `{clientId,category,targetIndex,fields:[{name,value,sourceBlockId,corrected}]}`. Null target deliberately creates an object record; contact updates selected singleton leaves; scalar lists append. Existing targets are validated against the owned canonical snapshot/revision before extracted merge. On 409, local values survive, all confirmations and prior target indices reset, and comparison must be reviewed again. Actual current values are shown before updating an existing target. Caller metadata/IDs/derived dates/arbitrary JSON paths are never accepted.
The same canonical contracts cover contact/labelled links; summary; work title/employer/location/literal dates/current flag/bullets/skills; education qualification/stated level/institution/location/dates/details; certificates name/issuer/location/date/details; project name/role/location/dates/bullets/skills; languages/name/explicit level/notes; skills; interests/awards/publications/organisations/references and custom title/items (including courses). Canonical structured skill categories/proficiency and arbitrary new entities remain unsupported; original source can be retained as custom/detail text.
Every assigned value requires an owned source block. Whole-token boundaries reject `Py` from `Python`, `SQL` from `NoSQL`, and `2020` from `12020`. Explicit corrections may differ and are labelled `user-correction`; literal user assignments are `user-source-mapping`, distinct from algorithmic extraction. Block/page/artifact/run/file hash metadata is stamped server-side. Limits:25 mappings,200 total fields,8000 characters/value; unknown categories/fields/targets/evidence, duplicate client IDs/scalar writes and invalid booleans/link pairs fail closed. Existing list values retain their original evidence and unrelated data survives.
Mappings participate in the existing canonical expectedVersion/atomic transaction/history/children/Identity projection and idempotency acknowledgement. Original candidates remain unchanged; actual decisions/mappings are retained in review audit. Old unmapped request hashes remain compatible with historical replay. No schema/storage/profile ownership was duplicated.
## Checkpoint evidence
- `mapping-red-01.log`: missing mapping contracts reproduced before implementation. `mapping-target-red-01.log`: missing existing-value comparison/catalog contracts. Whole-token tests were added before implementation.
- `mapping-green-02.log`:77 focused backend cases PASS, zero skips, including mapping-only apply, invalid input, current values, date precision, old review/provenance/preservation contracts. Focused counts overlap other suites.
- `mapping-mariadb-01`:72/72 actual MariaDB12.2.2 PASS, zero skips, isolated startup/test/cleanup exits0. Two new provider cases exercise17 explicitly mapped groups into an EMPTY canonical profile: two jobs, two education records, two languages, project, certification, contacts/portfolio, skills/summary and long-tail/custom sections. Independent database readback confirms relational child counts, source identity, absent language level, literal year dates and rejected original SQL proposal absent. Identical replay creates one revision only. Injected Identity failure leaves no canonical children/history/acknowledgement and the review stays pending.
- `ui-review-mapping-red.log`, `ui-target-values-red.log`, `ui-source-fill-red.log`: missing UI flow/comparison/proposal behavior reproduced first. UI review/mapping tests and TypeScript/lint pass; complete browser/mobile/new-user qualification is tracked in later checkpoints.
This is bounded mapping/new-record implementation evidence, not whole-CV acceptance. A separate source audit identified apply/discard/retention lifecycle concurrency risks for real-provider reproduction/remediation at CV-QA-04. No release decision is made here. Missing models/human qualifications remain independent.
+1 -1
View File
@@ -27,7 +27,7 @@ Approaches considered: positional/lexicon guessing would improve apparent recall
## Checkpoint tasks
- [x] **CV-QA-00:** verify ef425c2/remote/ancestry/raw evidence; audit source/authority; fresh actual MariaDB70; field matrix and preservation delta; commit/push/verify.
- [ ] **CV-QA-01:** add failing backend/controller/provider tests for explicit source-backed record/field mappings and mapping-only apply. Extend review DTO with owned source blocks, field catalog and current-record target labels. Request mappings have stable client ID, allowlisted category, optional target index, and fields `{name,value,sourceBlockId,corrected}`; no caller IDs/metadata/derived dates/arbitrary patches. Apply under existing revision/transaction, stamp actual source identity, include mappings in idempotency/audit; validator/size bounds remain. Root adds accessible EN/NB generic mapping controls and UI tests for rejection, source selection and stale retained drafts. Run focused tests/provider; commit/push/verify.
- [x] **CV-QA-01:** source-backed mapping/new records/current comparisons implemented; focused backend77 and actual MariaDB72 PASS, UI/TypeScript/lint PASS. See CAREER_PROFILE_MAPPING.md; complete integration/recovery qualification remains later checkpoints.
- [ ] **CV-QA-02:** red tests in CvMultilineImportTests for labelled multiline records, safe header-defined table order, repeated source occurrences and ambiguous record after resolved record. Update CvDocumentInterpreter to preserve source units and section-local state, attach only clear continuations, retain ambiguity for explicit assignment. Ground respects validated selected source block evidence. Extend a separate frozen synthetic corpus/oracle with both orders/missing dates/headings/overlaps/lists/tables; preserve original corpus/hash. Report parsing versus interpretation versus mapping versus reviewed correctness, denominators/F1/record/provenance/abstention. Real Docling fixtures; commit/push/verify.
- [ ] **CV-QA-03:** qualify same/revised/older/overlapping imports and explicit targets, similar-but-distinct employers, partial/conflicting dates, per-field rejection, source/corrections/mobile/keyboard. Extend reviewed HTTP/browser tests; fix bounded defects; commit/push/verify.
- [ ] **CV-QA-04:** actual MariaDB multi-record mapping/partial approval/rejected-field readback, replay/stale/concurrent first/existing write, rollback and owner checks. Existing lease/cancel/recovery retained; measure database interruption scope honestly. Commit/push/verify.
@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { I18nProvider } from './i18n/I18nProvider';
import CvImportMappingEditor, { type MappingDraft } from './views/career/CvImportMappingEditor';
let latest: MappingDraft[] = [];
function Harness() {
const [drafts, setDrafts] = useState<MappingDraft[]>([]);
return <I18nProvider><CvImportMappingEditor drafts={drafts} onChange={value => { latest = value; setDrafts(value); }} disabled={false}
categories={[{ category: 'jobs', fields: [{ name: 'title', multiple: false }, { name: 'company', multiple: false }, { name: 'bullets', multiple: true }] }]}
targets={[{ category: 'jobs', index: 0, label: 'Saved job', values: { title: 'Old title', company: 'Old company' } }]}
blocks={[{ id: 'b1', text: 'Engineer', kind: 'paragraph', page: 1 }, { id: 'b2', text: 'Example AS', kind: 'paragraph', page: 1 }]}
label={value => ({ jobs: 'Experience', title: 'Title', company: 'Company', bullets: 'Details' } as Record<string, string>)[value] ?? value} /></I18nProvider>;
}
beforeEach(() => { localStorage.clear(); latest = []; });
test('selecting source text fills an empty mapped value without approving or replacing a correction', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'Create missing record' }));
const record = screen.getByRole('group', { name: 'Mapped record 1' });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b1' } });
expect(within(record).getByRole('textbox', { name: 'Value 1' })).toHaveValue('Engineer');
expect(latest[0].confirmed).toBe(false);
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'My correction' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b2' } });
expect(within(record).getByRole('textbox', { name: 'Value 1' })).toHaveValue('My correction');
});
test('missing records require explicit source assignments and approval without a default existing target', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'Create missing record' }));
const record = screen.getByRole('group', { name: 'Mapped record 1' });
expect(within(record).getByRole('combobox', { name: 'Destination' })).toHaveValue('');
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'Engineer' } });
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 2' }), { target: { value: 'Example AS' } });
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
expect(latest[0].confirmed).toBe(false);
expect(within(record).getByRole('alert')).toBeInTheDocument();
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b1' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 2' }), { target: { value: 'b2' } });
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
expect(latest[0]).toMatchObject({ category: 'jobs', targetIndex: null, confirmed: true, fields: [{ name: 'title', value: 'Engineer', sourceBlockId: 'b1', corrected: false }, { name: 'company', value: 'Example AS', sourceBlockId: 'b2', corrected: false }] });
fireEvent.change(within(record).getByRole('combobox', { name: 'Destination' }), { target: { value: '0' } });
expect(latest[0]).toMatchObject({ targetIndex: 0, confirmed: false });
expect(within(record).getByText('Current: Old title')).toBeInTheDocument();
expect(within(record).getByText('Current: Old company')).toBeInTheDocument();
});
test('unsupported wording requires an explicit correction and edits reset record approval', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'Create missing record' }));
const record = screen.getByRole('group', { name: 'Mapped record 1' });
fireEvent.click(within(record).getByRole('button', { name: 'Remove field 2' }));
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'Senior Engineer' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b1' } });
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
expect(latest[0].confirmed).toBe(false);
fireEvent.click(within(record).getByRole('checkbox', { name: 'This value is my correction' }));
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
expect(latest[0].confirmed).toBe(true);
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'Engineer' } });
expect(latest[0].confirmed).toBe(false);
fireEvent.click(within(record).getByRole('button', { name: 'Remove mapped record' }));
expect(latest).toEqual([]);
});
+26 -1
View File
@@ -6,6 +6,7 @@ import { emptyStructuredCv } from './profileCv';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import CareerProfilePage from './views/CareerProfilePage';
import type { MappingBlock, MappingCategory, MappingTarget } from './views/career/CvImportMappingEditor';
jest.mock('./api', () => ({
getApiErrorMessage: jest.requireActual('./api').getApiErrorMessage,
@@ -22,7 +23,7 @@ const review = { runId: 23, status: 'pending_review', revision: 7, fileName: 'ca
{ id: 'Contact.fullName', label: 'Full name', currentValue: 'Saved name', extractedValue: 'Source name', sourceSnippet: 'Source name', sourcePage: 1, method: 'docling', reviewState: 'suggested' },
{ id: 'Contact.headline', label: 'Headline', currentValue: 'Saved headline', extractedValue: 'Engineer', sourceSnippet: null, sourcePage: null, method: 'deterministic', reviewState: 'suggested' },
] }] };
let currentReview = review;
let currentReview: typeof review & Partial<{ sourceBlocks: MappingBlock[]; mappingCategories: MappingCategory[]; targets: MappingTarget[] }> = review;
let runs = [run];
function renderImport() {
@@ -51,6 +52,30 @@ beforeEach(() => {
afterEach(() => jest.clearAllMocks());
test('a source-mapped missing record can be the only approved change and survives stale review without a stale target', async () => {
currentReview = { ...review, groups: [], sourceBlocks: [{ id: 'b1', text: 'Engineer', kind: 'paragraph', page: 1 }, { id: 'b2', text: 'Example AS', kind: 'paragraph', page: 1 }], mappingCategories: [{ category: 'jobs', fields: [{ name: 'title', multiple: false }, { name: 'company', multiple: false }] }], targets: [{ category: 'jobs', index: 0, label: 'Saved job' }] };
renderImport();
fireEvent.click(await screen.findByRole('button', { name: 'Create missing record' }));
const record = screen.getByRole('group', { name: 'Mapped record 1' });
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'Engineer' } });
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 2' }), { target: { value: 'Example AS' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b1' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 2' }), { target: { value: 'b2' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Destination' }), { target: { value: '0' } });
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
currentReview = { ...currentReview, revision: 8 };
mockedApi.post.mockRejectedValueOnce({ response: { status: 409 } });
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/23/apply-reviewed', expect.objectContaining({ expectedVersion: 7, decisions: [], mappings: [expect.objectContaining({ category: 'jobs', targetIndex: 0 })] })));
await waitFor(() => expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled());
expect(within(record).getByRole('textbox', { name: 'Value 1' })).toHaveValue('Engineer');
expect(within(record).getByRole('combobox', { name: 'Destination' })).toHaveValue('');
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/profile-cv/runs/23/apply-reviewed', expect.objectContaining({ expectedVersion: 8, mappings: [expect.objectContaining({ category: 'jobs', targetIndex: null })] })));
expect(await screen.findByText('Reviewed changes saved to your Career Profile.')).toBeInTheDocument();
});
test('Docling review requires explicit decisions and sends only reviewed corrections before showing durable completion', async () => {
renderImport();
const choice = await screen.findByRole('combobox', { name: 'Decision: Full name' });
+44
View File
@@ -36,6 +36,28 @@ export const translations = {
cvImportStageExtracting: "Reading document structure",
cvImportStageValidating: "Validating your document",
cvImportFieldName: "Name",
cvImportMapMissing: "Create missing record",
cvImportMappingRecord: "Mapped record {number}",
cvImportMappingCategory: "Record section",
cvImportMappingTarget: "Destination",
cvImportMappingNew: "Add as a new record",
cvImportMappingContactDestination: "Update the displayed contact fields",
cvImportMappingAppend: "Append the approved values",
cvImportMappingField: "Field {number}",
cvImportMappingValue: "Value {number}",
cvImportMappingValueLabel: "Value",
cvImportMappingSource: "Source evidence {number}",
cvImportMappingSelect: "Choose source evidence",
cvImportMappingCorrection: "This value is my correction",
cvImportMappingAddField: "Add field",
cvImportMappingRemoveField: "Remove field {number}",
cvImportMappingConfirm: "Approve mapped record",
cvImportMappingConfirmed: "Mapped record approved",
cvImportMappingRemove: "Remove mapped record",
cvImportMappingEvidenceError: "Choose source evidence for every value. Use correction only for values you have checked.",
cvImportMappingNotice: "Map missing information from the source. Choose a destination and approve each record. These are your reviewed assignments.",
cvImportLinkLabel: "Link label",
cvImportQualificationLevel: "Stated qualification level",
cvImportFieldRole: "Role",
cvImportMatchedRecord: "Matches an existing record — review the affected fields",
cvImportNewRecord: "New record — keep only the information you want to add",
@@ -3013,6 +3035,28 @@ export const translations = {
cvImportStageExtracting: "Leser dokumentstrukturen",
cvImportStageValidating: "Kontrollerer dokumentet ditt",
cvImportFieldName: "Navn",
cvImportMapMissing: "Opprett manglende oppføring",
cvImportMappingRecord: "Tilordnet oppføring {number}",
cvImportMappingCategory: "Del av profilen",
cvImportMappingTarget: "Mål",
cvImportMappingNew: "Legg til som ny oppføring",
cvImportMappingContactDestination: "Oppdater de viste kontaktfeltene",
cvImportMappingAppend: "Legg til de godkjente verdiene",
cvImportMappingField: "Felt {number}",
cvImportMappingValue: "Verdi {number}",
cvImportMappingValueLabel: "Verdi",
cvImportMappingSource: "Kildegrunnlag {number}",
cvImportMappingSelect: "Velg kildegrunnlag",
cvImportMappingCorrection: "Denne verdien er min rettelse",
cvImportMappingAddField: "Legg til felt",
cvImportMappingRemoveField: "Fjern felt {number}",
cvImportMappingConfirm: "Godkjenn tilordnet oppføring",
cvImportMappingConfirmed: "Tilordnet oppføring er godkjent",
cvImportMappingRemove: "Fjern tilordnet oppføring",
cvImportMappingEvidenceError: "Velg kildegrunnlag for hver verdi. Bruk rettelse bare for verdier du har kontrollert.",
cvImportMappingNotice: "Tilordne manglende informasjon fra kilden. Velg mål og godkjenn hver oppføring. Dette er dine gjennomgåtte tilordninger.",
cvImportLinkLabel: "Lenkenavn",
cvImportQualificationLevel: "Oppgitt kvalifikasjonsnivå",
cvImportFieldRole: "Rolle",
cvImportMatchedRecord: "Samsvarer med en eksisterende oppføring — gjennomgå feltene som berøres",
cvImportNewRecord: "Ny oppføring — behold bare informasjonen du vil legge til",
@@ -0,0 +1,99 @@
import React, { useState } from "react";
import { Alert, Box, Button, Checkbox, FormControl, FormControlLabel, InputLabel, NativeSelect, TextField, Typography } from "@mui/material";
import { useI18n } from "../../i18n/I18nProvider";
export type MappingCategory = { category: string; fields: { name: string; multiple: boolean; currentValue?: string | null }[] };
export type MappingBlock = { id: string; text: string; kind: string; page: number | null };
export type MappingTarget = { category: string; index: number; label: string; values?: Record<string, string | null> };
export type MappedField = { name: string; value: string; sourceBlockId: string; corrected: boolean };
export type MappingDraft = { clientId: string; category: string; targetIndex: number | null; fields: MappedField[]; confirmed: boolean };
const primary: Record<string, string[]> = { jobs: ["title", "company"], education: ["qualification", "institution"], certifications: ["name"], projects: ["name"], contact: ["fullName"], languages: ["name"], otherSections: ["title", "items"] };
const clean = (value: string) => value.replace(/\s+/g, " ").trim().toLocaleLowerCase();
const supported = (source: string, value: string) => new RegExp(`(?<![\\p{L}\\p{N}\\p{M}_])${clean(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}\\p{M}_])`, "iu").test(clean(source));
const empty = (name: string): MappedField => ({ name, value: "", sourceBlockId: "", corrected: false });
export default function CvImportMappingEditor({ categories, blocks, targets, drafts, onChange, disabled, label }: {
categories: MappingCategory[]; blocks: MappingBlock[]; targets: MappingTarget[]; drafts: MappingDraft[];
onChange: (drafts: MappingDraft[]) => void; disabled: boolean; label: (value: string) => string;
}) {
const { t } = useI18n();
const [errors, setErrors] = useState<Record<string, boolean>>({});
function update(index: number, value: Partial<MappingDraft>) {
onChange(drafts.map((draft, i) => i === index ? { ...draft, ...value, confirmed: false } : draft));
}
function defaultFields(category: MappingCategory) {
return (primary[category.category] ?? [category.fields[0]?.name ?? "value"]).filter(name => category.fields.some(field => field.name === name)).map(empty);
}
function approve(index: number) {
const draft = drafts[index];
const catalog = categories.find(category => category.category === draft.category);
const valid = draft.fields.length > 0 && draft.fields.every(field => {
const block = blocks.find(source => source.id === field.sourceBlockId);
return catalog?.fields.some(item => item.name === field.name) && field.value.trim().length > 0 && field.value.length <= 8000 && block && (field.corrected || supported(block.text, field.value));
});
setErrors(current => ({ ...current, [draft.clientId]: !valid }));
if (valid) onChange(drafts.map((item, i) => i === index ? { ...item, confirmed: true } : item));
}
if (categories.length === 0) return null;
return <Box sx={{ my: 2, minWidth: 0 }}>
<Typography variant="body2" sx={{ mb: 1 }}>{t("cvImportMappingNotice")}</Typography>
{drafts.map((draft, index) => {
const catalog = categories.find(category => category.category === draft.category);
const id = `cv-mapping-${draft.clientId}`;
return <Box component="fieldset" disabled={disabled} key={draft.clientId} sx={{ m: 0, mb: 2, p: 1.5, minWidth: 0, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Typography component="legend" variant="subtitle2">{t("cvImportMappingRecord", { number: index + 1 })}</Typography>
<FormControl variant="standard" fullWidth sx={{ mb: 2 }}>
<InputLabel htmlFor={`${id}-category`} shrink>{t("cvImportMappingCategory")}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-category` }} value={draft.category} onChange={event => {
const category = categories.find(item => item.category === event.target.value)!;
update(index, { category: category.category, targetIndex: null, fields: defaultFields(category) });
}}>{categories.map(category => <option key={category.category} value={category.category}>{label(category.category)}</option>)}</NativeSelect>
</FormControl>
<FormControl variant="standard" fullWidth sx={{ mb: 2 }}>
<InputLabel htmlFor={`${id}-target`} shrink>{t("cvImportMappingTarget")}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-target` }} value={draft.targetIndex ?? ""} onChange={event => update(index, { targetIndex: event.target.value === "" ? null : Number(event.target.value) })}>
<option value="">{t(draft.category === "contact" ? "cvImportMappingContactDestination" : catalog?.fields.every(field => field.name === "value") ? "cvImportMappingAppend" : "cvImportMappingNew")}</option>
{targets.filter(target => target.category === draft.category).map(target => <option key={target.index} value={target.index}>{target.label}</option>)}
</NativeSelect>
</FormControl>
{draft.fields.map((field, fieldIndex) => {
const number = fieldIndex + 1;
const currentValue = draft.targetIndex === null ? catalog?.fields.find(item => item.name === field.name)?.currentValue : targets.find(target => target.category === draft.category && target.index === draft.targetIndex)?.values?.[field.name];
function change(value: Partial<MappedField>) { update(index, { fields: draft.fields.map((item, i) => i === fieldIndex ? { ...item, ...value } : item) }); }
return <Box key={fieldIndex} sx={{ mb: 2, pt: 1, borderTop: "1px solid", borderColor: "divider" }}>
<FormControl variant="standard" fullWidth sx={{ mb: 2 }}>
<InputLabel htmlFor={`${id}-${fieldIndex}-field`} shrink>{t("cvImportMappingField", { number })}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-${fieldIndex}-field` }} value={field.name} onChange={event => change({ name: event.target.value })}>
{catalog?.fields.map(item => <option key={item.name} value={item.name}>{label(item.name)}</option>)}
</NativeSelect>
</FormControl>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere", mb: 1 }}>{t("careerImportCurrentValue", { value: currentValue || t("careerImportEmptyValue") })}</Typography>
<TextField label={t("cvImportMappingValue", { number })} value={field.value} onChange={event => change({ value: event.target.value })} multiline fullWidth minRows={1} maxRows={8} inputProps={{ maxLength: 8000 }} />
<FormControl variant="standard" fullWidth sx={{ mt: 2 }}>
<InputLabel htmlFor={`${id}-${fieldIndex}-source`} shrink>{t("cvImportMappingSource", { number })}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-${fieldIndex}-source` }} value={field.sourceBlockId} onChange={event => change({ sourceBlockId: event.target.value, value: field.value || blocks.find(block => block.id === event.target.value)?.text || "" })}>
<option value="">{t("cvImportMappingSelect")}</option>
{blocks.map(block => <option key={block.id} value={block.id}>{block.page ? `${t("cvImportPage", { page: block.page })}: ` : ""}{block.text.slice(0, 160)}</option>)}
</NativeSelect>
</FormControl>
{field.sourceBlockId ? <Typography variant="body2" sx={{ mt: 1, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{blocks.find(block => block.id === field.sourceBlockId)?.text}</Typography> : null}
<FormControlLabel control={<Checkbox checked={field.corrected} onChange={event => change({ corrected: event.target.checked })} />} label={t("cvImportMappingCorrection")} />
<Button size="small" color="inherit" onClick={() => update(index, { fields: draft.fields.filter((_, i) => i !== fieldIndex) })}>{t("cvImportMappingRemoveField", { number })}</Button>
</Box>;
})}
{errors[draft.clientId] ? <Alert severity="error" sx={{ mb: 1 }}>{t("cvImportMappingEvidenceError")}</Alert> : null}
{draft.confirmed ? <Alert severity="success" sx={{ mb: 1 }}>{t("cvImportMappingConfirmed")}</Alert> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button disabled={draft.fields.length >= 30} onClick={() => update(index, { fields: [...draft.fields, empty(catalog?.fields[0]?.name ?? "value")] })}>{t("cvImportMappingAddField")}</Button>
<Button variant="outlined" onClick={() => approve(index)}>{t("cvImportMappingConfirm")}</Button>
<Button color="inherit" onClick={() => onChange(drafts.filter((_, i) => i !== index))}>{t("cvImportMappingRemove")}</Button>
</Box>
</Box>;
})}
<Button disabled={disabled || blocks.length === 0 || drafts.length >= 25} onClick={() => {
const category = categories.find(item => item.category === "jobs") ?? categories[0];
onChange([...drafts, { clientId: crypto.randomUUID(), category: category.category, targetIndex: null, fields: defaultFields(category), confirmed: false }]);
}}>{t("cvImportMapMissing")}</Button>
</Box>;
}
@@ -3,6 +3,7 @@ import { Alert, Box, Button, FormControl, InputLabel, LinearProgress, NativeSele
import { api, getApiErrorMessage } from "../../api";
import { useI18n } from "../../i18n/I18nProvider";
import type { TranslationKey } from "../../i18n/translations";
import CvImportMappingEditor, { type MappingDraft, type MappingBlock, type MappingCategory, type MappingTarget } from "./CvImportMappingEditor";
type ReviewField = {
id: string; label: string; currentValue: string | null; extractedValue: string;
@@ -12,6 +13,7 @@ type ReviewGroup = { id: string; category: string; label: string; matchDescripti
type ImportReview = {
runId: number; status: string; revision: number; fileName: string; sourceText: string;
warnings: string[]; groups: ReviewGroup[];
sourceBlocks?: MappingBlock[]; mappingCategories?: MappingCategory[]; targets?: MappingTarget[];
};
type Choice = { action: "keep" | "use" | "correct" | "add"; value?: string; confirmed: boolean };
@@ -26,6 +28,7 @@ const labelKeys: Record<string, TranslationKey> = {
awards: "cvSectionAwards", publications: "cvSectionPublications", organisations: "cvSectionOrganisations", references: "cvSectionReferences",
qualification: "profileCvEducationQualification", institution: "profileCvEducationInstitution", level: "profileCvLanguageLevel", notes: "profileCvLanguageNotes",
name: "cvImportFieldName", issuer: "careerImportIssuer", date: "careerImportDates", role: "cvImportFieldRole", otherSections: "cvEditorCustom",
value: "cvImportMappingValueLabel", linkLabel: "cvImportLinkLabel", linkUrl: "profileCvContactWebsite", qualificationLevel: "cvImportQualificationLevel",
Contact: "careerProfileContactSection", "Full name": "profileCvContactFullName", Headline: "profileCvContactHeadline",
Email: "profileCvContactEmail", Phone: "profileCvContactPhone", Location: "profileCvContactLocation",
Website: "profileCvContactWebsite", LinkedIn: "profileCvContactLinkedIn", Summary: "profileCvStructuredSummary",
@@ -45,6 +48,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
translate.current = t;
const [review, setReview] = useState<ImportReview | null>(null);
const [choices, setChoices] = useState<Record<string, Choice>>({});
const [mappings, setMappings] = useState<MappingDraft[]>([]);
const [loading, setLoading] = useState(true);
const [comparisonCurrent, setComparisonCurrent] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -81,6 +85,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
useEffect(() => {
const requestState = generation;
setChoices({});
setMappings([]);
setStale(false);
void loadReview();
return () => { ++requestState.current; };
@@ -110,7 +115,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
const warningKeys: Record<string, TranslationKey> = { pdf_native_order_no_layout_or_tables: "cvImportWarningNative", docx_page_numbers_unavailable: "cvImportWarningPages", ocr_disabled: "cvImportWarningOcr" };
const fields = review?.groups.flatMap((group) => group.fields) ?? [];
const reviewedCount = fields.filter((field) => choices[field.id]?.confirmed).length;
const allReviewed = fields.length > 0 && reviewedCount === fields.length;
const allReviewed = (fields.length > 0 || mappings.length > 0) && reviewedCount === fields.length && mappings.every(mapping => mapping.confirmed);
async function apply() {
if (disabled || applyPending.current || !comparisonCurrent || !review || !allReviewed) return;
@@ -125,12 +130,14 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
const choice = choices[field.id];
return { id: field.id, action: choice.action, ...(choice.action === "correct" ? { value: choice.value ?? field.extractedValue } : {}) };
}),
...(mappings.length > 0 ? { mappings: mappings.map(({ clientId, category, targetIndex, fields: mappedFields }) => ({ clientId, category, targetIndex, fields: mappedFields })) } : {}),
});
setApplied(true);
await onApplied();
} catch (failure) {
if ((failure as { response?: { status?: number } }).response?.status === 409) {
setStale(true);
setMappings(current => current.map(mapping => ({ ...mapping, targetIndex: null, confirmed: false })));
setChoices((current) => Object.fromEntries(Object.entries(current).map(([id, choice]) => [id, { ...choice, confirmed: false }])));
await loadReview();
} else setError(getApiErrorMessage(failure, translate.current("profileCvApplyChangesFailed")));
@@ -209,6 +216,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
})}
</Box>)}
{fields.length === 0 ? <Alert severity="info" sx={{ mb: 1 }}>{t("cvImportNoFields")}</Alert> : null}
<CvImportMappingEditor categories={review.mappingCategories ?? []} blocks={review.sourceBlocks ?? []} targets={review.targets ?? []} drafts={mappings} onChange={setMappings} label={label} disabled={disabled || applying || loading || !comparisonCurrent} />
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="contained" disabled={disabled || applying || loading || !comparisonCurrent || !allReviewed} onClick={() => void apply()}>{t(applying ? "careerImportApplying" : "cvImportApplyReviewed")}</Button>
<Button color="inherit" disabled={disabled || applying} onClick={onDiscard}>{t("profileCvDiscard")}</Button>