feat(cv-import): add source-grounded review and explicit corrections
This commit is contained in:
@@ -22,7 +22,7 @@ public static class CvDocumentReview
|
||||
|
||||
public static StructuredCvProfile Ground(StructuredCvProfile candidate, CvImportDocument document)
|
||||
{
|
||||
var node = Node(candidate);
|
||||
var node = Node(StructuredCvProfileJson.NormalizeForPersistence(candidate));
|
||||
node["sections"] = new JsonArray();
|
||||
var metadata = new Dictionary<string, StructuredCvFieldMetadata>();
|
||||
void Walk(JsonNode? value, string path)
|
||||
@@ -162,6 +162,7 @@ public static class CvDocumentReview
|
||||
var selected = decisions.Where(d => d.Action != "keep").ToDictionary(d => d.Id, StringComparer.Ordinal);
|
||||
var result = Node(current);
|
||||
var linkTargets = new Dictionary<int, int>();
|
||||
var newTargets = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var map in maps)
|
||||
{
|
||||
var choices = map.Review.Fields.Where(f => selected.ContainsKey(f.Id)).ToList();
|
||||
@@ -171,8 +172,14 @@ public static class CvDocumentReview
|
||||
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(""));
|
||||
var identity = map.Category + "|" + map.Incoming.ToJsonString();
|
||||
var scalarText = selected[choices[0].Id].Action == "correct" ? selected[choices[0].Id].Value! : choices[0].ExtractedValue;
|
||||
var matches = Enumerable.Range(0, list.Count).Where(i => map.Incoming is JsonValue
|
||||
? string.Equals(Clean(list[i]?.ToString() ?? ""), Clean(scalarText), StringComparison.OrdinalIgnoreCase)
|
||||
: Same(map.Category, list[i], map.Incoming)).ToList();
|
||||
var index = map.ExistingIndex ?? (newTargets.TryGetValue(identity, out var prior) ? prior : matches.Count == 1 ? matches[0] : list.Count);
|
||||
if (index == list.Count) list.Add(map.Incoming is JsonObject ? new JsonObject() : JsonValue.Create(""));
|
||||
newTargets[identity] = index;
|
||||
target = list[index]!;
|
||||
targetPrefix = map.Category + "." + index;
|
||||
}
|
||||
@@ -205,17 +212,21 @@ public static class CvDocumentReview
|
||||
var list = result[map.Category]!.AsArray();
|
||||
list[int.Parse(targetPrefix.Split('.').Last())] = value;
|
||||
}
|
||||
else Set(target, suffix.Split('.'), value);
|
||||
else suffix = 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);
|
||||
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, SourceArtifactId = source?.SourceArtifactId, SourceExtractionRunId = source?.SourceExtractionRunId, SourceFileSha256 = source?.SourceFileSha256, ReviewState = decision.Action == "correct" ? "corrected" : "accepted", LastUpdatedAtUtc = DateTimeOffset.UtcNow }, Json);
|
||||
}
|
||||
}
|
||||
return result.Deserialize<StructuredCvProfile>(Json)!;
|
||||
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 reviewed contact link is incomplete, duplicated or exceeds the supported limit. Keep the existing link or correct its URL.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static void Set(JsonNode target, string[] path, JsonNode value)
|
||||
private static string Set(JsonNode target, string[] path, JsonNode value)
|
||||
{
|
||||
for (var i = 0; i < path.Length - 1; i++)
|
||||
{
|
||||
@@ -232,10 +243,12 @@ public static class CvDocumentReview
|
||||
}
|
||||
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);
|
||||
var index = Enumerable.Range(0, array.Count).FirstOrDefault(i => string.Equals(array[i]?.ToString(), value.ToString(), StringComparison.OrdinalIgnoreCase), -1);
|
||||
if (index < 0) { index = array.Count; array.Add(value); }
|
||||
path[^1] = index.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
else target[path[^1]] = value;
|
||||
return string.Join('.', path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ public sealed class CvProcessingOperationHandler : IAiOperationHandler
|
||||
services,
|
||||
runId,
|
||||
operation?.CancellationRequestedAtUtc is null ? "queued" : "cancelled",
|
||||
operation?.CancellationRequestedAtUtc is null ? "CV processing timed out and may be retried." : "CV processing was cancelled.");
|
||||
operation?.CancellationRequestedAtUtc is null ? "CV processing timed out and may be retried." : "CV processing was cancelled.", context.Lease);
|
||||
throw;
|
||||
}
|
||||
if (outcome is null)
|
||||
@@ -88,7 +88,7 @@ public sealed class CvProcessingOperationHandler : IAiOperationHandler
|
||||
var canRetry = operation is not null && operation.AttemptCount < operation.MaxAttempts &&
|
||||
(operation.DeadlineAtUtc is null || operation.DeadlineAtUtc > DateTime.UtcNow);
|
||||
if (!canRetry)
|
||||
await SetRunStatusAsync(services, runId, "failed", outcome.FailureMessage ?? "CV processing failed.");
|
||||
await SetRunStatusAsync(services, runId, "failed", outcome.FailureMessage ?? "CV processing failed.", context.Lease);
|
||||
}
|
||||
|
||||
if (outcome.Provider is not null || outcome.Model is not null || outcome.RouteReason is not null)
|
||||
@@ -108,18 +108,20 @@ public sealed class CvProcessingOperationHandler : IAiOperationHandler
|
||||
outcome.Retryable);
|
||||
}
|
||||
|
||||
var isDocumentImport = await services.GetRequiredService<JobTrackerContext>().CvExtractionRuns.Where(r => r.Id == runId).Select(r => r.ParserVersion).FirstAsync(cancellationToken) == ProfileCvController.DocumentParserVersion;
|
||||
return new AiOperationExecutionResult(
|
||||
$"/api/profile-cv/runs/{runId}/{(await services.GetRequiredService<JobTrackerContext>().CvExtractionRuns.Where(r => r.Id == runId).Select(r => r.ParserVersion).FirstAsync(cancellationToken) == ProfileCvController.DocumentParserVersion ? "review" : "diff")}",
|
||||
$"/api/profile-cv/runs/{runId}/{(isDocumentImport ? "review" : "diff")}",
|
||||
outcome.Provider,
|
||||
outcome.Model,
|
||||
outcome.RouteReason ?? "cv_pipeline");
|
||||
outcome.RouteReason ?? (isDocumentImport ? "cv_review_ready" : "cv_pipeline"));
|
||||
}
|
||||
|
||||
private static Task<int> SetRunStatusAsync(IServiceProvider services, int runId, string status, string message)
|
||||
private static Task<int> SetRunStatusAsync(IServiceProvider services, int runId, string status, string message, UserOperationLease lease)
|
||||
{
|
||||
var completedAtUtc = status == "failed" || status == "cancelled" ? DateTimeOffset.UtcNow : (DateTimeOffset?)null;
|
||||
return services.GetRequiredService<JobTrackerContext>().CvExtractionRuns
|
||||
.Where(run => run.Id == runId && run.Status != "expired" && run.Status != "applied")
|
||||
var db = services.GetRequiredService<JobTrackerContext>();
|
||||
return db.CvExtractionRuns
|
||||
.Where(run => run.Id == runId && (run.Status == "running" || run.Status == "queued") && db.UserOperations.Any(o => o.Id == lease.OperationId && o.LeaseToken == lease.LeaseToken && o.Status == OperationStatuses.Running))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(run => run.Status, status)
|
||||
.SetProperty(run => run.ErrorMessage, message)
|
||||
|
||||
Reference in New Issue
Block a user