refactor, security updates, cv extraction upgrades
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public static class AuthSessionOptions
|
||||
{
|
||||
public const string SessionCookieName = "jobtracker_auth";
|
||||
public const string CsrfCookieName = "XSRF-TOKEN";
|
||||
public const string CsrfHeaderName = "X-CSRF-TOKEN";
|
||||
|
||||
public static CookieOptions BuildSessionCookie(bool persistent, bool secure)
|
||||
{
|
||||
var options = new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
IsEssential = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Secure = secure,
|
||||
Path = "/",
|
||||
};
|
||||
|
||||
if (persistent)
|
||||
{
|
||||
options.Expires = DateTimeOffset.UtcNow.AddDays(30);
|
||||
options.MaxAge = TimeSpan.FromDays(30);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public static CookieOptions BuildCsrfCookie(bool persistent, bool secure)
|
||||
{
|
||||
var options = new CookieOptions
|
||||
{
|
||||
HttpOnly = false,
|
||||
IsEssential = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Secure = secure,
|
||||
Path = "/",
|
||||
};
|
||||
|
||||
if (persistent)
|
||||
{
|
||||
options.Expires = DateTimeOffset.UtcNow.AddDays(30);
|
||||
options.MaxAge = TimeSpan.FromDays(30);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public static CookieOptions BuildExpiredCookie(bool secure)
|
||||
{
|
||||
return new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
IsEssential = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Secure = secure,
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.UnixEpoch,
|
||||
MaxAge = TimeSpan.Zero,
|
||||
};
|
||||
}
|
||||
|
||||
public static CookieOptions BuildExpiredReadableCookie(bool secure)
|
||||
{
|
||||
return new CookieOptions
|
||||
{
|
||||
HttpOnly = false,
|
||||
IsEssential = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Secure = secure,
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.UnixEpoch,
|
||||
MaxAge = TimeSpan.Zero,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record CvNormalizationResult(
|
||||
double? Confidence,
|
||||
string? Reason,
|
||||
[property: JsonPropertyName("normalized_text")] string? NormalizedText);
|
||||
|
||||
public interface ICvAiNormalizer
|
||||
{
|
||||
Task<CvNormalizationResult?> NormalizeAsync(string text, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class CvAiNormalizer : ICvAiNormalizer
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public CvAiNormalizer(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task<CvNormalizationResult?> NormalizeAsync(string text, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("ai-service");
|
||||
var payload = JsonSerializer.Serialize(new { text });
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync("/cv/normalize", content, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return await JsonSerializer.DeserializeAsync<CvNormalizationResult>(stream, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoOpCvAiNormalizer : ICvAiNormalizer
|
||||
{
|
||||
public static NoOpCvAiNormalizer Instance { get; } = new();
|
||||
private NoOpCvAiNormalizer() { }
|
||||
public Task<CvNormalizationResult?> NormalizeAsync(string text, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<CvNormalizationResult?>(null);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Threading.Channels;
|
||||
using JobTrackerApi.Controllers;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public interface ICvProcessingQueue
|
||||
{
|
||||
ValueTask EnqueueAsync(int runId, CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<int> DequeueAllAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class CvProcessingQueue : ICvProcessingQueue
|
||||
{
|
||||
private readonly Channel<int> _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
public ValueTask EnqueueAsync(int runId, CancellationToken cancellationToken = default)
|
||||
=> _channel.Writer.WriteAsync(runId, cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<int> DequeueAllAsync(CancellationToken cancellationToken)
|
||||
=> _channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NoOpCvProcessingQueue : ICvProcessingQueue
|
||||
{
|
||||
public static readonly NoOpCvProcessingQueue Instance = new();
|
||||
public ValueTask EnqueueAsync(int runId, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
|
||||
public async IAsyncEnumerable<int> DequeueAllAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CvProcessingHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ICvProcessingQueue _queue;
|
||||
private readonly ILogger<CvProcessingHostedService> _logger;
|
||||
|
||||
public CvProcessingHostedService(IServiceScopeFactory scopeFactory, ICvProcessingQueue queue, ILogger<CvProcessingHostedService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_queue = queue;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var runId in _queue.DequeueAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var controller = scope.ServiceProvider.GetRequiredService<ProfileCvController>();
|
||||
await controller.ProcessQueuedRunAsync(runId, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled CV processing worker failure for run {RunId}", runId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,22 +10,26 @@ namespace JobTrackerApi.Services
|
||||
private readonly ILogger<DailyExportHostedService> _logger;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly AppPaths _paths;
|
||||
private readonly IStartupReadiness _startupReadiness;
|
||||
|
||||
public DailyExportHostedService(
|
||||
IServiceProvider sp,
|
||||
ILogger<DailyExportHostedService> logger,
|
||||
IConfiguration cfg,
|
||||
AppPaths paths)
|
||||
AppPaths paths,
|
||||
IStartupReadiness startupReadiness)
|
||||
{
|
||||
_sp = sp;
|
||||
_logger = logger;
|
||||
_cfg = cfg;
|
||||
_paths = paths;
|
||||
_startupReadiness = startupReadiness;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var enabled = _cfg.GetValue("Exports:DailyEnabled", true);
|
||||
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
if (!enabled)
|
||||
{
|
||||
_logger.LogInformation("Daily export disabled (Exports:DailyEnabled=false).");
|
||||
|
||||
@@ -10,16 +10,19 @@ public sealed class FollowUpReminderHostedService : BackgroundService
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<FollowUpReminderHostedService> _logger;
|
||||
private readonly IStartupReadiness _startupReadiness;
|
||||
|
||||
public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger<FollowUpReminderHostedService> logger)
|
||||
public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger<FollowUpReminderHostedService> logger, IStartupReadiness startupReadiness)
|
||||
{
|
||||
_services = services;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
_startupReadiness = startupReadiness;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
|
||||
@@ -9,15 +9,18 @@ public sealed class JobEnrichmentHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<JobEnrichmentHostedService> _logger;
|
||||
private readonly IStartupReadiness _startupReadiness;
|
||||
|
||||
public JobEnrichmentHostedService(IServiceProvider services, ILogger<JobEnrichmentHostedService> logger)
|
||||
public JobEnrichmentHostedService(IServiceProvider services, ILogger<JobEnrichmentHostedService> logger, IStartupReadiness startupReadiness)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
_startupReadiness = startupReadiness;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
|
||||
@@ -7,14 +7,17 @@ namespace JobTrackerApi.Services
|
||||
public sealed class RulesHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IStartupReadiness _startupReadiness;
|
||||
|
||||
public RulesHostedService(IServiceProvider services)
|
||||
public RulesHostedService(IServiceProvider services, IStartupReadiness startupReadiness)
|
||||
{
|
||||
_services = services;
|
||||
_startupReadiness = startupReadiness;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
// Small initial delay to let app start.
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public interface IStartupReadiness
|
||||
{
|
||||
Task WaitUntilReadyAsync(CancellationToken cancellationToken);
|
||||
void MarkReady();
|
||||
}
|
||||
|
||||
public sealed class StartupReadiness : IStartupReadiness
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task WaitUntilReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_ready.Task.IsCompleted)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return _ready.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void MarkReady()
|
||||
{
|
||||
_ready.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,35 @@ namespace JobTrackerApi.Services
|
||||
return $"summ:{hash}";
|
||||
}
|
||||
|
||||
|
||||
private static async Task<string> ReadErrorBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return $"HTTP {(int)response.StatusCode}";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("detail", out var detailEl) && detailEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return $"HTTP {(int)response.StatusCode}: {detailEl.GetString()}";
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("message", out var messageEl) && messageEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return $"HTTP {(int)response.StatusCode}: {messageEl.GetString()}";
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
body = body.Length <= 400 ? body : body[..400];
|
||||
return $"HTTP {(int)response.StatusCode}: {body}";
|
||||
}
|
||||
|
||||
public async Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
@@ -171,12 +200,12 @@ namespace JobTrackerApi.Services
|
||||
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await res.Content.ReadAsStringAsync();
|
||||
var errorBody = await ReadErrorBodyAsync(res);
|
||||
Interlocked.Increment(ref _failures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = $"AI summarize returned {(int)res.StatusCode}: {errorBody}";
|
||||
_lastError = $"AI summarize failed: {errorBody}";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -235,11 +264,12 @@ namespace JobTrackerApi.Services
|
||||
Interlocked.Add(ref _totalOcrLatencyTicks, sw.ElapsedTicks);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await ReadErrorBodyAsync(response, cancellationToken);
|
||||
Interlocked.Increment(ref _ocrFailures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastOcrFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = $"AI extraction returned {(int)response.StatusCode}.";
|
||||
_lastError = $"AI extraction failed: {errorBody}";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -296,11 +326,12 @@ namespace JobTrackerApi.Services
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await ReadErrorBodyAsync(res, cancellationToken);
|
||||
Interlocked.Increment(ref _probeFailures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = $"Probe returned {(int)res.StatusCode}.";
|
||||
_lastError = $"AI probe failed: {errorBody}";
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -358,6 +389,8 @@ namespace JobTrackerApi.Services
|
||||
double? healthLatencyMs = null;
|
||||
var healthy = false;
|
||||
string? healthError = null;
|
||||
bool? summarizeAvailable = null;
|
||||
string? modelLoadError = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -377,6 +410,8 @@ namespace JobTrackerApi.Services
|
||||
if (doc.RootElement.TryGetProperty("gpu_name", out var gpuNameEl)) gpuName = gpuNameEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ocr_available", out var ocrAvailableEl) && ocrAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ocrAvailable = ocrAvailableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ocr_languages", out var ocrLanguagesEl)) ocrLanguages = ocrLanguagesEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("summarize_available", out var summarizeAvailableEl) && summarizeAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) summarizeAvailable = summarizeAvailableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("model_load_error", out var modelLoadErrorEl) && modelLoadErrorEl.ValueKind == JsonValueKind.String) modelLoadError = modelLoadErrorEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ollama_configured", out var ollamaConfiguredEl) && ollamaConfiguredEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaConfigured = ollamaConfiguredEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ollama_reachable", out var ollamaReachableEl) && ollamaReachableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaReachable = ollamaReachableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ollama_model", out var ollamaModelEl)) ollamaModel = ollamaModelEl.GetString();
|
||||
@@ -391,6 +426,13 @@ namespace JobTrackerApi.Services
|
||||
ollamaLoadedModels = ollamaLoadedModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("ollama_loaded_count", out var ollamaLoadedCountEl) && ollamaLoadedCountEl.ValueKind == JsonValueKind.Number) ollamaLoadedCount = ollamaLoadedCountEl.GetInt32();
|
||||
if (summarizeAvailable == false)
|
||||
{
|
||||
healthy = false;
|
||||
healthError = string.IsNullOrWhiteSpace(modelLoadError)
|
||||
? "AI summarize capability is unavailable."
|
||||
: modelLoadError;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user