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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user