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