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.Tests/CvCorpusHarnessTests.cs b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs index c36aa55..e9ced3c 100644 --- a/JobTrackerApi.Tests/CvCorpusHarnessTests.cs +++ b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Security.Cryptography; using System.Text.Json; using JobTrackerApi.Controllers; using JobTrackerApi.Models; @@ -29,11 +30,19 @@ public sealed class CvCorpusHarnessTests || path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) - .Take(8) .ToList(); if (files.Count == 0) return; + var outputRoot = ResolveOutputRoot(); + var outputsDir = Path.Combine(outputRoot, "outputs"); + var candidateFixturesDir = Path.Combine(outputRoot, "candidate-fixtures"); + var approvedFixturesDir = ResolveApprovedFixturesRoot(outputRoot); + Directory.CreateDirectory(outputRoot); + Directory.CreateDirectory(outputsDir); + Directory.CreateDirectory(candidateFixturesDir); + Directory.CreateDirectory(approvedFixturesDir); + var user = new ApplicationUser { Id = "user-1", ProfileCvText = "seed" }; var userManager = TestHostFactory.CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); @@ -44,8 +53,8 @@ public sealed class CvCorpusHarnessTests aiService.Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), It.IsAny(), 2800, 900)).ReturnsAsync((string _, string text, int _, int __) => text); await using var db = TestHostFactory.CreateInMemoryDb(); - var paths = CreatePaths(); - var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, NoOpCvAiClassifier.Instance) + var paths = CreatePaths(outputRoot); + var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, null, NoOpCvAiClassifier.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; @@ -55,7 +64,7 @@ public sealed class CvCorpusHarnessTests Assert.NotNull(extractMethod); Assert.NotNull(buildMethod); - var report = new List(); + var entries = new List(); foreach (var path in files) { await using var stream = File.OpenRead(path); @@ -72,31 +81,212 @@ public sealed class CvCorpusHarnessTests Assert.False(string.IsNullOrWhiteSpace(text)); var buildTask = (Task)buildMethod!.Invoke(controller, new object[] { text, CancellationToken.None })!; - var structured = await buildTask; + var structured = StructuredCvProfileJson.Normalize(await buildTask); Assert.NotNull(structured); - report.Add(new + var slug = Slugify(fileName); + var normalizedJson = StructuredCvProfileJson.Serialize(structured); + var outputPath = Path.Combine(outputsDir, $"{slug}.json"); + await File.WriteAllTextAsync(outputPath, PrettyJson(normalizedJson)); + + var approvedPath = Path.Combine(approvedFixturesDir, $"{slug}.json"); + var candidateFixturePath = Path.Combine(candidateFixturesDir, $"{slug}.json"); + string? diffSummary = null; + var approvedExists = File.Exists(approvedPath); + if (approvedExists) { - file = fileName, - characters = text.Length, - contactLocation = structured.Contact.Location, - firstJob = structured.Jobs.FirstOrDefault()?.Title, - firstJobLocation = structured.Jobs.FirstOrDefault()?.Location, - firstEducation = structured.Education.FirstOrDefault()?.Qualification, - firstEducationLocation = structured.Education.FirstOrDefault()?.Location, - suspiciousLocations = structured.Jobs.Select(job => job.Location) + var approvedJson = await File.ReadAllTextAsync(approvedPath); + diffSummary = SummarizeDiff(approvedJson, normalizedJson); + } + else + { + await File.WriteAllTextAsync(candidateFixturePath, PrettyJson(normalizedJson)); + diffSummary = "No approved fixture yet — candidate fixture written."; + } + + entries.Add(new CvBenchmarkEntry( + FileName: fileName, + Slug: slug, + Extension: extension, + Characters: text.Length, + OutputPath: outputPath, + ApprovedFixturePath: approvedExists ? approvedPath : null, + CandidateFixturePath: approvedExists ? null : candidateFixturePath, + ContactLocation: structured.Contact.Location, + FirstJob: structured.Jobs.FirstOrDefault()?.Title, + FirstJobLocation: structured.Jobs.FirstOrDefault()?.Location, + FirstEducation: structured.Education.FirstOrDefault()?.Qualification, + FirstEducationLocation: structured.Education.FirstOrDefault()?.Location, + QualificationLevels: structured.Education.Select(x => x.QualificationLevel).Where(x => !string.IsNullOrWhiteSpace(x)).Cast().ToList(), + SuspiciousLocations: structured.Jobs.Select(job => job.Location) .Concat(structured.Education.Select(education => education.Location)) .Append(structured.Contact.Location) .Where(value => !string.IsNullOrWhiteSpace(value)) + .Cast() .Where(LooksSuspiciousLocation) - .ToList() - }); + .ToList(), + CoverageScore: ComputeCoverageScore(structured), + ConfidenceScore: ComputeConfidenceScore(structured), + ConsistencyScore: ComputeConsistencyScore(structured), + DiffSummary: diffSummary + )); } - var reportPath = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{DateTime.UtcNow:yyyyMMddHHmmss}.json"); - await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); + var summary = new CvBenchmarkSummary( + CorpusRoot, + outputRoot, + DateTimeOffset.UtcNow, + entries.Count, + Math.Round(entries.Average(x => x.CoverageScore), 3), + Math.Round(entries.Average(x => x.ConfidenceScore), 3), + Math.Round(entries.Average(x => x.ConsistencyScore), 3), + entries.Count(x => x.SuspiciousLocations.Count > 0), + entries.Count(x => x.ApprovedFixturePath is null), + entries + ); - Assert.True(report.Count > 0); + var indexPath = Path.Combine(outputRoot, "index.json"); + var reportPath = Path.Combine(outputRoot, "report.md"); + await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true })); + await File.WriteAllTextAsync(reportPath, RenderMarkdownReport(summary)); + + Assert.True(entries.Count > 0); + } + + private sealed record CvBenchmarkEntry( + string FileName, + string Slug, + string Extension, + int Characters, + string OutputPath, + string? ApprovedFixturePath, + string? CandidateFixturePath, + string? ContactLocation, + string? FirstJob, + string? FirstJobLocation, + string? FirstEducation, + string? FirstEducationLocation, + List QualificationLevels, + List SuspiciousLocations, + double CoverageScore, + double ConfidenceScore, + double ConsistencyScore, + string? DiffSummary); + + private sealed record CvBenchmarkSummary( + string CorpusRoot, + string OutputRoot, + DateTimeOffset GeneratedAtUtc, + int TotalFiles, + double AverageCoverage, + double AverageConfidence, + double AverageConsistency, + int FilesWithSuspiciousLocations, + int MissingApprovedFixtures, + List Entries); + + private static string ResolveOutputRoot() + { + var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_OUTPUT_DIR"); + if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim(); + return Path.Combine(Path.GetTempPath(), "jobtracker-cv-benchmark", DateTime.UtcNow.ToString("yyyyMMddHHmmss")); + } + + private static string ResolveApprovedFixturesRoot(string outputRoot) + { + var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_APPROVED_DIR"); + if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim(); + return Path.Combine(outputRoot, "approved-fixtures"); + } + + private static string PrettyJson(string normalizedJson) + { + using var doc = JsonDocument.Parse(normalizedJson); + return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); + } + + private static string SummarizeDiff(string approvedJson, string actualJson) + { + if (JsonDocument.Parse(approvedJson).RootElement.ToString() == JsonDocument.Parse(actualJson).RootElement.ToString()) + { + return "Matches approved fixture."; + } + + var approvedHash = Hash(approvedJson); + var actualHash = Hash(actualJson); + return $"Fixture differs (approved {approvedHash[..8]}, actual {actualHash[..8]})."; + } + + private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + + private static double ComputeCoverageScore(StructuredCvProfile structured) + { + var signals = new[] + { + !string.IsNullOrWhiteSpace(structured.Contact.FullName), + !string.IsNullOrWhiteSpace(structured.Contact.Email), + !string.IsNullOrWhiteSpace(structured.Contact.Location), + structured.Summary.Count > 0, + structured.Skills.Count > 0, + structured.Jobs.Count > 0, + structured.Education.Count > 0, + structured.Certifications.Count > 0 || structured.Projects.Count > 0 || structured.OtherSections.Count > 0, + }; + return signals.Count(x => x) / (double)signals.Length; + } + + private static double ComputeConfidenceScore(StructuredCvProfile structured) + { + var confidences = structured.Metadata.Fields.Values.Select(x => x.Confidence).Where(x => x.HasValue).Select(x => x!.Value).ToList(); + return confidences.Count == 0 ? 0.55 : Math.Clamp(confidences.Average(), 0, 1); + } + + private static double ComputeConsistencyScore(StructuredCvProfile structured) + { + var penalties = 0; + penalties += structured.Jobs.Count(job => LooksSuspiciousLocation(job.Location)); + penalties += structured.Education.Count(education => LooksSuspiciousLocation(education.Location)); + penalties += LooksSuspiciousLocation(structured.Contact.Location) ? 1 : 0; + penalties += structured.Education.Count(education => string.IsNullOrWhiteSpace(education.QualificationLevel) && !string.IsNullOrWhiteSpace(education.Qualification)); + return Math.Max(0, 1 - (penalties * 0.12)); + } + + private static string RenderMarkdownReport(CvBenchmarkSummary summary) + { + var lines = new List + { + "# CV benchmark report", + string.Empty, + $"- Generated: {summary.GeneratedAtUtc:O}", + $"- Corpus root: `{summary.CorpusRoot}`", + $"- Output root: `{summary.OutputRoot}`", + $"- Files: {summary.TotalFiles}", + $"- Average coverage: {summary.AverageCoverage:P0}", + $"- Average confidence: {summary.AverageConfidence:P0}", + $"- Average consistency: {summary.AverageConsistency:P0}", + $"- Files with suspicious locations: {summary.FilesWithSuspiciousLocations}", + $"- Missing approved fixtures: {summary.MissingApprovedFixtures}", + string.Empty, + "| File | Coverage | Confidence | Consistency | Suspicious locations | Fixture |", + "|---|---:|---:|---:|---:|---|", + }; + + lines.AddRange(summary.Entries.Select(entry => + $"| {entry.FileName} | {entry.CoverageScore:P0} | {entry.ConfidenceScore:P0} | {entry.ConsistencyScore:P0} | {entry.SuspiciousLocations.Count} | {entry.DiffSummary} |")); + + lines.Add(string.Empty); + lines.Add("## Notes"); + lines.Add("- `outputs/*.json` contains the latest normalized parser output for each CV."); + lines.Add("- `candidate-fixtures/*.json` is created when no approved fixture exists yet."); + lines.Add("- To build a regression baseline, review a candidate fixture and copy it into the approved-fixtures directory used by the runner."); + return string.Join(Environment.NewLine, lines); + } + + private static string Slugify(string value) + { + var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray()); + while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal); + return cleaned.Trim('-'); } private static bool LooksSuspiciousLocation(string? value) @@ -119,7 +309,7 @@ public sealed class CvCorpusHarnessTests }; } - private static AppPaths CreatePaths() + private static AppPaths CreatePaths(string outputRoot) { var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempRoot); @@ -128,7 +318,8 @@ public sealed class CvCorpusHarnessTests .AddInMemoryCollection(new Dictionary { ["Data:Root"] = tempRoot, - ["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts") + ["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"), + ["Data:CvBenchmarksRoot"] = outputRoot, }) .Build(); diff --git a/JobTrackerApi.Tests/JobApplicationsControllerTests.cs b/JobTrackerApi.Tests/JobApplicationsControllerTests.cs index 3d696ae..cde2de6 100644 --- a/JobTrackerApi.Tests/JobApplicationsControllerTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsControllerTests.cs @@ -27,7 +27,7 @@ public sealed class JobApplicationsControllerTests Assert.NotNull(type); var ctor = type!.GetConstructors().Single(); - var parameters = ctor.GetParameters().Select(x => x.Name).ToArray(); + var parameters = ctor.GetParameters().Select(x => x.Name).Where(x => x is not null).Select(x => x!).ToHashSet(StringComparer.OrdinalIgnoreCase); Assert.Contains("coverLetterText", parameters); Assert.Contains("notes", parameters); } diff --git a/JobTrackerApi.Tests/ProfileCvControllerTests.cs b/JobTrackerApi.Tests/ProfileCvControllerTests.cs index 0449f34..5492365 100644 --- a/JobTrackerApi.Tests/ProfileCvControllerTests.cs +++ b/JobTrackerApi.Tests/ProfileCvControllerTests.cs @@ -518,7 +518,12 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); - var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest(null, "harvard", null, 42, "harvard")); + var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest + { + Style = "harvard", + JobApplicationId = JsonDocument.Parse("42").RootElement.Clone(), + TemplateId = "harvard", + }); var ok = Assert.IsType(result); var json = JsonSerializer.Serialize(ok.Value); @@ -787,7 +792,7 @@ public sealed class ProfileCvControllerTests private static ProfileCvController CreateController(UserManager userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null) { - return new ProfileCvController(userManager, aiService, db, paths, cvAiClassifier ?? NoOpCvAiClassifier.Instance) + return new ProfileCvController(userManager, aiService, db, paths, null, cvAiClassifier ?? NoOpCvAiClassifier.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; diff --git a/JobTrackerApi/Controllers/AdminSystemController.cs b/JobTrackerApi/Controllers/AdminSystemController.cs index 428ae50..29cde58 100644 --- a/JobTrackerApi/Controllers/AdminSystemController.cs +++ b/JobTrackerApi/Controllers/AdminSystemController.cs @@ -35,6 +35,7 @@ public sealed class AdminSystemController : ControllerBase 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 CvBenchmarkStatusDto(string? IndexJson, string? ReportMarkdown, string RootPath, DateTimeOffset? LastUpdatedAtUtc); public sealed record SystemStatusDto( string Environment, string ContentRoot, @@ -64,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) @@ -75,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")] @@ -86,6 +138,22 @@ public sealed class AdminSystemController : ControllerBase return Ok(await _emailSettings.UpdateAsync(request, cancellationToken)); } + [HttpGet("cv-benchmark")] + public async Task> GetCvBenchmarkStatus(CancellationToken cancellationToken) + { + var indexPath = Path.Combine(_paths.CvBenchmarksRoot, "index.json"); + var reportPath = Path.Combine(_paths.CvBenchmarksRoot, "report.md"); + var indexJson = System.IO.File.Exists(indexPath) ? await System.IO.File.ReadAllTextAsync(indexPath, cancellationToken) : null; + var reportMarkdown = System.IO.File.Exists(reportPath) ? await System.IO.File.ReadAllTextAsync(reportPath, cancellationToken) : null; + var lastUpdated = new[] + { + System.IO.File.Exists(indexPath) ? System.IO.File.GetLastWriteTimeUtc(indexPath) : (DateTime?)null, + System.IO.File.Exists(reportPath) ? System.IO.File.GetLastWriteTimeUtc(reportPath) : (DateTime?)null, + }.Where(value => value.HasValue).Select(value => value!.Value).DefaultIfEmpty().Max(); + + return Ok(new CvBenchmarkStatusDto(indexJson, reportMarkdown, _paths.CvBenchmarksRoot, lastUpdated == default ? null : new DateTimeOffset(DateTime.SpecifyKind(lastUpdated, DateTimeKind.Utc)))); + } + [HttpGet] public async Task> Get(CancellationToken cancellationToken) { @@ -128,6 +196,10 @@ public sealed class AdminSystemController : ControllerBase OllamaReachable: null, OllamaModel: null, OllamaModelAvailable: null, + OllamaVersion: null, + OllamaInstalledModels: Array.Empty(), + OllamaLoadedModels: Array.Empty(), + OllamaLoadedCount: 0, HealthLatencyMs: null, ProbeLatencyMs: null, LastProbeAt: null, @@ -211,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/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index cf8c428..33e66a4 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -64,18 +64,42 @@ public sealed class ProfileCvController : ControllerBase private readonly ICvAiClassifier _cvAiClassifier; private readonly JobTrackerContext _db; private readonly AppPaths _paths; + private readonly ILogger _logger; + private readonly ICvTemplateRenderer _cvTemplateRenderer; + private readonly ICvPdfExporter _cvPdfExporter; - public ProfileCvController(UserManager users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null) + public ProfileCvController(UserManager users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null) { _users = users; _aiService = aiService; _cvAiClassifier = cvAiClassifier ?? NoOpCvAiClassifier.Instance; _db = db; _paths = paths; + _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + _cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer(); + _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); } - public sealed record RewriteSectionRequest(string? SectionName, string? Style, string? TargetRole, int? JobApplicationId, string? TemplateId); + private sealed class ThrowingCvPdfExporter : ICvPdfExporter + { + public Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) + { + throw new InvalidOperationException("CV PDF export is not configured for this controller instance."); + } + } + + public sealed class RewriteSectionRequest + { + public string? SectionName { get; set; } + public string? Style { get; set; } + public string? TargetRole { get; set; } + public JsonElement? JobApplicationId { get; set; } + public string? TemplateId { get; set; } + public string? SourceText { get; set; } + } 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); private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv); private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification); @@ -275,46 +299,63 @@ public sealed class ProfileCvController : ControllerBase var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); - var sourceText = string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim(); var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + var sourceText = string.IsNullOrWhiteSpace(request.SourceText) + ? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim()) + : request.SourceText.Trim(); if (string.IsNullOrWhiteSpace(sourceText) && structuredCv.Sections.Count == 0) { return BadRequest("Add or import CV text before rewriting your CV."); } - var sectionName = string.IsNullOrWhiteSpace(request.SectionName) ? null : request.SectionName.Trim(); + var sectionName = NormalizeRewriteSectionName(request.SectionName); var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim(); - var templateId = string.IsNullOrWhiteSpace(request.TemplateId) ? "ats-minimal" : request.TemplateId.Trim(); + var templateId = NormalizeTemplateId(request.TemplateId ?? style); var targetRole = string.IsNullOrWhiteSpace(request.TargetRole) ? null : request.TargetRole.Trim(); - var jobContext = request.JobApplicationId.HasValue - ? await _db.JobApplications.AsNoTracking().Where(job => job.Id == request.JobApplicationId.Value && job.OwnerUserId == user.Id).Select(job => new - { - job.Id, - job.JobTitle, - job.Description, - job.ShortSummary, - CompanyName = job.Company != null ? job.Company.Name : null - }).FirstOrDefaultAsync(HttpContext.RequestAborted) + var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId); + var jobContext = jobApplicationId.HasValue + ? await _db.JobApplications + .AsNoTracking() + .Include(job => job.Company) + .Where(job => job.Id == jobApplicationId.Value && job.OwnerUserId == user.Id) + .Select(job => new + { + job.Id, + job.JobTitle, + job.Description, + job.TranslatedDescription, + job.ShortSummary, + job.Notes, + job.JobUrl, + job.Status, + CompanyName = job.Company != null ? job.Company.Name : null, + RecruiterName = job.Company != null ? job.Company.RecruiterName : null, + RecruiterEmail = job.Company != null ? job.Company.RecruiterEmail : null + }) + .FirstOrDefaultAsync(HttpContext.RequestAborted) : null; var effectiveTargetRole = targetRole ?? jobContext?.JobTitle; var rewriteSource = BuildRewriteSourceText(sectionName, sourceText, structuredCv); var templateGuidance = DescribeRewriteTemplate(templateId); var roleGuidance = jobContext is not null - ? $"Target this toward the saved job '{jobContext.JobTitle}' at '{jobContext.CompanyName ?? "Unknown company"}'. Use the job context below to sharpen wording without inventing facts.\nJob summary: {jobContext.ShortSummary ?? "-"}\nJob description: {jobContext.Description ?? "-"}" + ? $"Target this toward the saved job '{jobContext.JobTitle}' at '{jobContext.CompanyName ?? "Unknown company"}'. Use the full job record below to sharpen wording without inventing facts.\nJob status: {jobContext.Status}\nJob summary: {jobContext.ShortSummary ?? "-"}\nJob description: {jobContext.Description ?? "-"}\nTranslated description: {jobContext.TranslatedDescription ?? "-"}\nNotes: {jobContext.Notes ?? "-"}\nJob URL: {jobContext.JobUrl ?? "-"}\nRecruiter name: {jobContext.RecruiterName ?? "-"}\nRecruiter email: {jobContext.RecruiterEmail ?? "-"}" : effectiveTargetRole is not null ? $"Target role: {effectiveTargetRole}. Keep it broadly reusable but clearly aligned to that role family." : "Keep it broadly reusable for future tailoring."; var subject = sectionName is null ? "this CV" : $"the '{sectionName}' section of this CV"; + var instruction = $"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} Return only the rewritten text with clean headings and bullets when useful."; var rewritten = await _aiService.SummarizeSectionAsync( - $"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} Return only the rewritten text with clean headings and bullets when useful.", + instruction, rewriteSource, sectionName is null ? 1800 : 900, sectionName is null ? 400 : 180); 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."); } @@ -329,6 +370,73 @@ public sealed class ProfileCvController : ControllerBase }); } + [HttpGet("templates")] + public ActionResult> GetTemplates() + { + return Ok(GetCvTemplateDescriptors()); + } + + [HttpPost("rewrite-preview")] + public async Task> BuildRewritePreview([FromBody] RewriteSectionRequest request) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + var sourceText = string.IsNullOrWhiteSpace(request.SourceText) + ? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim()) + : request.SourceText.Trim(); + if (string.IsNullOrWhiteSpace(sourceText) && structuredCv.Sections.Count == 0) + { + return BadRequest("Add or import CV text before rewriting your CV."); + } + + var sectionName = NormalizeRewriteSectionName(request.SectionName); + var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim(); + var templateId = NormalizeTemplateId(request.TemplateId ?? style); + var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId); + var job = jobApplicationId.HasValue + ? await _db.JobApplications.AsNoTracking().Include(job => job.Company) + .FirstOrDefaultAsync(job => job.Id == jobApplicationId.Value && job.OwnerUserId == user.Id, HttpContext.RequestAborted) + : null; + var effectiveTargetRole = string.IsNullOrWhiteSpace(request.TargetRole) + ? job?.JobTitle + : request.TargetRole.Trim(); + + var rewriteResult = await RewriteSection(request); + if (rewriteResult is not OkObjectResult ok) return StatusCode((rewriteResult as ObjectResult)?.StatusCode ?? 500, (rewriteResult as ObjectResult)?.Value); + + var rewrittenText = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)).RootElement.GetProperty("text").GetString()?.Trim() ?? string.Empty; + var baseText = string.IsNullOrWhiteSpace(sourceText) + ? string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}")) + : sourceText!; + var fullText = sectionName is null ? rewrittenText : ReplaceOrAppendCvSection(baseText, sectionName, rewrittenText); + var previewStructured = await BuildStructuredCvAsync(fullText, HttpContext.RequestAborted); + var document = BuildMasterCvDocument(previewStructured, templateId, effectiveTargetRole, job?.JobTitle, job?.Company?.Name); + var rendered = RenderProfileCv(document, user, effectiveTargetRole ?? user.DisplayName ?? "General CV", job?.Company?.Name); + + return Ok(new ProfileCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName, fullText, rewrittenText, sectionName, previewStructured, document, effectiveTargetRole, job?.Id)); + } + + [HttpPost("export-pdf")] + public async Task ExportProfileCvPdf([FromBody] RewriteSectionRequest request, CancellationToken cancellationToken) + { + var previewResult = await BuildRewritePreview(request); + if (previewResult.Result is ObjectResult errorResult && errorResult.StatusCode >= 400) + { + return StatusCode(errorResult.StatusCode ?? 500, errorResult.Value); + } + + var ok = previewResult.Result as OkObjectResult; + if (ok?.Value is not ProfileCvPreviewDto preview) + { + return StatusCode(StatusCodes.Status500InternalServerError, "The CV preview could not be prepared for PDF export."); + } + + var artifact = await _cvPdfExporter.ExportAsync(new TailoredCvRenderResult(preview.TemplateId, preview.SuggestedFileName, preview.Html), cancellationToken); + return File(artifact.Bytes, "application/pdf", artifact.FileName); + } + [HttpPost("parse")] public async Task> Parse([FromBody] ParseCvRequest? request) { @@ -400,10 +508,167 @@ public sealed class ProfileCvController : ControllerBase "harvard" => "Harvard template: refined, traditional, strong hierarchy, restrained and credible.", "auckland" => "Auckland template: modern sidebar layout, crisp highlights, confident but readable.", "edinburgh" => "Edinburgh template: polished editorial layout with stronger visual personality and premium spacing.", - _ => "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor.", + "monarch" => "Monarch template: executive, premium, high-contrast emphasis on summary and leadership signals.", + "fjord" => "Fjord template: calm technical layout with clear information density and practical scanability.", + _ => "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor." }; } + private static string NormalizeTemplateId(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "base" => "ats-minimal", + "legacy-text" => "ats-minimal", + "harvard" => "harvard", + "auckland" => "auckland", + "edinburgh" => "edinburgh", + "monarch" => "monarch", + "fjord" => "fjord", + _ => "ats-minimal" + }; + } + + private static string? NormalizeRewriteSectionName(string? value) + { + var trimmed = value?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) return null; + return SectionAliases.TryGetValue(trimmed, out var canonical) ? canonical : trimmed; + } + + private static int? ParseFlexibleNullableInt(JsonElement? value) + { + if (value is null) return null; + if (value.Value.ValueKind == JsonValueKind.Number && value.Value.TryGetInt32(out var number)) return number; + if (value.Value.ValueKind == JsonValueKind.String) + { + var raw = value.Value.GetString(); + if (int.TryParse(raw, out var parsed)) return parsed; + } + return null; + } + + private static string ReplaceOrAppendCvSection(string source, string sectionName, string sectionDraft) + { + var trimmedSource = (source ?? string.Empty).Trim(); + var trimmedDraft = (sectionDraft ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(trimmedDraft)) return trimmedSource; + if (string.IsNullOrWhiteSpace(trimmedSource)) return $"## {sectionName}\n{trimmedDraft}"; + + var normalizedHeading = sectionName.Trim().ToLowerInvariant(); + var headingPattern = new Regex(@"^(##\s+|#\s+)?(?[A-Z][A-Za-z &/]+):?\s*$", RegexOptions.Multiline); + var matches = headingPattern.Matches(trimmedSource).ToList(); + var targetIndex = matches.FindIndex(match => string.Equals(match.Groups["name"].Value.Trim(), normalizedHeading, StringComparison.OrdinalIgnoreCase)); + if (targetIndex < 0) + { + return $"{trimmedSource}\n\n## {sectionName}\n{trimmedDraft}".Trim(); + } + + var start = matches[targetIndex].Index; + var end = targetIndex + 1 < matches.Count ? matches[targetIndex + 1].Index : trimmedSource.Length; + var before = trimmedSource[..start].TrimEnd(); + var after = trimmedSource[end..].TrimStart(); + return string.Join("\n\n", new[] { before, $"## {sectionName}\n{trimmedDraft}", after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim(); + } + + private static IReadOnlyList GetCvTemplateDescriptors() + { + return new[] + { + new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }), + new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List { "Classic serif rhythm", "Strong chronology", "Credible tone" }), + new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List { "Sidebar details", "Compact highlights", "Modern contrast" }), + new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List { "Premium spacing", "Stronger personality", "Readable density" }), + new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }), + new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List { "Technical depth", "Dense but readable", "Practical hierarchy" }), + }; + } + + private TailoredCvRenderResult RenderProfileCv(TailoredCvDocument document, ApplicationUser user, string targetRole, string? companyName) + { + var candidateName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(value => !string.IsNullOrWhiteSpace(value))); + if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.DisplayName?.Trim(); + if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.UserName?.Trim(); + if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.Email?.Trim(); + if (string.IsNullOrWhiteSpace(candidateName)) candidateName = "Your Name"; + return _cvTemplateRenderer.Render(document, document.TemplateId, candidateName!, targetRole, companyName, user.AvatarImageDataUrl); + } + + private static TailoredCvDocument BuildMasterCvDocument(StructuredCvProfile structuredCv, string templateId, string? targetRole, string? fallbackHeadline, string? companyName) + { + var normalized = StructuredCvProfileJson.Normalize(structuredCv); + var customSections = new List(); + if (normalized.Certifications.Count > 0) + { + customSections.Add(new TailoredCvCustomSection + { + Title = "Certifications", + Items = normalized.Certifications.Select(certification => string.Join(" | ", new[] { certification.Name, certification.Issuer, certification.Location, certification.Date }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(), + }); + } + if (normalized.Projects.Count > 0) + { + customSections.Add(new TailoredCvCustomSection + { + Title = "Projects", + Items = normalized.Projects.Select(project => string.Join(" | ", new[] { project.Name, project.Role, project.Location, FormatDateRangeForSection(project.Start, project.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(), + }); + } + if (normalized.Languages.Count > 0) + { + customSections.Add(new TailoredCvCustomSection + { + Title = "Languages", + Items = normalized.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(), + }); + } + customSections.AddRange(normalized.OtherSections.Select(section => new TailoredCvCustomSection { Title = section.Title, Items = section.Items })); + + return TailoredCvDraftJson.Normalize(new TailoredCvDocument + { + TemplateId = templateId, + Headline = normalized.Contact.Headline ?? targetRole ?? fallbackHeadline ?? companyName, + Summary = normalized.Summary, + SelectedSkills = normalized.Skills, + Experience = normalized.Jobs.Select(job => new TailoredCvExperienceItem + { + Title = job.Title, + Company = job.Company, + Location = job.Location, + Start = job.Start, + End = job.End, + IsCurrent = job.IsCurrent, + Bullets = job.Bullets, + }).ToList(), + Education = normalized.Education.Select(education => new TailoredCvEducationItem + { + Qualification = education.Qualification, + QualificationLevel = education.QualificationLevel, + Institution = education.Institution, + Location = education.Location, + Start = education.Start, + End = education.End, + Details = education.Details, + }).ToList(), + CustomSections = customSections, + RenderOptions = new TailoredCvRenderOptions + { + ShowPhoto = true, + AccentColor = templateId switch + { + "harvard" => "brick", + "auckland" => "emerald", + "edinburgh" => "plum", + "monarch" => "#7c2d12", + "fjord" => "#0f4c5c", + _ => "slate", + }, + SectionOrder = new List { "summary", "skills", "experience", "education", "custom" }, + } + }); + } + private async Task BuildStructuredCvAsync(string text, CancellationToken cancellationToken) { var parseSource = NormalizeTextForStructuredParsing(text); @@ -601,7 +866,7 @@ public sealed class ProfileCvController : ControllerBase private async Task TryExtractStructuredCvAsync(string text, CancellationToken cancellationToken) { var structuredJson = await _aiService.SummarizeSectionAsync( - "Extract this CV into structured JSON. Return only valid JSON with this exact top-level shape: { \"version\": \"1\", \"contact\": { \"fullName\": string|null, \"headline\": string|null, \"email\": string|null, \"phone\": string|null, \"location\": string|null, \"website\": string|null, \"linkedin\": string|null }, \"summary\": string[], \"jobs\": [{ \"title\": string|null, \"company\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"isCurrent\": boolean, \"bullets\": string[], \"skills\": string[] }], \"education\": [{ \"qualification\": string|null, \"institution\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"details\": string[] }], \"skills\": string[], \"languages\": [{ \"name\": string|null, \"level\": string|null, \"notes\": string|null }], \"interests\": string[], \"otherSections\": [{ \"title\": string|null, \"items\": string[] }] }. Preserve facts only. Do not invent anything. If a field is unknown, use null or an empty array. Keep wording close to the source. Put unmatched content in otherSections.", + "Extract this CV into structured JSON. Return only valid JSON with this exact top-level shape: { \"version\": \"1\", \"contact\": { \"fullName\": string|null, \"headline\": string|null, \"email\": string|null, \"phone\": string|null, \"location\": string|null, \"website\": string|null, \"linkedin\": string|null }, \"summary\": string[], \"jobs\": [{ \"title\": string|null, \"company\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"isCurrent\": boolean, \"bullets\": string[], \"skills\": string[] }], \"education\": [{ \"qualification\": string|null, \"qualificationLevel\": \"Secondary\"|\"Diploma/Certificate\"|\"Bachelor\"|\"Master\"|\"PhD\"|\"Other\"|null, \"institution\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"details\": string[] }], \"certifications\": [{ \"name\": string|null, \"issuer\": string|null, \"location\": string|null, \"date\": string|null, \"details\": string[] }], \"projects\": [{ \"name\": string|null, \"role\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"bullets\": string[], \"skills\": string[] }], \"skills\": string[], \"languages\": [{ \"name\": string|null, \"level\": string|null, \"notes\": string|null }], \"interests\": string[], \"otherSections\": [{ \"title\": string|null, \"items\": string[] }] }. Preserve facts only. Do not invent anything. If a field is unknown, use null or an empty array. Keep wording close to the source. Profile location should only be the candidate's current/home location. Education location must be the institution location. Work location must be employer/job location. Never place skill lists such as Python or Ruby into location fields. Preserve the original qualification text in education. Set qualificationLevel to the normalized enum when you can infer it, otherwise null. Put unmatched content in otherSections.", text, 3200, 900); @@ -720,9 +985,20 @@ public sealed class ProfileCvController : ControllerBase profile.Contact.Website = NullIfWhitespace(Regex.Match(rawSource, @"\b(?:https?://)?(?:www\.)?[A-Z0-9.-]+\.[A-Z]{2,}(?:/[A-Z0-9._~:/?#\[\]@!$&'()*+,;=-]*)?", RegexOptions.IgnoreCase).Value); profile.Contact.LinkedIn = NullIfWhitespace(Regex.Match(rawSource, @"(?:linkedin(?:\.com)?/[A-Z0-9._~:/?#\[\]@!$&'()*+,;=-]+)", RegexOptions.IgnoreCase).Value); profile.Contact.FullName = GuessFullName(rawSource) ?? GuessFullNameFromEmail(profile.Contact.Email); - profile.Contact.Location = NullIfWhitespace(Regex.Match(rawSource, @"\b[A-Z][a-z]+(?:[\s-][A-Z][a-z]+)*,\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b").Value); var sections = ParseSections(normalized); + var contactSection = sections.FirstOrDefault(section => section.Name == "Contact"); + if (!string.IsNullOrWhiteSpace(contactSection.Content)) + { + var contactFallback = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Contact", Content = contactSection.Content } }); + profile.Contact.Location = contactFallback.Contact.Location; + profile.Contact.Headline ??= contactFallback.Contact.Headline; + } + else + { + profile.Contact.Location = NullIfWhitespace(Regex.Match(rawSource, @"\b[A-Z][a-z]+(?:[\s-][A-Z][a-z]+)*(?:,\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*){1,2}\b").Value); + } + var summarySection = sections.FirstOrDefault(section => section.Name == "Professional Summary"); var flattenedSummary = Regex.Match( rawSource, @@ -777,6 +1053,18 @@ public sealed class ProfileCvController : ControllerBase profile.Education = ParseEducationHeuristically(educationSection.Content); } + var certificationsSection = sections.FirstOrDefault(section => section.Name == "Certifications"); + if (!string.IsNullOrWhiteSpace(certificationsSection.Content)) + { + profile.Certifications = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Certifications", Content = certificationsSection.Content } }).Certifications; + } + + var projectsSection = sections.FirstOrDefault(section => section.Name == "Projects"); + if (!string.IsNullOrWhiteSpace(projectsSection.Content)) + { + profile.Projects = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Projects", Content = projectsSection.Content } }).Projects; + } + var experienceSection = sections.FirstOrDefault(section => section.Name == "Work Experience"); if (!string.IsNullOrWhiteSpace(experienceSection.Content)) { @@ -861,6 +1149,7 @@ public sealed class ProfileCvController : ControllerBase items.Add(new StructuredCvEducation { Qualification = TitleCasePreservingAcronyms(qualificationLine), + QualificationLevel = InferQualificationLevel(qualificationLine), Institution = TitleCasePreservingAcronyms(institutionLine), Start = dateMatch.Success ? dateMatch.Groups[1].Value : null, End = dateMatch.Success ? dateMatch.Groups[2].Value : null, @@ -913,6 +1202,18 @@ public sealed class ProfileCvController : ControllerBase return string.Join(" ", words); } + private static string? InferQualificationLevel(string? value) + { + var candidate = value?.Trim(); + if (string.IsNullOrWhiteSpace(candidate)) return null; + if (Regex.IsMatch(candidate, @"\b(phd|doctorate|dphil)\b", RegexOptions.IgnoreCase)) return "PhD"; + if (Regex.IsMatch(candidate, @"\b(master(?:'s)?|msc|m\.sc|ma|m\.a|mba|meng)\b", RegexOptions.IgnoreCase)) return "Master"; + if (Regex.IsMatch(candidate, @"\b(bachelor(?:'s)?|bsc|b\.sc|ba|b\.a|beng|degree)\b", RegexOptions.IgnoreCase)) return "Bachelor"; + if (Regex.IsMatch(candidate, @"\b(diploma|certificate|certification|nvq|btec|level\s*\d+|apprenticeship|associate)\b", RegexOptions.IgnoreCase)) return "Diploma/Certificate"; + if (Regex.IsMatch(candidate, @"\b(gcse|a-?level|secondary|high school)\b", RegexOptions.IgnoreCase)) return "Secondary"; + return "Other"; + } + private static int CountWords(string? text) { if (string.IsNullOrWhiteSpace(text)) return 0; diff --git a/JobTrackerApi/Services/AppPaths.cs b/JobTrackerApi/Services/AppPaths.cs index ac83c3f..337fe43 100644 --- a/JobTrackerApi/Services/AppPaths.cs +++ b/JobTrackerApi/Services/AppPaths.cs @@ -9,6 +9,7 @@ namespace JobTrackerApi.Services public string AttachmentsRoot { get; } public string CvArtifactsRoot { get; } public string CvExportsRoot { get; } + public string CvBenchmarksRoot { get; } public AppPaths(IConfiguration cfg, IHostEnvironment env) { @@ -39,6 +40,13 @@ namespace JobTrackerApi.Services Directory.CreateDirectory(cvExportsRoot); CvExportsRoot = cvExportsRoot; + + var cvBenchmarksRoot = (cfg["Data:CvBenchmarksRoot"] ?? "").Trim(); + if (string.IsNullOrWhiteSpace(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(DataRoot, "CvBenchmarks"); + if (!Path.IsPathRooted(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(env.ContentRootPath, cvBenchmarksRoot); + + Directory.CreateDirectory(cvBenchmarksRoot); + CvBenchmarksRoot = cvBenchmarksRoot; } public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName); diff --git a/JobTrackerApi/Services/CvTemplateRenderer.cs b/JobTrackerApi/Services/CvTemplateRenderer.cs index 7b5ff57..3bb7043 100644 --- a/JobTrackerApi/Services/CvTemplateRenderer.cs +++ b/JobTrackerApi/Services/CvTemplateRenderer.cs @@ -24,6 +24,8 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer "harvard" => RenderHarvard(normalized, candidateName, jobTitle, companyName), "auckland" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Auckland", roundedPhoto: false, curvedHeader: false), "edinburgh" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Edinburgh", roundedPhoto: true, curvedHeader: true), + "monarch" => RenderMonarch(normalized, candidateName, jobTitle, companyName, photoDataUrl), + "fjord" => RenderFjord(normalized, candidateName, jobTitle, companyName, photoDataUrl), _ => RenderAtsMinimal(normalized, candidateName, jobTitle, companyName, photoDataUrl) }; return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html); @@ -39,6 +41,8 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer "harvard" => "harvard", "auckland" => "auckland", "edinburgh" => "edinburgh", + "monarch" => "monarch", + "fjord" => "fjord", _ => "ats-minimal" }; } @@ -201,6 +205,106 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer "; } + private static string RenderMonarch(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl) + { + var accent = ResolveAccent(document.RenderOptions.AccentColor); + var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); + var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; + var body = RenderMainSections(document, accent, headingStyle: "sidebar"); + var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"
Tailored toward {Encode(companyName)}
"; + return $@" + + + + {Encode(candidateName)} — Monarch + + + +
+
+
+
+ Executive CV +

{Encode(candidateName)}

+
{Encode(document.Headline ?? jobTitle)}
+ {companyMarkup} +
+ {photoMarkup} +
+ {(!string.IsNullOrWhiteSpace(jobTitle) ? $"
Primary role target: {Encode(jobTitle)}
" : string.Empty)} + {body} +
+
+ +"; + } + + private static string RenderFjord(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl) + { + var accent = ResolveAccent(document.RenderOptions.AccentColor); + var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); + var body = RenderMainSections(document, accent, headingStyle: "sidebar"); + var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; + var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"{Encode(companyName)}"; + return $@" + + + + {Encode(candidateName)} — Fjord + + + +
+
+ +
{body}
+
+
+ +"; + } + private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle) { var sectionOrder = document.RenderOptions.SectionOrder.Count == 0 @@ -291,7 +395,10 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(Encode)); items.Append("
"); - items.Append($"
{Encode(entry.Qualification)}
"); + var title = string.IsNullOrWhiteSpace(entry.QualificationLevel) + ? entry.Qualification + : $"{entry.Qualification} ({entry.QualificationLevel})"; + items.Append($"
{Encode(title)}
"); if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"
{subtitle}
"); if (entry.Details.Count > 0) items.Append($"
    {string.Join(string.Empty, entry.Details.Select(detail => $"
  • {Encode(detail)}
  • "))}
"); items.Append("
"); diff --git a/JobTrackerApi/Services/SummarizerService.cs b/JobTrackerApi/Services/SummarizerService.cs index 6ebd044..1ae180c 100644 --- a/JobTrackerApi/Services/SummarizerService.cs +++ b/JobTrackerApi/Services/SummarizerService.cs @@ -25,6 +25,10 @@ namespace JobTrackerApi.Services bool? OllamaReachable, string? OllamaModel, bool? OllamaModelAvailable, + string? OllamaVersion, + IReadOnlyList? OllamaInstalledModels, + IReadOnlyList? OllamaLoadedModels, + int? OllamaLoadedCount, double? HealthLatencyMs, double? ProbeLatencyMs, DateTimeOffset? LastProbeAt, @@ -66,6 +70,7 @@ namespace JobTrackerApi.Services public interface ISummarizerService : IAiService { + new Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40); } public class SummarizerService : ISummarizerService @@ -318,6 +323,10 @@ namespace JobTrackerApi.Services bool? ollamaReachable = null; string? ollamaModel = null; bool? ollamaModelAvailable = null; + string? ollamaVersion = null; + List? ollamaInstalledModels = null; + List? ollamaLoadedModels = null; + int? ollamaLoadedCount = null; double? healthLatencyMs = null; var healthy = false; string? healthError = null; @@ -344,6 +353,16 @@ namespace JobTrackerApi.Services if (doc.RootElement.TryGetProperty("ollama_reachable", out var ollamaReachableEl) && ollamaReachableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaReachable = ollamaReachableEl.GetBoolean(); if (doc.RootElement.TryGetProperty("ollama_model", out var ollamaModelEl)) ollamaModel = ollamaModelEl.GetString(); if (doc.RootElement.TryGetProperty("ollama_model_available", out var ollamaModelAvailableEl) && ollamaModelAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaModelAvailable = ollamaModelAvailableEl.GetBoolean(); + if (doc.RootElement.TryGetProperty("ollama_version", out var ollamaVersionEl)) ollamaVersion = ollamaVersionEl.GetString(); + if (doc.RootElement.TryGetProperty("ollama_installed_models", out var ollamaInstalledModelsEl) && ollamaInstalledModelsEl.ValueKind == JsonValueKind.Array) + { + ollamaInstalledModels = ollamaInstalledModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast().ToList(); + } + if (doc.RootElement.TryGetProperty("ollama_loaded_models", out var ollamaLoadedModelsEl) && ollamaLoadedModelsEl.ValueKind == JsonValueKind.Array) + { + ollamaLoadedModels = ollamaLoadedModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast().ToList(); + } + if (doc.RootElement.TryGetProperty("ollama_loaded_count", out var ollamaLoadedCountEl) && ollamaLoadedCountEl.ValueKind == JsonValueKind.Number) ollamaLoadedCount = ollamaLoadedCountEl.GetInt32(); } else { @@ -406,6 +425,10 @@ namespace JobTrackerApi.Services OllamaReachable: ollamaReachable, OllamaModel: ollamaModel, OllamaModelAvailable: ollamaModelAvailable, + OllamaVersion: ollamaVersion, + OllamaInstalledModels: ollamaInstalledModels, + OllamaLoadedModels: ollamaLoadedModels, + OllamaLoadedCount: ollamaLoadedCount, HealthLatencyMs: healthLatencyMs, ProbeLatencyMs: probeLatencyMs, LastProbeAt: lastProbeAt, diff --git a/Models/StructuredCvProfile.cs b/Models/StructuredCvProfile.cs index e825902..6a4b628 100644 --- a/Models/StructuredCvProfile.cs +++ b/Models/StructuredCvProfile.cs @@ -8,6 +8,8 @@ public sealed class StructuredCvProfile public List Summary { get; set; } = new(); public List Jobs { get; set; } = new(); public List Education { get; set; } = new(); + public List Certifications { get; set; } = new(); + public List Projects { get; set; } = new(); public List Skills { get; set; } = new(); public List Languages { get; set; } = new(); public List Interests { get; set; } = new(); @@ -60,6 +62,7 @@ public sealed class StructuredCvJob public sealed class StructuredCvEducation { public string? Qualification { get; set; } + public string? QualificationLevel { get; set; } public string? Institution { get; set; } public string? Location { get; set; } public string? Start { get; set; } @@ -67,6 +70,26 @@ public sealed class StructuredCvEducation public List Details { get; set; } = new(); } +public sealed class StructuredCvCertification +{ + public string? Name { get; set; } + public string? Issuer { get; set; } + public string? Location { get; set; } + public string? Date { get; set; } + public List Details { get; set; } = new(); +} + +public sealed class StructuredCvProject +{ + public string? Name { get; set; } + public string? Role { get; set; } + public string? Location { get; set; } + public string? Start { get; set; } + public string? End { get; set; } + public List Bullets { get; set; } = new(); + public List Skills { get; set; } = new(); +} + public sealed class StructuredCvLanguage { public string? Name { get; set; } diff --git a/Models/StructuredCvProfileJson.cs b/Models/StructuredCvProfileJson.cs index 66ce75e..77dc8da 100644 --- a/Models/StructuredCvProfileJson.cs +++ b/Models/StructuredCvProfileJson.cs @@ -67,6 +67,8 @@ public static class StructuredCvProfileJson : primary.Summary.Concat(secondary.Summary).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); if (primary.Jobs.Count == 0) primary.Jobs = secondary.Jobs; if (primary.Education.Count == 0) primary.Education = secondary.Education; + if (primary.Certifications.Count == 0) primary.Certifications = secondary.Certifications; + if (primary.Projects.Count == 0) primary.Projects = secondary.Projects; primary.Skills = primary.Skills.Count == 0 ? secondary.Skills : primary.Skills.Concat(secondary.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); @@ -132,6 +134,14 @@ public static class StructuredCvProfileJson case "education": profile.Education = ParseEducation(section.Content); break; + case "certifications": + case "certificates": + profile.Certifications = ParseCertifications(section.Content); + break; + case "projects": + case "selected projects": + profile.Projects = ParseProjects(section.Content); + break; default: profile.OtherSections.Add(new StructuredCvOtherSection { @@ -165,6 +175,18 @@ public static class StructuredCvProfileJson || !string.IsNullOrWhiteSpace(education.Institution) || education.Details.Count > 0) .ToList(); + profile.Certifications = (profile.Certifications ?? new List()) + .Select(NormalizeCertification) + .Where(certification => !string.IsNullOrWhiteSpace(certification.Name) + || !string.IsNullOrWhiteSpace(certification.Issuer) + || certification.Details.Count > 0) + .ToList(); + profile.Projects = (profile.Projects ?? new List()) + .Select(NormalizeProject) + .Where(project => !string.IsNullOrWhiteSpace(project.Name) + || !string.IsNullOrWhiteSpace(project.Role) + || project.Bullets.Count > 0) + .ToList(); profile.Skills = CleanList(profile.Skills); profile.Languages = (profile.Languages ?? new List()) .Select(NormalizeLanguage) @@ -299,6 +321,8 @@ public static class StructuredCvProfileJson if (trimmed.Any(char.IsDigit) || trimmed.Length > 80) return null; var normalized = Regex.Replace(trimmed, @"\s+[A-Z](?:\s+[A-Z]){2,}(?:\b.*)?$", string.Empty).Trim(); + normalized = Regex.Replace(normalized, @"\b(?:remote|hybrid)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(); + normalized = Regex.Replace(normalized, @"\b(?:sales representative|developer|engineer|manager|consultant|analyst|designer|specialist|technician)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(); normalized = Regex.Replace(normalized, @"\s+", " ").Trim(' ', '|', ';', ':'); var parts = normalized.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length == 0 || parts.Length > 4) return null; @@ -421,10 +445,24 @@ public static class StructuredCvProfileJson return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; } + private static string? NormalizeQualificationLevel(string? explicitValue, string? qualificationText) + { + var candidate = TrimOrNull(explicitValue) ?? TrimOrNull(qualificationText); + if (candidate is null) return null; + + if (Regex.IsMatch(candidate, @"\b(phd|doctorate|dphil)\b", RegexOptions.IgnoreCase)) return "PhD"; + if (Regex.IsMatch(candidate, @"\b(master(?:'s)?|msc|m\.sc|ma|m\.a|mba|meng|meng)\b", RegexOptions.IgnoreCase)) return "Master"; + if (Regex.IsMatch(candidate, @"\b(bachelor(?:'s)?|bsc|b\.sc|ba|b\.a|beng|llb|undergraduate degree)\b", RegexOptions.IgnoreCase)) return "Bachelor"; + if (Regex.IsMatch(candidate, @"\b(diploma|certificate|certification|nvq|btec|level\s*\d+|apprenticeship|associate degree)\b", RegexOptions.IgnoreCase)) return "Diploma/Certificate"; + if (Regex.IsMatch(candidate, @"\b(gcse|a-?level|secondary|high school|gymnasium)\b", RegexOptions.IgnoreCase)) return "Secondary"; + return "Other"; + } + private static StructuredCvEducation NormalizeEducation(StructuredCvEducation? education) { education ??= new StructuredCvEducation(); education.Qualification = NormalizeQualification(education.Qualification); + education.QualificationLevel = NormalizeQualificationLevel(education.QualificationLevel, education.Qualification); education.Institution = NormalizeInstitution(education.Institution); education.Location = NormalizeLocationValue(education.Location); education.Start = NormalizeDateValue(education.Start); @@ -438,12 +476,41 @@ public static class StructuredCvProfileJson if (qualificationLooksInstitutional && institutionLooksQualification) { (education.Qualification, education.Institution) = (education.Institution, education.Qualification); + education.QualificationLevel = NormalizeQualificationLevel(education.QualificationLevel, education.Qualification); } } return education; } + private static StructuredCvCertification NormalizeCertification(StructuredCvCertification? certification) + { + certification ??= new StructuredCvCertification(); + certification.Name = NormalizeQualification(certification.Name); + certification.Issuer = NormalizeInstitution(certification.Issuer); + certification.Location = NormalizeLocationValue(certification.Location); + certification.Date = NormalizeDateValue(certification.Date); + certification.Details = CleanList(certification.Details); + return certification; + } + + private static StructuredCvProject NormalizeProject(StructuredCvProject? project) + { + project ??= new StructuredCvProject(); + project.Name = NormalizeQualification(project.Name); + project.Role = NormalizeJobTitle(project.Role); + project.Location = NormalizeLocationValue(project.Location); + project.Start = NormalizeDateValue(project.Start); + project.End = NormalizeDateValue(project.End); + project.Bullets = CleanList(project.Bullets) + .Select(NormalizeBullet) + .Where(bullet => bullet is not null) + .Select(bullet => bullet!) + .ToList(); + project.Skills = CleanList(project.Skills); + return project; + } + private static StructuredCvLanguage NormalizeLanguage(StructuredCvLanguage? language) { language ??= new StructuredCvLanguage(); @@ -512,12 +579,42 @@ public static class StructuredCvProfileJson AddIf(lines, $"### {education.Qualification}".Trim()); var meta = string.Join(" | ", new[] { education.Institution, education.Location, FormatDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value))); AddIf(lines, meta); + if (!string.IsNullOrWhiteSpace(education.QualificationLevel)) AddIf(lines, $"Level: {education.QualificationLevel}"); lines.AddRange(education.Details.Select(detail => $"- {detail}")); if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty); } AddSectionIfAny(sections, "Education", lines); } + if (profile.Certifications.Count > 0) + { + var lines = new List(); + foreach (var certification in profile.Certifications) + { + AddIf(lines, $"### {certification.Name}".Trim()); + var meta = string.Join(" | ", new[] { certification.Issuer, certification.Location, certification.Date }.Where(value => !string.IsNullOrWhiteSpace(value))); + AddIf(lines, meta); + lines.AddRange(certification.Details.Select(detail => $"- {detail}")); + if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty); + } + AddSectionIfAny(sections, "Certifications", lines); + } + + if (profile.Projects.Count > 0) + { + var lines = new List(); + foreach (var project in profile.Projects) + { + AddIf(lines, $"### {project.Name}".Trim()); + var meta = string.Join(" | ", new[] { project.Role, project.Location, FormatDateRange(project.Start, project.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value))); + AddIf(lines, meta); + lines.AddRange(project.Bullets.Select(bullet => $"- {bullet}")); + if (project.Skills.Count > 0) AddIf(lines, $"Skills: {string.Join(", ", project.Skills)}"); + if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty); + } + AddSectionIfAny(sections, "Projects", lines); + } + AddSectionIfAny(sections, "Skills", profile.Skills); if (profile.Languages.Count > 0) @@ -692,9 +789,76 @@ public static class StructuredCvProfileJson if (metadataWithoutDates.Count > 1) education.Location = metadataWithoutDates[1].NullIfWhitespace(); education.Details = lines.Skip(1).Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList(); + education.QualificationLevel = NormalizeQualificationLevel(null, education.Qualification); return string.IsNullOrWhiteSpace(education.Qualification) && string.IsNullOrWhiteSpace(education.Institution) && education.Details.Count == 0 ? null : education; } + private static List ParseCertifications(string content) + { + var blocks = SplitBlocks(content); + return blocks.Select(ParseCertificationBlock).Where(certification => certification is not null).Select(certification => certification!).ToList(); + } + + private static StructuredCvCertification? ParseCertificationBlock(string block) + { + var lines = block.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + if (lines.Count == 0) return null; + + var certification = new StructuredCvCertification(); + if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' '); + certification.Name = lines[0].NullIfWhitespace(); + + var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList(); + certification.Date = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null); + var metadataWithoutDates = metadata + .Select(line => string.IsNullOrWhiteSpace(certification.Date) ? line : line.Replace(certification.Date, string.Empty)) + .Select(line => line.Trim(' ', '|', ',', '-')) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + if (metadataWithoutDates.Count > 0) certification.Issuer = metadataWithoutDates[0].NullIfWhitespace(); + if (metadataWithoutDates.Count > 1) certification.Location = metadataWithoutDates[1].NullIfWhitespace(); + certification.Details = lines.Skip(1).Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList(); + return string.IsNullOrWhiteSpace(certification.Name) && string.IsNullOrWhiteSpace(certification.Issuer) ? null : certification; + } + + private static List ParseProjects(string content) + { + var blocks = SplitBlocks(content); + return blocks.Select(ParseProjectBlock).Where(project => project is not null).Select(project => project!).ToList(); + } + + private static StructuredCvProject? ParseProjectBlock(string block) + { + var lines = block.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + if (lines.Count == 0) return null; + + var project = new StructuredCvProject(); + if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' '); + project.Name = lines[0].NullIfWhitespace(); + var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line) && !line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)).ToList(); + var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)(?:\s*[-–]\s*(?:(?:\w+\s+)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null); + if (!string.IsNullOrWhiteSpace(dateValue)) + { + var parts = Regex.Split(dateValue, "\\s*[-–]\\s*"); + project.Start = parts.FirstOrDefault().NullIfWhitespace(); + project.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace(); + } + + var metadataWithoutDates = metadata + .Select(line => string.IsNullOrWhiteSpace(dateValue) ? line : line.Replace(dateValue, string.Empty)) + .Select(line => line.Trim(' ', '|', ',', '-')) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + if (metadataWithoutDates.Count > 0) project.Role = metadataWithoutDates[0].NullIfWhitespace(); + if (metadataWithoutDates.Count > 1) project.Location = metadataWithoutDates[1].NullIfWhitespace(); + project.Bullets = lines.Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList(); + project.Skills = lines + .Where(line => line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)) + .SelectMany(line => SplitList(line[(line.IndexOf(':') + 1)..])) + .ToList(); + return string.IsNullOrWhiteSpace(project.Name) && string.IsNullOrWhiteSpace(project.Role) && project.Bullets.Count == 0 ? null : project; + } + private static List SplitBlocks(string content) { var normalized = content.Replace("\r\n", "\n").Trim(); diff --git a/Models/TailoredCvDraft.cs b/Models/TailoredCvDraft.cs index bd7cb95..ce1320a 100644 --- a/Models/TailoredCvDraft.cs +++ b/Models/TailoredCvDraft.cs @@ -47,6 +47,7 @@ public sealed class TailoredCvExperienceItem public sealed class TailoredCvEducationItem { public string? Qualification { get; set; } + public string? QualificationLevel { get; set; } public string? Institution { get; set; } public string? Location { get; set; } public string? Start { get; set; } diff --git a/Models/TailoredCvDraftJson.cs b/Models/TailoredCvDraftJson.cs index 6ecf027..3b321f2 100644 --- a/Models/TailoredCvDraftJson.cs +++ b/Models/TailoredCvDraftJson.cs @@ -128,7 +128,7 @@ public static class TailoredCvDraftJson var block = new List(); foreach (var item in normalized.Education) { - AddLine(block, item.Qualification); + AddLine(block, string.IsNullOrWhiteSpace(item.QualificationLevel) ? item.Qualification : $"{item.Qualification} ({item.QualificationLevel})"); var meta = string.Join(" | ", new[] { item.Institution, @@ -170,6 +170,7 @@ public static class TailoredCvDraftJson { item ??= new TailoredCvEducationItem(); item.Qualification = TrimOrNull(item.Qualification); + item.QualificationLevel = TrimOrNull(item.QualificationLevel); item.Institution = TrimOrNull(item.Institution); item.Location = TrimOrNull(item.Location); item.Start = TrimOrNull(item.Start); diff --git a/docs/cv-builder-parser-benchmark.md b/docs/cv-builder-parser-benchmark.md new file mode 100644 index 0000000..2393264 --- /dev/null +++ b/docs/cv-builder-parser-benchmark.md @@ -0,0 +1,224 @@ +# CV builder, parser, benchmark, and Ollama admin integration + +## What changed + +This branch upgrades the Profile CV flow from a text-only rewrite helper into a template-driven CV builder backed by the server-side renderer/PDF pipeline, strengthens CV normalization around location and qualification handling, adds a repeatable local corpus benchmark workflow, and expands the admin system page with richer Ollama visibility. + +## Profile CV builder + +### New backend capabilities + +`JobTrackerApi/Controllers/ProfileCvController.cs` + +- Hardened `POST /api/profile-cv/rewrite-section` + - accepts flexible `jobApplicationId` payloads (number or blank string) + - uses richer saved-job context for tailoring + - logs empty AI responses with useful context +- Added `GET /api/profile-cv/templates` +- Added `POST /api/profile-cv/rewrite-preview` + - rewrites either the whole CV or one selected section + - rebuilds structured CV from the rewritten full text + - maps the result into the shared template renderer + - returns rendered HTML, file name, rewritten text, and full replacement text +- Added `POST /api/profile-cv/export-pdf` + - uses the same rendered HTML and the shared Playwright exporter + +### Frontend flow + +`job-tracker-ui/src/pages/ProfilePage.tsx` + +- Replaced the old rewrite draft box with a template-driven builder section. +- Users can: + - choose from 6 templates + - optionally target one section + - target by free-text role or saved job + - inspect the rewritten content + - inspect the actual rendered preview + - download a PDF + - replace the master CV with the rebuilt full-text result + +## Templates + +Shared renderer: `JobTrackerApi/Services/CvTemplateRenderer.cs` + +Available templates: +- `ats-minimal` +- `harvard` +- `auckland` +- `edinburgh` +- `monarch` +- `fjord` + +### Adding a new template + +1. Add the new template id to `NormalizeTemplateId()` in: + - `JobTrackerApi/Services/CvTemplateRenderer.cs` + - `JobTrackerApi/Controllers/ProfileCvController.cs` +2. Add a render branch in `CvTemplateRenderer.Render()`. +3. Add a descriptor to `GetCvTemplateDescriptors()`. +4. Add the matching card entry in `job-tracker-ui/src/pages/ProfilePage.tsx` if you want a custom preview card. + +## PDF generation + +The master CV builder now reuses the existing server-side pipeline: + +1. rewrite full text / section +2. rebuild structured CV +3. map to `TailoredCvDocument` +4. render HTML via `ICvTemplateRenderer` +5. export PDF via `ICvPdfExporter` / Playwright + +This keeps PDF output visually aligned with the selected template and avoids a separate client-only print implementation. + +## Parser and structured CV changes + +### Shared schema + +`Models/StructuredCvProfile.cs` + +Added: +- `education[].qualificationLevel` +- top-level `certifications[]` +- top-level `projects[]` + +`qualification` remains the original preserved text. + +### Normalization improvements + +`Models/StructuredCvProfileJson.cs` + +- tighter location sanitization to avoid skill or role spillover into location fields +- qualification level normalization to one of: + - `Secondary` + - `Diploma/Certificate` + - `Bachelor` + - `Master` + - `PhD` + - `Other` +- first-class normalization for certifications and projects +- section reconstruction now includes certifications and projects + +### Extraction prompt improvements + +`JobTrackerApi/Controllers/ProfileCvController.cs` + +The LLM extraction prompt now explicitly asks for: +- qualification level enum +- certifications +- projects +- strict location separation rules +- preservation of original qualification text + +## Benchmark workflow + +### Runner + +Use: + +```bash +./scripts/run-cv-benchmark.sh +``` + +Optional overrides: + +```bash +CV_BENCHMARK_OUTPUT_DIR=/absolute/output/path \ +CV_BENCHMARK_APPROVED_DIR=/absolute/approved/fixtures/path \ +./scripts/run-cv-benchmark.sh +``` + +### Inputs + +The runner scans: + +- `/home/pi/cvs` + +Supported corpus file types: +- PDF +- DOCX +- TXT +- MD + +### Outputs + +The runner writes: + +- `index.json` — machine-readable summary +- `report.md` — markdown overview +- `outputs/*.json` — latest normalized structured output per CV +- `candidate-fixtures/*.json` — created when no approved fixture exists yet + +Approved fixtures are local by design and should be reviewed manually before being promoted into the approved fixture path you use for regression comparisons. + +### Admin review + +`GET /api/admin/system/cv-benchmark` + +The admin system page surfaces: +- benchmark root path +- last benchmark update time +- latest markdown summary + +## Ollama admin visibility + +### Python health endpoint + +`tools/summarizer/app.py` + +`GET /health` now returns additional Ollama metadata when configured/reachable: +- `ollama_version` +- `ollama_installed_models` +- `ollama_loaded_models` +- `ollama_loaded_count` + +### Backend propagation + +`JobTrackerApi/Services/SummarizerService.cs` + +The backend metrics shape now carries those fields through to admin consumers. + +### Admin UI + +`job-tracker-ui/src/pages/AdminSystemPage.tsx` + +The system page now shows: +- Ollama version +- loaded model count +- installed model chips +- loaded model chips +- benchmark summary panel + +## Verification used on this branch + +### Backend + +```bash +dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter ProfileCvControllerTests +dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter "ProfileCvControllerTests|AuthAndSystemControllerTests|JobApplicationsApplicationPackageTests" +dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter CvCorpusHarnessTests +``` + +### Frontend + +```bash +cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/profile-page.test.tsx +cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/admin-system-page.test.tsx +cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/profile-page.test.tsx src/admin-system-page.test.tsx src/job-details-generated-drafts.test.tsx +``` + +### Benchmark runner + +```bash +CV_BENCHMARK_OUTPUT_DIR="$(pwd)/tmp/cv-benchmarks/latest" \ +CV_BENCHMARK_APPROVED_DIR="$(pwd)/tmp/cv-benchmarks/approved" \ +./scripts/run-cv-benchmark.sh +``` + +### Python service tests + +The summarizer Python unit tests were updated for the new health payload, but this machine currently lacks `pip` / `venv` support (`python3 -m venv` fails because `python3.12-venv` is not installed), so test execution is environment-blocked here. Once Python packaging is available, run: + +```bash +cd tools/summarizer +python3 -m pytest -q tests/test_app.py +``` diff --git a/job-tracker-ui/src/admin-system-page.test.tsx b/job-tracker-ui/src/admin-system-page.test.tsx index c3560ec..cb09087 100644 --- a/job-tracker-ui/src/admin-system-page.test.tsx +++ b/job-tracker-ui/src/admin-system-page.test.tsx @@ -43,6 +43,14 @@ describe('AdminSystemPage', () => { gpuName: null, ocrAvailable: true, ocrLanguages: 'eng', + ollamaConfigured: true, + ollamaReachable: true, + ollamaModel: 'qwen2.5:7b', + ollamaModelAvailable: true, + ollamaVersion: '0.7.0', + ollamaInstalledModels: ['qwen2.5:7b', 'nomic-embed-text'], + ollamaLoadedModels: ['qwen2.5:7b'], + ollamaLoadedCount: 1, healthLatencyMs: 12.4, probeLatencyMs: 25.8, lastProbeAt: '2026-03-23T10:00:00Z', @@ -82,6 +90,48 @@ describe('AdminSystemPage', () => { }, } as any); } + if (url === '/admin/system/cv-benchmark') { + return Promise.resolve({ + data: { + 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); + } return Promise.resolve({ data: {} } as any); }); mockedApi.put.mockResolvedValue({ @@ -118,6 +168,11 @@ describe('AdminSystemPage', () => { expect(screen.getByText(/25.8 ms probe/i)).toBeTruthy(); expect(screen.getByText('OCR eng')).toBeTruthy(); expect(screen.getAllByText(/ollama configured/i).length).toBeGreaterThan(0); + 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 b559315..bd1f385 100644 --- a/job-tracker-ui/src/pages/AdminSystemPage.tsx +++ b/job-tracker-ui/src/pages/AdminSystemPage.tsx @@ -26,6 +26,14 @@ type AiServiceMetrics = { gpuName?: string | null; ocrAvailable?: boolean | null; ocrLanguages?: string | null; + ollamaConfigured?: boolean | null; + ollamaReachable?: boolean | null; + ollamaModel?: string | null; + ollamaModelAvailable?: boolean | null; + ollamaVersion?: string | null; + ollamaInstalledModels?: string[] | null; + ollamaLoadedModels?: string[] | null; + ollamaLoadedCount?: number | null; healthLatencyMs?: number | null; probeLatencyMs?: number | null; lastProbeAt?: string | null; @@ -60,6 +68,47 @@ 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; + rootPath: string; + lastUpdatedAtUtc?: string | null; +}; + type SystemStatus = { environment: string; contentRoot: string; @@ -121,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 ( @@ -141,6 +210,7 @@ export default function AdminSystemPage() { const [tab, setTab] = useState(0); const [status, setStatus] = useState(null); const [emailSettings, setEmailSettings] = useState(null); + const [benchmarkStatus, setBenchmarkStatus] = useState(null); const [smtpPassword, setSmtpPassword] = useState(""); const [clearPassword, setClearPassword] = useState(false); const [loading, setLoading] = useState(false); @@ -156,18 +226,21 @@ export default function AdminSystemPage() { setLoading(true); setError(null); try { - const [statusRes, emailRes] = await Promise.all([ + const [statusRes, emailRes, benchmarkRes] = await Promise.all([ api.get("/admin/system"), api.get("/admin/system/email-settings"), + api.get("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)), ]); setStatus(statusRes.data); setEmailSettings(emailRes.data); + setBenchmarkStatus(benchmarkRes.data ?? null); setSmtpPassword(""); setClearPassword(false); } catch (e: any) { setError(getApiErrorMessage(e, "Failed to load system status.")); setStatus(null); setEmailSettings(null); + setBenchmarkStatus(null); } finally { setLoading(false); } @@ -191,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 { @@ -367,6 +468,8 @@ export default function AdminSystemPage() { + + @@ -395,8 +498,79 @@ export default function AdminSystemPage() { + {(status?.ai.ollamaInstalledModels ?? []).slice(0, 4).map((model) => ( + + ))} + {(status?.ai.ollamaLoadedModels ?? []).slice(0, 3).map((model) => ( + + ))} + + + CV benchmark review + + + + + + + {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."} + + + )} + ) : ( diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index ddf32dd..5992d35 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -26,7 +26,7 @@ import { JobApplication } from "../types"; type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects"; -type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh"; +type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord"; type ExtractionRun = { id: number; @@ -60,6 +60,18 @@ type RewriteTemplateOption = { sampleBullets: string[]; }; +type CvBuilderPreview = { + templateId: CvSectionStyle; + html: string; + suggestedFileName: string; + fullText: string; + rewrittenText: string; + structuredCv: StructuredCvProfile; + sectionName?: string | null; + targetRole?: string | null; + jobApplicationId?: number | null; +}; + type MeResponse = { provider?: "local" | "google" | "external"; id?: string; @@ -122,6 +134,26 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [ sampleMeta: "Premium spacing · stronger visual voice", sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."] }, + { + id: "monarch", + title: "Monarch", + eyebrow: "Executive", + accent: "#7c2d12", + blurb: "High-contrast premium presentation for leadership-heavy applications.", + sampleHeading: "Executive Profile", + sampleMeta: "Leadership clarity · premium hierarchy", + sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."] + }, + { + id: "fjord", + title: "Fjord", + eyebrow: "Technical", + accent: "#0f4c5c", + blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.", + sampleHeading: "Projects & Systems", + sampleMeta: "Technical depth · practical readability", + sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."] + }, ]; function initialsFrom(values: Array) { @@ -208,7 +240,7 @@ export default function ProfilePage() { const [cvSectionStyle, setCvSectionStyle] = useState("ats-minimal"); const [cvSectionTargetRole, setCvSectionTargetRole] = useState(""); const [selectedRewriteJobId, setSelectedRewriteJobId] = useState(""); - const [cvSectionDraft, setCvSectionDraft] = useState(""); + const [rewritePreview, setRewritePreview] = useState(null); const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState(null); const [savedJobs, setSavedJobs] = useState([]); const [parsingCvSections, setParsingCvSections] = useState(false); @@ -263,6 +295,7 @@ export default function ProfilePage() { const latestRun = extractionRuns[0]; 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()); return ( @@ -742,39 +775,22 @@ export default function ProfilePage() { ))} - - + + - CV style rewrite studio - {t("profileCvSectionToolsHelp")} + Template-driven CV builder + + Choose a template, optionally target one section, and tailor the output toward a saved job or free-text role target. The preview below renders the actual PDF layout before you apply it. + + + + + {selectedRewriteJob ? : null} + {rewriteReady ? : null} - - + {REWRITE_TEMPLATES.map((option) => { const selected = option.id === cvSectionStyle; return ( @@ -791,25 +807,27 @@ export default function ProfilePage() { }} sx={{ p: 1.5, - borderRadius: 3, + borderRadius: 3.5, cursor: "pointer", border: "1px solid", borderColor: selected ? "primary.main" : "divider", - boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.15)" : "none", - backgroundColor: selected ? "rgba(25,118,210,0.06)" : "background.paper", + boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.18), 0 12px 30px rgba(15,23,42,0.08)" : "0 6px 18px rgba(15,23,42,0.04)", + background: selected ? `linear-gradient(180deg, ${option.accent}12 0%, rgba(255,255,255,0.96) 100%)` : "background.paper", + transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease", + '&:hover': { transform: 'translateY(-2px)' }, }} > - {option.eyebrow} - {option.title} + {option.eyebrow} + {option.title} { event.stopPropagation(); setRewritePreviewTemplate(option); }}> - - {option.sampleHeading} + + {option.sampleHeading} {option.sampleMeta} {option.sampleBullets.map((bullet) => ( • {bullet} @@ -821,7 +839,7 @@ export default function ProfilePage() { })} - + {t("profileCvSectionLabel")} - setCvSectionTargetRole(e.target.value)} fullWidth helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : undefined} /> + setCvSectionTargetRole(e.target.value)} + fullWidth + helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."} + /> Saved job context