fix(cv): gate AI proposals on grounding and source freshness
This commit is contained in:
@@ -59,6 +59,8 @@ namespace JobTrackerApi.Services
|
||||
string? FileName
|
||||
);
|
||||
|
||||
public enum AiGenerationOutputMode { Text, CvBlocksJson }
|
||||
|
||||
public sealed record AiGenerationResult(
|
||||
string Text,
|
||||
string? Provider = null,
|
||||
@@ -94,6 +96,16 @@ namespace JobTrackerApi.Services
|
||||
{
|
||||
new Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
|
||||
|
||||
// Older providers cannot approve factual changes without implementing this review boundary.
|
||||
Task<AiGenerationResult?> EvaluateCvRewriteAsync(string candidateSource, string proposal, string? contextOnly, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<AiGenerationResult?>(null);
|
||||
|
||||
Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(string instruction, string text,
|
||||
AiGenerationOutputMode outputMode, int maxLength = 180, int minLength = 40, CancellationToken cancellationToken = default)
|
||||
=> outputMode == AiGenerationOutputMode.Text
|
||||
? GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength, cancellationToken)
|
||||
: Task.FromResult<AiGenerationResult?>(null);
|
||||
|
||||
async Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(
|
||||
string instruction,
|
||||
string text,
|
||||
@@ -213,6 +225,51 @@ namespace JobTrackerApi.Services
|
||||
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(string instruction, string text,
|
||||
AiGenerationOutputMode outputMode, int maxLength = 180, int minLength = 40, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (outputMode == AiGenerationOutputMode.Text)
|
||||
return GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength, cancellationToken);
|
||||
if (outputMode != AiGenerationOutputMode.CvBlocksJson || string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text))
|
||||
return Task.FromResult<AiGenerationResult?>(null);
|
||||
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength, cancellationToken, outputMode);
|
||||
}
|
||||
|
||||
public async Task<AiGenerationResult?> EvaluateCvRewriteAsync(string candidateSource, string proposal, string? contextOnly, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidateSource) || string.IsNullOrWhiteSpace(proposal)
|
||||
|| (contextOnly?.Length ?? 0) > 6000
|
||||
|| (long)candidateSource.Length + proposal.Length + (contextOnly?.Length ?? 0) > AiSummarizeMaxInputChars)
|
||||
return null; // Evaluation never truncates evidence or the proposal.
|
||||
|
||||
Interlocked.Increment(ref _requests);
|
||||
Interlocked.Increment(ref _cacheMisses);
|
||||
var watch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(JsonSerializer.Serialize(new { candidate_source = candidateSource, proposal, context_only = contextOnly ?? string.Empty }), Encoding.UTF8, "application/json");
|
||||
using var response = await _httpFactory.CreateClient("ai-service").PostAsync("/cv/evaluate-rewrite", content, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new AiGenerationException("provider_unavailable", "CV factual review is unavailable.", (int)response.StatusCode is 408 or 429 or >= 500);
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
if (!document.RootElement.TryGetProperty("assessment", out var assessment) || assessment.ValueKind != JsonValueKind.Object)
|
||||
throw new AiGenerationException("invalid_response", "CV factual review returned an invalid assessment.", true);
|
||||
lock (_metricsLock) { _lastSuccessAt = DateTimeOffset.UtcNow; _lastError = null; }
|
||||
return new AiGenerationResult(assessment.GetRawText(), ReadBoundedHeader(response, "X-Ai-Provider"),
|
||||
ReadBoundedHeader(response, "X-Ai-Model"), ReadBoundedHeader(response, "X-Ai-Fallback-Reason"), ReadBoundedHeader(response, "X-Ai-Route-Reason"));
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (Exception exception)
|
||||
{
|
||||
Interlocked.Increment(ref _failures);
|
||||
lock (_metricsLock) { _lastFailureAt = DateTimeOffset.UtcNow; _lastError = "CV factual review failed."; }
|
||||
if (exception is AiGenerationException) throw;
|
||||
throw new AiGenerationException("provider_unavailable", "CV factual review could not reach the provider boundary.", true);
|
||||
}
|
||||
finally { Interlocked.Add(ref _totalLatencyTicks, watch.ElapsedTicks); }
|
||||
}
|
||||
|
||||
private static string ComposeBoundedPrompt(string instruction, string text)
|
||||
{
|
||||
var prefix = $"{instruction}\n\n";
|
||||
@@ -235,7 +292,8 @@ namespace JobTrackerApi.Services
|
||||
string text,
|
||||
int maxLength,
|
||||
int minLength,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
AiGenerationOutputMode outputMode = AiGenerationOutputMode.Text)
|
||||
{
|
||||
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
|
||||
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
|
||||
@@ -245,7 +303,7 @@ namespace JobTrackerApi.Services
|
||||
}
|
||||
|
||||
var composed = ComposeBoundedPrompt(instruction, text);
|
||||
var key = BuildCacheKey($"rewrite::{composed}", normalizedMaxLength, normalizedMinLength);
|
||||
var key = BuildCacheKey($"rewrite::{outputMode}::{composed}", normalizedMaxLength, normalizedMinLength);
|
||||
Interlocked.Increment(ref _requests);
|
||||
|
||||
if (_cache.TryGetValue<AiGenerationResult>(key, out var cached))
|
||||
@@ -268,6 +326,7 @@ namespace JobTrackerApi.Services
|
||||
text,
|
||||
max_length = normalizedMaxLength,
|
||||
min_length = normalizedMinLength,
|
||||
output_mode = outputMode == AiGenerationOutputMode.CvBlocksJson ? "cv_blocks_json" : "text",
|
||||
});
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
Reference in New Issue
Block a user