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
@@ -15,6 +15,17 @@ public sealed class ProductionConfigTests
Assert.NotNull(method); Assert.NotNull(method);
} }
[Fact]
public void Summarizer_metrics_include_runtime_device_details()
{
var ctor = typeof(SummarizerMetrics).GetConstructors().Single();
var parameterNames = ctor.GetParameters().Select(x => x.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("device", parameterNames);
Assert.Contains("gpuAvailable", parameterNames);
Assert.Contains("gpuName", parameterNames);
}
[Fact] [Fact]
public void Profile_cv_controller_supports_pdf_and_docx_extensions() public void Profile_cv_controller_supports_pdf_and_docx_extensions()
{ {
@@ -1,4 +1,5 @@
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
using JobTrackerApi.Data; using JobTrackerApi.Data;
using JobTrackerApi.Services; using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization; 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 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 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( public sealed record SystemStatusDto(
string Environment, string Environment,
string ContentRoot, string ContentRoot,
@@ -37,6 +41,9 @@ public sealed class AdminSystemController : ControllerBase
string? BuildStamp, string? BuildStamp,
StorageStatusDto Storage, StorageStatusDto Storage,
EmailStatusDto Email, EmailStatusDto Email,
DatabaseStatusDto Database,
RuntimeStatusDto Runtime,
AuthStatusDto Auth,
SummarizerMetrics Summarizer SummarizerMetrics Summarizer
); );
@@ -65,6 +72,8 @@ public sealed class AdminSystemController : ControllerBase
[HttpGet] [HttpGet]
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken) 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 dbPath = _paths.GetDbPath();
var dbFile = new FileInfo(dbPath); var dbFile = new FileInfo(dbPath);
@@ -80,6 +89,53 @@ public sealed class AdminSystemController : ControllerBase
var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]); var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]);
var buildStamp = NormalizeBuildMetadata(_cfg["App:BuildStamp"]); 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( return Ok(new SystemStatusDto(
Environment: _env.EnvironmentName, Environment: _env.EnvironmentName,
@@ -104,6 +160,26 @@ public sealed class AdminSystemController : ControllerBase
From: (_cfg["Email:From"] ?? string.Empty).Trim(), From: (_cfg["Email:From"] ?? string.Empty).Trim(),
FromName: (_cfg["Email:FromName"] ?? 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 Summarizer: summarizer
)); ));
} }
@@ -71,6 +71,20 @@ namespace JobTrackerApi.Controllers
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); 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) private static string? NormalizeTags(string? raw)
{ {
var normalized = SplitTags(raw) var normalized = SplitTags(raw)
@@ -363,8 +377,9 @@ namespace JobTrackerApi.Controllers
.MaxAsync(c => (DateTime?)c.Date, cancellationToken); .MaxAsync(c => (DateTime?)c.Date, cancellationToken);
var d = RulesEngine.Evaluate(settings, job, now, lm); var d = RulesEngine.Evaluate(settings, job, now, lm);
// Return persisted short summary and compute a fuller summary on demand for the details view. // Prefer translated content for the detailed summary so Norwegian postings
var full = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 250, 40); // surface readable English analysis while the original text remains available.
var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40);
return Ok(new JobApplicationDto( return Ok(new JobApplicationDto(
Id: job.Id, Id: job.Id,
@@ -596,7 +611,7 @@ namespace JobTrackerApi.Controllers
// Generate and persist a short summary at creation time to avoid repeated model calls. // Generate and persist a short summary at creation time to avoid repeated model calls.
try try
{ {
var shortSum = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 160, 60); var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
job.ShortSummary = shortSum; job.ShortSummary = shortSum;
} }
catch catch
@@ -752,11 +767,10 @@ namespace JobTrackerApi.Controllers
if (job is null) return NotFound(); if (job is null) return NotFound();
var sourceText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes } var sourceText = BuildSummarySource(job);
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(sourceText)) 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) var tags = SkillTagger.Detect(sourceText)
@@ -1710,7 +1724,7 @@ Candidate master CV:
)) ))
.ToList(); .ToList();
return Ok(new DuplicateCheckResult(matches.Count > 0, matches)); return Ok(new DuplicateCheckResult(matches.Any(), matches));
} }
[HttpGet("{id:int}/followup-draft")] [HttpGet("{id:int}/followup-draft")]
+156 -147
View File
@@ -37,13 +37,22 @@ builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
var cfg = sp.GetRequiredService<IConfiguration>(); var cfg = sp.GetRequiredService<IConfiguration>();
var paths = sp.GetRequiredService<AppPaths>(); var paths = sp.GetRequiredService<AppPaths>();
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var cs = cfg.GetConnectionString("JobTracker"); var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs)) if (string.IsNullOrWhiteSpace(cs))
{ {
cs = $"Data Source={paths.GetDbPath()}"; cs = $"Data Source={paths.GetDbPath()}";
provider = "sqlite";
} }
options.UseSqlite(cs); if (provider is "mysql" or "mariadb")
{
options.UseMySql(cs, ServerVersion.AutoDetect(cs));
}
else
{
options.UseSqlite(cs);
}
// We create Identity tables on startup in environments where `dotnet ef` isn't available. // We create Identity tables on startup in environments where `dotnet ef` isn't available.
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it. // That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
@@ -299,67 +308,71 @@ using (var scope = app.Services.CreateScope())
var paths = scope.ServiceProvider.GetRequiredService<AppPaths>(); var paths = scope.ServiceProvider.GetRequiredService<AppPaths>();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>(); var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>(); var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var useSqliteBootstrap = provider is not "mysql" and not "mariadb";
// Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied). if (useSqliteBootstrap)
// If the schema already contains the columns added by migration 20260310195000, record that migration
// so EF doesn't try to apply it again and fail on duplicate columns.
const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete";
const string legacyProductVersion = "7.0.17";
using DbConnection conn = db.Database.GetDbConnection();
conn.Open();
static bool HasTable(DbConnection c, string table)
{ {
using var cmd = c.CreateCommand(); // Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied).
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;"; // If the schema already contains the columns added by migration 20260310195000, record that migration
var p = cmd.CreateParameter(); // so EF doesn't try to apply it again and fail on duplicate columns.
p.ParameterName = "$name"; const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete";
p.Value = table; const string legacyProductVersion = "7.0.17";
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static bool HasColumn(DbConnection c, string table, string column) using DbConnection conn = db.Database.GetDbConnection();
{ conn.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
return cmd.ExecuteScalar() is not null;
}
static bool HasMigration(DbConnection c, string migrationId) static bool HasTable(DbConnection c, string table)
{ {
if (!HasTable(c, "__EFMigrationsHistory")) return false; using var cmd = c.CreateCommand();
using var cmd = c.CreateCommand(); cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;"; var p = cmd.CreateParameter();
var p = cmd.CreateParameter(); p.ParameterName = "$name";
p.ParameterName = "$id"; p.Value = table;
p.Value = migrationId; cmd.Parameters.Add(p);
cmd.Parameters.Add(p); return cmd.ExecuteScalar() is not null;
return cmd.ExecuteScalar() is not null; }
}
static void Exec(DbConnection c, string sql) static bool HasColumn(DbConnection c, string table, string column)
{ {
using var cmd = c.CreateCommand(); using var cmd = c.CreateCommand();
cmd.CommandText = sql; cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
cmd.ExecuteNonQuery(); return cmd.ExecuteScalar() is not null;
} }
static void EnsureColumn(DbConnection c, string table, string column, string ddl) static bool HasMigration(DbConnection c, string migrationId)
{ {
// Fresh databases won't have the table until EF migrations run. if (!HasTable(c, "__EFMigrationsHistory")) return false;
if (!HasTable(c, table)) return; using var cmd = c.CreateCommand();
if (!HasColumn(c, table, column)) Exec(c, ddl); cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
} var p = cmd.CreateParameter();
p.ParameterName = "$id";
p.Value = migrationId;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static void EnsureIdentityTables(DbConnection c) static void Exec(DbConnection c, string sql)
{ {
// EF migrations are used for the app schema. In some environments `dotnet ef` isnt available, using var cmd = c.CreateCommand();
// so create the ASP.NET Core Identity tables directly if they dont exist yet. cmd.CommandText = sql;
if (HasTable(c, "AspNetUsers")) return; cmd.ExecuteNonQuery();
}
Exec(c, """ static void EnsureColumn(DbConnection c, string table, string column, string ddl)
{
// Fresh databases won't have the table until EF migrations run.
if (!HasTable(c, table)) return;
if (!HasColumn(c, table, column)) Exec(c, ddl);
}
static void EnsureIdentityTables(DbConnection c)
{
// EF migrations are used for the app schema. In some environments `dotnet ef` isnt available,
// so create the ASP.NET Core Identity tables directly if they dont exist yet.
if (HasTable(c, "AspNetUsers")) return;
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetRoles" ( CREATE TABLE IF NOT EXISTS "AspNetRoles" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY, "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY,
"Name" TEXT NULL, "Name" TEXT NULL,
@@ -368,7 +381,7 @@ CREATE TABLE IF NOT EXISTS "AspNetRoles" (
); );
"""); """);
Exec(c, """ Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUsers" ( CREATE TABLE IF NOT EXISTS "AspNetUsers" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY, "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY,
"UserName" TEXT NULL, "UserName" TEXT NULL,
@@ -394,7 +407,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" (
); );
"""); """);
Exec(c, """ Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT, "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT,
"RoleId" TEXT NOT NULL, "RoleId" TEXT NOT NULL,
@@ -404,7 +417,7 @@ CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" (
); );
"""); """);
Exec(c, """ Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( CREATE TABLE IF NOT EXISTS "AspNetUserClaims" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT, "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL, "UserId" TEXT NOT NULL,
@@ -414,7 +427,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserClaims" (
); );
"""); """);
Exec(c, """ Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( CREATE TABLE IF NOT EXISTS "AspNetUserLogins" (
"LoginProvider" TEXT NOT NULL, "LoginProvider" TEXT NOT NULL,
"ProviderKey" TEXT NOT NULL, "ProviderKey" TEXT NOT NULL,
@@ -425,7 +438,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserLogins" (
); );
"""); """);
Exec(c, """ Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( CREATE TABLE IF NOT EXISTS "AspNetUserRoles" (
"UserId" TEXT NOT NULL, "UserId" TEXT NOT NULL,
"RoleId" TEXT NOT NULL, "RoleId" TEXT NOT NULL,
@@ -435,7 +448,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserRoles" (
); );
"""); """);
Exec(c, """ Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
"UserId" TEXT NOT NULL, "UserId" TEXT NOT NULL,
"LoginProvider" TEXT NOT NULL, "LoginProvider" TEXT NOT NULL,
@@ -446,29 +459,29 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
); );
"""); """);
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");"""); Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");"""); Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");""");
} }
EnsureIdentityTables(conn); EnsureIdentityTables(conn);
EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
static void EnsureUserRuleSettingsTable(DbConnection c) static void EnsureUserRuleSettingsTable(DbConnection c)
{ {
if (HasTable(c, "UserRuleSettings")) return; if (HasTable(c, "UserRuleSettings")) return;
Exec(c, """ Exec(c, """
CREATE TABLE IF NOT EXISTS "UserRuleSettings" ( CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
"OwnerUserId" TEXT NOT NULL CONSTRAINT "PK_UserRuleSettings" PRIMARY KEY, "OwnerUserId" TEXT NOT NULL CONSTRAINT "PK_UserRuleSettings" PRIMARY KEY,
"AppliedFollowUpDays" INTEGER NOT NULL, "AppliedFollowUpDays" INTEGER NOT NULL,
@@ -479,13 +492,13 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
"FeedbackGhostDays" INTEGER NOT NULL "FeedbackGhostDays" INTEGER NOT NULL
); );
"""); """);
} }
EnsureUserRuleSettingsTable(conn); EnsureUserRuleSettingsTable(conn);
static void EnsureGmailConnectionsTable(DbConnection c) static void EnsureGmailConnectionsTable(DbConnection c)
{ {
Exec(c, """ Exec(c, """
CREATE TABLE IF NOT EXISTS "GmailConnections" ( CREATE TABLE IF NOT EXISTS "GmailConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT, "Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "OwnerUserId" TEXT NOT NULL,
@@ -499,69 +512,70 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
); );
"""); """);
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");"""); Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
} }
EnsureGmailConnectionsTable(conn); EnsureGmailConnectionsTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists. // and at least one of the new columns already exists.
var isLegacy = var isLegacy =
HasMigration(conn, "20260310174114_AddCorrespondence") && HasMigration(conn, "20260310174114_AddCorrespondence") &&
!HasMigration(conn, legacyMigrationId) && !HasMigration(conn, legacyMigrationId) &&
(HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted")); (HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted"));
if (isLegacy) if (isLegacy)
{ {
EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;"); EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;");
EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;"); EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;");
EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;"); EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;"); EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;");
EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;"); EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;"); EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;");
EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;"); EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;");
// Ensure the persisted short summary column exists for older dev DBs. // Ensure the persisted short summary column exists for older dev DBs.
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
// Multi-user support: scope data to the authenticated user. // Multi-user support: scope data to the authenticated user.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
// Legacy DBs may be missing later correspondence columns (Subject/Channel). // Legacy DBs may be missing later correspondence columns (Subject/Channel).
if (HasTable(conn, "Correspondences")) if (HasTable(conn, "Correspondences"))
{ {
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
}
// Record the migration as applied.
Exec(
conn,
"INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " +
$"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');"
);
}
// Some dev DBs may not match the "legacy" fingerprint above but still lack
// the ShortSummary column. Ensure it exists unconditionally if missing.
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
// Ensure data folder exists before creating/opening SQLite files.
Directory.CreateDirectory(paths.DataRoot);
} }
// Record the migration as applied.
Exec(
conn,
"INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " +
$"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');"
);
}
// Some dev DBs may not match the "legacy" fingerprint above but still lack
// the ShortSummary column. Ensure it exists unconditionally if missing.
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
// Ensure data folder exists before creating/opening SQLite files.
Directory.CreateDirectory(paths.DataRoot);
db.Database.Migrate(); db.Database.Migrate();
// Optional: seed an initial admin user for local username/password login. // Optional: seed an initial admin user for local username/password login.
@@ -602,16 +616,11 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult(); var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
if (admin is not null) if (admin is not null)
{ {
using var cmd = conn.CreateCommand(); db.Database.ExecuteSqlRaw(
cmd.CommandText = """ "UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;" +
UPDATE Companies SET OwnerUserId=$uid WHERE OwnerUserId IS NULL; "UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;",
UPDATE JobApplications SET OwnerUserId=$uid WHERE OwnerUserId IS NULL; admin.Id
"""; );
var p = cmd.CreateParameter();
p.ParameterName = "$uid";
p.Value = admin.Id;
cmd.Parameters.Add(p);
cmd.ExecuteNonQuery();
} }
} }
} }
@@ -16,6 +16,9 @@ namespace JobTrackerApi.Services
public sealed record SummarizerMetrics( public sealed record SummarizerMetrics(
bool Healthy, bool Healthy,
string? Model, string? Model,
string? Device,
bool? GpuAvailable,
string? GpuName,
double? HealthLatencyMs, double? HealthLatencyMs,
double? ProbeLatencyMs, double? ProbeLatencyMs,
DateTimeOffset? LastProbeAt, DateTimeOffset? LastProbeAt,
@@ -216,6 +219,9 @@ namespace JobTrackerApi.Services
{ {
var client = _httpFactory.CreateClient("summarizer"); var client = _httpFactory.CreateClient("summarizer");
string? model = null; string? model = null;
string? device = null;
bool? gpuAvailable = null;
string? gpuName = null;
double? healthLatencyMs = null; double? healthLatencyMs = null;
var healthy = false; var healthy = false;
string? healthError = null; string? healthError = null;
@@ -236,6 +242,21 @@ namespace JobTrackerApi.Services
{ {
model = modelEl.GetString(); 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();
}
} }
else else
{ {
@@ -283,6 +304,9 @@ namespace JobTrackerApi.Services
return new SummarizerMetrics( return new SummarizerMetrics(
Healthy: healthy, Healthy: healthy,
Model: model, Model: model,
Device: device,
GpuAvailable: gpuAvailable,
GpuName: gpuName,
HealthLatencyMs: healthLatencyMs, HealthLatencyMs: healthLatencyMs,
ProbeLatencyMs: probeLatencyMs, ProbeLatencyMs: probeLatencyMs,
LastProbeAt: lastProbeAt, LastProbeAt: lastProbeAt,
@@ -155,7 +155,10 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application"; const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application";
const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || ""; const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || "";
const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet."; const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet.";
const rawDescriptionText = job?.translatedDescription || job?.description || ""; const translatedDescriptionText = job?.translatedDescription?.trim() || "";
const originalDescriptionText = job?.description?.trim() || "";
const showTranslatedText = translatedDescriptionText.length > 0;
const showOriginalText = originalDescriptionText.length > 0;
const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]); const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]);
return ( return (
@@ -219,7 +222,18 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
</Box> </Box>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography> <Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography>
</Box> </Box>
{rawDescriptionText ? <Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Original role text</Typography><Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{rawDescriptionText}</Typography></Box> : null} {showTranslatedText ? (
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Translated role text</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{translatedDescriptionText}</Typography>
</Box>
) : null}
{showOriginalText ? (
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Original role text</Typography>
<Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{originalDescriptionText}</Typography>
</Box>
) : null}
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Notes</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography></Box> <Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Notes</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography></Box>
</Box> </Box>
)} )}
+170 -68
View File
@@ -1,12 +1,23 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Alert, Box, Button, Paper, Typography } from "@mui/material"; import {
Alert,
Box,
Button,
Chip,
Paper,
Stack,
Typography,
} from "@mui/material";
import { api } from "../api"; import { api } from "../api";
type SummarizerMetrics = { type SummarizerMetrics = {
healthy: boolean; healthy: boolean;
model?: string | null; model?: string | null;
device?: string | null;
gpuAvailable?: boolean;
gpuName?: string | null;
healthLatencyMs?: number | null; healthLatencyMs?: number | null;
probeLatencyMs?: number | null; probeLatencyMs?: number | null;
lastProbeAt?: string | null; lastProbeAt?: string | null;
@@ -18,6 +29,8 @@ type SummarizerMetrics = {
cacheMisses: number; cacheMisses: number;
failures: number; failures: number;
averageLatencyMs?: number | null; averageLatencyMs?: number | null;
lastSuccessAt?: string | null;
lastFailureAt?: string | null;
lastError?: string | null; lastError?: string | null;
}; };
@@ -44,6 +57,26 @@ type SystemStatus = {
from?: string | null; from?: string | null;
fromName?: string | null; fromName?: string | null;
}; };
database: {
provider: string;
looksConfigured: boolean;
canConnect: boolean;
target?: string | null;
usesFileStorage: boolean;
warning?: string | null;
};
runtime: {
framework: string;
osDescription: string;
processArchitecture: string;
machineName?: string | null;
};
auth: {
required: boolean;
hasJwtKey: boolean;
googleConfigured: boolean;
gmailConfigured: boolean;
};
summarizer: SummarizerMetrics; summarizer: SummarizerMetrics;
}; };
@@ -58,6 +91,25 @@ function displayMetadata(value?: string | null) {
return value && value.trim().length > 0 ? value : "-"; return value && value.trim().length > 0 ? value : "-";
} }
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "-";
}
function SummaryCard({ title, value, subtitle, tone = "default" }: { title: string; value: string; subtitle?: string; tone?: "default" | "success" | "warning" | "error" }) {
const color = tone === "success" ? "success.main" : tone === "warning" ? "warning.main" : tone === "error" ? "error.main" : "text.primary";
return (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{title}</Typography>
<Typography variant="h5" sx={{ fontWeight: 950, color }}>{value}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{subtitle || "-"}</Typography>
</Paper>
);
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return <Typography variant="body2"><strong>{label}:</strong> {value}</Typography>;
}
export default function AdminSystemPage() { export default function AdminSystemPage() {
const [status, setStatus] = useState<SystemStatus | null>(null); const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -82,12 +134,26 @@ export default function AdminSystemPage() {
void load(); void load();
}, []); }, []);
const dbTone = useMemo(() => {
if (!status) return "default" as const;
if (!status.database.looksConfigured || !status.database.canConnect) return "error" as const;
if (status.database.warning) return "warning" as const;
return "success" as const;
}, [status]);
const summarizerTone = useMemo(() => {
if (!status) return "default" as const;
if (!status.summarizer.healthy) return "error" as const;
if (status.summarizer.probeFailures > 0 || status.summarizer.failures > 0) return "warning" as const;
return "success" as const;
}, [status]);
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 950 }}>System status</Typography> <Typography variant="h5" sx={{ fontWeight: 950 }}>System status</Typography>
<Typography sx={{ color: "text.secondary" }}>Quick operational view of storage, email, and summarizer health.</Typography> <Typography sx={{ color: "text.secondary" }}>Production diagnostics for runtime, database, auth, email, and summarizer health.</Typography>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button <Button
@@ -115,85 +181,121 @@ export default function AdminSystemPage() {
</Box> </Box>
{error ? <Alert severity="error">{error}</Alert> : null} {error ? <Alert severity="error">{error}</Alert> : null}
{status?.database.warning ? <Alert severity={status.database.canConnect ? "warning" : "error"}>{status.database.warning}</Alert> : null}
{status?.summarizer.lastError ? <Alert severity={status.summarizer.healthy ? "warning" : "error"}>{status.summarizer.lastError}</Alert> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
<Paper sx={{ p: 2 }}> <SummaryCard
<Typography variant="overline" sx={{ color: "text.secondary" }}>Environment</Typography> title="Environment"
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.environment ?? "-"}</Typography> value={status?.environment ?? "-"}
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>Version {displayMetadata(status?.version)}</Typography> subtitle={`Version ${displayMetadata(status?.version)} · Commit ${displayMetadata(status?.commitSha)}`}
<Typography variant="body2" sx={{ color: "text.secondary" }}>Commit {displayMetadata(status?.commitSha)}</Typography> />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{displayMetadata(status?.buildStamp)}</Typography> <SummaryCard
title="Database"
value={status ? (status.database.canConnect ? "Connected" : "Offline") : "-"}
subtitle={status ? `${status.database.provider} · ${status.database.target || "No target"}` : "-"}
tone={dbTone}
/>
<SummaryCard
title="SMTP"
value={status?.email.enabled ? "Enabled" : "Disabled"}
subtitle={status?.email.host || "No SMTP host configured"}
tone={status?.email.enabled ? "success" : "default"}
/>
<SummaryCard
title="Summarizer"
value={status?.summarizer.healthy ? "Healthy" : "Offline"}
subtitle={status?.summarizer.probeLatencyMs != null
? `${status.summarizer.probeLatencyMs} ms probe · ${status.summarizer.device || "unknown device"}`
: status?.summarizer.healthLatencyMs != null
? `${status.summarizer.healthLatencyMs} ms health · ${status.summarizer.device || "unknown device"}`
: "No latency data"}
tone={summarizerTone}
/>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Database and storage</Typography>
<Stack spacing={0.75}>
<DetailRow label="Provider" value={status?.database.provider || "-"} />
<DetailRow label="Target" value={status?.database.target || "-"} />
<DetailRow label="Configured" value={status?.database.looksConfigured ? "Yes" : "No"} />
<DetailRow label="Can connect" value={status?.database.canConnect ? "Yes" : "No"} />
<DetailRow label="Uses file storage" value={status?.database.usesFileStorage ? "Yes" : "No"} />
<DetailRow label="Data root" value={status?.storage.dataRoot || "-"} />
<DetailRow label="DB path" value={status?.storage.dbPath || "-"} />
<DetailRow label="DB file exists" value={status?.storage.dbExists ? "Yes" : "No"} />
<DetailRow label="DB size" value={formatBytes(status?.storage.dbSizeBytes)} />
<DetailRow label="Companies" value={status?.storage.companyCount ?? 0} />
<DetailRow label="Jobs" value={status?.storage.jobCount ?? 0} />
<DetailRow label="Deleted jobs" value={status?.storage.deletedCount ?? 0} />
</Stack>
</Paper> </Paper>
<Paper sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Database</Typography> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.storage.dbExists ? "Ready" : "Missing"}</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Runtime and auth</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{formatBytes(status?.storage.dbSizeBytes)}</Typography> <Stack spacing={0.75}>
</Paper> <DetailRow label="Framework" value={status?.runtime.framework || "-"} />
<Paper sx={{ p: 2 }}> <DetailRow label="OS" value={status?.runtime.osDescription || "-"} />
<Typography variant="overline" sx={{ color: "text.secondary" }}>SMTP</Typography> <DetailRow label="Architecture" value={status?.runtime.processArchitecture || "-"} />
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.email.enabled ? "Enabled" : "Disabled"}</Typography> <DetailRow label="Machine" value={status?.runtime.machineName || "-"} />
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{status?.email.host || "No SMTP host configured"}</Typography> <DetailRow label="Content root" value={status?.contentRoot || "-"} />
</Paper> <DetailRow label="Build stamp" value={displayMetadata(status?.buildStamp)} />
<Paper sx={{ p: 2 }}> <DetailRow label="Auth required" value={status?.auth.required ? "Yes" : "No"} />
<Typography variant="overline" sx={{ color: "text.secondary" }}>Summarizer</Typography> <DetailRow label="JWT key configured" value={status?.auth.hasJwtKey ? "Yes" : "No"} />
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.summarizer.healthy ? "Healthy" : "Offline"}</Typography> <DetailRow label="Google login configured" value={status?.auth.googleConfigured ? "Yes" : "No"} />
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}> <DetailRow label="Gmail integration configured" value={status?.auth.gmailConfigured ? "Yes" : "No"} />
{status?.summarizer.probeLatencyMs != null </Stack>
? `${status.summarizer.probeLatencyMs} ms probe`
: status?.summarizer.healthLatencyMs != null
? `${status.summarizer.healthLatencyMs} ms health`
: "No latency data"}
</Typography>
</Paper> </Paper>
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Storage</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Email configuration</Typography>
<Typography variant="body2"><strong>Data root:</strong> {status?.storage.dataRoot || "-"}</Typography> <Stack spacing={0.75}>
<Typography variant="body2"><strong>DB path:</strong> {status?.storage.dbPath || "-"}</Typography> <DetailRow label="Enabled" value={status?.email.enabled ? "Yes" : "No"} />
<Typography variant="body2"><strong>Companies:</strong> {status?.storage.companyCount ?? 0}</Typography> <DetailRow label="From" value={status?.email.from || "-"} />
<Typography variant="body2"><strong>Jobs:</strong> {status?.storage.jobCount ?? 0}</Typography> <DetailRow label="From name" value={status?.email.fromName || "-"} />
<Typography variant="body2"><strong>Deleted jobs:</strong> {status?.storage.deletedCount ?? 0}</Typography> <DetailRow label="Host" value={status?.email.host || "-"} />
<Typography variant="body2"><strong>Content root:</strong> {status?.contentRoot || "-"}</Typography> <DetailRow label="Port" value={status?.email.port ?? "-"} />
<DetailRow label="SSL" value={status?.email.enableSsl ? "Yes" : "No"} />
</Stack>
</Paper> </Paper>
<Paper sx={{ p: 2 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Email</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer runtime</Typography>
<Typography variant="body2"><strong>From:</strong> {status?.email.from || "-"}</Typography> <Stack spacing={0.75}>
<Typography variant="body2"><strong>From name:</strong> {status?.email.fromName || "-"}</Typography> <DetailRow label="Model" value={status?.summarizer.model || "-"} />
<Typography variant="body2"><strong>Host:</strong> {status?.email.host || "-"}</Typography> <DetailRow label="Device" value={status?.summarizer.device || "-"} />
<Typography variant="body2"><strong>Port:</strong> {status?.email.port ?? "-"}</Typography> <DetailRow label="GPU available" value={status?.summarizer.gpuAvailable ? "Yes" : "No"} />
<Typography variant="body2"><strong>SSL:</strong> {status?.email.enableSsl ? "Yes" : "No"}</Typography> <DetailRow label="GPU name" value={status?.summarizer.gpuName || "-"} />
<DetailRow label="Health latency" value={status?.summarizer.healthLatencyMs != null ? `${status.summarizer.healthLatencyMs} ms` : "-"} />
<DetailRow label="Probe latency" value={status?.summarizer.probeLatencyMs != null ? `${status.summarizer.probeLatencyMs} ms` : "-"} />
<DetailRow label="Last probe" value={formatDate(status?.summarizer.lastProbeAt)} />
<DetailRow label="Last successful probe" value={formatDate(status?.summarizer.lastProbeSuccessAt)} />
<DetailRow label="Last summarization success" value={formatDate(status?.summarizer.lastSuccessAt)} />
</Stack>
</Paper> </Paper>
</Box> </Box>
<Paper sx={{ p: 2 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(6, 1fr)" }, gap: 2 }}>
<Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography></Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheHits ?? 0}</Typography></Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache misses</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheMisses ?? 0}</Typography></Box>
</Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.failures ?? 0}</Typography></Box>
<Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Probe failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.probeFailures ?? 0}</Typography></Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography></Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheHits ?? 0}</Typography> </Box>
</Box> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 2 }}>
<Box> <Chip label={status?.database.canConnect ? "Database connected" : "Database issue"} color={status?.database.canConnect ? "success" : "error"} size="small" />
<Typography variant="overline" sx={{ color: "text.secondary" }}>Failures</Typography> <Chip label={status?.auth.required ? "Auth enforced" : "Auth optional"} color={status?.auth.required ? "success" : "warning"} size="small" />
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.failures ?? 0}</Typography> <Chip label={status?.auth.googleConfigured ? "Google sign-in ready" : "Google sign-in off"} variant="outlined" size="small" />
</Box> <Chip label={status?.auth.gmailConfigured ? "Gmail ready" : "Gmail incomplete"} variant="outlined" size="small" />
<Box> <Chip label={status?.summarizer.gpuAvailable ? "GPU visible" : "CPU mode"} color={status?.summarizer.gpuAvailable ? "success" : "default"} size="small" />
<Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography>
</Box>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Probe latency</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.probeLatencyMs != null ? `${status.summarizer.probeLatencyMs} ms` : "-"}</Typography>
</Box>
</Box> </Box>
<Typography variant="body2" sx={{ mt: 1 }}><strong>Probe failures:</strong> {status?.summarizer.probeFailures ?? 0}</Typography>
{status?.summarizer.lastError ? <Alert severity="warning" sx={{ mt: 2 }}>{status.summarizer.lastError}</Alert> : null}
</Paper> </Paper>
</Box> </Box>
); );
+111 -37
View File
@@ -10,12 +10,21 @@ app = FastAPI(title="Local Summarizer")
MODEL_NAME = "sshleifer/distilbart-cnn-12-6" MODEL_NAME = "sshleifer/distilbart-cnn-12-6"
MAX_INPUT_CHARS = 20000 MAX_INPUT_CHARS = 20000
MAX_CONTEXT_CHARS = 2200
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME) def _load_runtime():
model.eval() tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME)
model.to(device) model.eval()
has_cuda = torch.cuda.is_available()
device = torch.device("cuda" if has_cuda else "cpu")
model.to(device)
gpu_name = torch.cuda.get_device_name(0) if has_cuda else None
return tokenizer, model, device, has_cuda, gpu_name
tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = _load_runtime()
cache = TTLCache(maxsize=1024, ttl=60 * 60) cache = TTLCache(maxsize=1024, ttl=60 * 60)
@@ -33,7 +42,13 @@ def _key(text: str, max_length: int, min_length: int, top_skills: int) -> str:
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"ok": True, "model": MODEL_NAME} return {
"ok": True,
"model": MODEL_NAME,
"device": str(device),
"gpu_available": GPU_AVAILABLE,
"gpu_name": GPU_NAME,
}
_TECH = [ _TECH = [
@@ -54,6 +69,17 @@ _TECH_PRIORITY = [
] ]
_MUST_HAVE_HINTS = [
"must have", "required", "requirements", "you have", "you bring", "essential", "we are looking for",
]
_NICE_TO_HAVE_HINTS = [
"nice to have", "bonus", "preferred", "advantageous", "extra plus",
]
_SCREENING_HINTS = [
"experience with", "hands-on", "demonstrated", "proven", "track record", "delivered",
]
def _rank_tech_skills(skills): def _rank_tech_skills(skills):
ordered = [] ordered = []
seen = set() seen = set()
@@ -84,7 +110,7 @@ def _extract_bullets(lines, max_items=8):
continue continue
if re.match(r"^([-*]|\u2022)\s+", s): if re.match(r"^([-*]|\u2022)\s+", s):
s = re.sub(r"^([-*]|\u2022)\s+", "", s).strip() s = re.sub(r"^([-*]|\u2022)\s+", "", s).strip()
if 3 <= len(s) <= 200: if 3 <= len(s) <= 220:
out.append(s) out.append(s)
if len(out) >= max_items: if len(out) >= max_items:
break break
@@ -96,6 +122,7 @@ def _top_keywords(text: str, limit=6):
stop = { stop = {
"with", "from", "that", "this", "will", "have", "your", "their", "about", "role", "team", "work", "with", "from", "that", "this", "will", "have", "your", "their", "about", "role", "team", "work",
"experience", "skills", "requirements", "responsibilities", "company", "using", "ability", "years", "experience", "skills", "requirements", "responsibilities", "company", "using", "ability", "years",
"looking", "candidate", "position", "working", "across", "strong", "building", "support",
} }
counts = {} counts = {}
for word in words: for word in words:
@@ -106,6 +133,27 @@ def _top_keywords(text: str, limit=6):
return [word for word, _ in ordered[:limit]] return [word for word, _ in ordered[:limit]]
def _first_matching_sentences(text: str, hints, limit=3):
sentences = re.split(r"(?<=[.!?])\s+", text)
found = []
for sentence in sentences:
low = sentence.lower()
if any(hint in low for hint in hints):
cleaned = sentence.strip()
if 20 <= len(cleaned) <= 220:
found.append(cleaned)
if len(found) >= limit:
break
return found
def _trim_line(text: str, max_len: int = 140) -> str:
text = re.sub(r"\s+", " ", text).strip(" -•\t")
if len(text) <= max_len:
return text
return text[: max_len - 1].rstrip() + ""
def _role_focused_excerpt(text: str) -> dict: def _role_focused_excerpt(text: str) -> dict:
cleaned = _strip_html(text) cleaned = _strip_html(text)
lines = [ln.strip() for ln in cleaned.splitlines()] lines = [ln.strip() for ln in cleaned.splitlines()]
@@ -162,6 +210,11 @@ def _role_focused_excerpt(text: str) -> dict:
responsibilities = any_bullets[:6] responsibilities = any_bullets[:6]
requirements = any_bullets[6:10] requirements = any_bullets[6:10]
if not requirements:
requirements = [_trim_line(x) for x in _first_matching_sentences(cleaned, _MUST_HAVE_HINTS, limit=4)]
if not nice:
nice = [_trim_line(x) for x in _first_matching_sentences(cleaned, _NICE_TO_HAVE_HINTS, limit=3)]
focused_parts = [] focused_parts = []
if responsibilities: if responsibilities:
focused_parts.append("Responsibilities:\n- " + "\n- ".join(responsibilities)) focused_parts.append("Responsibilities:\n- " + "\n- ".join(responsibilities))
@@ -169,7 +222,14 @@ def _role_focused_excerpt(text: str) -> dict:
focused_parts.append("Requirements:\n- " + "\n- ".join(requirements)) focused_parts.append("Requirements:\n- " + "\n- ".join(requirements))
if nice: if nice:
focused_parts.append("Nice to have:\n- " + "\n- ".join(nice)) focused_parts.append("Nice to have:\n- " + "\n- ".join(nice))
focused_parts.append("Context:\n" + cleaned[:1500]) focused_parts.append("Context:\n" + cleaned[:MAX_CONTEXT_CHARS])
screen_focus = []
for item in requirements[:4]:
if any(hint in item.lower() for hint in _SCREENING_HINTS) or len(screen_focus) < 2:
screen_focus.append(_trim_line(item))
if not screen_focus:
screen_focus = [_trim_line(x) for x in _first_matching_sentences(cleaned, _SCREENING_HINTS, limit=3)]
return { return {
"cleaned": cleaned, "cleaned": cleaned,
@@ -180,6 +240,7 @@ def _role_focused_excerpt(text: str) -> dict:
"tech": tech_found, "tech": tech_found,
"soft": soft_found, "soft": soft_found,
"keywords": _top_keywords(cleaned), "keywords": _top_keywords(cleaned),
"screen_focus": screen_focus[:3],
} }
@@ -193,7 +254,9 @@ def _model_summarize(text: str, max_length: int, min_length: int) -> str:
attention_mask=attention_mask, attention_mask=attention_mask,
max_length=max_length, max_length=max_length,
min_length=min_length, min_length=min_length,
num_beams=4, num_beams=3,
length_penalty=1.0,
no_repeat_ngram_size=3,
early_stopping=True, early_stopping=True,
) )
return tokenizer.decode(outputs[0], skip_special_tokens=True).strip() return tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
@@ -211,53 +274,64 @@ async def summarize(req: SummarizeRequest):
info = _role_focused_excerpt(req.text) info = _role_focused_excerpt(req.text)
summary = _model_summarize(info["focused_input"], req.max_length, req.min_length) summary = _model_summarize(info["focused_input"], req.max_length, req.min_length)
ranked_tech = []
for t in _rank_tech_skills(info["tech"]):
if t not in ranked_tech:
ranked_tech.append(t)
uniq_soft = []
for s in info["soft"]:
if s not in uniq_soft:
uniq_soft.append(s)
lines = ["Role summary:", summary] lines = ["Role summary:", summary]
if info["requirements"]: if info["requirements"]:
lines.append("") lines.append("")
lines.append("What the company wants most:") lines.append("What the company wants most:")
for x in info["requirements"][:7]: for x in info["requirements"][:5]:
lines.append(f"- {x}") lines.append(f"- {_trim_line(x)}")
if ranked_tech:
lines.append("")
lines.append("Top hard skills:")
for skill in ranked_tech[: req.top_skills]:
lines.append(f"- {skill}")
if info["keywords"]:
lines.append("")
lines.append("Keywords to mirror:")
for keyword in info["keywords"][:5]:
lines.append(f"- {keyword}")
if info["responsibilities"]: if info["responsibilities"]:
lines.append("") lines.append("")
lines.append("What you would be doing:") lines.append("What you would be doing:")
for x in info["responsibilities"][:6]: for x in info["responsibilities"][:4]:
lines.append(f"- {x}") lines.append(f"- {_trim_line(x)}")
if info["nice"]: if info["nice"]:
lines.append("") lines.append("")
lines.append("Nice to have:") lines.append("Nice to have:")
for x in info["nice"][:5]: for x in info["nice"][:3]:
lines.append(f"- {x}") lines.append(f"- {_trim_line(x)}")
if info["tech"]: if uniq_soft:
uniq = []
for t in _rank_tech_skills(info["tech"]):
if t not in uniq:
uniq.append(t)
lines.append("") lines.append("")
lines.append("Top hard skills: " + ", ".join(uniq[: req.top_skills])) lines.append("Relevant soft skills:")
for soft in uniq_soft[:5]:
if info["soft"]: lines.append(f"- {soft}")
uniq_soft = []
for s in info["soft"]:
if s not in uniq_soft:
uniq_soft.append(s)
lines.append("")
lines.append("Relevant soft skills: " + ", ".join(uniq_soft[:8]))
if info["keywords"]:
lines.append("")
lines.append("Key themes: " + ", ".join(info["keywords"][:6]))
lines.append("") lines.append("")
lines.append("Interview focus:") lines.append("Interview focus:")
if info["requirements"]: if info["screen_focus"]:
for x in info["screen_focus"]:
lines.append(f"- Be ready to prove: {_trim_line(x)}")
elif info["requirements"]:
for x in info["requirements"][:3]: for x in info["requirements"][:3]:
lines.append(f"- Prepare examples that demonstrate: {x}") lines.append(f"- Prepare examples that demonstrate: {_trim_line(x)}")
elif info["tech"]: elif ranked_tech:
for x in _rank_tech_skills(info["tech"])[:3]: for x in ranked_tech[:3]:
lines.append(f"- Be ready to explain your hands-on experience with {x}") lines.append(f"- Be ready to explain your hands-on experience with {x}")
else: else:
lines.append("- Prepare examples showing relevant impact, collaboration, and delivery.") lines.append("- Prepare examples showing relevant impact, collaboration, and delivery.")