Files
jobtrackingapp/JobTrackerApi/Services/SummarizerService.cs
T

480 lines
21 KiB
C#

using System;
using System.Diagnostics;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace JobTrackerApi.Services
{
public sealed record AiServiceMetrics(
bool Healthy,
string? Model,
string? Device,
bool? GpuAvailable,
string? GpuName,
bool? OcrAvailable,
string? OcrLanguages,
bool? OllamaConfigured,
bool? OllamaReachable,
string? OllamaModel,
bool? OllamaModelAvailable,
double? HealthLatencyMs,
double? ProbeLatencyMs,
DateTimeOffset? LastProbeAt,
DateTimeOffset? LastProbeSuccessAt,
DateTimeOffset? LastProbeFailureAt,
int ProbeFailures,
int Requests,
int CacheHits,
int CacheMisses,
int Failures,
double? AverageLatencyMs,
int OcrRequests,
int OcrFailures,
double? AverageOcrLatencyMs,
DateTimeOffset? LastOcrSuccessAt,
DateTimeOffset? LastOcrFailureAt,
DateTimeOffset? LastSuccessAt,
DateTimeOffset? LastFailureAt,
string? LastError
);
public sealed record AiTextExtractionResult(
string? Text,
bool OcrUsed,
string? ContentType,
int? PageCount,
int Characters,
string? FileName
);
public interface IAiService
{
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
Task<AiTextExtractionResult?> ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default);
Task RunProbeAsync(CancellationToken cancellationToken = default);
Task<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default);
}
public interface ISummarizerService : IAiService
{
}
public class SummarizerService : ISummarizerService
{
private readonly IHttpClientFactory _httpFactory;
private readonly IMemoryCache _cache;
private readonly object _metricsLock = new();
private int _requests;
private int _cacheHits;
private int _cacheMisses;
private int _failures;
private long _totalLatencyTicks;
private DateTimeOffset? _lastSuccessAt;
private DateTimeOffset? _lastFailureAt;
private double? _lastProbeLatencyMs;
private DateTimeOffset? _lastProbeAt;
private DateTimeOffset? _lastProbeSuccessAt;
private DateTimeOffset? _lastProbeFailureAt;
private int _probeFailures;
private int _ocrRequests;
private int _ocrFailures;
private long _totalOcrLatencyTicks;
private DateTimeOffset? _lastOcrSuccessAt;
private DateTimeOffset? _lastOcrFailureAt;
private string? _lastError;
public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache)
{
_httpFactory = httpFactory;
_cache = cache;
}
private static string BuildCacheKey(string text, int maxLength, int minLength)
{
var payload = $"{text}\n::{maxLength}:{minLength}";
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
return $"summ:{hash}";
}
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)
{
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(null);
var composed = $"{instruction.Trim()}\n\n{text.Trim()}";
return SummarizeCoreAsync(composed, maxLength, minLength);
}
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
{
var key = BuildCacheKey(text, maxLength, minLength);
Interlocked.Increment(ref _requests);
if (_cache.TryGetValue<string>(key, out var cached))
{
Interlocked.Increment(ref _cacheHits);
lock (_metricsLock)
{
_lastSuccessAt = DateTimeOffset.UtcNow;
_lastError = null;
}
return cached;
}
Interlocked.Increment(ref _cacheMisses);
var client = _httpFactory.CreateClient("ai-service");
var payload = JsonSerializer.Serialize(new { text, max_length = maxLength, min_length = minLength });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
var sw = Stopwatch.StartNew();
try
{
var res = await client.PostAsync("/summarize", content);
sw.Stop();
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
if (!res.IsSuccessStatusCode) return null;
using var stream = await res.Content.ReadAsStreamAsync();
using var doc = await JsonDocument.ParseAsync(stream);
if (doc.RootElement.TryGetProperty("summary", out var el))
{
var s = el.GetString();
if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6));
lock (_metricsLock)
{
_lastSuccessAt = DateTimeOffset.UtcNow;
_lastError = null;
}
return s;
}
return null;
}
catch (Exception ex)
{
sw.Stop();
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
Interlocked.Increment(ref _failures);
lock (_metricsLock)
{
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = ex.Message;
}
return null;
}
}
public async Task<AiTextExtractionResult?> ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
if (string.IsNullOrWhiteSpace(fileName)) fileName = "document";
Interlocked.Increment(ref _ocrRequests);
var client = _httpFactory.CreateClient("ai-service");
var sw = Stopwatch.StartNew();
try
{
using var form = new MultipartFormDataContent();
using var fileContent = new StreamContent(stream);
if (!string.IsNullOrWhiteSpace(contentType))
{
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
}
form.Add(fileContent, "file", fileName);
using var response = await client.PostAsync("/extract-text", form, cancellationToken);
sw.Stop();
Interlocked.Add(ref _totalOcrLatencyTicks, sw.ElapsedTicks);
if (!response.IsSuccessStatusCode)
{
Interlocked.Increment(ref _ocrFailures);
lock (_metricsLock)
{
_lastOcrFailureAt = DateTimeOffset.UtcNow;
_lastError = $"AI extraction returned {(int)response.StatusCode}.";
}
return null;
}
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var doc = await JsonDocument.ParseAsync(responseStream, cancellationToken: cancellationToken);
var text = doc.RootElement.TryGetProperty("text", out var textEl) ? textEl.GetString() : null;
var ocrUsed = doc.RootElement.TryGetProperty("ocr_used", out var ocrEl) && ocrEl.ValueKind is JsonValueKind.True or JsonValueKind.False && ocrEl.GetBoolean();
var detectedContentType = doc.RootElement.TryGetProperty("content_type", out var contentTypeEl) ? contentTypeEl.GetString() : contentType;
int? pageCount = doc.RootElement.TryGetProperty("page_count", out var pageCountEl) && pageCountEl.ValueKind == JsonValueKind.Number ? pageCountEl.GetInt32() : null;
var characters = doc.RootElement.TryGetProperty("characters", out var charactersEl) && charactersEl.ValueKind == JsonValueKind.Number ? charactersEl.GetInt32() : (text?.Length ?? 0);
var returnedFileName = doc.RootElement.TryGetProperty("file_name", out var fileNameEl) ? fileNameEl.GetString() : fileName;
lock (_metricsLock)
{
_lastOcrSuccessAt = DateTimeOffset.UtcNow;
_lastError = null;
}
return new AiTextExtractionResult(text, ocrUsed, detectedContentType, pageCount, characters, returnedFileName);
}
catch (Exception ex)
{
sw.Stop();
Interlocked.Add(ref _totalOcrLatencyTicks, sw.ElapsedTicks);
Interlocked.Increment(ref _ocrFailures);
lock (_metricsLock)
{
_lastOcrFailureAt = DateTimeOffset.UtcNow;
_lastError = ex.Message;
}
return null;
}
}
public async Task RunProbeAsync(CancellationToken cancellationToken = default)
{
const string probeText = "AI service latency probe for Jobbjakt telemetry.";
var client = _httpFactory.CreateClient("ai-service");
var payload = JsonSerializer.Serialize(new { text = probeText, max_length = 48, min_length = 12 });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
var sw = Stopwatch.StartNew();
try
{
using var res = await client.PostAsync("/summarize", content, cancellationToken);
sw.Stop();
lock (_metricsLock)
{
_lastProbeAt = DateTimeOffset.UtcNow;
_lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1);
}
if (!res.IsSuccessStatusCode)
{
Interlocked.Increment(ref _probeFailures);
lock (_metricsLock)
{
_lastProbeFailureAt = DateTimeOffset.UtcNow;
_lastError = $"Probe returned {(int)res.StatusCode}.";
}
return;
}
using var stream = await res.Content.ReadAsStreamAsync(cancellationToken);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("summary", out var summaryEl) || string.IsNullOrWhiteSpace(summaryEl.GetString()))
{
Interlocked.Increment(ref _probeFailures);
lock (_metricsLock)
{
_lastProbeFailureAt = DateTimeOffset.UtcNow;
_lastError = "Probe returned an empty summary.";
}
return;
}
lock (_metricsLock)
{
_lastProbeSuccessAt = DateTimeOffset.UtcNow;
_lastError = null;
}
}
catch (Exception ex)
{
sw.Stop();
Interlocked.Increment(ref _probeFailures);
lock (_metricsLock)
{
_lastProbeAt = DateTimeOffset.UtcNow;
_lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1);
_lastProbeFailureAt = DateTimeOffset.UtcNow;
_lastError = ex.Message;
}
}
}
public async Task<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)
{
var client = _httpFactory.CreateClient("ai-service");
string? model = null;
string? device = null;
bool? gpuAvailable = null;
string? gpuName = null;
bool? ocrAvailable = null;
string? ocrLanguages = null;
bool? ollamaConfigured = null;
bool? ollamaReachable = null;
string? ollamaModel = null;
bool? ollamaModelAvailable = null;
double? healthLatencyMs = null;
var healthy = false;
string? healthError = null;
try
{
var sw = Stopwatch.StartNew();
using var res = await client.GetAsync("/health", cancellationToken);
sw.Stop();
healthLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1);
healthy = res.IsSuccessStatusCode;
if (healthy)
{
using var stream = await res.Content.ReadAsStreamAsync(cancellationToken);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (doc.RootElement.TryGetProperty("model", out var modelEl)) model = modelEl.GetString();
if (doc.RootElement.TryGetProperty("device", out var deviceEl)) device = deviceEl.GetString();
if (doc.RootElement.TryGetProperty("gpu_available", out var gpuAvailableEl) && gpuAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) gpuAvailable = gpuAvailableEl.GetBoolean();
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("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();
if (doc.RootElement.TryGetProperty("ollama_model_available", out var ollamaModelAvailableEl) && ollamaModelAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaModelAvailable = ollamaModelAvailableEl.GetBoolean();
}
else
{
healthError = $"Health check returned {(int)res.StatusCode}.";
}
}
catch (Exception ex)
{
healthError = ex.Message;
}
var requests = Volatile.Read(ref _requests);
var cacheHits = Volatile.Read(ref _cacheHits);
var cacheMisses = Volatile.Read(ref _cacheMisses);
var failures = Volatile.Read(ref _failures);
var totalLatencyTicks = Volatile.Read(ref _totalLatencyTicks);
var ocrRequests = Volatile.Read(ref _ocrRequests);
var ocrFailures = Volatile.Read(ref _ocrFailures);
var totalOcrLatencyTicks = Volatile.Read(ref _totalOcrLatencyTicks);
DateTimeOffset? lastSuccessAt;
DateTimeOffset? lastFailureAt;
double? probeLatencyMs;
DateTimeOffset? lastProbeAt;
DateTimeOffset? lastProbeSuccessAt;
DateTimeOffset? lastProbeFailureAt;
DateTimeOffset? lastOcrSuccessAt;
DateTimeOffset? lastOcrFailureAt;
string? lastError;
lock (_metricsLock)
{
lastSuccessAt = _lastSuccessAt;
lastFailureAt = _lastFailureAt;
probeLatencyMs = _lastProbeLatencyMs;
lastProbeAt = _lastProbeAt;
lastProbeSuccessAt = _lastProbeSuccessAt;
lastProbeFailureAt = _lastProbeFailureAt;
lastOcrSuccessAt = _lastOcrSuccessAt;
lastOcrFailureAt = _lastOcrFailureAt;
lastError = _lastError;
}
if (!healthy && !string.IsNullOrWhiteSpace(healthError))
{
lastError = healthError;
}
double? averageLatencyMs = requests > 0 ? Math.Round(TimeSpan.FromTicks(totalLatencyTicks).TotalMilliseconds / requests, 1) : null;
double? averageOcrLatencyMs = ocrRequests > 0 ? Math.Round(TimeSpan.FromTicks(totalOcrLatencyTicks).TotalMilliseconds / ocrRequests, 1) : null;
return new AiServiceMetrics(
Healthy: healthy,
Model: model,
Device: device,
GpuAvailable: gpuAvailable,
GpuName: gpuName,
OcrAvailable: ocrAvailable,
OcrLanguages: ocrLanguages,
OllamaConfigured: ollamaConfigured,
OllamaReachable: ollamaReachable,
OllamaModel: ollamaModel,
OllamaModelAvailable: ollamaModelAvailable,
HealthLatencyMs: healthLatencyMs,
ProbeLatencyMs: probeLatencyMs,
LastProbeAt: lastProbeAt,
LastProbeSuccessAt: lastProbeSuccessAt,
LastProbeFailureAt: lastProbeFailureAt,
ProbeFailures: Volatile.Read(ref _probeFailures),
Requests: requests,
CacheHits: cacheHits,
CacheMisses: cacheMisses,
Failures: failures,
AverageLatencyMs: averageLatencyMs,
OcrRequests: ocrRequests,
OcrFailures: ocrFailures,
AverageOcrLatencyMs: averageOcrLatencyMs,
LastOcrSuccessAt: lastOcrSuccessAt,
LastOcrFailureAt: lastOcrFailureAt,
LastSuccessAt: lastSuccessAt,
LastFailureAt: lastFailureAt,
LastError: lastError
);
}
}
public sealed class SummarizerProbeHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SummarizerProbeHostedService> _logger;
private readonly IConfiguration _cfg;
public SummarizerProbeHostedService(IServiceScopeFactory scopeFactory, ILogger<SummarizerProbeHostedService> logger, IConfiguration cfg)
{
_scopeFactory = scopeFactory;
_logger = logger;
_cfg = cfg;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var enabled = _cfg.GetValue("Ai:ProbeEnabled", _cfg.GetValue("Summarizer:ProbeEnabled", true));
if (!enabled) return;
var intervalSeconds = Math.Clamp(_cfg.GetValue("Ai:ProbeIntervalSeconds", _cfg.GetValue("Summarizer:ProbeIntervalSeconds", 300)), 30, 3600);
var initialDelaySeconds = Math.Clamp(_cfg.GetValue("Ai:ProbeInitialDelaySeconds", _cfg.GetValue("Summarizer:ProbeInitialDelaySeconds", 15)), 0, 600);
if (initialDelaySeconds > 0)
{
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
}
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(intervalSeconds));
do
{
try
{
using var scope = _scopeFactory.CreateScope();
var aiService = scope.ServiceProvider.GetRequiredService<ISummarizerService>();
await aiService.RunProbeAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI service latency probe failed.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}
}