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
+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" };
}