Files
jobtrackingapp/JobTrackerApi/Services/CvDocumentReview.cs
T

242 lines
14 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
public sealed record CvImportBlock(string Id, string Text, string Kind, int? Page);
public sealed record CvImportDocument(string Text, bool OcrUsed, string? ContentType, int? PageCount, int Characters, string? FileName, IReadOnlyList<CvImportBlock> Blocks, IReadOnlyList<string> Warnings, string Method);
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);
// Paths are generated from the canonical typed schema, never accepted as arbitrary JSON patches.
public static class CvDocumentReview
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private static readonly HashSet<string> Excluded = new(StringComparer.Ordinal) { "id", "metadata", "version", "sections", "startDate", "endDate", "dateNormalized" };
private static JsonObject Node(StructuredCvProfile profile) => JsonSerializer.SerializeToNode(profile, Json)!.AsObject();
private static string Clean(string text) => Regex.Replace(text, @"\s+", " ").Trim();
public static StructuredCvProfile Ground(StructuredCvProfile candidate, CvImportDocument document)
{
var node = Node(candidate);
node["sections"] = new JsonArray();
var metadata = new Dictionary<string, StructuredCvFieldMetadata>();
void Walk(JsonNode? value, string path)
{
if (value is JsonObject obj)
{
foreach (var entry in obj.ToList())
{
if (Excluded.Contains(entry.Key)) { if (entry.Key is "startDate" or "endDate" or "dateNormalized" or "id") obj[entry.Key] = null; continue; }
Walk(entry.Value, path.Length == 0 ? entry.Key : path + "." + entry.Key);
}
}
else if (value is JsonArray array)
{
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);
}
for (var i = 0; i < array.Count; i++) Walk(array[i], path + "." + i);
}
else if (value is JsonValue scalar)
{
if (scalar.TryGetValue<bool>(out var flag))
{
if (flag && value.Parent is JsonObject record)
{
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;
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));
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" };
}
}
Walk(node, "");
var result = node.Deserialize<StructuredCvProfile>(Json)!;
result.Metadata = new() { Fields = metadata };
return result;
}
private sealed record GroupMap(CvImportGroup Review, JsonNode Incoming, string Category, int? ExistingIndex, bool IsArray);
public static IReadOnlyList<CvImportGroup> Build(StructuredCvProfile current, StructuredCvProfile incoming) => Maps(current, incoming).Select(m => m.Review).ToList();
private static List<GroupMap> Maps(StructuredCvProfile current, StructuredCvProfile incoming)
{
var old = Node(current);
var next = Node(incoming);
var groups = new List<GroupMap>();
foreach (var category in next)
{
if (Excluded.Contains(category.Key) || category.Value is null) continue;
if (category.Value is JsonArray items)
{
var existing = old[category.Key]!.AsArray();
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
if (item is null) continue;
var matches = Enumerable.Range(0, existing.Count).Where(j => Same(category.Key, existing[j], item)).ToList();
int? match = matches.Count == 1 ? matches[0] : null;
Add(category.Key + "." + i, category.Key, item, match is int index ? existing[index] : null, match, true);
}
}
else Add(category.Key, category.Key, category.Value, old[category.Key], null, false);
}
return groups;
void Add(string id, string category, JsonNode item, JsonNode? existing, int? index, bool array)
{
var fields = new List<CvImportField>();
void Walk(JsonNode? value, JsonNode? previous, string path)
{
if (value is JsonObject obj)
{
foreach (var property in obj) if (!Excluded.Contains(property.Key)) Walk(property.Value, (previous as JsonObject)?[property.Key], path + "." + property.Key);
}
else if (value is JsonArray list)
{
for (var j = 0; j < list.Count; j++)
{
var index = path == "contact.links" && previous is JsonArray links && list[j] is JsonObject link ? LinkIndex(links, link) : j;
Walk(list[j], previous is JsonArray before && index is int matched && matched < before.Count ? before[matched] : null, path + "." + j);
}
}
else if (value is JsonValue scalar)
{
var text = scalar.ToString();
if (string.IsNullOrWhiteSpace(text) || (scalar.TryGetValue<bool>(out var boolean) && !boolean)) return;
incoming.Metadata.Fields.TryGetValue(path, out var source);
var label = Regex.Replace(path.Split('.').Last(), "([a-z])([A-Z])", "$1 $2");
if (int.TryParse(label, out _)) label = "Item";
label = char.ToUpperInvariant(label[0]) + label[1..];
fields.Add(new(path, label, previous?.ToString(), text, source?.SourceSnippet, source?.SourcePage, source?.Method ?? "deterministic-source", "suggested"));
}
}
Walk(item, existing, id);
if (fields.Count > 0) groups.Add(new(new(id, category, category, array ? index.HasValue ? "Matched existing record" : "New record" : null, fields), item, category, index, array));
}
}
private static bool Same(string category, JsonNode? left, JsonNode right)
{
if (left is JsonValue && right is JsonValue) return string.Equals(left.ToString(), right.ToString(), StringComparison.OrdinalIgnoreCase);
var keys = category switch
{
"jobs" => new[] { "company", "title", "start", "end" },
"education" => new[] { "institution", "qualification", "start", "end" },
"certifications" => new[] { "name", "issuer", "date" },
"projects" => new[] { "name", "role", "start", "end" },
"languages" => new[] { "name" },
"otherSections" => new[] { "title" },
_ => Array.Empty<string>()
};
return keys.Length > 0 && keys.All(k => !string.IsNullOrWhiteSpace(right[k]?.ToString()) && string.Equals(Clean(left?[k]?.ToString() ?? ""), Clean(right[k]!.ToString()), StringComparison.OrdinalIgnoreCase));
}
private static int? LinkIndex(JsonArray links, JsonObject incoming)
{
var label = Clean(incoming["label"]?.ToString() ?? "");
var matches = Enumerable.Range(0, links.Count).Where(i => label.Length > 0 && string.Equals(Clean(links[i]?["label"]?.ToString() ?? ""), label, StringComparison.OrdinalIgnoreCase)).ToList();
return matches.Count == 1 ? matches[0] : null;
}
public static StructuredCvProfile Apply(StructuredCvProfile current, StructuredCvProfile incoming, IReadOnlyList<CvImportDecision> decisions)
{
var maps = Maps(current, incoming);
var known = maps.SelectMany(m => m.Review.Fields).ToDictionary(f => f.Id, StringComparer.Ordinal);
if (decisions.Count > 2000 || decisions.Select(d => d.Id).Distinct(StringComparer.Ordinal).Count() != decisions.Count) throw new ArgumentException("Duplicate or excessive review decisions.");
foreach (var d in decisions)
if (!known.ContainsKey(d.Id) || d.Action is not ("keep" or "use" or "correct" or "add") || (d.Action == "correct" && (string.IsNullOrWhiteSpace(d.Value) || d.Value.Length > 8000 || d.Value.Any(c => char.IsControl(c) && c is not '\n' and not '\r' and not '\t')))) throw new ArgumentException("Invalid review decision.");
var selected = decisions.Where(d => d.Action != "keep").ToDictionary(d => d.Id, StringComparer.Ordinal);
var result = Node(current);
var linkTargets = new Dictionary<int, int>();
foreach (var map in maps)
{
var choices = map.Review.Fields.Where(f => selected.ContainsKey(f.Id)).ToList();
if (choices.Count == 0) continue;
JsonNode target;
string targetPrefix;
if (map.IsArray)
{
var list = result[map.Category]!.AsArray();
var index = map.ExistingIndex ?? list.Count;
if (!map.ExistingIndex.HasValue) list.Add(map.Incoming is JsonObject ? new JsonObject() : JsonValue.Create(""));
target = list[index]!;
targetPrefix = map.Category + "." + index;
}
else { target = result[map.Category]!; targetPrefix = map.Category; }
foreach (var field in choices)
{
var decision = selected[field.Id];
var text = decision.Action == "correct" ? decision.Value!.Trim() : field.ExtractedValue;
var suffix = field.Id[map.Review.Id.Length..].TrimStart('.');
if (map.Category == "contact" && suffix.StartsWith("links.", StringComparison.Ordinal))
{
var segments = suffix.Split('.');
var incomingIndex = int.Parse(segments[1]);
if (!linkTargets.TryGetValue(incomingIndex, out var targetIndex))
{
var links = result["contact"]!["links"]!.AsArray();
targetIndex = LinkIndex(links, map.Incoming["links"]![incomingIndex]!.AsObject()) ?? links.Count;
linkTargets[incomingIndex] = targetIndex;
}
suffix = $"links.{targetIndex}.{segments[2]}";
}
JsonNode value = JsonValue.Create(text)!;
if (field.Id.EndsWith(".isCurrent", StringComparison.Ordinal))
{
if (!bool.TryParse(text, out var boolean)) throw new ArgumentException("isCurrent must be true or false.");
value = JsonValue.Create(boolean)!;
}
if (suffix.Length == 0)
{
var list = result[map.Category]!.AsArray();
list[int.Parse(targetPrefix.Split('.').Last())] = value;
}
else Set(target, suffix.Split('.'), value);
var path = targetPrefix + (suffix.Length == 0 ? "" : "." + suffix);
incoming.Metadata.Fields.TryGetValue(field.Id, out var source);
var metadataNode = result["metadata"]!["fields"]!.AsObject();
metadataNode[path] = JsonSerializer.SerializeToNode(new StructuredCvFieldMetadata { Method = decision.Action == "correct" ? "user-correction" : source?.Method ?? "deterministic-source", SourceSnippet = source?.SourceSnippet, SourcePage = source?.SourcePage, SourceBlockId = source?.SourceBlockId, ReviewState = decision.Action == "correct" ? "corrected" : "accepted", LastUpdatedAtUtc = DateTimeOffset.UtcNow }, Json);
}
}
return result.Deserialize<StructuredCvProfile>(Json)!;
}
private static void Set(JsonNode target, string[] path, JsonNode value)
{
for (var i = 0; i < path.Length - 1; i++)
{
var key = path[i];
var arrayNext = int.TryParse(path[i + 1], out _);
if (target is JsonArray list)
{
var index = int.Parse(key);
while (list.Count <= index) list.Add(null);
list[index] ??= arrayNext ? new JsonArray() : new JsonObject();
target = list[index]!;
}
else { target[key] ??= arrayNext ? new JsonArray() : new JsonObject(); target = target[key]!; }
}
if (target is JsonArray array)
{
var index = int.Parse(path[^1]);
// Individual approved list entries append instead of erasing user-only entries.
if (!array.Any(v => v?.ToString() == value.ToString())) array.Add(value);
}
else target[path[^1]] = value;
}
}