From 9191e4cc5b274022c53bbf1e790b27703386d7fd Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 1 Apr 2026 13:38:22 +0200 Subject: [PATCH] fix: harden admin system fallback and benchmark review --- .../AuthAndSystemControllerTests.cs | 29 ++++ .../Controllers/AdminSystemController.cs | 63 +++++++- job-tracker-ui/src/admin-system-page.test.tsx | 35 +++++ job-tracker-ui/src/pages/AdminSystemPage.tsx | 144 +++++++++++++++++- 4 files changed, 264 insertions(+), 7 deletions(-) diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 265243c..b5a37b0 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -133,6 +133,35 @@ public sealed class AuthAndSystemControllerTests Assert.Equal("person@example.com", result.GoogleLink.Email); } + [Fact] + public async Task Admin_system_email_settings_falls_back_when_override_store_is_unavailable() + { + var emailSettings = new Mock(); + emailSettings.Setup(x => x.GetAdminDtoAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("missing SystemEmailSettings")); + + var cfg = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Email:Enabled"] = "false", + ["Email:FromName"] = "Jobbjakt" + }) + .Build(); + + var controller = new AdminSystemController( + cfg, + new AppPaths(cfg, new FakeHostEnv()), + null!, + Mock.Of(), + new FakeEnv(), + emailSettings.Object); + + var result = await controller.GetEmailSettings(CancellationToken.None); + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.False(dto.Enabled); + Assert.Contains("fallback", dto.FromName, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Admin_system_probe_endpoint_runs_probe_once() { diff --git a/JobTrackerApi/Controllers/AdminSystemController.cs b/JobTrackerApi/Controllers/AdminSystemController.cs index 6b0eb60..29cde58 100644 --- a/JobTrackerApi/Controllers/AdminSystemController.cs +++ b/JobTrackerApi/Controllers/AdminSystemController.cs @@ -65,6 +65,50 @@ public sealed class AdminSystemController : ControllerBase return trimmed; } + private EmailSettingsSnapshot BuildFallbackEmailSettingsSnapshot() + { + var host = (_cfg["Email:SmtpHost"] ?? string.Empty).Trim(); + var user = (_cfg["Email:SmtpUser"] ?? string.Empty).Trim(); + var password = (_cfg["Email:SmtpPassword"] ?? string.Empty).Trim(); + var from = (_cfg["Email:From"] ?? user).Trim(); + var fromName = (_cfg["Email:FromName"] ?? "Jobbjakt").Trim(); + var port = _cfg.GetValue("Email:SmtpPort", 587); + if (port <= 0) port = 587; + var enableSsl = _cfg.GetValue("Email:SmtpEnableSsl", true); + var timeoutMs = _cfg.GetValue("Email:SmtpTimeoutMs", 15000); + if (timeoutMs <= 0) timeoutMs = 15000; + var enabled = _cfg.GetValue("Email:Enabled", false); + + return new EmailSettingsSnapshot( + Enabled: enabled, + Host: host, + Port: port, + User: user, + Password: password, + From: from, + FromName: fromName, + EnableSsl: enableSsl, + TimeoutMs: timeoutMs, + UsesOverrides: false, + HasPassword: !string.IsNullOrWhiteSpace(password)); + } + + private EmailSettingsAdminDto BuildFallbackEmailSettings(string? reason = null) + { + var snapshot = BuildFallbackEmailSettingsSnapshot(); + return new EmailSettingsAdminDto( + Enabled: snapshot.Enabled, + Host: snapshot.Host, + Port: snapshot.Port, + User: snapshot.User, + From: snapshot.From, + FromName: string.IsNullOrWhiteSpace(reason) ? snapshot.FromName : $"{snapshot.FromName} (fallback)", + EnableSsl: snapshot.EnableSsl, + TimeoutMs: snapshot.TimeoutMs, + UsesOverrides: snapshot.UsesOverrides, + HasPassword: snapshot.HasPassword); + } + [HttpPost("ai/probe")] [HttpPost("summarizer/probe")] public async Task RunSummarizerProbe(CancellationToken cancellationToken) @@ -76,7 +120,14 @@ public sealed class AdminSystemController : ControllerBase [HttpGet("email-settings")] public async Task> GetEmailSettings(CancellationToken cancellationToken) { - return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken)); + try + { + return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken)); + } + catch (Exception ex) + { + return Ok(BuildFallbackEmailSettings(ex.Message)); + } } [HttpPut("email-settings")] @@ -232,7 +283,15 @@ public sealed class AdminSystemController : ControllerBase var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim()) && !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim()); - var emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken); + EmailSettingsSnapshot emailSettings; + try + { + emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken); + } + catch (Exception) + { + emailSettings = BuildFallbackEmailSettingsSnapshot(); + } return Ok(new SystemStatusDto( Environment: _env.EnvironmentName, diff --git a/job-tracker-ui/src/admin-system-page.test.tsx b/job-tracker-ui/src/admin-system-page.test.tsx index 9c4c3b1..cb09087 100644 --- a/job-tracker-ui/src/admin-system-page.test.tsx +++ b/job-tracker-ui/src/admin-system-page.test.tsx @@ -96,6 +96,39 @@ describe('AdminSystemPage', () => { rootPath: '/data/CvBenchmarks/latest', lastUpdatedAtUtc: '2026-03-23T10:10:00Z', reportMarkdown: '# CV benchmark report\n\n- Files: 4', + indexJson: JSON.stringify({ + CorpusRoot: '/home/pi/cvs', + OutputRoot: '/data/CvBenchmarks/latest', + GeneratedAtUtc: '2026-03-23T10:10:00Z', + TotalFiles: 4, + AverageCoverage: 0.72, + AverageConfidence: 0.66, + AverageConsistency: 0.94, + FilesWithSuspiciousLocations: 1, + MissingApprovedFixtures: 4, + Entries: [ + { + FileName: 'cv.txt', + Slug: 'cv-txt', + Extension: '.txt', + Characters: 2000, + OutputPath: '/data/CvBenchmarks/latest/outputs/cv-txt.json', + ApprovedFixturePath: null, + CandidateFixturePath: '/data/CvBenchmarks/latest/candidate-fixtures/cv-txt.json', + ContactLocation: 'San Francisco, Hobbies', + FirstJob: '* July', + FirstJobLocation: null, + FirstEducation: '* September', + FirstEducationLocation: null, + QualificationLevels: ['Other'], + SuspiciousLocations: [], + CoverageScore: 0.5, + ConfidenceScore: 0.65, + ConsistencyScore: 0.8, + DiffSummary: 'No approved fixture yet — candidate fixture written.', + }, + ], + }), }, } as any); } @@ -138,6 +171,8 @@ describe('AdminSystemPage', () => { expect(screen.getByText(/ollama version/i)).toBeTruthy(); expect(screen.getByText(/model · qwen2.5:7b/i)).toBeTruthy(); expect(screen.getByText(/cv benchmark review/i)).toBeTruthy(); + expect(screen.getByText(/top parser findings/i)).toBeTruthy(); + expect(screen.getByText(/suspicious contact location: san francisco, hobbies/i)).toBeTruthy(); expect(screen.getByText('OCR avg latency')).toBeTruthy(); expect(screen.getByText('88.4 ms')).toBeTruthy(); }); diff --git a/job-tracker-ui/src/pages/AdminSystemPage.tsx b/job-tracker-ui/src/pages/AdminSystemPage.tsx index 7b43b08..bd1f385 100644 --- a/job-tracker-ui/src/pages/AdminSystemPage.tsx +++ b/job-tracker-ui/src/pages/AdminSystemPage.tsx @@ -68,6 +68,40 @@ type EditableEmailSettings = { hasPassword: boolean; }; +type CvBenchmarkEntry = { + FileName: string; + Slug: string; + Extension: string; + Characters: number; + OutputPath: string; + ApprovedFixturePath?: string | null; + CandidateFixturePath?: string | null; + ContactLocation?: string | null; + FirstJob?: string | null; + FirstJobLocation?: string | null; + FirstEducation?: string | null; + FirstEducationLocation?: string | null; + QualificationLevels: string[]; + SuspiciousLocations: string[]; + CoverageScore: number; + ConfidenceScore: number; + ConsistencyScore: number; + DiffSummary?: string | null; +}; + +type CvBenchmarkIndex = { + CorpusRoot: string; + OutputRoot: string; + GeneratedAtUtc: string; + TotalFiles: number; + AverageCoverage: number; + AverageConfidence: number; + AverageConsistency: number; + FilesWithSuspiciousLocations: number; + MissingApprovedFixtures: number; + Entries: CvBenchmarkEntry[]; +}; + type CvBenchmarkStatus = { indexJson?: string | null; reportMarkdown?: string | null; @@ -136,6 +170,26 @@ function formatDate(value?: string | null) { return value ? new Date(value).toLocaleString() : "-"; } +function formatPercent(value?: number | null) { + return typeof value === "number" ? `${Math.round(value * 100)}%` : "-"; +} + +function parseBenchmarkIndex(indexJson?: string | null): CvBenchmarkIndex | null { + if (!indexJson?.trim()) return null; + try { + return JSON.parse(indexJson) as CvBenchmarkIndex; + } catch { + return null; + } +} + +function benchmarkTone(value?: number | null) { + if (typeof value !== "number") return "default" as const; + if (value >= 0.8) return "success" as const; + if (value >= 0.6) return "warning" as const; + return "error" as const; +} + 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 ( @@ -210,6 +264,34 @@ export default function AdminSystemPage() { return "success" as const; }, [status]); + const benchmarkIndex = useMemo(() => parseBenchmarkIndex(benchmarkStatus?.indexJson), [benchmarkStatus?.indexJson]); + const weakestEntries = useMemo(() => { + if (!benchmarkIndex) return [] as CvBenchmarkEntry[]; + return [...benchmarkIndex.Entries] + .sort((a, b) => (a.CoverageScore + a.ConfidenceScore + a.ConsistencyScore) - (b.CoverageScore + b.ConfidenceScore + b.ConsistencyScore)) + .slice(0, 6); + }, [benchmarkIndex]); + + const benchmarkFindings = useMemo(() => { + if (!benchmarkIndex) return [] as Array<{ file: string; issue: string }>; + return benchmarkIndex.Entries.flatMap((entry) => { + const findings: Array<{ file: string; issue: string }> = []; + if (entry.ContactLocation && /(culture|education|arial|hobbies|cooperate|ag, ni|bold)/i.test(entry.ContactLocation)) { + findings.push({ file: entry.FileName, issue: `Suspicious contact location: ${entry.ContactLocation}` }); + } + if (entry.FirstEducation && entry.FirstEducation.length > 120) { + findings.push({ file: entry.FileName, issue: "Education qualification looks over-captured." }); + } + if ((entry.FirstJob ?? "").length > 120) { + findings.push({ file: entry.FileName, issue: "Work title looks over-captured." }); + } + if ((entry.QualificationLevels ?? []).includes("Other")) { + findings.push({ file: entry.FileName, issue: "Qualification level fell back to Other." }); + } + return findings; + }).slice(0, 10); + }, [benchmarkIndex]); + const sendTestEmail = async () => { setSendingTestEmail(true); try { @@ -430,12 +512,64 @@ export default function AdminSystemPage() { + - - - {benchmarkStatus?.reportMarkdown || "Run scripts/run-cv-benchmark.sh to generate the latest corpus report and fixture candidates."} - - + + {benchmarkIndex ? ( + <> + + + + + + 0 ? "warning" : "success"} /> + + + + + Top parser findings + + {benchmarkFindings.length > 0 ? benchmarkFindings.map((finding) => ( + + {finding.file} + {finding.issue} + + )) : No standout benchmark anomalies in the current run.} + + + + + Weakest files in current run + + {weakestEntries.map((entry) => ( + + {entry.FileName} + + + + + + {entry.DiffSummary || "-"} + + ))} + + + + + + Latest markdown summary + + {benchmarkStatus?.reportMarkdown || "-"} + + + + ) : ( + + + {benchmarkStatus?.reportMarkdown || "Run scripts/run-cv-benchmark.sh to generate the latest corpus report and fixture candidates."} + + + )} ) : (