diff --git a/JobTrackerApi.Tests/ProfileCvControllerTests.cs b/JobTrackerApi.Tests/ProfileCvControllerTests.cs index 3f90d1c..cee20a0 100644 --- a/JobTrackerApi.Tests/ProfileCvControllerTests.cs +++ b/JobTrackerApi.Tests/ProfileCvControllerTests.cs @@ -556,6 +556,129 @@ public sealed class ProfileCvControllerTests Assert.Equal("Warwickshire College, UK", structured.Education[0].Location); } + [Fact] + public async Task Rewrite_section_returns_ai_service_unavailable_detail_when_ai_health_is_unhealthy() + { + var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var aiService = new Mock(); + aiService + .Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), 1800, 400)) + .ReturnsAsync(string.Empty); + aiService + .Setup(x => x.GetMetricsAsync(It.IsAny())) + .ReturnsAsync(new AiServiceMetrics( + Healthy: false, + Model: "distilbart", + Device: "cpu", + GpuAvailable: false, + GpuName: null, + OcrAvailable: true, + OcrLanguages: "eng", + OllamaConfigured: true, + OllamaReachable: true, + OllamaModel: "qwen2.5:7b", + OllamaModelAvailable: true, + OllamaVersion: "0.6.0", + OllamaInstalledModels: new List { "qwen2.5:7b" }, + OllamaLoadedModels: new List(), + OllamaLoadedCount: 0, + HealthLatencyMs: 21, + ProbeLatencyMs: null, + LastProbeAt: null, + LastProbeSuccessAt: null, + LastProbeFailureAt: null, + ProbeFailures: 1, + Requests: 1, + CacheHits: 0, + CacheMisses: 1, + Failures: 1, + AverageLatencyMs: 21, + OcrRequests: 0, + OcrFailures: 0, + AverageOcrLatencyMs: null, + LastOcrSuccessAt: null, + LastOcrFailureAt: null, + LastSuccessAt: null, + LastFailureAt: DateTimeOffset.UtcNow, + LastError: "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD.")); + + await using var db = CreateDb(); + var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); + + var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest()); + + var objectResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); + var payload = Assert.IsType(objectResult.Value); + Assert.Equal("ai-service-unavailable", payload.Code); + Assert.Contains("could not rewrite", payload.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unavailable", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AI_SERVICE_SKIP_MODEL_LOAD", payload.LastAiError ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Rewrite_section_returns_rewrite_empty_detail_when_ai_health_is_healthy() + { + var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var aiService = new Mock(); + aiService + .Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), 1800, 400)) + .ReturnsAsync(string.Empty); + aiService + .Setup(x => x.GetMetricsAsync(It.IsAny())) + .ReturnsAsync(new AiServiceMetrics( + Healthy: true, + Model: "distilbart", + Device: "cpu", + GpuAvailable: false, + GpuName: null, + OcrAvailable: true, + OcrLanguages: "eng", + OllamaConfigured: true, + OllamaReachable: true, + OllamaModel: "qwen2.5:7b", + OllamaModelAvailable: true, + OllamaVersion: "0.6.0", + OllamaInstalledModels: new List { "qwen2.5:7b" }, + OllamaLoadedModels: new List(), + OllamaLoadedCount: 0, + HealthLatencyMs: 21, + ProbeLatencyMs: null, + LastProbeAt: null, + LastProbeSuccessAt: null, + LastProbeFailureAt: null, + ProbeFailures: 0, + Requests: 1, + CacheHits: 0, + CacheMisses: 1, + Failures: 0, + AverageLatencyMs: 21, + OcrRequests: 0, + OcrFailures: 0, + AverageOcrLatencyMs: null, + LastOcrSuccessAt: null, + LastOcrFailureAt: null, + LastSuccessAt: DateTimeOffset.UtcNow, + LastFailureAt: null, + LastError: null)); + + await using var db = CreateDb(); + var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); + + var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest()); + + var objectResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); + var payload = Assert.IsType(objectResult.Value); + Assert.Equal("rewrite-empty", payload.Code); + Assert.Contains("empty", payload.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("no usable text", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv() { diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 81d7c72..35363dd 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -113,6 +113,7 @@ public sealed class ProfileCvController : ControllerBase public sealed record ParseCvRequest(string? Text); public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List PreviewBullets); public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId); + public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null); private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv); private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification); @@ -337,9 +338,23 @@ public sealed class ProfileCvController : ControllerBase if (string.IsNullOrWhiteSpace(rewritten)) { - _logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount}", - sectionName ?? "", templateId, effectiveTargetRole ?? "", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count); - return StatusCode(StatusCodes.Status502BadGateway, "The AI service could not rewrite your CV right now."); + var metrics = await _aiService.GetMetricsAsync(HttpContext.RequestAborted); + var detail = metrics.Healthy + ? "The rewrite request reached the AI service, but it returned no usable text." + : "The AI rewrite service is unavailable or not ready."; + var failureCode = metrics.Healthy ? "rewrite-empty" : "ai-service-unavailable"; + var message = metrics.Healthy + ? "The AI service returned an empty CV rewrite." + : "The AI service could not rewrite your CV right now."; + + _logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount} AiHealthy={AiHealthy} AiLastError={AiLastError}", + sectionName ?? "", templateId, effectiveTargetRole ?? "", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count, metrics.Healthy, metrics.LastError ?? ""); + + return StatusCode(StatusCodes.Status502BadGateway, new CvRewriteFailureDto( + failureCode, + message, + detail, + metrics.LastError)); } return Ok(new diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 39492ae..fd5d0d8 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -7,7 +7,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined"; import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined"; -import { api } from "../api"; +import { api, getApiErrorMessage } from "../api"; import GoogleAuthCard from "../components/GoogleAuthCard"; import CropImageDialog from "../components/CropImageDialog"; import { useToast } from "../toast"; @@ -78,6 +78,24 @@ type CvBuilderPreview = { jobApplicationId?: number | null; }; +type PdfCarouselItem = { + templateId: CvSectionStyle; + title: string; + fileName: string; + pdfUrl?: string; + status: "loading" | "ready" | "error"; + error?: string; +}; + +type RewriteRequestPayload = { + sectionName: string | null; + style: CvSectionStyle; + templateId: CvSectionStyle; + targetRole: string | null; + jobApplicationId: number | null; + sourceText: string | null; +}; + type MeResponse = { provider?: "local" | "google" | "external"; id?: string; @@ -227,6 +245,10 @@ export default function ProfilePage() { const [selectedRewriteJobId, setSelectedRewriteJobId] = useState(""); const [rewritePreview, setRewritePreview] = useState(null); const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState(null); + const [pdfCarousel, setPdfCarousel] = useState([]); + const [activePdfIndex, setActivePdfIndex] = useState(0); + const [buildingPdfDeck, setBuildingPdfDeck] = useState(false); + const [downloadingPdf, setDownloadingPdf] = useState(false); const [savedJobs, setSavedJobs] = useState([]); const [parsingCvSections, setParsingCvSections] = useState(false); const [reprocessingCv, setReprocessingCv] = useState(false); @@ -236,6 +258,16 @@ export default function ProfilePage() { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); + useEffect(() => { + return () => { + pdfCarousel.forEach((item) => { + if (item.pdfUrl) { + window.URL.revokeObjectURL(item.pdfUrl); + } + }); + }; + }, [pdfCarousel]); + const loadProfile = useCallback(async () => { setLoading(true); try { @@ -312,6 +344,100 @@ export default function ProfilePage() { const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0]; const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null; const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim()); + const activePdfItem = pdfCarousel[activePdfIndex] ?? null; + + const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => { + items.forEach((item) => { + if (item.pdfUrl) { + window.URL.revokeObjectURL(item.pdfUrl); + } + }); + }, []); + + const buildRewritePayload = useCallback((templateId: CvSectionStyle): RewriteRequestPayload => ({ + sectionName: cvSection || null, + style: templateId, + templateId, + targetRole: cvSectionTargetRole.trim() || null, + jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null, + sourceText: profileCvText.trim() || null, + }), [cvSection, cvSectionTargetRole, profileCvText, selectedRewriteJob]); + + const resetPdfCarousel = useCallback(() => { + setPdfCarousel((current) => { + releasePdfCarousel(current); + return []; + }); + setActivePdfIndex(0); + }, [releasePdfCarousel]); + + const savePdfToCarousel = useCallback(async (templateId: CvSectionStyle, download = false) => { + const template = REWRITE_TEMPLATES.find((option) => option.id === templateId) ?? REWRITE_TEMPLATES[0]; + const payload = buildRewritePayload(templateId); + const response = await api.post("/profile-cv/export-pdf", payload, { responseType: "blob" }); + const blob = new Blob([response.data], { type: "application/pdf" }); + const url = window.URL.createObjectURL(blob); + const item: PdfCarouselItem = { + templateId, + title: template.title, + fileName: rewritePreview?.suggestedFileName || `${templateId}-cv.pdf`, + pdfUrl: url, + status: "ready", + }; + + setPdfCarousel((current) => { + const existing = current.find((entry) => entry.templateId === templateId); + if (existing?.pdfUrl) { + window.URL.revokeObjectURL(existing.pdfUrl); + } + const next = existing + ? current.map((entry) => (entry.templateId === templateId ? item : entry)) + : [...current, item]; + setActivePdfIndex(next.findIndex((entry) => entry.templateId === templateId)); + return next; + }); + + if (download) { + const link = document.createElement("a"); + link.href = url; + link.download = item.fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + } + + return item; + }, [buildRewritePayload, rewritePreview?.suggestedFileName]); + + const buildPdfCarousel = useCallback(async () => { + setBuildingPdfDeck(true); + resetPdfCarousel(); + const orderedTemplates = [selectedRewriteTemplate.id, ...REWRITE_TEMPLATES.map((option) => option.id).filter((id) => id !== selectedRewriteTemplate.id)]; + const seedItems = orderedTemplates.map((templateId) => ({ + templateId, + title: REWRITE_TEMPLATES.find((option) => option.id === templateId)?.title ?? templateId, + fileName: `${templateId}-cv.pdf`, + status: "loading" as const, + })); + setPdfCarousel(seedItems); + setActivePdfIndex(0); + + for (const templateId of orderedTemplates) { + try { + const item = await savePdfToCarousel(templateId, false); + setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? item : entry)); + } catch (error: any) { + const message = getApiErrorMessage(error, `Failed to generate the ${templateId} PDF preview.`); + setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? { ...entry, status: "error", error: message } : entry)); + } + } + + setBuildingPdfDeck(false); + }, [resetPdfCarousel, savePdfToCarousel, selectedRewriteTemplate.id]); + + useEffect(() => { + resetPdfCarousel(); + }, [rewritePreview?.fullText, rewritePreview?.templateId, rewritePreview?.targetRole, resetPdfCarousel]); return ( @@ -903,19 +1029,13 @@ export default function ProfilePage() { disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv} onClick={async () => { setRewritingSection(true); + resetPdfCarousel(); try { - const res = await api.post("/profile-cv/rewrite-preview", { - sectionName: cvSection || null, - style: cvSectionStyle, - templateId: cvSectionStyle, - targetRole: cvSectionTargetRole.trim() || null, - jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null, - sourceText: profileCvText.trim() || null, - }); + const res = await api.post("/profile-cv/rewrite-preview", buildRewritePayload(cvSectionStyle)); setRewritePreview(res.data); toast(t("profileCvSectionRewritten"), "success"); } catch (e: any) { - toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error"); + toast(getApiErrorMessage(e, t("profileCvSectionRewriteFailed")), "error"); } finally { setRewritingSection(false); } @@ -925,33 +1045,27 @@ export default function ProfilePage() { + @@ -987,22 +1101,64 @@ export default function ProfilePage() { - Styled preview - {selectedRewriteTemplate.title} · print-ready layout + PDF carousel + + {activePdfItem?.title ? `${activePdfItem.title} · generated PDF` : `${selectedRewriteTemplate.title} · print-ready layout`} + - {rewriteReady ? : null} + {activePdfItem?.fileName ? : rewriteReady ? : null} - - {rewriteReady ? ( -