fix(cv): gate AI proposals on grounding and source freshness
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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<Part> 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<Part> 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<Part>();
|
||||
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<string> Numbers(IEnumerable<Part> values) => values.SelectMany(value => Regex.Matches(value.Text,
|
||||
@"(?<![\p{L}\p{N}])[0-9]+(?:[.,][0-9]+)?(?:\s*(?:%|percent\b|prosent\b))?", RegexOptions.IgnoreCase).Cast<Match>())
|
||||
.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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user