feat(ai): enforce local-first routing

Keep external providers behind server consent, task, and prompt-cost gates while persisting actual provider provenance.
This commit is contained in:
cesnimda
2026-08-09 12:30:11 +02:00
parent c3f4a57195
commit 5eb9b3cb96
29 changed files with 967 additions and 145 deletions
+108 -14
View File
@@ -59,6 +59,28 @@ namespace JobTrackerApi.Services
string? FileName
);
public sealed record AiGenerationResult(
string Text,
string? Provider = null,
string? Model = null,
string? FallbackReason = null,
string? RouteReason = null);
public sealed class AiGenerationException(
string category,
string message,
bool retryable,
string? provider = null,
string? model = null,
string? routeReason = null) : Exception(message)
{
public string Category { get; } = category;
public bool Retryable { get; } = retryable;
public string? Provider { get; } = provider;
public string? Model { get; } = model;
public string? RouteReason { get; } = routeReason;
}
public interface IAiService
{
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
@@ -71,6 +93,17 @@ namespace JobTrackerApi.Services
public interface ISummarizerService : IAiService
{
new Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
async Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(
string instruction,
string text,
int maxLength = 180,
int minLength = 40,
CancellationToken cancellationToken = default)
{
var generated = await SummarizeSectionAsync(instruction, text, maxLength, minLength);
return string.IsNullOrWhiteSpace(generated) ? null : new AiGenerationResult(generated);
}
}
public class SummarizerService : ISummarizerService
@@ -144,16 +177,40 @@ namespace JobTrackerApi.Services
return $"HTTP {(int)response.StatusCode}: {body}";
}
private static string? ReadBoundedHeader(HttpResponseMessage response, string name)
{
if (!response.Headers.TryGetValues(name, out var values)) return null;
var value = values.FirstOrDefault()?.Trim();
return string.IsNullOrWhiteSpace(value) ? null : value[..Math.Min(value.Length, 128)];
}
public async Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30)
{
if (string.IsNullOrWhiteSpace(text)) return null;
return await SummarizeCoreAsync(text, maxLength, minLength);
}
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
public async Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
{
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(null);
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength);
try
{
return (await GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength))?.Text;
}
catch (AiGenerationException)
{
return null;
}
}
public Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(
string instruction,
string text,
int maxLength = 180,
int minLength = 40,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<AiGenerationResult?>(null);
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength, cancellationToken);
}
private static string ComposeBoundedPrompt(string instruction, string text)
@@ -173,7 +230,12 @@ namespace JobTrackerApi.Services
return prefix + text[..remaining];
}
private async Task<string?> RewriteCoreAsync(string instruction, string text, int maxLength, int minLength)
private async Task<AiGenerationResult?> RewriteCoreAsync(
string instruction,
string text,
int maxLength,
int minLength,
CancellationToken cancellationToken)
{
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
@@ -186,7 +248,7 @@ namespace JobTrackerApi.Services
var key = BuildCacheKey($"rewrite::{composed}", normalizedMaxLength, normalizedMinLength);
Interlocked.Increment(ref _requests);
if (_cache.TryGetValue<string>(key, out var cached))
if (_cache.TryGetValue<AiGenerationResult>(key, out var cached))
{
Interlocked.Increment(ref _cacheHits);
lock (_metricsLock)
@@ -212,33 +274,48 @@ namespace JobTrackerApi.Services
try
{
var res = await client.PostAsync("/cv/rewrite", content);
using var res = await client.PostAsync("/cv/rewrite", content, cancellationToken);
sw.Stop();
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
if (!res.IsSuccessStatusCode)
{
var errorBody = await ReadErrorBodyAsync(res);
var errorBody = await ReadErrorBodyAsync(res, cancellationToken);
Interlocked.Increment(ref _failures);
lock (_metricsLock)
{
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = $"AI rewrite failed: {errorBody}";
}
return null;
var status = (int)res.StatusCode;
throw new AiGenerationException(
status is 408 or 429 or >= 500 ? "provider_unavailable" : "provider_rejected",
"AI generation failed at the configured provider boundary.",
status is 408 or 429 or >= 500,
ReadBoundedHeader(res, "X-Ai-Provider"),
ReadBoundedHeader(res, "X-Ai-Model"),
ReadBoundedHeader(res, "X-Ai-Route-Reason"));
}
using var stream = await res.Content.ReadAsStreamAsync();
using var doc = await JsonDocument.ParseAsync(stream);
using var stream = await res.Content.ReadAsStreamAsync(cancellationToken);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (doc.RootElement.TryGetProperty("rewritten_text", out var el))
{
var s = el.GetString();
if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6));
var result = string.IsNullOrWhiteSpace(s)
? null
: new AiGenerationResult(
s,
ReadBoundedHeader(res, "X-Ai-Provider"),
ReadBoundedHeader(res, "X-Ai-Model"),
ReadBoundedHeader(res, "X-Ai-Fallback-Reason"),
ReadBoundedHeader(res, "X-Ai-Route-Reason"));
if (result is not null) _cache.Set(key, result, TimeSpan.FromHours(6));
lock (_metricsLock)
{
_lastSuccessAt = DateTimeOffset.UtcNow;
_lastError = null;
}
return s;
return result;
}
lock (_metricsLock)
@@ -246,7 +323,21 @@ namespace JobTrackerApi.Services
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = "AI rewrite failed: response did not contain rewritten_text.";
}
return null;
throw new AiGenerationException(
"invalid_response",
"AI generation returned an invalid response.",
true,
ReadBoundedHeader(res, "X-Ai-Provider"),
ReadBoundedHeader(res, "X-Ai-Model"),
ReadBoundedHeader(res, "X-Ai-Route-Reason"));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (AiGenerationException)
{
throw;
}
catch (Exception ex)
{
@@ -258,7 +349,10 @@ namespace JobTrackerApi.Services
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = ex.Message;
}
return null;
throw new AiGenerationException(
"provider_unavailable",
"AI generation could not reach the provider boundary.",
true);
}
}