diff --git a/.env.example b/.env.example index 65bf413..52d10dd 100644 --- a/.env.example +++ b/.env.example @@ -48,13 +48,19 @@ AI_SERVICE_TOKEN= OLLAMA_BASE_URL=http://ollama:11434 OLLAMA_MODEL=qwen2.5:7b -# AI provider for the heavy /cv/* calls: ollama (default, local) | gemini | groq. -# External processing is denied unless the administrator gate below is true AND -# the authenticated Pro user has explicitly opted in under Settings. /summarize -# always stays local (distilbart). +# Optional external fallback provider for heavy /cv/* calls: ollama (none) | gemini | groq. +# Local Ollama is always attempted first unless the explicitly configured mode is +# external_only. External processing still requires the administrator gate, an +# allowed task, and the authenticated Pro user's opt-in. /summarize stays local. # Keys are read from the environment only — never commit real keys. AI_PROVIDER=ollama EXTERNAL_AI_ENABLED=false +AI_ROUTING_MODE=local_first +EXTERNAL_AI_ALLOWED_TASKS=cv-normalize,cv-classify,cv-rewrite +# Per-request cost/privacy ceiling. Requests above this size remain local even after local failure. +EXTERNAL_AI_MAX_PROMPT_CHARS=24000 +LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD=3 +LOCAL_AI_CIRCUIT_OPEN_SECONDS=30 GEMINI_API_KEY= GEMINI_MODEL=gemini-2.0-flash GROQ_API_KEY= diff --git a/JobTrackerApi.Tests/AiOperationQueueTests.cs b/JobTrackerApi.Tests/AiOperationQueueTests.cs index 5051973..bd2632d 100644 --- a/JobTrackerApi.Tests/AiOperationQueueTests.cs +++ b/JobTrackerApi.Tests/AiOperationQueueTests.cs @@ -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().RunOnceAsync(default)); + + await using var scope = fixture.Provider.CreateAsyncScope(); + var row = await scope.ServiceProvider.GetRequiredService() + .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 ExecuteAsync(AiOperationExecutionContext context, IServiceProvider services, CancellationToken cancellationToken) { if (Failure is not null) throw Failure; + if (GenerationFailure is not null) throw GenerationFailure; OwnerUserId = services.GetRequiredService().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(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddSingleton(handler); services.AddSingleton(); diff --git a/JobTrackerApi.Tests/AiPrivacyPolicyTests.cs b/JobTrackerApi.Tests/AiPrivacyPolicyTests.cs index bf4e98c..8c4f975 100644 --- a/JobTrackerApi.Tests/AiPrivacyPolicyTests.cs +++ b/JobTrackerApi.Tests/AiPrivacyPolicyTests.cs @@ -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 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 CreateAsync(bool adminEnabled) + public static async Task 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(); diff --git a/JobTrackerApi.Tests/AiWorkspaceTests.cs b/JobTrackerApi.Tests/AiWorkspaceTests.cs index 7db2788..160329b 100644 --- a/JobTrackerApi.Tests/AiWorkspaceTests.cs +++ b/JobTrackerApi.Tests/AiWorkspaceTests.cs @@ -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 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 SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next); + public async Task 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 ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task 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() { diff --git a/JobTrackerApi.Tests/SummarizerServiceTests.cs b/JobTrackerApi.Tests/SummarizerServiceTests.cs index 3940727..97a92ae 100644 --- a/JobTrackerApi.Tests/SummarizerServiceTests.cs +++ b/JobTrackerApi.Tests/SummarizerServiceTests.cs @@ -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(); + 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(); + 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(() => + 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 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); } } } diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index be7a1ef..939e22d 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -38,6 +38,7 @@ else builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(externalOrigin); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); diff --git a/JobTrackerApi/Services/AiOperationQueue.cs b/JobTrackerApi/Services/AiOperationQueue.cs index 758f8a3..fa02084 100644 --- a/JobTrackerApi/Services/AiOperationQueue.cs +++ b/JobTrackerApi/Services/AiOperationQueue.cs @@ -92,7 +92,31 @@ public sealed class AiOperationAdmission( } public sealed record AiOperationExecutionContext(UserOperationLease Lease, string EffectivePrivacyPolicy); -public sealed record AiOperationExecutionResult(string? ResultReference); +public sealed record AiOperationExecutionResult( + string? ResultReference, + string? Provider = null, + string? Model = null, + string? RouteReason = null); + +public sealed class AiOperationExecutionScope +{ + private readonly AsyncLocal _current = new(); + + public AiOperationExecutionContext? Current => _current.Value; + + public IDisposable Use(AiOperationExecutionContext context) + { + var previous = _current.Value; + _current.Value = context; + return new Restore(() => _current.Value = previous); + } + + private sealed class Restore(Action restore) : IDisposable + { + private Action? _restore = restore; + public void Dispose() => Interlocked.Exchange(ref _restore, null)?.Invoke(); + } +} public interface IAiOperationHandler { @@ -110,6 +134,7 @@ public sealed class AiOperationWorker( IServiceScopeFactory scopes, IEnumerable registeredHandlers, AiPrivacyPolicy privacy, + AiOperationExecutionScope executionScope, IConfiguration configuration) { private readonly IReadOnlyDictionary _handlers = registeredHandlers @@ -147,19 +172,27 @@ public sealed class AiOperationWorker( try { - var result = await _handlers[lease.TaskType].ExecuteAsync( - new AiOperationExecutionContext(lease, effectivePrivacy), ownerScope.ServiceProvider, execution.Token); + var context = new AiOperationExecutionContext(lease, effectivePrivacy); + using var routing = executionScope.Use(context); + var result = await _handlers[lease.TaskType].ExecuteAsync(context, ownerScope.ServiceProvider, execution.Token); var row = await store.GetAsync(lease.OperationId, stoppingToken); if (row?.CancellationRequestedAtUtc is not null) await store.AcknowledgeCancellationAsync(lease.OperationId, lease.LeaseToken, stoppingToken); else - await store.CompleteAsync(lease.OperationId, lease.LeaseToken, result.ResultReference, stoppingToken); + await store.CompleteAsync(lease.OperationId, lease.LeaseToken, result.ResultReference, + result.Provider, result.Model, result.RouteReason, stoppingToken); } catch (AiOperationFailure failure) { await store.FailAsync(lease.OperationId, lease.LeaseToken, failure.Retryable, failure.Category, failure.Message, RetryDelay(lease.AttemptCount), stoppingToken); } + catch (AiGenerationException failure) + { + await store.FailAsync(lease.OperationId, lease.LeaseToken, failure.Retryable, failure.Category, + failure.Message, RetryDelay(lease.AttemptCount), failure.Provider, failure.Model, + failure.RouteReason, stoppingToken); + } catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) { var row = await store.GetAsync(lease.OperationId, stoppingToken); diff --git a/JobTrackerApi/Services/AiPrivacyPolicy.cs b/JobTrackerApi/Services/AiPrivacyPolicy.cs index f6d29b9..9b10b43 100644 --- a/JobTrackerApi/Services/AiPrivacyPolicy.cs +++ b/JobTrackerApi/Services/AiPrivacyPolicy.cs @@ -9,6 +9,7 @@ public sealed record AiPrivacyDecision(bool ExternalProcessingAllowed, string Pr public sealed class AiPrivacyPolicy(IConfiguration configuration, IServiceScopeFactory scopes) { public const string ExternalAllowedHeader = "X-Ai-External-Allowed"; + public const string TaskTypeHeader = "X-Ai-Task-Type"; public string ExternalProvider { @@ -19,8 +20,18 @@ public sealed class AiPrivacyPolicy(IConfiguration configuration, IServiceScopeF } } + public string RoutingMode + { + get + { + var mode = (configuration["Ai:RoutingMode"] ?? "local_first").Trim().ToLowerInvariant(); + return mode is "local_only" or "local_first" or "external_only" ? mode : "local_only"; + } + } + public bool ExternalProcessingAvailable => configuration.GetValue("Ai:ExternalProcessingEnabled", false) + && RoutingMode != "local_only" && ExternalProvider is "gemini" or "groq"; public async Task EvaluateAsync(string? userId, CancellationToken cancellationToken = default) @@ -44,7 +55,8 @@ public sealed class AiPrivacyPolicy(IConfiguration configuration, IServiceScopeF public sealed class AiPrivacyHeaderHandler( IHttpContextAccessor httpContext, - AiPrivacyPolicy privacyPolicy) : DelegatingHandler + AiPrivacyPolicy privacyPolicy, + AiOperationExecutionScope executionScope) : DelegatingHandler { protected override async Task SendAsync( HttpRequestMessage request, @@ -52,10 +64,18 @@ public sealed class AiPrivacyHeaderHandler( { if (request.RequestUri?.AbsolutePath.StartsWith("/cv/", StringComparison.OrdinalIgnoreCase) == true) { - var userId = httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? httpContext.HttpContext?.User.FindFirstValue("sub"); - var decision = await privacyPolicy.EvaluateAsync(userId, cancellationToken); - if (decision.ExternalProcessingAllowed) + var operationContext = executionScope.Current; + var externalAllowed = operationContext?.EffectivePrivacyPolicy == "external_allowed"; + if (operationContext is not null) + request.Headers.TryAddWithoutValidation(AiPrivacyPolicy.TaskTypeHeader, operationContext.Lease.TaskType); + else + { + var userId = httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? httpContext.HttpContext?.User.FindFirstValue("sub"); + externalAllowed = (await privacyPolicy.EvaluateAsync(userId, cancellationToken)).ExternalProcessingAllowed; + } + + if (externalAllowed) request.Headers.TryAddWithoutValidation(AiPrivacyPolicy.ExternalAllowedHeader, "true"); } diff --git a/JobTrackerApi/Services/AiWorkspaceService.cs b/JobTrackerApi/Services/AiWorkspaceService.cs index 685265b..7be6207 100644 --- a/JobTrackerApi/Services/AiWorkspaceService.cs +++ b/JobTrackerApi/Services/AiWorkspaceService.cs @@ -134,12 +134,23 @@ public sealed class AiWorkspaceService : IAiWorkspaceService }; var prompt = $"{instruction} {Guardrail}"; - var result = await _ai.SummarizeSectionAsync(prompt, source, max, 120); + AiGenerationResult? generation; + try + { + generation = await _ai.GenerateSectionWithMetadataAsync(prompt, source, max, 120, ct); + } + catch (AiGenerationException) + { + throw new AiUnavailableException("The AI service could not generate this right now. Please try again in a moment."); + } + var result = generation?.Text; if (string.IsNullOrWhiteSpace(result)) { throw new AiUnavailableException("The AI service could not generate this right now. Please try again in a moment."); } + var actualProvider = string.IsNullOrWhiteSpace(generation?.Provider) ? provider : generation.Provider; + var interaction = new AiInteraction { OwnerUserId = ownerUserId, @@ -147,8 +158,17 @@ public sealed class AiWorkspaceService : IAiWorkspaceService Module = module, Mode = mode, Title = title, - Provider = string.IsNullOrWhiteSpace(provider) ? "ai-service" : provider, - ResultJson = JsonSerializer.Serialize(new { text = result.Trim() }, Json), + Provider = string.IsNullOrWhiteSpace(actualProvider) ? "ai-service" : actualProvider, + ResultJson = JsonSerializer.Serialize(new + { + text = result.Trim(), + meta = new + { + model = generation?.Model, + fallbackReason = generation?.FallbackReason, + routeReason = generation?.RouteReason, + }, + }, Json), InputCharacterCount = prompt.Length + source.Length, OutputCharacterCount = result.Trim().Length, EstimatedTokenCount = EstimateTokens(prompt.Length + source.Length + result.Trim().Length), diff --git a/JobTrackerApi/Services/SummarizerService.cs b/JobTrackerApi/Services/SummarizerService.cs index acb141b..5266e78 100644 --- a/JobTrackerApi/Services/SummarizerService.cs +++ b/JobTrackerApi/Services/SummarizerService.cs @@ -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 SummarizeAsync(string text, int maxLength = 150, int minLength = 30); @@ -71,6 +93,17 @@ namespace JobTrackerApi.Services public interface ISummarizerService : IAiService { new Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40); + + async Task 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 SummarizeAsync(string text, int maxLength = 150, int minLength = 30) { if (string.IsNullOrWhiteSpace(text)) return null; return await SummarizeCoreAsync(text, maxLength, minLength); } - public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) + public async Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) { - if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult(null); - return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength); + try + { + return (await GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength))?.Text; + } + catch (AiGenerationException) + { + return null; + } + } + + public Task GenerateSectionWithMetadataAsync( + string instruction, + string text, + int maxLength = 180, + int minLength = 40, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult(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 RewriteCoreAsync(string instruction, string text, int maxLength, int minLength) + private async Task 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(key, out var cached)) + if (_cache.TryGetValue(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); } } diff --git a/JobTrackerApi/Services/UserOperationStore.cs b/JobTrackerApi/Services/UserOperationStore.cs index 70c41d1..dc7e5d7 100644 --- a/JobTrackerApi/Services/UserOperationStore.cs +++ b/JobTrackerApi/Services/UserOperationStore.cs @@ -159,10 +159,23 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr cancellationToken); } - public async Task CompleteAsync(Guid operationId, string leaseToken, string? resultReference, CancellationToken cancellationToken) + public Task CompleteAsync(Guid operationId, string leaseToken, string? resultReference, CancellationToken cancellationToken) + => CompleteAsync(operationId, leaseToken, resultReference, null, null, null, cancellationToken); + + public async Task CompleteAsync( + Guid operationId, + string leaseToken, + string? resultReference, + string? provider, + string? model, + string? completionStage, + CancellationToken cancellationToken) { EnsureOwnerScope(); ValidateOptional(resultReference, 256, nameof(resultReference)); + ValidateOptional(provider, 128, nameof(provider)); + ValidateOptional(model, 128, nameof(model)); + ValidateOptional(completionStage, 64, nameof(completionStage)); var now = UtcNow; await using var transaction = await BeginTransactionAsync(cancellationToken); var operation = await db.UserOperations.AsNoTracking().FirstOrDefaultAsync(item => item.Id == operationId, cancellationToken); @@ -173,6 +186,9 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr .ExecuteUpdateAsync(setters => setters .SetProperty(operation => operation.Status, OperationStatuses.Succeeded) .SetProperty(operation => operation.ResultReference, resultReference) + .SetProperty(operation => operation.Provider, provider) + .SetProperty(operation => operation.Model, model) + .SetProperty(operation => operation.ProgressStage, completionStage) .SetProperty(operation => operation.CompletedAtUtc, now) .SetProperty(operation => operation.ProgressPercent, 100) .SetProperty(operation => operation.LeaseToken, (string?)null) @@ -187,11 +203,27 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr return affected; } - public async Task FailAsync(Guid operationId, string leaseToken, bool retryable, string category, string message, TimeSpan retryDelay, CancellationToken cancellationToken) + public Task FailAsync(Guid operationId, string leaseToken, bool retryable, string category, string message, TimeSpan retryDelay, CancellationToken cancellationToken) + => FailAsync(operationId, leaseToken, retryable, category, message, retryDelay, null, null, null, cancellationToken); + + public async Task FailAsync( + Guid operationId, + string leaseToken, + bool retryable, + string category, + string message, + TimeSpan retryDelay, + string? provider, + string? model, + string? progressStage, + CancellationToken cancellationToken) { EnsureOwnerScope(); ValidateRequired(category, 64, nameof(category)); ValidateRequired(message, 512, nameof(message)); + ValidateOptional(provider, 128, nameof(provider)); + ValidateOptional(model, 128, nameof(model)); + ValidateOptional(progressStage, 64, nameof(progressStage)); if (retryDelay < TimeSpan.Zero || retryDelay > TimeSpan.FromHours(1)) throw new ArgumentOutOfRangeException(nameof(retryDelay)); await using var transaction = await BeginTransactionAsync(cancellationToken); var operation = await db.UserOperations.AsNoTracking() @@ -209,6 +241,9 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr .SetProperty(item => item.CompletedAtUtc, canRetry ? null : now) .SetProperty(item => item.FailureCategory, category) .SetProperty(item => item.FailureMessage, message) + .SetProperty(item => item.Provider, provider) + .SetProperty(item => item.Model, model) + .SetProperty(item => item.ProgressStage, progressStage) .SetProperty(item => item.LeaseToken, (string?)null) .SetProperty(item => item.LeaseExpiresAtUtc, (DateTime?)null), cancellationToken); @@ -290,6 +325,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr operation.FailureCategory = null; operation.FailureMessage = null; operation.ResultReference = null; + operation.Provider = null; + operation.Model = null; operation.ProgressStage = null; operation.ProgressPercent = null; var existingNotification = await db.UserNotifications.FirstOrDefaultAsync(item => item.OperationId == operationId, cancellationToken); diff --git a/JobTrackerApi/appsettings.json b/JobTrackerApi/appsettings.json index 0c46dda..54409f7 100644 --- a/JobTrackerApi/appsettings.json +++ b/JobTrackerApi/appsettings.json @@ -19,7 +19,8 @@ }, "Ai": { "ExternalProcessingEnabled": false, - "ExternalProvider": "ollama" + "ExternalProvider": "ollama", + "RoutingMode": "local_first" }, "AiQueue": { "WorkerConcurrency": 1, diff --git a/docker-compose.yml b/docker-compose.yml index 31c8432..d6fd47a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,7 @@ services: # External processing requires this admin gate AND a per-user opt-in. Default is local-only. - Ai__ExternalProcessingEnabled=${EXTERNAL_AI_ENABLED:-false} - Ai__ExternalProvider=${AI_PROVIDER:-ollama} + - Ai__RoutingMode=${AI_ROUTING_MODE:-local_first} # Shared secret for calls to ai-service. Must match AI_SERVICE_TOKEN below. # Quoted: the `:?` message contains a colon-space, which YAML would otherwise read as a map. - "Ai__ServiceToken=${AI_SERVICE_TOKEN:?AI_SERVICE_TOKEN must be set - generate one with python -c 'import secrets; print(secrets.token_hex(32))'}" @@ -151,10 +152,14 @@ services: # and no duplicate Ollama container is created. - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434} - OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b} - # AI provider for heavy /cv/* calls: ollama (default) | gemini | groq. - # Set AI_PROVIDER=gemini + GEMINI_API_KEY in prod to offload a weak local GPU. + # External fallback provider for heavy /cv/* calls. Ollama remains primary by default. - AI_PROVIDER=${AI_PROVIDER:-ollama} - EXTERNAL_AI_ENABLED=${EXTERNAL_AI_ENABLED:-false} + - AI_ROUTING_MODE=${AI_ROUTING_MODE:-local_first} + - EXTERNAL_AI_ALLOWED_TASKS=${EXTERNAL_AI_ALLOWED_TASKS:-cv-normalize,cv-classify,cv-rewrite} + - EXTERNAL_AI_MAX_PROMPT_CHARS=${EXTERNAL_AI_MAX_PROMPT_CHARS:-24000} + - LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD=${LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD:-3} + - LOCAL_AI_CIRCUIT_OPEN_SECONDS=${LOCAL_AI_CIRCUIT_OPEN_SECONDS:-30} - GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash} - GROQ_API_KEY=${GROQ_API_KEY:-} diff --git a/docs/architecture/ai-career-assistant.md b/docs/architecture/ai-career-assistant.md index 26e05e9..d6ec1e8 100644 --- a/docs/architecture/ai-career-assistant.md +++ b/docs/architecture/ai-career-assistant.md @@ -65,16 +65,14 @@ and all-time totals; the workspace displays the monthly calls and estimated toke ## Provider abstraction -Generation goes through the existing `ISummarizerService` → ai-service, which routes to the active -provider (`AI_PROVIDER`: ollama | gemini | groq) — production can offload a weak local GPU to a cloud -provider. Each `AiInteraction` records the resolved provider for transparency, and `GET …/ai/modules` -returns the current provider so the UI can show it. +Generation goes through `ISummarizerService` to the ai-service. Ollama is primary; `AI_PROVIDER` names +only the optional external fallback candidate. Fallback is sequential and requires administrator +enablement, task approval, live Pro/user consent and the prompt cost/privacy ceiling. Each +`AiInteraction` records the provider returned by the sidecar, plus bounded model/route metadata in +`ResultJson.meta`; configuration alone is not treated as proof that a provider executed. -**Per-request user-selectable providers** (module 8's "users can choose provider") is a plumbing -extension, not yet wired end-to-end: it needs (a) ai-service to accept a per-request `provider` -override and (b) an API **key configured for each selectable provider**. Both are deployment/credential -concerns (a live paid key per provider), so the code path is left as a documented extension point -rather than shipped half-configured. The abstraction already isolates the change to one method. +Per-request user-selectable providers remain intentionally unsupported. The server-side privacy +policy selects a route, not the browser, and provider credentials remain deployment-only. ## Extension points @@ -83,8 +81,8 @@ rather than shipped half-configured. The abstraction already isolates the change - **New cover-letter tone**: add to `CoverLetterModes` + `ModeGuidance`. - **Structured (JSON) results**: swap a module's prompt for JSON and parse into `ResultJson.meta`; the UI already renders `result.text` as markdown and can read `meta`. -- **User-selectable provider**: thread a `provider` param through `ISummarizerService` → - ai-service; gate on the provider having a configured key (see above). +- **Provider policy**: add task types to the explicit server-side allowlist only after their payload, + accounting and production checks pass; do not add browser provider overrides. ## Security diff --git a/docs/architecture/ai-privacy.md b/docs/architecture/ai-privacy.md index 337d9b9..b528dff 100644 --- a/docs/architecture/ai-privacy.md +++ b/docs/architecture/ai-privacy.md @@ -1,6 +1,6 @@ # AI privacy and external-processing policy -Updated: 2026-08-03 +Updated: 2026-08-09 The default execution mode is local-only. External processing of `/cv/*` payloads requires all of: @@ -11,9 +11,10 @@ The default execution mode is local-only. External processing of `/cv/*` payload 5. AI enabled in the user's server-side settings; and 6. the user's explicit `ExternalAiProcessingAllowed` opt-in. -The backend adds `X-Ai-External-Allowed: true` only after that live policy check. The sidecar otherwise routes `/cv/*` to Ollama even when an external provider is configured. `/summarize` always uses the local summarization model. Provider keys remain server-side and are never returned by the settings API. +The backend adds `X-Ai-External-Allowed: true` only after that live policy check. Durable workers use the same header only from their admitted policy snapshot after a live execution-time recheck, and also send the bounded task type. The sidecar otherwise routes `/cv/*` to Ollama even when an external provider is configured. `/summarize` always uses the local summarization model. Provider keys remain server-side and are never returned by the settings API. `GET/PUT /api/ai/settings` owns the user settings. Disabling AI takes effect on the next protected request and is also rechecked by the current enrichment and queued-CV workers. Existing users migrate with AI enabled to preserve current behaviour; external consent always defaults to false. -This is the privacy admission foundation, not the final routing system. AI-001/AI-002 must carry an immutable policy snapshot into durable operations, recheck it at execution, record the actual provider/reason, add bounded local-first fallback triggers and minimize each external payload. Background CV work currently fails safe to local because it has no HTTP user context. +AI-002 makes provider execution local-first and sequential. External fallback additionally requires an allowed task and stays below the configured per-request prompt ceiling. Actual provider/model/route metadata is returned by the sidecar and persisted by AI Workspace or durable operations. The process-local circuit and health diagnostics expose no prompt or credential data. +This is not permission to enable external processing globally. New durable task types remain local until explicitly allowlisted; AI-003/004 must minimize their exact payloads and complete cross-feature monthly accounting before rollout. `EXTERNAL_AI_ENABLED=false` or `Ai:RoutingMode=local_only` is the immediate rollback switch. diff --git a/docs/architecture/durable-operations.md b/docs/architecture/durable-operations.md index 76f1e2e..a979a8a 100644 --- a/docs/architecture/durable-operations.md +++ b/docs/architecture/durable-operations.md @@ -22,4 +22,6 @@ Terminal notifications are described in `notifications.md`. Authenticated owner `AiOperationAdmission` now provides the shared AI producer boundary: it rechecks live Pro/AI settings, snapshots `local_only` or `external_allowed`, applies per-user/global capacity, assigns a deadline and returns the stable `/api/operations/{id}` status URL. It stores only subject type/ID, never raw CV/email/prompt text. The current process-local admission semaphore is correct for the documented single-backend deployment; multi-replica rollout requires a database capacity reservation. -`AiOperationWorker` claims only registered task types by priority, enters the explicit owner scope, rechecks entitlement/privacy/cancellation, runs one inference by default, heartbeats the lease, enforces a timeout, classifies bounded retry/permanent failure and commits the existing terminal notification. `Workers:AiOperationsEnabled` defaults false and no production feature handler is registered yet. AI-003/004 add the Strategy/CV handlers and 202 producer endpoints; AI-002 adds provider/model concurrency, circuit and actual-provider provenance. +`AiOperationWorker` claims only registered task types by priority, enters the explicit owner scope, rechecks entitlement/privacy/cancellation, runs one inference by default, heartbeats the lease, enforces a timeout, classifies bounded retry/permanent failure and commits the existing terminal notification. Its execution scope carries the rechecked privacy/task decision to the shared sidecar client. Successful and failed provider attempts persist bounded provider/model/route provenance in existing operation fields. + +`Workers:AiOperationsEnabled` defaults false and no production feature handler is registered yet. AI-002 supplies sequential local-first routing and a process-local single-model circuit; AI-003/004 add the Strategy/CV handlers and 202 producer endpoints. The current one-worker default is the local-model concurrency limit until PROD-003 benchmarks justify anything else. diff --git a/docs/audits/audit-progress.md b/docs/audits/audit-progress.md index a0044db..4a59ce0 100644 --- a/docs/audits/audit-progress.md +++ b/docs/audits/audit-progress.md @@ -463,3 +463,14 @@ Status: Implemented; real-handler/browser/production verification incomplete (20 - **Findings:** no new schema/raw private queue payload; multi-replica capacity needs a future database reservation; actual 202 producers and provider controls remain AI-003/004 and AI-002. - **Blockers:** browser, MariaDB and production unavailable; worker intentionally off. - **Next phase:** AI-002 Ollama adapter and central local-first routing. + +## Post-audit programme execution — AI-002 + +Status: Implemented; browser/model/provider/production verification incomplete (2026-08-09). + +- **Work completed:** revalidated every AI endpoint/caller; replaced direct configured-provider dispatch with one sequential local-first router; added task/consent/config/prompt-cost gates, bounded circuit/health state, typed sanitized failures and actual provider/model/route persistence for AI history and durable operations. +- **Commands/evidence:** verification-log V-098–V-100; `docs/verification/ai-002-provider-routing.md`; `docs/audits/evidence/ai-002/README.md`. +- **Findings:** no schema/dependency change and no provider race. New durable tasks fail safe to local until allowlisted. Per-request prompt ceiling exists, but complete monthly cross-feature accounting remains incomplete. Circuit state is process-local for the current single-sidecar design. +- **Checks that remain:** real AI-003/004 handlers/producers; selected-model benchmark; browser disclosure; MariaDB; controlled synthetic external fallback; production health/restart/canary/rollback. +- **Blockers and limitations:** no browser, production access, model benchmark or provider authority/configuration; no real/private input used. +- **Next phase:** AI-003 Strategy Snapshot durable-operation migration. diff --git a/docs/audits/evidence/ai-002/README.md b/docs/audits/evidence/ai-002/README.md new file mode 100644 index 0000000..0ac4dc8 --- /dev/null +++ b/docs/audits/evidence/ai-002/README.md @@ -0,0 +1,8 @@ +# AI-002 evidence + +- `../../../verification/ai-002-provider-routing.md` — route matrix, implementation and remaining gates. +- `../../verification-log.md` entries V-098 through V-100 — exact commands and results. +- `../../../../tools/summarizer/tests/test_app.py` — fake-transport local-first, consent, circuit, schema, cost-cap and failure tests. +- `../../../../JobTrackerApi.Tests/AiOperationQueueTests.cs`, `AiPrivacyPolicyTests.cs`, `SummarizerServiceTests.cs` and `AiWorkspaceTests.cs` — policy propagation and provenance tests. + +No screenshot was captured because localhost browser access remains denied by administrator policy. No model/provider call, paid service, real private data or production system was used. diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index f7ac278..4653d2b 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -103,6 +103,9 @@ Only non-destructive commands are run. Commands that restore dependencies may po | V-095 | Full backend `dotnet test`; full frontend `npm test -- --runInBand`; `npm run build`; `docker compose config --quiet`; `git diff --check` | Repository root / `job-tracker-ui` | POL-002 regression, production build, deployment syntax and patch hygiene | PASS — backend 576/576; frontend 47 suites/158 tests; build/config/diff pass | Expected unset optional Compose variables and line-ending notices only. Temporary API launch command was rejected before execution by policy; no service started | Runtime/browser/production verification blocked | | V-096 | Source re-read plus `dotnet test ... --filter "...AiOperationQueueTests|...UserOperationStoreTests|...OperationsControllerTests"` | Repository root | AI-001 admission, capacity, priority, claim, owner execution, policy recheck and existing state/API regression | PASS — 17/17 final | Initial 15/15 and 16/16 passes preceded priority and retry/downgrade additions | N/A | | V-097 | Full `dotnet test`; `docker compose config --quiet`; `git diff --check` | Repository root | AI-001 full backend regression and deployment/patch syntax | PASS — backend 581/581; config/diff pass | Expected unset optional Compose variables and line-ending notices only; worker remains default-off with no real task handler | Browser/provider/production verification pending later feature packages | +| V-098 | Bounded `rg`/`Get-Content` of every `/summarize`, `/extract-text`, `/cv/*`, `ISummarizerService`, privacy-header, operation-worker, provider-config, Compose and test path | Repository root | Revalidate AI-002 route convergence and existing mitigations before design | PASS — deterministic and summarize/extract paths remain local; all generative CV paths converge in the sidecar; configured external provider was direct rather than fallback; actual provider/failure provenance was lost | Browser/provider behavior was not inferred from source; route matrix records code-inspected status | Confirmed implementation gap | +| V-099 | `dotnet test JobTrackerApi.Tests\JobTrackerApi.Tests.csproj --no-restore --filter "FullyQualifiedName~AiOperationQueueTests\|FullyQualifiedName~AiPrivacyPolicyTests\|FullyQualifiedName~SummarizerServiceTests\|FullyQualifiedName~AiWorkspaceTests"`; `python -m pytest -q` | Repository root / `tools/summarizer` | Verify local-first order, policy/task propagation, schema/circuit/cost/failure routing and actual provenance | PASS — backend 26/26; sidecar 22/22 | First sidecar run passed 15/18 and correctly failed three obsolete external-first expectations; tests were updated to the new approved policy, then extended. No network/provider call occurred | Expected test-contract transition resolved | +| V-100 | Full `dotnet test JobTrackerApi.Tests\JobTrackerApi.Tests.csproj --no-restore`; `python -m pytest -q`; synthetic-token `docker compose config --quiet`; `git diff --check` | Repository root / `tools/summarizer` | AI-002 wider regression, deployment syntax and patch hygiene | PASS — backend 588/588; sidecar 22/22; Compose/diff pass | Five existing SWIG deprecation warnings; Docker config-file access and unset optional-variable warnings; line-ending notices only. Worker/external gate remain off | Browser, MariaDB, selected-model, real-provider and production checks remain blocked | ## Secret-scan commands diff --git a/docs/production/production-ai-validation.md b/docs/production/production-ai-validation.md index 36b34ce..26d308a 100644 --- a/docs/production/production-ai-validation.md +++ b/docs/production/production-ai-validation.md @@ -33,3 +33,5 @@ PROD-002 provides a code-derived P0–P3 workload/privacy inventory and 19-case POL-002 now persists user AI/privacy preferences and requires independent backend/sidecar administrator gates, live Pro entitlement, AI enabled and explicit consent before `/cv/*` can use a configured external provider. The default remains local and mocked routing checks pass. No external/paid provider or production egress was exercised; durable policy snapshots, actual-provider/reason recording, payload minimization, cost controls and local-first fallback triggers remain AI-001/002 rollout gates. See `docs/verification/pol-002-ai-privacy.md`. AI-001 adds the reusable bounded database-backed admission/worker layer over OPS-001A/B/C. It defaults to one worker and remains switched off; no real handler, model or external provider was invoked. Production activation remains blocked until AI-002 provider controls, AI-003/004 typed handlers, browser verification, MariaDB execution, monitoring and rollback/canary evidence pass. See `docs/verification/ai-001-durable-ai-queue.md`. + +AI-002 now enforces sequential local-first routing, explicit task/consent/config/prompt-cost gates, a bounded process-local circuit and actual provider/model/route provenance. Backend 588/588 and sidecar fake-transport 22/22 pass. This is repository evidence only: no model/provider call or production egress occurred, the worker remains off, and PROD-001/003 plus AI-003/004 remain mandatory before any rollout. See `docs/verification/ai-002-provider-routing.md`. diff --git a/docs/verification/ai-001-durable-ai-queue.md b/docs/verification/ai-001-durable-ai-queue.md index a188ec5..4d686aa 100644 --- a/docs/verification/ai-001-durable-ai-queue.md +++ b/docs/verification/ai-001-durable-ai-queue.md @@ -24,8 +24,7 @@ Status: `IMPLEMENTED — NOT VERIFIED`. ## Remaining gates - AI-003/004 must register real Strategy/CV handlers and return actual 202 responses; no generic create API was exposed because it would bypass task ownership/policy. -- AI-002 must add provider/model concurrency, circuit health, provider/reason/model recording, payload minimization and external fallback decisions. +- AI-002 added sequential local-first routing, single-model circuit health, provider/reason/model recording and task/prompt fallback gates. AI-003/004 still own producer-specific payload minimization and accounting. - Browser refresh/double-click/cancel/retry must be repeated against each real producer. - Worker remains off; MariaDB and production canary/restart/queue telemetry are unavailable. - The process-local capacity gate assumes one backend replica. Add a database reservation only before multi-replica rollout. - diff --git a/docs/verification/ai-002-provider-routing.md b/docs/verification/ai-002-provider-routing.md new file mode 100644 index 0000000..8ba3493 --- /dev/null +++ b/docs/verification/ai-002-provider-routing.md @@ -0,0 +1,59 @@ +# AI-002 verification — local-first provider routing + +Updated: 2026-08-09 + +Status: `IMPLEMENTED — NOT VERIFIED`. + +## Confirmed route matrix + +| Sidecar path | Reachable application callers | Workload/privacy | Provider policy | +|---|---|---|---| +| `/summarize` | job create/detail/refresh, job-enrichment worker, health probe | `JOB-SUMMARY` / `HEALTH-PROBE`; P0–P2 depending on source | local DistilBART only; never external | +| `/extract-text` | profile-CV upload and selected application attachments | `DOC-EXTRACT`; P2 | local parser/OCR only; never external | +| `/cv/normalize` | profile-CV reconstruction/normalization | `CV-NORMALIZE`; P2 | primary Ollama; permitted external fallback only after all gates | +| `/cv/classify-block` | ambiguous profile-CV block classification | `CV-CLASSIFY`; P2 | primary Ollama; permitted external fallback only after all gates | +| `/cv/rewrite` | profile/CV rewrite, CV Builder assistance, candidate fit/focus/strategy/application drafting, follow-up drafting, selected attachment context and AI Workspace modules | `PROFILE-EXTRACT`, `STRATEGY`, `CV-TAILOR`, `APPLICATION-DRAFT`, `FOLLOWUP-DRAFT`, `INTERVIEW`, `WRITING`; P2 | primary Ollama; permitted external fallback only after all gates | + +Deterministic match, profile diff, keyword, email-classification and application-intelligence paths do not enter the provider router. Existing synchronous `/cv/*` calls use the endpoint task identifier. Durable handlers receive their typed operation task through `AiOperationExecutionScope`; a new operation task remains local until it is explicitly added to `EXTERNAL_AI_ALLOWED_TASKS`. + +## Implemented policy + +`tools/summarizer/app.py` is the one generation router. For each generative CV request it: + +1. validates routing mode, task allowlist, administrator enablement, backend permission, external provider configuration and a per-request external prompt ceiling; +2. uses Ollama first in the default `local_first` mode; +3. validates non-empty text or structured JSON before accepting the local result; +4. records consecutive local failures in a bounded process-local circuit; +5. calls one external provider only after an eligible local failure/circuit-open decision and only when every gate still passes; +6. never races local and external calls; and +7. returns sanitized provider/model/route/fallback headers for persistence and diagnostics. + +`local_only`, `local_first` and `external_only` are supported. Invalid modes fail closed to `local_only`. `external_only` still requires explicit backend permission and an allowed task. The default remains `local_first`, while `EXTERNAL_AI_ENABLED=false` makes it effectively local-only. + +The external prompt ceiling is a per-request cost/privacy control, not a monthly spend ledger. Current plan-level monthly accounting covers AI Workspace interactions only; complete cross-feature accounting remains a POL-001/AI-003/AI-004 rollout gate. + +## Backend integration + +- `AiPrivacyHeaderHandler` uses live request policy for synchronous calls and the worker's rechecked immutable policy/task for durable calls. +- `SummarizerService.GenerateSectionWithMetadataAsync` preserves cancellation and returns actual provider/model/fallback metadata. +- Provider failures use a typed, sanitized `AiGenerationException`; legacy string callers retain their previous `null` behavior. +- `AiWorkspaceService` stores the actual provider and bounded model/route metadata instead of treating deployment configuration as execution evidence. +- `AiOperationWorker` persists provider, model and route stage on success and retryable/permanent provider failure. Existing operation APIs continue to hide provider internals while exposing the bounded progress stage. +- No schema migration or dependency change was needed; existing nullable `UserOperations.Provider`, `Model` and `ProgressStage` columns are reused. + +## Automated evidence + +- Focused backend provider/privacy/queue/history tests: 26/26. +- Full backend: 588/588. +- Sidecar: 22/22 with fake transports only. +- Compose configuration and `git diff --check`: pass; expected missing optional-environment and line-ending warnings only. +- Tests cover local success, sequential fallback, missing consent/key, invalid JSON, prompt ceiling, open circuit, external outage, unapproved durable task, external-only permission, actual metadata, sanitized failures and operation persistence. + +## Remaining gates + +- No Ollama model, external provider, paid API, real CV/email, production service or production egress was used. +- PROD-001/003 must identify hardware and benchmark/select the primary and optional secondary local model. No secondary local model is configured yet. +- AI-003/004 must register real Strategy/CV handlers, choose explicit task allowlists, pass cancellation through their work and verify retry/deduplication with durable results. +- The local circuit is intentionally process-local for the current single-sidecar deployment. Multi-replica or restart-persistent circuit coordination requires measured need and a separate design. +- The existing named HTTP client still has a 30-second transport timeout for synchronous callers. AI-003/004 must move long work to durable handlers and align their cancellation/transport budget; increasing the synchronous timeout is not accepted as the timeout fix. +- Browser disclosure, MariaDB execution, controlled synthetic provider fallback, production health/circuit telemetry and rollback/canary checks remain unverified. diff --git a/docs/verification/application-action-matrix.md b/docs/verification/application-action-matrix.md index e749fa9..23149e0 100644 --- a/docs/verification/application-action-matrix.md +++ b/docs/verification/application-action-matrix.md @@ -34,5 +34,9 @@ This is the rolling action-level evidence index. `PASS (automated/runtime)` is n | Durable AI | Pro admission, idempotent status URL and bounded capacity | PASS (real SQLite + synthetic subject IDs) | BLOCKED until real producer | NOT RUN; worker off | `ai-001-durable-ai-queue.md` | | Durable AI | priority/task-filtered atomic claim and owner-scoped success | PASS (fake handler, real operation/notification state) | N/A | NOT RUN | `ai-001-durable-ai-queue.md` | | Durable AI | retryable failure, downgrade recheck, lease/cancel/restart recovery | PASS (automated) | BLOCKED until real producer | NOT RUN | `ai-001-durable-ai-queue.md` | +| AI routing | local primary success and no parallel external call | PASS (fake transports) | N/A | NOT RUN | `ai-002-provider-routing.md` | +| AI routing | consent/admin/task/config/prompt-cap fallback denial | PASS (backend + sidecar policy tests) | BLOCKED | NOT RUN | `ai-002-provider-routing.md` | +| AI routing | schema/local-outage/circuit fallback and external failure | PASS (fake transports) | BLOCKED | NOT RUN | `ai-002-provider-routing.md` | +| AI routing | actual provider/model/route persistence on success/failure | PASS (real SQLite operation/history state; fake provider) | BLOCKED until real producer | NOT RUN | `ai-002-provider-routing.md` | Remaining product actions are `NOT STARTED` in the master plan and will be added as their work packages enter verification. Browser localhost is currently denied by administrator policy; production access is not documented/configured. diff --git a/docs/verification/pol-002-ai-privacy.md b/docs/verification/pol-002-ai-privacy.md index e28577c..77b782b 100644 --- a/docs/verification/pol-002-ai-privacy.md +++ b/docs/verification/pol-002-ai-privacy.md @@ -33,5 +33,4 @@ Status: `IMPLEMENTED — NOT VERIFIED`. - No external provider, paid service, production environment or real private data was used. - MariaDB migration execution remains unavailable. - Direct clean `dotnet ef database update` fails in the pre-existing historical SQLite migration chain before this migration (`AddJobEntityAndProspectStages` expects a reconciler-added column). The application startup reconciler path was not exercised because the local process-launch command was blocked by execution policy. -- Background operations do not yet carry a policy snapshot; they fail safe to local. AI-001/AI-002 own durable admission/recheck, actual-provider/reason recording, cost controls, payload minimization and bounded local-first fallback triggers. - +- AI-001/002 now carry admitted/rechecked policy/task context into durable calls, enforce bounded local-first fallback and record actual provider/model/route metadata. AI-003/004 still own task-specific payload minimization, complete cross-feature monthly accounting and real producer verification. diff --git a/docs/work-programmes/decisions.md b/docs/work-programmes/decisions.md index b6fa331..9d606b5 100644 --- a/docs/work-programmes/decisions.md +++ b/docs/work-programmes/decisions.md @@ -279,3 +279,13 @@ - **Consequences:** AI-003/004 only add typed handlers/producers. Current capacity serialization is process-local for the documented single-backend deployment; database reservation is required before multiple backend replicas. Provider/model semaphores and circuit/provenance remain AI-002 responsibilities. - **User approval required:** No; this follows both programmes' explicit instruction to reuse the smallest reliable existing infrastructure. - **Reversible:** Yes. Keep the worker switch false, remove admission/worker registrations, and retain operation rows/API history. No new schema was added in this slice. + +## DEC-029 — One sequential sidecar router owns local-first fallback + +- **Date:** 2026-08-09 +- **Decision:** Keep provider execution behind the existing sidecar boundary, make Ollama the default primary, permit at most one sequential external fallback, and carry the backend's rechecked privacy/task decision through the AI-001 execution scope. Reuse existing operation provider/model/progress fields for provenance; add no queue/provider schema or dependency. +- **Reason/evidence:** every generative `/cv/*` path already converges on one `_provider_generate` family, while deterministic tasks and `/summarize` must remain local. Sidecar fake-transport tests prove local success, consent/config/task/cost denials, schema fallback, circuit behavior, external failure and no parallel duplicate call. Backend tests prove policy propagation and success/failure provenance. +- **Alternatives considered:** provider selection in each controller; browser-selected providers; a second provider abstraction in .NET; simultaneous local/cloud racing; a new circuit/attempt table; increasing synchronous timeouts. These scatter policy, expose authority, duplicate the established boundary, risk double charge/output, add unneeded schema, or mask the queued-operation root cause. +- **Consequences:** `AI_ROUTING_MODE` supports `local_only`, `local_first` and explicitly gated `external_only`; invalid values fail closed. New durable task IDs stay local until allowlisted. The current circuit is process-local and one AI worker is the effective single-model concurrency limit. Per-request prompt size limits external spend/exposure, but complete monthly cross-feature accounting and model selection remain rollout gates. +- **User approval required:** No; this directly implements the approved local-first programme without invoking a provider or production service. +- **Reversible:** Yes. Set `EXTERNAL_AI_ENABLED=false` or `AI_ROUTING_MODE=local_only`; the older `AI_PROVIDER`/model configuration is retained. Existing nullable operation fields and AI history remain readable. diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 418a04d..f0063a5 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -1,21 +1,21 @@ # JobTracker master programme progress -Updated: 2026-08-03 +Updated: 2026-08-09 -- **Overall programme status:** Active. Six packages are locally verified; eleven packages including POL-001/002 and AI-001 are implemented with automated/runtime evidence but blocked from later browser/provider/production gates; AI-002 is now in progress. -- **Current work package:** `AI-002` — Ollama adapter and local-first provider routing (`IN PROGRESS`), extending the POL-002 boundary and AI-001 execution context. +- **Overall programme status:** Active. Six packages are locally verified; twelve packages through AI-002 are implemented with automated/runtime evidence but blocked from later browser/provider/production gates; AI-003 is now in progress. +- **Current work package:** `AI-003` — Strategy Snapshot durable-operation migration (`IN PROGRESS`), reusing AI-001 operations and AI-002 routing rather than creating a Strategy-specific queue. - **Completed work packages:** None are `DONE`; all repository security packages still have applicable browser, provider and/or production gates. - **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001 and PROD-002 (`VERIFIED LOCALLY`). -- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002 and AI-001 (`IMPLEMENTED — NOT VERIFIED`): foundations through durable bounded AI execution pass local checks; real handlers, browser, provider, accounting and production gates remain. +- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001 and AI-002 (`IMPLEMENTED — NOT VERIFIED`): foundations through bounded local-first routing pass local checks; real producers, browser, selected-model/provider, accounting and production gates remain. - **Production-verified work:** None. - **Blocked work:** SEC-006 requires explicit internet/package-index permission; PROD-001/003/004 and REL-001 require documented production access and unfinished dependencies. Browser access was denied by the browser admin policy check; SMTP/MariaDB environments are unavailable. - **Deferred work:** None. Conditional large abstractions, model deletion, multi-replica coordination and unrelated production changes remain outside current packages. -- **Next five work packages:** AI-002 Ollama adapter/routing; AI-003 Strategy Snapshot queue migration; AI-004 CV processing queue migration; UX-001 authentication/theme corrections; QA-001 job-analysis/keyword quality. SEC-006/007 resume after package-index permission. -- **Status counts:** 6 `VERIFIED LOCALLY`; 11 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 16 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`. -- **Test status:** backend 581/581; AI-001 focused queue/state/API 17/17; frontend 47/47 suites and 158/158 tests plus build; Python sidecar 18/18. Prior POL/PROD/OPS/BG/SEC/CORE evidence remains green. +- **Next five work packages:** AI-003 Strategy Snapshot queue migration; AI-004 CV processing queue migration; UX-001 authentication/theme corrections; QA-001 job-analysis/keyword quality; UX-002 Career Workspace/CV Builder redesign. SEC-006/007 resume after package-index permission. +- **Status counts:** 6 `VERIFIED LOCALLY`; 12 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 15 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`. +- **Test status:** backend 588/588; AI-002 focused provider/privacy/queue/history 26/26; frontend baseline 47/47 suites and 158/158 tests plus build; Python sidecar 22/22. Compose and patch checks pass. Prior POL/PROD/OPS/BG/SEC/CORE evidence remains green. - **Deployment status:** No deployment performed. No production migrations were run. - **Production status:** Unchanged and unverified. Pre-existing Docker development services on 3000/5202 were observed and left untouched. -- **Known regressions:** No known regression from the implemented packages. POL-002 preserves local AI behaviour and defaults external consent off. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before the new migration; normal startup owns reconciliation. Complete usage/provider provenance remains a known pre-rollout gap. +- **Known regressions:** No known regression from the implemented packages. AI-002 preserves local behavior, defaults external consent off, performs no parallel provider race and records actual provenance. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before the new migration; normal startup owns reconciliation. Complete cross-feature monthly usage accounting remains a pre-rollout gap. - **Outstanding security findings:** JT-001 repository ownership is implemented but remains High deployment risk until migration/inventory/provider checks; production portion of JT-002; JT-006, JT-009 and associated JT-011/JT-012/JT-022 prerequisites. JT-005 owner foundation is implemented but workers remain off until persistent notification, entitlement and privacy gates. JT-007/JT-008/JT-010 repository behavior is not fully browser/provider/production verified. ## Current evidence @@ -38,5 +38,6 @@ Updated: 2026-08-03 - `docs/verification/pol-001-free-pro-entitlements.md` - `docs/verification/pol-002-ai-privacy.md` - `docs/verification/ai-001-durable-ai-queue.md` +- `docs/verification/ai-002-provider-routing.md` - `docs/verification/prod-002-ai-evaluation.md` - `docs/work-programmes/master-work-plan.md` diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md index 8bd0966..c93f3f3 100644 --- a/docs/work-programmes/master-work-plan.md +++ b/docs/work-programmes/master-work-plan.md @@ -16,7 +16,7 @@ Allowed statuses are `NOT STARTED`, `IN PROGRESS`, `IMPLEMENTED — NOT VERIFIED `DONE` requires every applicable acceptance criterion, focused and regression tests, browser/accessibility/theme/mobile checks, tenant and entitlement checks, documentation, migration/rollback evidence, and production verification. Repository-only work that still requires production is at most `VERIFIED LOCALLY`. -Exactly one implementation item may be `IN PROGRESS`. As of this revision it is **AI-002**. +Exactly one implementation item may be `IN PROGRESS`. As of this revision it is **AI-003**. ## Consolidated dependency order @@ -383,7 +383,7 @@ Ordering differences from the suggested list: - **Blocker:** browser localhost is denied; Stripe/MariaDB/production are unavailable. Usage accounting is complete only for AI Workspace, so provider rollout remains blocked until durable execution centralizes it. - **Evidence:** `docs/verification/pol-001-free-pro-entitlements.md`; focused backend 74/74; full backend 568/568; focused frontend 22/22; full frontend 47 suites/157 tests; production build. - **Commit:** none. -- **Remaining work:** browser locked/Pro state checks; mocked Stripe expiry/downgrade lifecycle; central all-task usage accounting through AI-001/002; production role/config smoke. PRODUCT-001 separately removes the known landing-page price/third-tier/unlimited claims. +- **Remaining work:** browser locked/Pro state checks; mocked Stripe expiry/downgrade lifecycle; central all-task usage accounting through AI-003/004 producers; production role/config smoke. PRODUCT-001 separately removes the known landing-page price/third-tier/unlimited claims. ### POL-002 — AI privacy, consent and external-fallback policy @@ -398,10 +398,10 @@ Ordering differences from the suggested list: - **Required browser verification:** user/admin controls and disclosure/locked/failure states. - **Required production verification:** external egress capture with synthetic data only; no real private CV/email. - **Status:** `IMPLEMENTED — NOT VERIFIED`. -- **Blocker:** browser localhost is denied; MariaDB/production/external-provider verification is unavailable. Final fallback triggers and provenance depend on AI-001/002. +- **Blocker:** browser localhost is denied; MariaDB/production/external-provider verification is unavailable. Task-specific payload minimization/accounting depend on AI-003/004. - **Evidence:** `docs/verification/pol-002-ai-privacy.md`; focused backend 72/72 and final policy 28/28; sidecar 18/18; focused frontend 8/8; full backend 576/576; full frontend 47 suites/158 tests; production build; config and migration script checks. - **Commit:** none. -- **Remaining work:** browser user/admin disclosure checks; MariaDB and production synthetic egress proof; AI-001 policy snapshot/recheck; AI-002 provider provenance, payload minimization, cost controls and bounded local-first fallback. Background CV calls currently fail safe to local. +- **Remaining work:** browser user/admin disclosure checks; MariaDB and production synthetic egress proof; AI-003/004 task-specific payload minimization, accounting and real producer verification. AI-001/002 now carry rechecked policy/task context and record bounded local-first provenance. ### AI-001 — Durable AI queue, backpressure and operation APIs @@ -416,10 +416,10 @@ Ordering differences from the suggested list: - **Required browser verification:** synthetic operation status across refresh/nav/double-click/offline/retry/cancel. - **Required production verification:** queue depth/age, one-worker canary, Ollama offline/restart and app/worker restart. - **Status:** `IMPLEMENTED — NOT VERIFIED`. -- **Blocker:** real 202 producers/browser verification depend on AI-003/004; provider/model circuit and provenance depend on AI-002; MariaDB/production are unavailable and the worker remains off. +- **Blocker:** real 202 producers/browser verification depend on AI-003/004; MariaDB/production are unavailable and the worker remains off. - **Evidence:** `docs/verification/ai-001-durable-ai-queue.md`; focused queue/state/API tests 17/17; full backend 581/581; Compose config and diff checks. - **Commit:** none. -- **Remaining work:** AI-002 provider/model controls; AI-003/004 task handlers and 202 endpoints; browser refresh/double-click/cancel/retry; MariaDB and monitored single-worker production canary. Do not create a second CV- or Strategy-specific queue. +- **Remaining work:** AI-003/004 task handlers and 202 endpoints; browser refresh/double-click/cancel/retry; MariaDB and monitored single-worker production canary. AI-002 supplies local-first circuit/provenance. Do not create a second CV- or Strategy-specific queue. ### AI-002 — Ollama adapter and local-first provider routing @@ -433,11 +433,11 @@ Ordering differences from the suggested list: - **Required tests:** routing matrix, Ollama adapter, schema failure, local circuit, fallback allowed/prohibited/unavailable, cost limits and deduplication. - **Required browser verification:** provider-agnostic queued states and appropriate fallback disclosure. - **Required production verification:** actual selected local model and controlled synthetic fallback. -- **Status:** `IN PROGRESS`. -- **Blocker:** actual model/config depends on PROD-003; adapter/policy can use fakes first. -- **Evidence:** new programme explicitly supersedes ADR-004's single-provider decision for this scope. +- **Status:** `IMPLEMENTED — NOT VERIFIED`. +- **Blocker:** browser and production checks, actual local-model selection and controlled provider fallback depend on administrator browser policy plus PROD-001/003 access/benchmarks. Repository behavior is not blocked. +- **Evidence:** `docs/verification/ai-002-provider-routing.md`; V-098–V-100; focused backend 26/26, full backend 588/588, sidecar fake-transport 22/22, Compose/diff checks pass. - **Commit:** none. -- **Remaining work:** design smallest central policy; preserve old provider config for rollback. +- **Remaining work:** AI-003/004 must register typed producers/handlers and explicit external task allowlists; complete monthly cross-feature accounting; browser/MariaDB/selected-model/controlled-provider/production verification. Old provider/model configuration remains available for rollback. ### PROD-001 — Read-only production AI inventory and rollout safety @@ -523,9 +523,9 @@ Ordering differences from the suggested list: - **Required tests:** endpoint/handler/provider fakes, all required states, entitlement/privacy/tenant checks, E2E. - **Required browser verification:** complete queue/status/error/retry/cancel/refresh/back-forward/mobile/theme flow. - **Required production verification:** local model success, timeout and restart recovery. -- **Status:** `NOT STARTED`. -- **Blocker:** dependencies. -- **Evidence:** reported timeout not yet reproduced in this programme. +- **Status:** `IN PROGRESS`. +- **Blocker:** browser and production checks remain unavailable, but repository tracing, handler/API implementation and fake-provider tests can proceed. +- **Evidence:** reported timeout was code-traced as synchronous `/cv/rewrite` work; live browser/provider reproduction remains blocked and must not be inferred. - **Commit:** none. - **Remaining work:** do not build a Strategy-specific queue. diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index 0fb7634..0892285 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -15,6 +15,8 @@ import os import re import torch import pytesseract +import threading +import time from urllib import request as urllib_request from urllib.error import URLError, HTTPError from contextvars import ContextVar @@ -35,7 +37,18 @@ AI_SERVICE_TOKEN_HEADER = "X-Ai-Service-Token" # exposes no user data and no generation path. AI_SERVICE_OPEN_PATHS = {"/health"} EXTERNAL_AI_ALLOWED_HEADER = "X-Ai-External-Allowed" +AI_TASK_TYPE_HEADER = "X-Ai-Task-Type" _external_ai_allowed = ContextVar("external_ai_allowed", default=False) +_ai_task_type = ContextVar("ai_task_type", default="unknown") +_route_state = ContextVar("route_state", default=None) + + +_PATH_TASKS = { + "/cv/normalize": "cv-normalize", + "/cv/classify-block": "cv-classify", + "/cv/rewrite": "cv-rewrite", + "/summarize": "job-summary", +} @app.middleware("http") @@ -52,14 +65,28 @@ async def require_service_token(request: Request, call_next): EXTERNAL_AI_ENABLED and request.headers.get(EXTERNAL_AI_ALLOWED_HEADER, "").strip().lower() == "true" ) - token = _external_ai_allowed.set(allowed) + requested_task = request.headers.get(AI_TASK_TYPE_HEADER, "").strip().lower() + task_type = requested_task if re.fullmatch(r"[a-z0-9._-]{1,64}", requested_task) else _PATH_TASKS.get(request.url.path, "unknown") + state = {"provider": None, "model": None, "fallback_reason": None, "route_reason": None} + allowed_token = _external_ai_allowed.set(allowed) + task_token = _ai_task_type.set(task_type) + state_token = _route_state.set(state) try: response = await call_next(request) if request.url.path.startswith("/cv/"): - response.headers["X-Ai-Provider"] = _effective_provider() + if state["provider"]: + response.headers["X-Ai-Provider"] = state["provider"] + if state["model"]: + response.headers["X-Ai-Model"] = state["model"] + if state["fallback_reason"]: + response.headers["X-Ai-Fallback-Reason"] = state["fallback_reason"] + if state["route_reason"]: + response.headers["X-Ai-Route-Reason"] = state["route_reason"] return response finally: - _external_ai_allowed.reset(token) + _route_state.reset(state_token) + _ai_task_type.reset(task_token) + _external_ai_allowed.reset(allowed_token) MODEL_NAME = "sshleifer/distilbart-cnn-12-6" MAX_INPUT_CHARS = 20000 @@ -76,6 +103,17 @@ OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "") # (distilbart) regardless of this setting. AI_PROVIDER = (os.getenv("AI_PROVIDER", "ollama").strip().lower() or "ollama") EXTERNAL_AI_ENABLED = os.getenv("EXTERNAL_AI_ENABLED", "").strip().lower() in {"1", "true", "yes"} +AI_ROUTING_MODE = (os.getenv("AI_ROUTING_MODE", "local_first").strip().lower() or "local_first") +if AI_ROUTING_MODE not in {"local_only", "local_first", "external_only"}: + AI_ROUTING_MODE = "local_only" +EXTERNAL_AI_ALLOWED_TASKS = frozenset( + item.strip().lower() + for item in os.getenv("EXTERNAL_AI_ALLOWED_TASKS", "cv-normalize,cv-classify,cv-rewrite").split(",") + if item.strip() +) +EXTERNAL_AI_MAX_PROMPT_CHARS = max(1000, min(int(os.getenv("EXTERNAL_AI_MAX_PROMPT_CHARS", "24000")), 100000)) +LOCAL_CIRCUIT_FAILURE_THRESHOLD = max(1, min(int(os.getenv("LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD", "3")), 20)) +LOCAL_CIRCUIT_OPEN_SECONDS = max(1, min(int(os.getenv("LOCAL_AI_CIRCUIT_OPEN_SECONDS", "30")), 600)) GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip() GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash").strip() GEMINI_BASE_URL = os.getenv("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com").rstrip("/") @@ -85,6 +123,10 @@ GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").rst SKIP_MODEL_LOAD = os.getenv("AI_SERVICE_SKIP_MODEL_LOAD", "") == "1" EAGER_MODEL_LOAD = os.getenv("AI_SERVICE_EAGER_MODEL_LOAD", "") == "1" +_local_circuit_lock = threading.Lock() +_local_failure_count = 0 +_local_circuit_open_until = 0.0 + tokenizer = None model = None @@ -231,7 +273,12 @@ async def health(): "summarize_available": MODEL_LOADED and not MODEL_DISABLED, "model_load_error": MODEL_LOAD_ERROR, "ai_provider": AI_PROVIDER, - "ai_provider_configured": _provider_configured(), + "ai_provider_configured": _provider_configured(AI_PROVIDER), + "ai_routing_mode": AI_ROUTING_MODE, + "external_ai_enabled": EXTERNAL_AI_ENABLED, + "external_ai_allowed_tasks": sorted(EXTERNAL_AI_ALLOWED_TASKS), + "external_ai_max_prompt_chars": EXTERNAL_AI_MAX_PROMPT_CHARS, + **_local_circuit_status(), **_ollama_status(), } @@ -455,18 +502,94 @@ def _provider_display(provider: str) -> str: return _PROVIDER_DISPLAY.get(provider, provider or "AI provider") -def _provider_configured() -> bool: - if AI_PROVIDER == "gemini": +def _provider_configured(provider: str | None = None) -> bool: + provider = provider or AI_PROVIDER + if provider == "gemini": return bool(GEMINI_API_KEY) - if AI_PROVIDER == "groq": + if provider == "groq": return bool(GROQ_API_KEY) return bool(OLLAMA_MODEL) -def _effective_provider() -> str: - if EXTERNAL_AI_ENABLED and _external_ai_allowed.get() and AI_PROVIDER in {"gemini", "groq"}: - return AI_PROVIDER - return "ollama" +def _provider_model(provider: str) -> str | None: + return { + "ollama": OLLAMA_MODEL, + "gemini": GEMINI_MODEL, + "groq": GROQ_MODEL, + }.get(provider) or None + + +def _set_route_metadata(provider: str | None, route_reason: str, fallback_reason: str | None = None): + state = _route_state.get() + if state is None: + state = {"provider": None, "model": None, "fallback_reason": None, "route_reason": None} + _route_state.set(state) + state["provider"] = provider + state["model"] = _provider_model(provider) if provider else None + state["fallback_reason"] = fallback_reason + state["route_reason"] = route_reason + + +def _local_circuit_is_open() -> bool: + global _local_failure_count, _local_circuit_open_until + now = time.monotonic() + with _local_circuit_lock: + if _local_circuit_open_until <= now: + _local_circuit_open_until = 0.0 + if _local_failure_count >= LOCAL_CIRCUIT_FAILURE_THRESHOLD: + _local_failure_count = 0 + return False + return True + + +def _record_local_success(): + global _local_failure_count, _local_circuit_open_until + with _local_circuit_lock: + _local_failure_count = 0 + _local_circuit_open_until = 0.0 + + +def _record_local_failure(): + global _local_failure_count, _local_circuit_open_until + with _local_circuit_lock: + _local_failure_count += 1 + if _local_failure_count >= LOCAL_CIRCUIT_FAILURE_THRESHOLD: + _local_circuit_open_until = time.monotonic() + LOCAL_CIRCUIT_OPEN_SECONDS + + +def _local_circuit_status() -> dict: + now = time.monotonic() + with _local_circuit_lock: + remaining = max(0.0, _local_circuit_open_until - now) + return { + "local_circuit_open": remaining > 0, + "local_circuit_failures": _local_failure_count, + "local_circuit_retry_after_seconds": round(remaining, 1), + } + + +class _ProviderFailure(Exception): + def __init__(self, provider: str, category: str, status_code: int): + super().__init__(category) + self.provider = provider + self.category = category + self.status_code = status_code + + +def _external_denial_reason(prompt: str) -> str | None: + if AI_ROUTING_MODE == "local_only": + return "local_only" + if not EXTERNAL_AI_ENABLED or not _external_ai_allowed.get(): + return "external_not_permitted" + if AI_PROVIDER not in {"gemini", "groq"}: + return "external_not_configured" + if _ai_task_type.get() not in EXTERNAL_AI_ALLOWED_TASKS: + return "task_not_allowed_external" + if not _provider_configured(AI_PROVIDER): + return "external_not_configured" + if len(prompt) > EXTERNAL_AI_MAX_PROMPT_CHARS: + return "external_prompt_limit" + return None def _http_post_json(url: str, payload: dict, headers: dict, timeout: int) -> dict: @@ -534,43 +657,115 @@ def _groq_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: return ((choices[0].get("message") or {}).get("content") or "").strip() -def _provider_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: - provider = _effective_provider() +def _generate_from_provider(provider: str, prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: try: if provider == "gemini": return _gemini_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) if provider == "groq": return _groq_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) return _ollama_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) - except HTTPException: - raise + except HTTPException as ex: + category = "provider_not_configured" if ex.status_code == 503 else "provider_rejected" + raise _ProviderFailure(provider, category, ex.status_code) from ex except HTTPError as ex: - raise HTTPException(status_code=502, detail=f"{_provider_display(provider)} request failed with {ex.code}.") - except URLError as ex: - raise HTTPException(status_code=503, detail=f"{_provider_display(provider)} is unreachable: {ex.reason}.") + category = "provider_busy" if ex.code == 429 else "provider_unavailable" + raise _ProviderFailure(provider, category, 503 if ex.code in {408, 429, 502, 503, 504} else 502) from ex + except (URLError, TimeoutError) as ex: + raise _ProviderFailure(provider, "provider_unavailable", 503) from ex -def _ollama_generate_json(prompt: str): - provider = _effective_provider() - raw = _provider_generate(prompt, json_mode=True, temperature=0.1, timeout=120) +def _parse_provider_json(raw: str, provider: str): if not raw: - raise HTTPException(status_code=502, detail=f"{_provider_display(provider)} returned an empty response.") + raise _ProviderFailure(provider, "empty_response", 502) try: return json.loads(raw) - except json.JSONDecodeError: + except json.JSONDecodeError as first_error: start = raw.find("{") end = raw.rfind("}") if start >= 0 and end > start: - return json.loads(raw[start:end + 1]) - raise HTTPException(status_code=502, detail=f"{_provider_display(provider)} did not return valid JSON.") + try: + return json.loads(raw[start:end + 1]) + except json.JSONDecodeError: + pass + raise _ProviderFailure(provider, "schema_invalid", 502) from first_error + + +def _validated_generation(provider: str, prompt: str, *, json_mode: bool, temperature: float, timeout: int): + raw = _generate_from_provider(provider, prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) + if json_mode: + return _parse_provider_json(raw, provider) + if not raw: + raise _ProviderFailure(provider, "empty_response", 502) + return raw + + +def _raise_route_failure(failure: _ProviderFailure): + display = _provider_display(failure.provider) + messages = { + "provider_not_configured": f"{display} is not configured.", + "provider_busy": f"{display} is busy. Try again later.", + "schema_invalid": f"{display} returned an invalid structured response.", + "empty_response": f"{display} returned an empty response.", + "provider_rejected": f"{display} rejected the request.", + } + raise HTTPException( + status_code=failure.status_code, + detail=messages.get(failure.category, f"{display} is unavailable."), + ) + + +def _route_generation(prompt: str, *, json_mode: bool, temperature: float, timeout: int): + denial_reason = _external_denial_reason(prompt) + + if AI_ROUTING_MODE == "external_only": + if denial_reason is not None: + _set_route_metadata(None, denial_reason) + raise HTTPException(status_code=403, detail="External AI processing is not permitted for this request.") + try: + result = _validated_generation(AI_PROVIDER, prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) + _set_route_metadata(AI_PROVIDER, "external_only") + return result + except _ProviderFailure as failure: + _set_route_metadata(failure.provider, failure.category) + _raise_route_failure(failure) + + if _local_circuit_is_open(): + if denial_reason is None: + try: + result = _validated_generation(AI_PROVIDER, prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) + _set_route_metadata(AI_PROVIDER, "external_fallback", "local_circuit_open") + return result + except _ProviderFailure as failure: + _set_route_metadata(failure.provider, failure.category, "local_circuit_open") + _raise_route_failure(failure) + _set_route_metadata(None, f"local_circuit_open:{denial_reason}") + raise HTTPException(status_code=503, detail="Local AI is temporarily unavailable. Try again later.") + + try: + result = _validated_generation("ollama", prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) + _record_local_success() + _set_route_metadata("ollama", "local_primary") + return result + except _ProviderFailure as local_failure: + _record_local_failure() + if denial_reason is not None: + _set_route_metadata("ollama", f"{local_failure.category}:{denial_reason}") + _raise_route_failure(local_failure) + try: + result = _validated_generation(AI_PROVIDER, prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) + _set_route_metadata(AI_PROVIDER, "external_fallback", f"local_{local_failure.category}") + return result + except _ProviderFailure as external_failure: + _set_route_metadata(external_failure.provider, external_failure.category, f"local_{local_failure.category}") + _raise_route_failure(external_failure) + + +def _ollama_generate_json(prompt: str): + return _route_generation(prompt, json_mode=True, temperature=0.1, timeout=120) def _ollama_generate_text(prompt: str) -> str: - provider = _effective_provider() - raw = _provider_generate(prompt, json_mode=False, temperature=0.2, timeout=180) - if not raw: - raise HTTPException(status_code=502, detail=f"{_provider_display(provider)} returned an empty rewrite.") - return raw + return _route_generation(prompt, json_mode=False, temperature=0.2, timeout=180) @app.post("/cv/normalize") @@ -755,9 +950,6 @@ section. """.strip() rewritten = _ollama_generate_text(prompt).strip() - if not rewritten: - raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.") - return {"rewritten_text": rewritten} diff --git a/tools/summarizer/tests/test_app.py b/tools/summarizer/tests/test_app.py index 30363a4..bf8c44a 100644 --- a/tools/summarizer/tests/test_app.py +++ b/tools/summarizer/tests/test_app.py @@ -11,7 +11,17 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None, service_token=None, external_ai_enabled=False): +def load_app_module( + monkeypatch, + *, + skip_model_load=True, + ollama_model=None, + service_token=None, + external_ai_enabled=False, + routing_mode="local_first", + circuit_threshold=3, + external_prompt_limit=24000, +): if skip_model_load: monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1") else: @@ -30,6 +40,10 @@ def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None, ser monkeypatch.setenv("EXTERNAL_AI_ENABLED", "true") else: monkeypatch.delenv("EXTERNAL_AI_ENABLED", raising=False) + monkeypatch.setenv("AI_ROUTING_MODE", routing_mode) + monkeypatch.setenv("LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD", str(circuit_threshold)) + monkeypatch.setenv("EXTERNAL_AI_MAX_PROMPT_CHARS", str(external_prompt_limit)) + monkeypatch.delenv("EXTERNAL_AI_ALLOWED_TASKS", raising=False) if "app" in sys.modules: del sys.modules["app"] module = importlib.import_module("app") @@ -227,56 +241,62 @@ def test_provider_defaults_to_ollama_and_is_unchanged(monkeypatch): assert captured["body"]["options"]["temperature"] == 0.1 -def test_provider_gemini_dispatch(monkeypatch): +def test_local_success_wins_even_when_external_fallback_is_permitted(monkeypatch): monkeypatch.setenv("AI_PROVIDER", "gemini") monkeypatch.setenv("GEMINI_API_KEY", "test-key") monkeypatch.setenv("GEMINI_MODEL", "gemini-2.0-flash") - module = load_app_module(monkeypatch, external_ai_enabled=True) + module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True) module._external_ai_allowed.set(True) + module._ai_task_type.set("cv-normalize") - captured = {} - payload = {"candidates": [{"content": {"parts": [{"text": '{"score": 9}'}]}}]} - _install_fake_urlopen(monkeypatch, module, payload, captured) + calls = [] + monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: calls.append("ollama") or '{"score": 7}') + monkeypatch.setattr(module, "_gemini_generate", lambda *args, **kwargs: calls.append("gemini") or '{"score": 9}') - assert module._ollama_generate_json("hi") == {"score": 9} - assert "generativelanguage" in captured["url"] - assert "gemini-2.0-flash:generateContent" in captured["url"] - assert "key=" not in captured["url"] # key must not be in the URL - assert captured["headers"].get("x-goog-api-key") == "test-key" - assert captured["body"]["generationConfig"]["responseMimeType"] == "application/json" + assert module._ollama_generate_json("hi") == {"score": 7} + assert calls == ["ollama"] -def test_provider_groq_dispatch(monkeypatch): +def test_local_failure_uses_permitted_groq_fallback_sequentially(monkeypatch): monkeypatch.setenv("AI_PROVIDER", "groq") monkeypatch.setenv("GROQ_API_KEY", "test-key") - module = load_app_module(monkeypatch, external_ai_enabled=True) + module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True) module._external_ai_allowed.set(True) + module._ai_task_type.set("cv-rewrite") - captured = {} - payload = {"choices": [{"message": {"content": "rewritten CV text"}}]} - _install_fake_urlopen(monkeypatch, module, payload, captured) + calls = [] + + def local_failure(*args, **kwargs): + calls.append("ollama") + raise module.URLError("synthetic local outage") + + monkeypatch.setattr(module, "_ollama_generate", local_failure) + monkeypatch.setattr(module, "_groq_generate", lambda *args, **kwargs: calls.append("groq") or "rewritten CV text") assert module._ollama_generate_text("rewrite this") == "rewritten CV text" - assert captured["url"].endswith("/chat/completions") - assert captured["headers"].get("authorization") == "Bearer test-key" - assert captured["body"]["messages"][0]["content"] == "rewrite this" + assert calls == ["ollama", "groq"] + assert module._route_state.get()["provider"] == "groq" + assert module._route_state.get()["fallback_reason"] == "local_provider_unavailable" -def test_provider_missing_cloud_key_raises_503(monkeypatch): +def test_missing_external_key_never_bypasses_local_failure(monkeypatch): monkeypatch.setenv("AI_PROVIDER", "gemini") monkeypatch.delenv("GEMINI_API_KEY", raising=False) - module = load_app_module(monkeypatch, external_ai_enabled=True) + module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True) module._external_ai_allowed.set(True) + module._ai_task_type.set("cv-normalize") + monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: (_ for _ in ()).throw(module.URLError("synthetic outage"))) + monkeypatch.setattr(module, "_gemini_generate", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("must not call Gemini"))) from fastapi import HTTPException try: module._ollama_generate_json("hi") - except HTTPException as ex: + except module.HTTPException as ex: assert ex.status_code == 503 - assert "GEMINI_API_KEY" in ex.detail + assert "Ollama" in ex.detail else: - raise AssertionError("expected HTTPException for missing GEMINI_API_KEY") + raise AssertionError("expected the local failure") def test_health_reports_active_provider(monkeypatch): @@ -289,31 +309,161 @@ def test_health_reports_active_provider(monkeypatch): assert payload["ai_provider"] == "gemini" assert payload["ai_provider_configured"] is True + assert payload["ai_routing_mode"] == "local_first" + assert payload["local_circuit_open"] is False -def test_external_provider_requires_admin_gate_and_backend_consent_header(monkeypatch): +def test_external_fallback_requires_admin_gate_and_backend_consent_header(monkeypatch): monkeypatch.setenv("AI_PROVIDER", "gemini") monkeypatch.setenv("GEMINI_API_KEY", "test-key") module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True) - captured = {} - _install_fake_urlopen(monkeypatch, module, {"response": "rewritten locally"}, captured) + calls = [] + + def fake_generate(provider, *args, **kwargs): + calls.append(provider) + if provider == "ollama": + raise module._ProviderFailure("ollama", "provider_unavailable", 503) + return "rewritten externally" + + monkeypatch.setattr(module, "_generate_from_provider", fake_generate) client = TestClient(module.app) local_response = client.post("/cv/rewrite", json={"instruction": "Rewrite", "text": "Synthetic CV"}) - assert local_response.status_code == 200 - assert captured["url"].startswith("http://127.0.0.1:11434/") + assert local_response.status_code == 503 + assert calls == ["ollama"] assert local_response.headers["X-Ai-Provider"] == "ollama" - external_payload = {"candidates": [{"content": {"parts": [{"text": "rewritten externally"}]}}]} - _install_fake_urlopen(monkeypatch, module, external_payload, captured) + calls.clear() external_response = client.post( "/cv/rewrite", json={"instruction": "Rewrite", "text": "Synthetic CV"}, headers={"X-Ai-External-Allowed": "true"}, ) assert external_response.status_code == 200 - assert "generativelanguage" in captured["url"] + assert calls == ["ollama", "gemini"] assert external_response.headers["X-Ai-Provider"] == "gemini" + assert external_response.headers["X-Ai-Model"] == "gemini-2.0-flash" + assert external_response.headers["X-Ai-Fallback-Reason"] == "local_provider_unavailable" + assert external_response.headers["X-Ai-Route-Reason"] == "external_fallback" + + +def test_invalid_local_json_can_fallback_but_prompt_cost_cap_cannot(monkeypatch): + monkeypatch.setenv("AI_PROVIDER", "gemini") + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + module = load_app_module( + monkeypatch, + ollama_model="qwen2.5:7b", + external_ai_enabled=True, + external_prompt_limit=1000, + ) + module._external_ai_allowed.set(True) + module._ai_task_type.set("cv-normalize") + calls = [] + monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: calls.append("ollama") or "not json") + monkeypatch.setattr(module, "_gemini_generate", lambda *args, **kwargs: calls.append("gemini") or '{"score": 9}') + + assert module._ollama_generate_json("short") == {"score": 9} + assert calls == ["ollama", "gemini"] + assert module._route_state.get()["fallback_reason"] == "local_schema_invalid" + + calls.clear() + try: + module._ollama_generate_json("x" * 1001) + except module.HTTPException as ex: + assert ex.status_code == 502 + else: + raise AssertionError("expected local schema failure above the external prompt cap") + assert calls == ["ollama"] + assert "external_prompt_limit" in module._route_state.get()["route_reason"] + + +def test_open_local_circuit_skips_local_only_when_fallback_is_permitted(monkeypatch): + monkeypatch.setenv("AI_PROVIDER", "groq") + monkeypatch.setenv("GROQ_API_KEY", "test-key") + module = load_app_module( + monkeypatch, + ollama_model="qwen2.5:7b", + external_ai_enabled=True, + circuit_threshold=1, + ) + module._external_ai_allowed.set(True) + module._ai_task_type.set("cv-rewrite") + calls = [] + + def local_failure(*args, **kwargs): + calls.append("ollama") + raise module.URLError("synthetic local outage") + + monkeypatch.setattr(module, "_ollama_generate", local_failure) + monkeypatch.setattr(module, "_groq_generate", lambda *args, **kwargs: calls.append("groq") or "external") + + assert module._ollama_generate_text("first") == "external" + assert module._ollama_generate_text("second") == "external" + assert calls == ["ollama", "groq", "groq"] + assert module._route_state.get()["fallback_reason"] == "local_circuit_open" + + +def test_external_failure_is_clear_and_unapproved_background_task_stays_local(monkeypatch): + monkeypatch.setenv("AI_PROVIDER", "gemini") + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True) + module._external_ai_allowed.set(True) + calls = [] + + def provider_failure(provider, *args, **kwargs): + calls.append(provider) + raise module._ProviderFailure(provider, "provider_unavailable", 503) + + monkeypatch.setattr(module, "_generate_from_provider", provider_failure) + module._ai_task_type.set("cv-rewrite") + try: + module._ollama_generate_text("allowed") + except module.HTTPException as ex: + assert ex.status_code == 503 + assert "Gemini" in ex.detail + else: + raise AssertionError("expected external provider failure") + assert calls == ["ollama", "gemini"] + assert module._route_state.get()["fallback_reason"] == "local_provider_unavailable" + + calls.clear() + module._ai_task_type.set("strategy.snapshot") + try: + module._ollama_generate_text("not task-approved") + except module.HTTPException as ex: + assert ex.status_code == 503 + assert "Ollama" in ex.detail + else: + raise AssertionError("expected local provider failure") + assert calls == ["ollama"] + assert "task_not_allowed_external" in module._route_state.get()["route_reason"] + + +def test_external_only_mode_still_requires_explicit_permission_and_task_allowlist(monkeypatch): + monkeypatch.setenv("AI_PROVIDER", "groq") + monkeypatch.setenv("GROQ_API_KEY", "test-key") + module = load_app_module( + monkeypatch, + ollama_model="qwen2.5:7b", + external_ai_enabled=True, + routing_mode="external_only", + ) + module._ai_task_type.set("cv-rewrite") + calls = [] + monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: calls.append("ollama") or "local") + monkeypatch.setattr(module, "_groq_generate", lambda *args, **kwargs: calls.append("groq") or "external") + + try: + module._ollama_generate_text("without consent") + except module.HTTPException as ex: + assert ex.status_code == 403 + else: + raise AssertionError("expected external permission denial") + assert calls == [] + + module._external_ai_allowed.set(True) + assert module._ollama_generate_text("with consent") == "external" + assert calls == ["groq"] def test_service_token_rejects_calls_without_the_header(monkeypatch):