feat(ai): enforce local-first routing

Keep external providers behind server consent, task, and prompt-cost gates while persisting actual provider provenance.
This commit is contained in:
cesnimda
2026-08-09 12:30:11 +02:00
parent c3f4a57195
commit 5eb9b3cb96
29 changed files with 967 additions and 145 deletions
+10 -4
View File
@@ -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=
+32 -1
View File
@@ -64,6 +64,9 @@ public sealed class AiOperationQueueTests
var operation = await db.UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync();
Assert.Equal(OperationStatuses.Succeeded, operation.Status);
Assert.Equal("synthetic-result", operation.ResultReference);
Assert.Equal("ollama", operation.Provider);
Assert.Equal("qwen-test", operation.Model);
Assert.Equal("local_primary", operation.ProgressStage);
Assert.Equal("pro-1", fixture.Handler.OwnerUserId);
Assert.Equal("local_only", fixture.Handler.PrivacyPolicy);
Assert.Equal("operation_succeeded", (await db.UserNotifications.IgnoreQueryFilters().AsNoTracking().SingleAsync()).Kind);
@@ -95,19 +98,46 @@ public sealed class AiOperationQueueTests
Assert.Equal("entitlement_changed", failed.FailureCategory);
}
[Fact]
public async Task Worker_records_provider_metadata_for_generation_failures()
{
await using var fixture = await Fixture.CreateAsync();
await fixture.SeedUserAsync("pro-1", pro: true);
await fixture.EnqueueAsync("pro-1", "provider-failure");
fixture.Handler.GenerationFailure = new AiGenerationException(
"provider_unavailable",
"AI provider unavailable.",
retryable: true,
provider: "gemini",
model: "gemini-test",
routeReason: "external_fallback");
Assert.True(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
await using var scope = fixture.Provider.CreateAsyncScope();
var row = await scope.ServiceProvider.GetRequiredService<JobTrackerContext>()
.UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync();
Assert.Equal(OperationStatuses.WaitingForRetry, row.Status);
Assert.Equal("gemini", row.Provider);
Assert.Equal("gemini-test", row.Model);
Assert.Equal("external_fallback", row.ProgressStage);
}
private sealed class SyntheticHandler : IAiOperationHandler
{
public string TaskType => "synthetic.ai";
public string? OwnerUserId { get; private set; }
public string? PrivacyPolicy { get; private set; }
public AiOperationFailure? Failure { get; set; }
public AiGenerationException? GenerationFailure { get; set; }
public Task<AiOperationExecutionResult> ExecuteAsync(AiOperationExecutionContext context, IServiceProvider services, CancellationToken cancellationToken)
{
if (Failure is not null) throw Failure;
if (GenerationFailure is not null) throw GenerationFailure;
OwnerUserId = services.GetRequiredService<ICurrentUserService>().UserId;
PrivacyPolicy = context.EffectivePrivacyPolicy;
return Task.FromResult(new AiOperationExecutionResult("synthetic-result"));
return Task.FromResult(new AiOperationExecutionResult("synthetic-result", "ollama", "qwen-test", "local_primary"));
}
}
@@ -147,6 +177,7 @@ public sealed class AiOperationQueueTests
services.AddSingleton(TimeProvider.System);
services.AddScoped<UserOperationStore>();
services.AddSingleton<AiPrivacyPolicy>();
services.AddSingleton<AiOperationExecutionScope>();
services.AddScoped<AiOperationAdmission>();
services.AddSingleton<IAiOperationHandler>(handler);
services.AddSingleton<AiOperationWorker>();
+45 -2
View File
@@ -46,6 +46,20 @@ public sealed class AiPrivacyPolicyTests
Assert.Equal("local", decision.Provider);
}
[Theory]
[InlineData("local_only")]
[InlineData("unexpected")]
public async Task Local_only_or_invalid_admin_mode_disables_external_processing(string routingMode)
{
await using var fixture = await Fixture.CreateAsync(adminEnabled: true, routingMode: routingMode);
await fixture.CreateUserAsync(externalAllowed: true, pro: true);
var decision = await fixture.Policy.EvaluateAsync("user-1");
Assert.False(decision.ExternalProcessingAllowed);
Assert.Equal("local", decision.Provider);
}
[Fact]
public async Task Cv_request_carries_external_permission_only_after_the_policy_allows_it()
{
@@ -57,7 +71,10 @@ public sealed class AiPrivacyPolicyTests
new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")),
};
var capture = new CaptureHandler();
var handler = new AiPrivacyHeaderHandler(new HttpContextAccessor { HttpContext = context }, fixture.Policy)
var handler = new AiPrivacyHeaderHandler(
new HttpContextAccessor { HttpContext = context },
fixture.Policy,
new AiOperationExecutionScope())
{
InnerHandler = capture,
};
@@ -68,15 +85,40 @@ public sealed class AiPrivacyPolicyTests
Assert.Equal("true", capture.ExternalAllowed);
}
[Fact]
public async Task Background_operation_carries_its_rechecked_policy_and_task_without_http_user_context()
{
await using var fixture = await Fixture.CreateAsync(adminEnabled: true);
var executionScope = new AiOperationExecutionScope();
var capture = new CaptureHandler();
var handler = new AiPrivacyHeaderHandler(new HttpContextAccessor(), fixture.Policy, executionScope)
{
InnerHandler = capture,
};
var lease = new UserOperationLease(Guid.NewGuid(), "user-1", "lease", "strategy.snapshot",
"external_allowed", "job", "42", 1, DateTime.UtcNow.AddMinutes(5));
using var routing = executionScope.Use(new AiOperationExecutionContext(lease, "external_allowed"));
using var client = new HttpClient(handler);
await client.PostAsync("http://ai-service/cv/rewrite", new StringContent("{}"));
Assert.Equal("true", capture.ExternalAllowed);
Assert.Equal("strategy.snapshot", capture.TaskType);
}
private sealed class CaptureHandler : HttpMessageHandler
{
public string? ExternalAllowed { get; private set; }
public string? TaskType { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
ExternalAllowed = request.Headers.TryGetValues(AiPrivacyPolicy.ExternalAllowedHeader, out var values)
? values.Single()
: null;
TaskType = request.Headers.TryGetValues(AiPrivacyPolicy.TaskTypeHeader, out var taskValues)
? taskValues.Single()
: null;
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
}
}
@@ -94,7 +136,7 @@ public sealed class AiPrivacyPolicyTests
Policy = policy;
}
public static async Task<Fixture> CreateAsync(bool adminEnabled)
public static async Task<Fixture> CreateAsync(bool adminEnabled, string routingMode = "local_first")
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
@@ -102,6 +144,7 @@ public sealed class AiPrivacyPolicyTests
{
["Ai:ExternalProcessingEnabled"] = adminEnabled.ToString(),
["Ai:ExternalProvider"] = "gemini",
["Ai:RoutingMode"] = routingMode,
}).Build();
var services = new ServiceCollection();
services.AddLogging();
+29
View File
@@ -14,6 +14,8 @@ public sealed class AiWorkspaceTests
public string? Next = "## Result\nGenerated suggestion.";
public int Calls;
public string? LastInstruction;
public string? ActualProvider;
public string? FallbackReason;
// The source text the module assembled — what the prompt actually saw.
public string? LastText;
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
@@ -24,6 +26,17 @@ public sealed class AiWorkspaceTests
return Task.FromResult(Next);
}
public Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next);
public async Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(
string instruction,
string text,
int maxLength = 180,
int minLength = 40,
CancellationToken cancellationToken = default)
{
var generated = await SummarizeSectionAsync(instruction, text, maxLength, minLength);
return generated is null ? null : new AiGenerationResult(generated, ActualProvider, "test-model", FallbackReason,
FallbackReason is null ? "local_primary" : "external_fallback");
}
public Task<AiTextExtractionResult?> ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException();
@@ -72,6 +85,22 @@ public sealed class AiWorkspaceTests
Assert.Single(await db.AiInteractions.IgnoreQueryFilters().Where(x => x.JobApplicationId == jobId).ToListAsync());
}
[Fact]
public async Task Generate_records_the_actual_provider_and_fallback_metadata()
{
var (db, svc, ai) = New("user-1");
await using var _ = db;
ai.ActualProvider = "groq";
ai.FallbackReason = "local_circuit_open";
var jobId = await SeedJobAsync(db, "user-1");
var result = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("job-analysis"), "configured-label", default);
Assert.Equal("groq", result!.Provider);
Assert.Contains("local_circuit_open", result.ResultJson);
Assert.Contains("test-model", result.ResultJson);
}
[Fact]
public async Task Cover_letter_normalizes_an_unknown_mode_and_labels_the_title()
{
+64 -1
View File
@@ -57,6 +57,46 @@ public sealed class SummarizerServiceTests
Assert.Contains("\"min_length\":180", handler.LastBody);
}
[Fact]
public async Task Generate_section_returns_actual_provider_model_and_fallback_metadata()
{
var handler = new CapturingHandler();
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:8001") };
var httpFactory = new Mock<IHttpClientFactory>();
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var service = new SummarizerService(httpFactory.Object, memoryCache);
var result = await service.GenerateSectionWithMetadataAsync("Rewrite", "Synthetic CV", cancellationToken: default);
Assert.NotNull(result);
Assert.Equal("rewritten cv", result!.Text);
Assert.Equal("gemini", result.Provider);
Assert.Equal("gemini-test", result.Model);
Assert.Equal("local_provider_unavailable", result.FallbackReason);
Assert.Equal("external_fallback", result.RouteReason);
}
[Fact]
public async Task Metadata_path_returns_a_typed_sanitized_provider_failure_while_legacy_path_returns_null()
{
var httpClient = new HttpClient(new FailureHandler()) { BaseAddress = new Uri("http://localhost:8001") };
var httpFactory = new Mock<IHttpClientFactory>();
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var service = new SummarizerService(httpFactory.Object, memoryCache);
var failure = await Assert.ThrowsAsync<AiGenerationException>(() =>
service.GenerateSectionWithMetadataAsync("Rewrite", "Synthetic CV", cancellationToken: default));
Assert.Equal("provider_unavailable", failure.Category);
Assert.True(failure.Retryable);
Assert.Equal("gemini", failure.Provider);
Assert.Equal("external_fallback", failure.RouteReason);
Assert.DoesNotContain("synthetic upstream detail", failure.Message, StringComparison.OrdinalIgnoreCase);
Assert.Null(await service.SummarizeSectionAsync("Rewrite again", "Synthetic CV"));
}
private sealed class CapturingHandler : HttpMessageHandler
{
public string? LastBody { get; private set; }
@@ -69,10 +109,33 @@ public sealed class SummarizerServiceTests
var responseBody = LastPath == "/cv/rewrite"
? "{\"rewritten_text\":\"rewritten cv\"}"
: "{\"summary\":\"ok\"}";
return new HttpResponseMessage(HttpStatusCode.OK)
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
};
if (LastPath == "/cv/rewrite")
{
response.Headers.Add("X-Ai-Provider", "gemini");
response.Headers.Add("X-Ai-Model", "gemini-test");
response.Headers.Add("X-Ai-Fallback-Reason", "local_provider_unavailable");
response.Headers.Add("X-Ai-Route-Reason", "external_fallback");
}
return response;
}
}
private sealed class FailureHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent("{\"detail\":\"synthetic upstream detail\"}", Encoding.UTF8, "application/json"),
};
response.Headers.Add("X-Ai-Provider", "gemini");
response.Headers.Add("X-Ai-Model", "gemini-test");
response.Headers.Add("X-Ai-Route-Reason", "external_fallback");
return Task.FromResult(response);
}
}
}
+1
View File
@@ -38,6 +38,7 @@ else
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(externalOrigin);
builder.Services.AddSingleton<AiPrivacyPolicy>();
builder.Services.AddSingleton<AiOperationExecutionScope>();
builder.Services.AddTransient<AiPrivacyHeaderHandler>();
builder.Services.AddScoped<CurrentUserService>();
builder.Services.AddScoped<ICurrentUserService>(sp => sp.GetRequiredService<CurrentUserService>());
+37 -4
View File
@@ -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<AiOperationExecutionContext?> _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<IAiOperationHandler> registeredHandlers,
AiPrivacyPolicy privacy,
AiOperationExecutionScope executionScope,
IConfiguration configuration)
{
private readonly IReadOnlyDictionary<string, IAiOperationHandler> _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);
+25 -5
View File
@@ -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<AiPrivacyDecision> 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<HttpResponseMessage> 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");
}
+23 -3
View File
@@ -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),
+108 -14
View File
@@ -59,6 +59,28 @@ namespace JobTrackerApi.Services
string? FileName
);
public sealed record AiGenerationResult(
string Text,
string? Provider = null,
string? Model = null,
string? FallbackReason = null,
string? RouteReason = null);
public sealed class AiGenerationException(
string category,
string message,
bool retryable,
string? provider = null,
string? model = null,
string? routeReason = null) : Exception(message)
{
public string Category { get; } = category;
public bool Retryable { get; } = retryable;
public string? Provider { get; } = provider;
public string? Model { get; } = model;
public string? RouteReason { get; } = routeReason;
}
public interface IAiService
{
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
@@ -71,6 +93,17 @@ namespace JobTrackerApi.Services
public interface ISummarizerService : IAiService
{
new Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
async Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(
string instruction,
string text,
int maxLength = 180,
int minLength = 40,
CancellationToken cancellationToken = default)
{
var generated = await SummarizeSectionAsync(instruction, text, maxLength, minLength);
return string.IsNullOrWhiteSpace(generated) ? null : new AiGenerationResult(generated);
}
}
public class SummarizerService : ISummarizerService
@@ -144,16 +177,40 @@ namespace JobTrackerApi.Services
return $"HTTP {(int)response.StatusCode}: {body}";
}
private static string? ReadBoundedHeader(HttpResponseMessage response, string name)
{
if (!response.Headers.TryGetValues(name, out var values)) return null;
var value = values.FirstOrDefault()?.Trim();
return string.IsNullOrWhiteSpace(value) ? null : value[..Math.Min(value.Length, 128)];
}
public async Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30)
{
if (string.IsNullOrWhiteSpace(text)) return null;
return await SummarizeCoreAsync(text, maxLength, minLength);
}
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
public async Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
{
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(null);
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength);
try
{
return (await GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength))?.Text;
}
catch (AiGenerationException)
{
return null;
}
}
public Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(
string instruction,
string text,
int maxLength = 180,
int minLength = 40,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<AiGenerationResult?>(null);
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength, cancellationToken);
}
private static string ComposeBoundedPrompt(string instruction, string text)
@@ -173,7 +230,12 @@ namespace JobTrackerApi.Services
return prefix + text[..remaining];
}
private async Task<string?> RewriteCoreAsync(string instruction, string text, int maxLength, int minLength)
private async Task<AiGenerationResult?> RewriteCoreAsync(
string instruction,
string text,
int maxLength,
int minLength,
CancellationToken cancellationToken)
{
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
@@ -186,7 +248,7 @@ namespace JobTrackerApi.Services
var key = BuildCacheKey($"rewrite::{composed}", normalizedMaxLength, normalizedMinLength);
Interlocked.Increment(ref _requests);
if (_cache.TryGetValue<string>(key, out var cached))
if (_cache.TryGetValue<AiGenerationResult>(key, out var cached))
{
Interlocked.Increment(ref _cacheHits);
lock (_metricsLock)
@@ -212,33 +274,48 @@ namespace JobTrackerApi.Services
try
{
var res = await client.PostAsync("/cv/rewrite", content);
using var res = await client.PostAsync("/cv/rewrite", content, cancellationToken);
sw.Stop();
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
if (!res.IsSuccessStatusCode)
{
var errorBody = await ReadErrorBodyAsync(res);
var errorBody = await ReadErrorBodyAsync(res, cancellationToken);
Interlocked.Increment(ref _failures);
lock (_metricsLock)
{
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = $"AI rewrite failed: {errorBody}";
}
return null;
var status = (int)res.StatusCode;
throw new AiGenerationException(
status is 408 or 429 or >= 500 ? "provider_unavailable" : "provider_rejected",
"AI generation failed at the configured provider boundary.",
status is 408 or 429 or >= 500,
ReadBoundedHeader(res, "X-Ai-Provider"),
ReadBoundedHeader(res, "X-Ai-Model"),
ReadBoundedHeader(res, "X-Ai-Route-Reason"));
}
using var stream = await res.Content.ReadAsStreamAsync();
using var doc = await JsonDocument.ParseAsync(stream);
using var stream = await res.Content.ReadAsStreamAsync(cancellationToken);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (doc.RootElement.TryGetProperty("rewritten_text", out var el))
{
var s = el.GetString();
if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6));
var result = string.IsNullOrWhiteSpace(s)
? null
: new AiGenerationResult(
s,
ReadBoundedHeader(res, "X-Ai-Provider"),
ReadBoundedHeader(res, "X-Ai-Model"),
ReadBoundedHeader(res, "X-Ai-Fallback-Reason"),
ReadBoundedHeader(res, "X-Ai-Route-Reason"));
if (result is not null) _cache.Set(key, result, TimeSpan.FromHours(6));
lock (_metricsLock)
{
_lastSuccessAt = DateTimeOffset.UtcNow;
_lastError = null;
}
return s;
return result;
}
lock (_metricsLock)
@@ -246,7 +323,21 @@ namespace JobTrackerApi.Services
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = "AI rewrite failed: response did not contain rewritten_text.";
}
return null;
throw new AiGenerationException(
"invalid_response",
"AI generation returned an invalid response.",
true,
ReadBoundedHeader(res, "X-Ai-Provider"),
ReadBoundedHeader(res, "X-Ai-Model"),
ReadBoundedHeader(res, "X-Ai-Route-Reason"));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (AiGenerationException)
{
throw;
}
catch (Exception ex)
{
@@ -258,7 +349,10 @@ namespace JobTrackerApi.Services
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = ex.Message;
}
return null;
throw new AiGenerationException(
"provider_unavailable",
"AI generation could not reach the provider boundary.",
true);
}
}
+39 -2
View File
@@ -159,10 +159,23 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
cancellationToken);
}
public async Task<int> CompleteAsync(Guid operationId, string leaseToken, string? resultReference, CancellationToken cancellationToken)
public Task<int> CompleteAsync(Guid operationId, string leaseToken, string? resultReference, CancellationToken cancellationToken)
=> CompleteAsync(operationId, leaseToken, resultReference, null, null, null, cancellationToken);
public async Task<int> 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<bool> FailAsync(Guid operationId, string leaseToken, bool retryable, string category, string message, TimeSpan retryDelay, CancellationToken cancellationToken)
public Task<bool> 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<bool> 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);
+2 -1
View File
@@ -19,7 +19,8 @@
},
"Ai": {
"ExternalProcessingEnabled": false,
"ExternalProvider": "ollama"
"ExternalProvider": "ollama",
"RoutingMode": "local_first"
},
"AiQueue": {
"WorkerConcurrency": 1,
+7 -2
View File
@@ -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:-}
+9 -11
View File
@@ -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
+4 -3
View File
@@ -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.
+3 -1
View File
@@ -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.
+11
View File
@@ -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-098V-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.
+8
View File
@@ -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.
+3
View File
@@ -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
@@ -33,3 +33,5 @@ PROD-002 provides a code-derived P0P3 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`.
+1 -2
View File
@@ -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.
@@ -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`; P0P2 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.
@@ -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.
+1 -2
View File
@@ -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.
+10
View File
@@ -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.
+9 -8
View File
@@ -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`
+13 -13
View File
@@ -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-098V-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.
+225 -33
View File
@@ -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}
+183 -33
View File
@@ -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):