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