using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; namespace JobTrackerApi.Services; // Validates the semantic review's evidence/coverage contract, not the model's reasoning itself. // Approval never makes a model judgment universal factual proof. public static class CvAiGrounding { public static bool IsSupported(string candidateSource, string proposal, string? assessmentJson) { if (string.IsNullOrWhiteSpace(candidateSource) || string.IsNullOrWhiteSpace(proposal) || string.IsNullOrWhiteSpace(assessmentJson)) return false; try { var candidateParts = Parts(candidateSource); var proposalParts = Parts(proposal); if (candidateParts.Count == 0 || proposalParts.Count == 0) return false; // A summary may consolidate known facts, but an employment entry cannot // borrow another entry's employer, responsibility or numeric result. IEnumerable Evidence(Part part) => part.Scope?.StartsWith("entry:", StringComparison.Ordinal) == true ? candidateParts.Where(source => source.Scope == part.Scope) : candidateParts; foreach (var part in proposalParts) { var sourceNumbers = Numbers(Evidence(part)).ToHashSet(StringComparer.Ordinal); if (Numbers([part]).Any(number => !sourceNumbers.Contains(number))) return false; } using var document = JsonDocument.Parse(assessmentJson); var assessment = document.RootElement; if (!Keys(assessment, "verdict", "coverageComplete", "claims") || !Supported(assessment) || assessment.GetProperty("coverageComplete").ValueKind != JsonValueKind.True) return false; var claims = assessment.GetProperty("claims"); if (claims.ValueKind != JsonValueKind.Array || claims.GetArrayLength() != proposalParts.Count) return false; var remaining = proposalParts.OrderByDescending(part => part.Scope?.StartsWith("entry:", StringComparison.Ordinal) == true).ToList(); foreach (var claim in claims.EnumerateArray()) { if (!Keys(claim, "text", "verdict", "sourceQuotes") || !Supported(claim) || claim.GetProperty("text").ValueKind != JsonValueKind.String) return false; var quotes = claim.GetProperty("sourceQuotes"); if (quotes.ValueKind != JsonValueKind.Array || quotes.GetArrayLength() == 0) return false; foreach (var quote in quotes.EnumerateArray()) { if (quote.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(quote.GetString())) return false; } var index = remaining.FindIndex(part => part.Text == claim.GetProperty("text").GetString() && quotes.EnumerateArray().All(quote => Evidence(part).Any(source => source.Text.Contains(quote.GetString()!, StringComparison.Ordinal)))); if (index < 0) return false; remaining.RemoveAt(index); } return remaining.Count == 0; } catch (Exception exception) when (exception is JsonException or InvalidOperationException or FormatException or OverflowException) { return false; } } private static bool Supported(JsonElement value) => value.GetProperty("verdict").ValueKind == JsonValueKind.String && value.GetProperty("verdict").GetString() == "supported"; private static bool Keys(JsonElement value, params string[] names) => value.ValueKind == JsonValueKind.Object && value.EnumerateObject().Count() == names.Length && names.All(name => value.TryGetProperty(name, out _)); // Whole-document reviews use the existing builder block shape. IDs are routing data, not facts. private sealed record Part(string? Scope, string Text); private static List Parts(string value) { if (!value.TrimStart().StartsWith('[')) return value.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).Select(line => new Part(null, line)).ToList(); using var document = JsonDocument.Parse(value); if (document.RootElement.ValueKind != JsonValueKind.Array) throw new JsonException(); var result = new List(); foreach (var block in document.RootElement.EnumerateArray()) { if (block.ValueKind != JsonValueKind.Object) throw new JsonException(); if (!block.TryGetProperty("id", out var scope) || scope.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(scope.GetString())) throw new JsonException(); foreach (var property in block.EnumerateObject()) { if (property.Name == "id") { if (property.Value.ValueKind != JsonValueKind.String) throw new JsonException(); continue; } if (property.Name is "title" or "subtitle") Add(property.Value, scope.GetString()); else if (property.Name == "items" && property.Value.ValueKind == JsonValueKind.Array) foreach (var item in property.Value.EnumerateArray()) Add(item, scope.GetString()); else throw new JsonException(); } } return result; void Add(JsonElement text, string? scope) { if (text.ValueKind == JsonValueKind.Null) return; if (text.ValueKind != JsonValueKind.String) throw new JsonException(); if (!string.IsNullOrWhiteSpace(text.GetString())) result.Add(new(scope, text.GetString()!)); } } private static IEnumerable Numbers(IEnumerable values) => values.SelectMany(value => Regex.Matches(value.Text, @"(?()) .Select(match => { var quantity = Regex.Match(match.Value, @"^[0-9]+(?:[.,][0-9]+)?").Value; var number = decimal.Parse(quantity.Replace(',', '.'), CultureInfo.InvariantCulture).ToString("G29", CultureInfo.InvariantCulture); return number + (match.Value.Length > quantity.Length ? "%" : string.Empty); }); }