feat: improve admin observability and translation-first summaries

This commit is contained in:
cesnimda
2026-03-22 21:37:30 +01:00
parent 8014c1e890
commit 4c49ffb0d6
8 changed files with 585 additions and 261 deletions
@@ -1,4 +1,5 @@
using System.Reflection;
using System.Runtime.InteropServices;
using JobTrackerApi.Data;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
@@ -29,6 +30,9 @@ public sealed class AdminSystemController : ControllerBase
public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount);
public sealed record EmailStatusDto(bool Enabled, string? Host, int Port, bool EnableSsl, string? From, string? FromName);
public sealed record DatabaseStatusDto(string Provider, bool LooksConfigured, bool CanConnect, string? Target, bool UsesFileStorage, string? Warning);
public sealed record RuntimeStatusDto(string Framework, string OSDescription, string ProcessArchitecture, string? MachineName);
public sealed record AuthStatusDto(bool Required, bool HasJwtKey, bool GoogleConfigured, bool GmailConfigured);
public sealed record SystemStatusDto(
string Environment,
string ContentRoot,
@@ -37,6 +41,9 @@ public sealed class AdminSystemController : ControllerBase
string? BuildStamp,
StorageStatusDto Storage,
EmailStatusDto Email,
DatabaseStatusDto Database,
RuntimeStatusDto Runtime,
AuthStatusDto Auth,
SummarizerMetrics Summarizer
);
@@ -65,6 +72,8 @@ public sealed class AdminSystemController : ControllerBase
[HttpGet]
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
{
var provider = (_cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var usesFileStorage = provider is not "mysql" and not "mariadb";
var dbPath = _paths.GetDbPath();
var dbFile = new FileInfo(dbPath);
@@ -80,6 +89,53 @@ public sealed class AdminSystemController : ControllerBase
var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]);
var buildStamp = NormalizeBuildMetadata(_cfg["App:BuildStamp"]);
var connectionString = _cfg.GetConnectionString("JobTracker") ?? string.Empty;
var looksConfigured = !string.IsNullOrWhiteSpace(connectionString) || usesFileStorage;
string? dbTarget;
if (usesFileStorage)
{
dbTarget = dbPath;
}
else if (string.IsNullOrWhiteSpace(connectionString))
{
dbTarget = null;
}
else
{
dbTarget = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Trim())
.FirstOrDefault(part => part.StartsWith("server=", StringComparison.OrdinalIgnoreCase)
|| part.StartsWith("host=", StringComparison.OrdinalIgnoreCase)
|| part.StartsWith("database=", StringComparison.OrdinalIgnoreCase));
}
bool canConnect;
try
{
canConnect = await _db.Database.CanConnectAsync(cancellationToken);
}
catch
{
canConnect = false;
}
string? dbWarning = null;
if (!looksConfigured)
{
dbWarning = "Connection string is missing.";
}
else if (!canConnect)
{
dbWarning = "Database connection failed.";
}
else if (!usesFileStorage && jobs.Count == 0 && companies == 0)
{
dbWarning = "Connected, but no data is present yet. Check whether this is the intended production database.";
}
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim())
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
return Ok(new SystemStatusDto(
Environment: _env.EnvironmentName,
@@ -104,6 +160,26 @@ public sealed class AdminSystemController : ControllerBase
From: (_cfg["Email:From"] ?? string.Empty).Trim(),
FromName: (_cfg["Email:FromName"] ?? string.Empty).Trim()
),
Database: new DatabaseStatusDto(
Provider: provider,
LooksConfigured: looksConfigured,
CanConnect: canConnect,
Target: dbTarget,
UsesFileStorage: usesFileStorage,
Warning: dbWarning
),
Runtime: new RuntimeStatusDto(
Framework: RuntimeInformation.FrameworkDescription,
OSDescription: RuntimeInformation.OSDescription,
ProcessArchitecture: RuntimeInformation.ProcessArchitecture.ToString(),
MachineName: Environment.MachineName
),
Auth: new AuthStatusDto(
Required: _cfg.GetValue("Auth:Require", false),
HasJwtKey: !string.IsNullOrWhiteSpace((_cfg["Auth:JwtKey"] ?? string.Empty).Trim()),
GoogleConfigured: !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim()),
GmailConfigured: gmailConfigured
),
Summarizer: summarizer
));
}
@@ -71,6 +71,20 @@ namespace JobTrackerApi.Controllers
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
}
private static string BuildSummarySource(JobApplication job)
{
// Prefer translated text for summaries and skill extraction so non-English
// postings become easier to understand while keeping the original text intact.
var parts = new[]
{
job.TranslatedDescription,
job.Description,
job.Notes
};
return string.Join("\n\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()));
}
private static string? NormalizeTags(string? raw)
{
var normalized = SplitTags(raw)
@@ -363,8 +377,9 @@ namespace JobTrackerApi.Controllers
.MaxAsync(c => (DateTime?)c.Date, cancellationToken);
var d = RulesEngine.Evaluate(settings, job, now, lm);
// Return persisted short summary and compute a fuller summary on demand for the details view.
var full = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 250, 40);
// Prefer translated content for the detailed summary so Norwegian postings
// surface readable English analysis while the original text remains available.
var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40);
return Ok(new JobApplicationDto(
Id: job.Id,
@@ -596,7 +611,7 @@ namespace JobTrackerApi.Controllers
// Generate and persist a short summary at creation time to avoid repeated model calls.
try
{
var shortSum = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 160, 60);
var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
job.ShortSummary = shortSum;
}
catch
@@ -752,11 +767,10 @@ namespace JobTrackerApi.Controllers
if (job is null) return NotFound();
var sourceText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes }
.Where(x => !string.IsNullOrWhiteSpace(x)));
var sourceText = BuildSummarySource(job);
if (string.IsNullOrWhiteSpace(sourceText))
{
return BadRequest("This job does not have enough description or notes to generate a summary and skills.");
return BadRequest("This job does not have enough translated text, description, or notes to generate a summary and skills.");
}
var tags = SkillTagger.Detect(sourceText)
@@ -1710,7 +1724,7 @@ Candidate master CV:
))
.ToList();
return Ok(new DuplicateCheckResult(matches.Count > 0, matches));
return Ok(new DuplicateCheckResult(matches.Any(), matches));
}
[HttpGet("{id:int}/followup-draft")]