feat(cv-qa): map missing records from reviewed source evidence
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user