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
+32 -1
View File
@@ -64,6 +64,9 @@ public sealed class AiOperationQueueTests
var operation = await db.UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync();
Assert.Equal(OperationStatuses.Succeeded, operation.Status);
Assert.Equal("synthetic-result", operation.ResultReference);
Assert.Equal("ollama", operation.Provider);
Assert.Equal("qwen-test", operation.Model);
Assert.Equal("local_primary", operation.ProgressStage);
Assert.Equal("pro-1", fixture.Handler.OwnerUserId);
Assert.Equal("local_only", fixture.Handler.PrivacyPolicy);
Assert.Equal("operation_succeeded", (await db.UserNotifications.IgnoreQueryFilters().AsNoTracking().SingleAsync()).Kind);
@@ -95,19 +98,46 @@ public sealed class AiOperationQueueTests
Assert.Equal("entitlement_changed", failed.FailureCategory);
}
[Fact]
public async Task Worker_records_provider_metadata_for_generation_failures()
{
await using var fixture = await Fixture.CreateAsync();
await fixture.SeedUserAsync("pro-1", pro: true);
await fixture.EnqueueAsync("pro-1", "provider-failure");
fixture.Handler.GenerationFailure = new AiGenerationException(
"provider_unavailable",
"AI provider unavailable.",
retryable: true,
provider: "gemini",
model: "gemini-test",
routeReason: "external_fallback");
Assert.True(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
await using var scope = fixture.Provider.CreateAsyncScope();
var row = await scope.ServiceProvider.GetRequiredService<JobTrackerContext>()
.UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync();
Assert.Equal(OperationStatuses.WaitingForRetry, row.Status);
Assert.Equal("gemini", row.Provider);
Assert.Equal("gemini-test", row.Model);
Assert.Equal("external_fallback", row.ProgressStage);
}
private sealed class SyntheticHandler : IAiOperationHandler
{
public string TaskType => "synthetic.ai";
public string? OwnerUserId { get; private set; }
public string? PrivacyPolicy { get; private set; }
public AiOperationFailure? Failure { get; set; }
public AiGenerationException? GenerationFailure { get; set; }
public Task<AiOperationExecutionResult> ExecuteAsync(AiOperationExecutionContext context, IServiceProvider services, CancellationToken cancellationToken)
{
if (Failure is not null) throw Failure;
if (GenerationFailure is not null) throw GenerationFailure;
OwnerUserId = services.GetRequiredService<ICurrentUserService>().UserId;
PrivacyPolicy = context.EffectivePrivacyPolicy;
return Task.FromResult(new AiOperationExecutionResult("synthetic-result"));
return Task.FromResult(new AiOperationExecutionResult("synthetic-result", "ollama", "qwen-test", "local_primary"));
}
}
@@ -147,6 +177,7 @@ public sealed class AiOperationQueueTests
services.AddSingleton(TimeProvider.System);
services.AddScoped<UserOperationStore>();
services.AddSingleton<AiPrivacyPolicy>();
services.AddSingleton<AiOperationExecutionScope>();
services.AddScoped<AiOperationAdmission>();
services.AddSingleton<IAiOperationHandler>(handler);
services.AddSingleton<AiOperationWorker>();
+45 -2
View File
@@ -46,6 +46,20 @@ public sealed class AiPrivacyPolicyTests
Assert.Equal("local", decision.Provider);
}
[Theory]
[InlineData("local_only")]
[InlineData("unexpected")]
public async Task Local_only_or_invalid_admin_mode_disables_external_processing(string routingMode)
{
await using var fixture = await Fixture.CreateAsync(adminEnabled: true, routingMode: routingMode);
await fixture.CreateUserAsync(externalAllowed: true, pro: true);
var decision = await fixture.Policy.EvaluateAsync("user-1");
Assert.False(decision.ExternalProcessingAllowed);
Assert.Equal("local", decision.Provider);
}
[Fact]
public async Task Cv_request_carries_external_permission_only_after_the_policy_allows_it()
{
@@ -57,7 +71,10 @@ public sealed class AiPrivacyPolicyTests
new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")),
};
var capture = new CaptureHandler();
var handler = new AiPrivacyHeaderHandler(new HttpContextAccessor { HttpContext = context }, fixture.Policy)
var handler = new AiPrivacyHeaderHandler(
new HttpContextAccessor { HttpContext = context },
fixture.Policy,
new AiOperationExecutionScope())
{
InnerHandler = capture,
};
@@ -68,15 +85,40 @@ public sealed class AiPrivacyPolicyTests
Assert.Equal("true", capture.ExternalAllowed);
}
[Fact]
public async Task Background_operation_carries_its_rechecked_policy_and_task_without_http_user_context()
{
await using var fixture = await Fixture.CreateAsync(adminEnabled: true);
var executionScope = new AiOperationExecutionScope();
var capture = new CaptureHandler();
var handler = new AiPrivacyHeaderHandler(new HttpContextAccessor(), fixture.Policy, executionScope)
{
InnerHandler = capture,
};
var lease = new UserOperationLease(Guid.NewGuid(), "user-1", "lease", "strategy.snapshot",
"external_allowed", "job", "42", 1, DateTime.UtcNow.AddMinutes(5));
using var routing = executionScope.Use(new AiOperationExecutionContext(lease, "external_allowed"));
using var client = new HttpClient(handler);
await client.PostAsync("http://ai-service/cv/rewrite", new StringContent("{}"));
Assert.Equal("true", capture.ExternalAllowed);
Assert.Equal("strategy.snapshot", capture.TaskType);
}
private sealed class CaptureHandler : HttpMessageHandler
{
public string? ExternalAllowed { get; private set; }
public string? TaskType { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
ExternalAllowed = request.Headers.TryGetValues(AiPrivacyPolicy.ExternalAllowedHeader, out var values)
? values.Single()
: null;
TaskType = request.Headers.TryGetValues(AiPrivacyPolicy.TaskTypeHeader, out var taskValues)
? taskValues.Single()
: null;
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
}
}
@@ -94,7 +136,7 @@ public sealed class AiPrivacyPolicyTests
Policy = policy;
}
public static async Task<Fixture> CreateAsync(bool adminEnabled)
public static async Task<Fixture> CreateAsync(bool adminEnabled, string routingMode = "local_first")
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
@@ -102,6 +144,7 @@ public sealed class AiPrivacyPolicyTests
{
["Ai:ExternalProcessingEnabled"] = adminEnabled.ToString(),
["Ai:ExternalProvider"] = "gemini",
["Ai:RoutingMode"] = routingMode,
}).Build();
var services = new ServiceCollection();
services.AddLogging();
+29
View File
@@ -14,6 +14,8 @@ public sealed class AiWorkspaceTests
public string? Next = "## Result\nGenerated suggestion.";
public int Calls;
public string? LastInstruction;
public string? ActualProvider;
public string? FallbackReason;
// The source text the module assembled — what the prompt actually saw.
public string? LastText;
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
@@ -24,6 +26,17 @@ public sealed class AiWorkspaceTests
return Task.FromResult(Next);
}
public Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next);
public 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 generated is null ? null : new AiGenerationResult(generated, ActualProvider, "test-model", FallbackReason,
FallbackReason is null ? "local_primary" : "external_fallback");
}
public Task<AiTextExtractionResult?> ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException();
@@ -72,6 +85,22 @@ public sealed class AiWorkspaceTests
Assert.Single(await db.AiInteractions.IgnoreQueryFilters().Where(x => x.JobApplicationId == jobId).ToListAsync());
}
[Fact]
public async Task Generate_records_the_actual_provider_and_fallback_metadata()
{
var (db, svc, ai) = New("user-1");
await using var _ = db;
ai.ActualProvider = "groq";
ai.FallbackReason = "local_circuit_open";
var jobId = await SeedJobAsync(db, "user-1");
var result = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("job-analysis"), "configured-label", default);
Assert.Equal("groq", result!.Provider);
Assert.Contains("local_circuit_open", result.ResultJson);
Assert.Contains("test-model", result.ResultJson);
}
[Fact]
public async Task Cover_letter_normalizes_an_unknown_mode_and_labels_the_title()
{
+64 -1
View File
@@ -57,6 +57,46 @@ public sealed class SummarizerServiceTests
Assert.Contains("\"min_length\":180", handler.LastBody);
}
[Fact]
public async Task Generate_section_returns_actual_provider_model_and_fallback_metadata()
{
var handler = new CapturingHandler();
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:8001") };
var httpFactory = new Mock<IHttpClientFactory>();
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var service = new SummarizerService(httpFactory.Object, memoryCache);
var result = await service.GenerateSectionWithMetadataAsync("Rewrite", "Synthetic CV", cancellationToken: default);
Assert.NotNull(result);
Assert.Equal("rewritten cv", result!.Text);
Assert.Equal("gemini", result.Provider);
Assert.Equal("gemini-test", result.Model);
Assert.Equal("local_provider_unavailable", result.FallbackReason);
Assert.Equal("external_fallback", result.RouteReason);
}
[Fact]
public async Task Metadata_path_returns_a_typed_sanitized_provider_failure_while_legacy_path_returns_null()
{
var httpClient = new HttpClient(new FailureHandler()) { BaseAddress = new Uri("http://localhost:8001") };
var httpFactory = new Mock<IHttpClientFactory>();
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var service = new SummarizerService(httpFactory.Object, memoryCache);
var failure = await Assert.ThrowsAsync<AiGenerationException>(() =>
service.GenerateSectionWithMetadataAsync("Rewrite", "Synthetic CV", cancellationToken: default));
Assert.Equal("provider_unavailable", failure.Category);
Assert.True(failure.Retryable);
Assert.Equal("gemini", failure.Provider);
Assert.Equal("external_fallback", failure.RouteReason);
Assert.DoesNotContain("synthetic upstream detail", failure.Message, StringComparison.OrdinalIgnoreCase);
Assert.Null(await service.SummarizeSectionAsync("Rewrite again", "Synthetic CV"));
}
private sealed class CapturingHandler : HttpMessageHandler
{
public string? LastBody { get; private set; }
@@ -69,10 +109,33 @@ public sealed class SummarizerServiceTests
var responseBody = LastPath == "/cv/rewrite"
? "{\"rewritten_text\":\"rewritten cv\"}"
: "{\"summary\":\"ok\"}";
return new HttpResponseMessage(HttpStatusCode.OK)
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
};
if (LastPath == "/cv/rewrite")
{
response.Headers.Add("X-Ai-Provider", "gemini");
response.Headers.Add("X-Ai-Model", "gemini-test");
response.Headers.Add("X-Ai-Fallback-Reason", "local_provider_unavailable");
response.Headers.Add("X-Ai-Route-Reason", "external_fallback");
}
return response;
}
}
private sealed class FailureHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent("{\"detail\":\"synthetic upstream detail\"}", Encoding.UTF8, "application/json"),
};
response.Headers.Add("X-Ai-Provider", "gemini");
response.Headers.Add("X-Ai-Model", "gemini-test");
response.Headers.Add("X-Ai-Route-Reason", "external_fallback");
return Task.FromResult(response);
}
}
}