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
@@ -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);
}
}
}