fix(cv): gate AI proposals on grounding and source freshness

This commit is contained in:
cesnimda
2026-10-08 14:28:06 +02:00
parent d8d262e4d1
commit 2398ff354e
27 changed files with 2553 additions and 157 deletions
+61 -2
View File
@@ -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();