refactor, security updates, cv extraction upgrades

This commit is contained in:
2026-04-11 01:34:32 +02:00
parent 806b200ac5
commit 27fd70a2d7
59 changed files with 6817 additions and 1561 deletions
@@ -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,
};
}
}
+58
View File
@@ -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)
+4 -1
View File
@@ -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);
}
}
+46 -4
View File
@@ -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
{