Evolve summarizer into AI service with OCR support

This commit is contained in:
cesnimda
2026-03-23 20:12:34 +01:00
parent 90fdd8e1a5
commit 653f713a78
20 changed files with 475 additions and 129 deletions
@@ -0,0 +1,75 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import AdminSystemPage from './pages/AdminSystemPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
const mockedApi = api as jest.Mocked<typeof api>;
describe('AdminSystemPage', () => {
it('renders AI service health, latency, and OCR readiness', async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/admin/system') {
return Promise.resolve({
data: {
environment: 'Production',
contentRoot: '/app',
version: '1.2.3',
commitSha: 'abc1234',
buildStamp: '2026-03-23 11:00 UTC',
storage: { dataRoot: '/data', dbPath: '/data/jobtracker.db', dbExists: true, dbSizeBytes: 2048, companyCount: 3, jobCount: 7, deletedCount: 1 },
email: { enabled: true, host: 'smtp.example.test', port: 587, enableSsl: true, from: 'noreply@example.test', fromName: 'Jobbjakt' },
database: { provider: 'mariadb', looksConfigured: true, canConnect: true, target: 'server=db', usesFileStorage: false, warning: null },
runtime: { framework: '.NET 9', osDescription: 'Linux', processArchitecture: 'X64', machineName: 'app-01' },
auth: { required: true, hasJwtKey: true, googleConfigured: true, gmailConfigured: true },
ai: {
healthy: true,
model: 'distilbart',
device: 'cpu',
gpuAvailable: false,
gpuName: null,
ocrAvailable: true,
ocrLanguages: 'eng',
healthLatencyMs: 12.4,
probeLatencyMs: 25.8,
lastProbeAt: '2026-03-23T10:00:00Z',
lastProbeSuccessAt: '2026-03-23T10:00:00Z',
lastProbeFailureAt: null,
probeFailures: 0,
requests: 18,
cacheHits: 9,
cacheMisses: 9,
failures: 0,
averageLatencyMs: 42.2,
ocrRequests: 5,
ocrFailures: 0,
averageOcrLatencyMs: 88.4,
lastOcrSuccessAt: '2026-03-23T10:05:00Z',
lastOcrFailureAt: null,
lastSuccessAt: '2026-03-23T10:04:00Z',
lastFailureAt: null,
lastError: null,
},
},
} as any);
}
return Promise.resolve({ data: {} } as any);
});
render(
<I18nProvider>
<AdminSystemPage />
</I18nProvider>,
);
await waitFor(() => {
expect(screen.getByText('AI service')).toBeTruthy();
});
expect(screen.getByText(/25.8 ms probe/i)).toBeTruthy();
expect(screen.getByText('OCR eng')).toBeTruthy();
expect(screen.getByText('OCR avg latency')).toBeTruthy();
expect(screen.getByText('88.4 ms')).toBeTruthy();
});
});
+12 -12
View File
@@ -171,7 +171,7 @@ export const translations = {
profileHeadline: "Profile headline",
profileHeadlineHelp: "Stored only in this browser to personalize your workspace.",
profileMasterCv: "Master CV",
profileMasterCvBody: "Upload a PDF, DOCX, plain text file, or markdown file. The app extracts text where supported and populates your master CV text for tailoring and outreach.",
profileMasterCvBody: "Upload a PDF, DOCX, plain text file, markdown file, or image scan. The AI service extracts text where possible and falls back to OCR for supported scanned files.",
profileUploadCv: "Upload CV",
profileUploading: "Uploading...",
profileCopyCvText: "Copy CV text",
@@ -179,7 +179,7 @@ export const translations = {
profileCvUploadFailed: "Failed to upload CV.",
profileCvTextLabel: "Profile CV / master resume text",
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD.",
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
profileSaveChanges: "Save changes",
profileUpdated: "Profile updated.",
profileUpdateFailed: "Failed to update profile.",
@@ -272,7 +272,7 @@ export const translations = {
adminUsersCreated: "User created.",
adminUsersCreateFailed: "Failed to create user.",
adminSystemTitle: "System status",
adminSystemSubtitle: "Production diagnostics for runtime, database, auth, email, and summarizer health.",
adminSystemSubtitle: "Production diagnostics for runtime, database, auth, email, AI service health, and OCR readiness.",
adminSystemRunProbe: "Run probe now",
adminSystemRunningProbe: "Running probe...",
adminSystemRefresh: "Refresh",
@@ -284,13 +284,13 @@ export const translations = {
adminSystemSmtp: "SMTP",
adminSystemEnabled: "Enabled",
adminSystemDisabled: "Disabled",
adminSystemSummarizer: "Summarizer",
adminSystemSummarizer: "AI service",
adminSystemHealthy: "Healthy",
adminSystemNoLatencyData: "No latency data",
adminSystemDatabaseStorage: "Database and storage",
adminSystemRuntimeAuth: "Runtime and auth",
adminSystemEmailConfig: "Email configuration",
adminSystemSummarizerRuntime: "Summarizer runtime",
adminSystemSummarizerRuntime: "AI runtime",
adminSystemSmtpTest: "SMTP test email",
adminSystemSmtpTestBody: "Send a quick delivery check using the configured SMTP settings. Leave the recipient blank to use your admin email.",
adminSystemRecipientEmail: "Recipient email",
@@ -299,7 +299,7 @@ export const translations = {
adminSystemMessage: "Message",
adminSystemSendTestEmail: "Send test email",
adminSystemSending: "Sending...",
adminSystemSummarizerTelemetry: "Summarizer telemetry",
adminSystemSummarizerTelemetry: "AI service telemetry",
adminSystemDatabaseConnected: "Database connected",
adminSystemDatabaseIssue: "Database issue",
adminSystemAuthEnforced: "Auth enforced",
@@ -591,7 +591,7 @@ export const translations = {
profileHeadline: "Profiloverskrift",
profileHeadlineHelp: "Lagres bare i denne nettleseren for å gjøre arbeidsområdet mer personlig.",
profileMasterCv: "Hoved-CV",
profileMasterCvBody: "Last opp en PDF, DOCX, ren tekstfil eller markdown-fil. Appen henter ut tekst der det støttes og fyller inn hoved-CV-en din for tilpasning og kontakt.",
profileMasterCvBody: "Last opp en PDF, DOCX, ren tekstfil, markdown-fil eller et bildeskann. AI-tjenesten henter ut tekst der det er mulig og faller tilbake til OCR for støttede skannede filer.",
profileUploadCv: "Last opp CV",
profileUploading: "Laster opp...",
profileCopyCvText: "Kopier CV-tekst",
@@ -599,7 +599,7 @@ export const translations = {
profileCvUploadFailed: "Kunne ikke laste opp CV.",
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD.",
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
profileSaveChanges: "Lagre endringer",
profileUpdated: "Profil oppdatert.",
profileUpdateFailed: "Kunne ikke oppdatere profil.",
@@ -692,7 +692,7 @@ export const translations = {
adminUsersCreated: "Bruker opprettet.",
adminUsersCreateFailed: "Kunne ikke opprette bruker.",
adminSystemTitle: "Systemstatus",
adminSystemSubtitle: "Produksjonsdiagnostikk for kjøretid, database, autentisering, e-post og oppsummeringshelse.",
adminSystemSubtitle: "Produksjonsdiagnostikk for kjøretid, database, autentisering, e-post, AI-tjenestehelse og OCR-beredskap.",
adminSystemRunProbe: "Kjør probe nå",
adminSystemRunningProbe: "Kjører probe...",
adminSystemRefresh: "Oppdater",
@@ -704,13 +704,13 @@ export const translations = {
adminSystemSmtp: "SMTP",
adminSystemEnabled: "Aktivert",
adminSystemDisabled: "Deaktivert",
adminSystemSummarizer: "Oppsummerer",
adminSystemSummarizer: "AI-tjeneste",
adminSystemHealthy: "Frisk",
adminSystemNoLatencyData: "Ingen latensdata",
adminSystemDatabaseStorage: "Database og lagring",
adminSystemRuntimeAuth: "Kjøretid og autentisering",
adminSystemEmailConfig: "E-postkonfigurasjon",
adminSystemSummarizerRuntime: "Oppsummeringskjøretid",
adminSystemSummarizerRuntime: "AI-kjøretid",
adminSystemSmtpTest: "SMTP-test e-post",
adminSystemSmtpTestBody: "Send en rask leveringssjekk med de konfigurerte SMTP-innstillingene. La mottakeren stå tom for å bruke admin-eposten din.",
adminSystemRecipientEmail: "Mottaker e-post",
@@ -719,7 +719,7 @@ export const translations = {
adminSystemMessage: "Melding",
adminSystemSendTestEmail: "Send test-e-post",
adminSystemSending: "Sender...",
adminSystemSummarizerTelemetry: "Oppsummeringstelemetri",
adminSystemSummarizerTelemetry: "AI-tjenestetelemetri",
adminSystemDatabaseConnected: "Database tilkoblet",
adminSystemDatabaseIssue: "Databaseproblem",
adminSystemAuthEnforced: "Autentisering påkrevd",
+41 -31
View File
@@ -14,12 +14,14 @@ import {
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type SummarizerMetrics = {
type AiServiceMetrics = {
healthy: boolean;
model?: string | null;
device?: string | null;
gpuAvailable?: boolean;
gpuName?: string | null;
ocrAvailable?: boolean | null;
ocrLanguages?: string | null;
healthLatencyMs?: number | null;
probeLatencyMs?: number | null;
lastProbeAt?: string | null;
@@ -31,6 +33,11 @@ type SummarizerMetrics = {
cacheMisses: number;
failures: number;
averageLatencyMs?: number | null;
ocrRequests: number;
ocrFailures: number;
averageOcrLatencyMs?: number | null;
lastOcrSuccessAt?: string | null;
lastOcrFailureAt?: string | null;
lastSuccessAt?: string | null;
lastFailureAt?: string | null;
lastError?: string | null;
@@ -79,7 +86,7 @@ type SystemStatus = {
googleConfigured: boolean;
gmailConfigured: boolean;
};
summarizer: SummarizerMetrics;
ai: AiServiceMetrics;
};
function formatBytes(bytes?: number | null) {
@@ -148,10 +155,10 @@ export default function AdminSystemPage() {
return "success" as const;
}, [status]);
const summarizerTone = useMemo(() => {
const aiTone = 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;
if (!status.ai.healthy) return "error" as const;
if (status.ai.probeFailures > 0 || status.ai.failures > 0 || (status.ai.ocrFailures ?? 0) > 0) return "warning" as const;
return "success" as const;
}, [status]);
@@ -184,10 +191,10 @@ export default function AdminSystemPage() {
setRunningProbe(true);
setError(null);
try {
await api.post("/admin/system/summarizer/probe");
await api.post("/admin/system/ai/probe");
await load();
} catch (e: any) {
setError(getApiErrorMessage(e, "Failed to run summarizer probe."));
setError(getApiErrorMessage(e, "Failed to run AI service probe."));
} finally {
setRunningProbe(false);
}
@@ -204,7 +211,7 @@ export default function AdminSystemPage() {
{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}
{status?.ai.lastError ? <Alert severity={status.ai.healthy ? "warning" : "error"}>{status.ai.lastError}</Alert> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
<SummaryCard
@@ -226,13 +233,13 @@ export default function AdminSystemPage() {
/>
<SummaryCard
title={t("adminSystemSummarizer")}
value={status?.summarizer.healthy ? t("adminSystemHealthy") : t("adminSystemOffline")}
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"}`
value={status?.ai.healthy ? t("adminSystemHealthy") : t("adminSystemOffline")}
subtitle={status?.ai.probeLatencyMs != null
? `${status.ai.probeLatencyMs} ms probe · ${status.ai.device || "unknown device"}`
: status?.ai.healthLatencyMs != null
? `${status.ai.healthLatencyMs} ms health · ${status.ai.device || "unknown device"}`
: t("adminSystemNoLatencyData")}
tone={summarizerTone}
tone={aiTone}
/>
</Box>
@@ -288,15 +295,15 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerRuntime")}</Typography>
<Stack spacing={0.75}>
<DetailRow label="Model" value={status?.summarizer.model || "-"} />
<DetailRow label="Device" value={status?.summarizer.device || "-"} />
<DetailRow label="GPU available" value={status?.summarizer.gpuAvailable ? "Yes" : "No"} />
<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)} />
<DetailRow label="Model" value={status?.ai.model || "-"} />
<DetailRow label="Device" value={status?.ai.device || "-"} />
<DetailRow label="GPU available" value={status?.ai.gpuAvailable ? "Yes" : "No"} />
<DetailRow label="GPU name" value={status?.ai.gpuName || "-"} />
<DetailRow label="Health latency" value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
<DetailRow label="Probe latency" value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
<DetailRow label="Last probe" value={formatDate(status?.ai.lastProbeAt)} />
<DetailRow label="Last successful probe" value={formatDate(status?.ai.lastProbeSuccessAt)} />
<DetailRow label="Last summarization success" value={formatDate(status?.ai.lastSuccessAt)} />
</Stack>
</Paper>
</Box>
@@ -320,20 +327,23 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerTelemetry")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(6, 1fr)" }, gap: 2 }}>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheHits ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache misses</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheMisses ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.failures ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Probe failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.probeFailures ?? 0}</Typography></Box>
<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>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(8, 1fr)" }, gap: 2 }}>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.requests ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheHits ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache misses</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheMisses ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.failures ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Probe failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.probeFailures ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageLatencyMs != null ? `${status.ai.averageLatencyMs} ms` : "-"}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>OCR requests</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.ocrRequests ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>OCR avg latency</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageOcrLatencyMs != null ? `${status.ai.averageOcrLatencyMs} ms` : "-"}</Typography></Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 2 }}>
<Chip label={status?.database.canConnect ? t("adminSystemDatabaseConnected") : t("adminSystemDatabaseIssue")} color={status?.database.canConnect ? "success" : "error"} size="small" />
<Chip label={status?.auth.required ? t("adminSystemAuthEnforced") : t("adminSystemAuthOptional")} color={status?.auth.required ? "success" : "warning"} size="small" />
<Chip label={status?.auth.googleConfigured ? t("adminSystemGoogleReady") : t("adminSystemGoogleOff")} variant="outlined" size="small" />
<Chip label={status?.auth.gmailConfigured ? t("adminSystemGmailReady") : t("adminSystemGmailIncomplete")} variant="outlined" size="small" />
<Chip label={status?.summarizer.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.summarizer.gpuAvailable ? "success" : "default"} size="small" />
<Chip label={status?.ai.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.ai.gpuAvailable ? "success" : "default"} size="small" />
<Chip label={status?.ai.ocrAvailable ? `OCR ${status.ai.ocrLanguages || "enabled"}` : "OCR unavailable"} variant="outlined" size="small" />
</Box>
</Paper>
</Box>
+1 -1
View File
@@ -29,7 +29,7 @@ type MeResponse = {
} | null;
};
const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
function initialsFrom(values: Array<string | undefined>) {
+5
View File
@@ -9,6 +9,11 @@ jest.mock('./api', () => ({
delete: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: jest.fn((error: any, fallback?: string) => {
if (typeof error?.response?.data === 'string' && error.response.data.trim()) return error.response.data;
if (typeof error?.message === 'string' && error.message.trim()) return error.message;
return fallback || 'Request failed.';
}),
}));
jest.mock('./components/GoogleAuthCard', () => () => null);