From f22c6791a72a468a516dc6eb8bd688db9f069f33 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 1 Apr 2026 11:30:37 +0200 Subject: [PATCH] Improve CV rewrite flow and parser accuracy --- JobTrackerApi.Tests/CvCorpusHarnessTests.cs | 139 ++++++++++++ .../ProfileCvControllerTests.cs | 56 ++++- .../Controllers/ProfileCvController.cs | 86 ++++++- Models/StructuredCvProfileJson.cs | 78 ++++++- job-tracker-ui/src/admin-system-page.test.tsx | 1 + job-tracker-ui/src/i18n/translations.ts | 10 + job-tracker-ui/src/pages/AdminSystemPage.tsx | 4 + job-tracker-ui/src/pages/ProfilePage.tsx | 214 +++++++++++++++--- job-tracker-ui/src/profile-page.test.tsx | 48 ++++ 9 files changed, 581 insertions(+), 55 deletions(-) create mode 100644 JobTrackerApi.Tests/CvCorpusHarnessTests.cs diff --git a/JobTrackerApi.Tests/CvCorpusHarnessTests.cs b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs new file mode 100644 index 0000000..c36aa55 --- /dev/null +++ b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs @@ -0,0 +1,139 @@ +using System.Reflection; +using System.Text.Json; +using JobTrackerApi.Controllers; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class CvCorpusHarnessTests +{ + private static readonly string CorpusRoot = "/home/pi/cvs"; + + [Fact] + public async Task Local_cv_corpus_harness_produces_repeatable_parse_report_when_available() + { + if (!Directory.Exists(CorpusRoot)) return; + + var files = Directory.EnumerateFiles(CorpusRoot, "*.*", SearchOption.TopDirectoryOnly) + .Where(path => path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .Take(8) + .ToList(); + + if (files.Count == 0) return; + + var user = new ApplicationUser { Id = "user-1", ProfileCvText = "seed" }; + var userManager = TestHostFactory.CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(It.IsAny())).ReturnsAsync(IdentityResult.Success); + + var aiService = new Mock(); + aiService.Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)).ReturnsAsync(string.Empty); + 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) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var extractMethod = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static); + var buildMethod = typeof(ProfileCvController).GetMethod("BuildStructuredCvAsync", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(extractMethod); + Assert.NotNull(buildMethod); + + var report = new List(); + foreach (var path in files) + { + await using var stream = File.OpenRead(path); + var fileName = Path.GetFileName(path); + var formFile = new FormFile(stream, 0, stream.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = GuessContentType(path) + }; + + var extension = Path.GetExtension(path); + var extractTask = (Task)extractMethod!.Invoke(null, new object[] { formFile, extension })!; + var text = await extractTask; + Assert.False(string.IsNullOrWhiteSpace(text)); + + var buildTask = (Task)buildMethod!.Invoke(controller, new object[] { text, CancellationToken.None })!; + var structured = await buildTask; + Assert.NotNull(structured); + + report.Add(new + { + 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) + .Concat(structured.Education.Select(education => education.Location)) + .Append(structured.Contact.Location) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Where(LooksSuspiciousLocation) + .ToList() + }); + } + + var reportPath = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{DateTime.UtcNow:yyyyMMddHHmmss}.json"); + await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); + + Assert.True(report.Count > 0); + } + + private static bool LooksSuspiciousLocation(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + return value.Contains("Python", StringComparison.OrdinalIgnoreCase) + || value.Contains("Ruby", StringComparison.OrdinalIgnoreCase) + || value.Contains(" S A L E S ", StringComparison.OrdinalIgnoreCase) + || value.Any(char.IsDigit); + } + + private static string GuessContentType(string path) + { + return Path.GetExtension(path).ToLowerInvariant() switch + { + ".pdf" => "application/pdf", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".md" => "text/markdown", + _ => "text/plain" + }; + } + + private static AppPaths CreatePaths() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Data:Root"] = tempRoot, + ["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts") + }) + .Build(); + + var env = new Mock(); + env.SetupGet(x => x.ContentRootPath).Returns(tempRoot); + return new AppPaths(config, env.Object); + } +} diff --git a/JobTrackerApi.Tests/ProfileCvControllerTests.cs b/JobTrackerApi.Tests/ProfileCvControllerTests.cs index 9ab8b07..0449f34 100644 --- a/JobTrackerApi.Tests/ProfileCvControllerTests.cs +++ b/JobTrackerApi.Tests/ProfileCvControllerTests.cs @@ -431,7 +431,7 @@ public sealed class ProfileCvControllerTests { "version": "1", "contact": { - "location": "Tønsberg, Norway", + "location": "Python,Ruby", "website": "https://cesnimda.co.uk/about", "linkedin": "linkedin.com/in/demo-user?trk=foo" }, @@ -456,9 +456,28 @@ public sealed class ProfileCvControllerTests "isCurrent": false, "bullets": ["Kept services running"], "skills": [] + }, + { + "title": "Developer", + "company": "Demo Co", + "location": "Warwickshire College, UK S A L E S R E P R E S E N T A T I V E", + "start": "2021", + "end": "2022", + "isCurrent": false, + "bullets": ["Managed account handovers"], + "skills": [] + } + ], + "education": [ + { + "qualification": "Warwickshire College", + "institution": "ICT Level 3", + "location": "Warwickshire College, UK S A L E S R E P R E S E N T A T I V E", + "start": "2012", + "end": "2015", + "details": [] } ], - "education": [], "skills": [], "languages": [], "interests": [], @@ -466,7 +485,7 @@ public sealed class ProfileCvControllerTests } """); - Assert.Equal("Tønsberg, Norway", structured.Contact.Location); + Assert.Null(structured.Contact.Location); Assert.Equal("cesnimda.co.uk", structured.Contact.Website); Assert.Equal("https://www.linkedin.com/in/demo-user", structured.Contact.LinkedIn); Assert.Equal("Warwickshire, England, UK", structured.Jobs[0].Location); @@ -475,6 +494,37 @@ public sealed class ProfileCvControllerTests Assert.Null(structured.Jobs[1].Location); Assert.Null(structured.Jobs[1].Start); Assert.Null(structured.Jobs[1].End); + Assert.Equal("Warwickshire College, UK", structured.Jobs[2].Location); + Assert.Equal("ICT Level 3", structured.Education[0].Qualification); + Assert.Equal("Warwickshire College", structured.Education[0].Institution); + Assert.Equal("Warwickshire College, UK", structured.Education[0].Location); + } + + [Fact] + public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv() + { + var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var aiService = new Mock(); + aiService + .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Harvard template", StringComparison.Ordinal) && instruction.Contains("Senior Backend Engineer", StringComparison.Ordinal)), It.Is(text => text.Contains("Professional Summary", StringComparison.Ordinal)), 1800, 400)) + .ReturnsAsync("Professional Summary\nSharper backend platform positioning."); + + await using var db = CreateDb(); + db.Companies.Add(new Company { Id = 7, Name = "Acme Systems", OwnerUserId = "user-1" }); + db.JobApplications.Add(new JobApplication { Id = 42, JobTitle = "Senior Backend Engineer", Description = "Build API integrations and platform workflows.", OwnerUserId = "user-1", CompanyId = 7, Status = "Waiting", DateApplied = DateTime.UtcNow }); + await db.SaveChangesAsync(); + 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 ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + Assert.Contains("Sharper backend platform positioning", json); + Assert.Contains("harvard", json, StringComparison.OrdinalIgnoreCase); + Assert.Contains("42", json, StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 1162acb..cf8c428 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -74,7 +74,7 @@ public sealed class ProfileCvController : ControllerBase _paths = paths; } - public sealed record RewriteSectionRequest(string SectionName, string? Style, string? TargetRole); + public sealed record RewriteSectionRequest(string? SectionName, string? Style, string? TargetRole, int? JobApplicationId, string? TemplateId); public sealed record ParseCvRequest(string? Text); private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv); @@ -274,24 +274,59 @@ public sealed class ProfileCvController : ControllerBase { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); - if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before rewriting a section."); - var sectionName = string.IsNullOrWhiteSpace(request.SectionName) ? "Professional Summary" : request.SectionName.Trim(); - var style = string.IsNullOrWhiteSpace(request.Style) ? "balanced" : request.Style.Trim(); + var sourceText = string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim(); + var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + 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 style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim(); + var templateId = string.IsNullOrWhiteSpace(request.TemplateId) ? "ats-minimal" : request.TemplateId.Trim(); 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) + : 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 ?? "-"}" + : 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 rewritten = await _aiService.SummarizeSectionAsync( - $"Rewrite only the '{sectionName}' section of this CV. Preserve facts, avoid inventing employers or metrics, and output only the rewritten section text. Style: {style}. {(targetRole is not null ? $"Target role: {targetRole}." : "Make it broadly reusable for future tailoring.")}", - user.ProfileCvText, - 900, - 180); + $"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.", + rewriteSource, + sectionName is null ? 1800 : 900, + sectionName is null ? 400 : 180); if (string.IsNullOrWhiteSpace(rewritten)) { - return BadRequest("The AI service could not rewrite that CV section right now."); + return StatusCode(StatusCodes.Status502BadGateway, "The AI service could not rewrite your CV right now."); } - return Ok(new { sectionName, style, targetRole, text = rewritten.Trim() }); + return Ok(new + { + sectionName, + style, + templateId, + targetRole = effectiveTargetRole, + jobApplicationId = jobContext?.Id, + text = rewritten.Trim() + }); } [HttpPost("parse")] @@ -338,6 +373,37 @@ public sealed class ProfileCvController : ControllerBase return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText, structuredCv, sections = structuredCv.Sections, extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion }); } + private static string BuildRewriteSourceText(string? sectionName, string? sourceText, StructuredCvProfile structuredCv) + { + if (string.IsNullOrWhiteSpace(sectionName)) + { + return !string.IsNullOrWhiteSpace(sourceText) + ? sourceText.Trim() + : string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}")); + } + + var matchingSection = structuredCv.Sections.FirstOrDefault(section => string.Equals(section.Name, sectionName, StringComparison.OrdinalIgnoreCase)); + if (matchingSection is not null && !string.IsNullOrWhiteSpace(matchingSection.Content)) + { + return $"## {matchingSection.Name}\n{matchingSection.Content}"; + } + + return !string.IsNullOrWhiteSpace(sourceText) + ? sourceText.Trim() + : string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}")); + } + + private static string DescribeRewriteTemplate(string templateId) + { + return templateId.ToLowerInvariant() switch + { + "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.", + }; + } + private async Task BuildStructuredCvAsync(string text, CancellationToken cancellationToken) { var parseSource = NormalizeTextForStructuredParsing(text); diff --git a/Models/StructuredCvProfileJson.cs b/Models/StructuredCvProfileJson.cs index 6903838..66ce75e 100644 --- a/Models/StructuredCvProfileJson.cs +++ b/Models/StructuredCvProfileJson.cs @@ -12,6 +12,13 @@ public static class StructuredCvProfileJson DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; + private static readonly HashSet NonLocationTokens = new(StringComparer.OrdinalIgnoreCase) + { + "python", "ruby", "sql", "mysql", "postgresql", "postgres", "sqlite", "javascript", "typescript", + "react", "node", "node.js", "c#", ".net", "asp.net", "java", "azure", "aws", "gcp", "docker", + "kubernetes", "terraform", "git", "github", "gitlab", "ci/cd", "rest", "graphql", "php", "golang", "go" + }; + public static StructuredCvProfile Empty() => Normalize(new StructuredCvProfile()); public static StructuredCvProfile Deserialize(string? json) @@ -291,10 +298,12 @@ public static class StructuredCvProfileJson if (LooksLikeDateRange(trimmed) || LooksLikeSectionHeading(trimmed) || LooksLikeUrlOrEmail(trimmed)) return null; if (trimmed.Any(char.IsDigit) || trimmed.Length > 80) return null; - var normalized = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ';', ':'); + var normalized = Regex.Replace(trimmed, @"\s+[A-Z](?:\s+[A-Z]){2,}(?:\b.*)?$", string.Empty).Trim(); + normalized = Regex.Replace(normalized, @"\s+", " ").Trim(' ', '|', ';', ':'); var parts = normalized.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length == 0 || parts.Length > 4) return null; if (parts.Any(part => !Regex.IsMatch(part, @"^[\p{L}][\p{L}'’\-. ]+$"))) return null; + if (parts.Any(LooksLikeSkillToken)) return null; return string.Join(", ", parts); } @@ -378,15 +387,60 @@ public static class StructuredCvProfileJson || Regex.IsMatch(value, @"\b[A-Z]{2,}\b"); } + private static bool LooksLikeSkillToken(string value) + { + var normalized = TrimOrNull(value)?.Trim('.', ' '); + return normalized is not null && NonLocationTokens.Contains(normalized); + } + + private static bool LooksLikeQualification(string value) + { + return Regex.IsMatch(value, @"\b(level\s*\d+|nvq|btec|gcse|a-?level|diploma|certificate|certification|bachelor(?:'s)?|master(?:'s)?|phd|doctorate|mba|ba|bsc|msc|ma|associate|apprenticeship|degree|ict)\b", RegexOptions.IgnoreCase); + } + + private static bool LooksLikeInstitutionName(string value) + { + return Regex.IsMatch(value, @"\b(university|college|school|academy|institute|faculty|campus|council|polytechnic)\b", RegexOptions.IgnoreCase); + } + + private static string? NormalizeQualification(string? value) + { + var trimmed = TrimOrNull(value); + if (trimmed is null) return null; + if (LooksLikeDateRange(trimmed) || LooksLikeUrlOrEmail(trimmed) || LooksLikeSectionHeading(trimmed)) return null; + trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ';', ':'); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + + private static string? NormalizeInstitution(string? value) + { + var trimmed = TrimOrNull(value); + if (trimmed is null) return null; + if (LooksLikeDateRange(trimmed) || LooksLikeUrlOrEmail(trimmed) || LooksLikeSectionHeading(trimmed)) return null; + trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ';', ':'); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + private static StructuredCvEducation NormalizeEducation(StructuredCvEducation? education) { education ??= new StructuredCvEducation(); - education.Qualification = TrimOrNull(education.Qualification); - education.Institution = TrimOrNull(education.Institution); - education.Location = TrimOrNull(education.Location); - education.Start = TrimOrNull(education.Start); - education.End = TrimOrNull(education.End); + education.Qualification = NormalizeQualification(education.Qualification); + education.Institution = NormalizeInstitution(education.Institution); + education.Location = NormalizeLocationValue(education.Location); + education.Start = NormalizeDateValue(education.Start); + education.End = NormalizeDateValue(education.End); education.Details = CleanList(education.Details); + + if (!string.IsNullOrWhiteSpace(education.Qualification) && !string.IsNullOrWhiteSpace(education.Institution)) + { + var qualificationLooksInstitutional = LooksLikeInstitutionName(education.Qualification) && !LooksLikeQualification(education.Qualification); + var institutionLooksQualification = LooksLikeQualification(education.Institution) && !LooksLikeInstitutionName(education.Institution); + if (qualificationLooksInstitutional && institutionLooksQualification) + { + (education.Qualification, education.Institution) = (education.Institution, education.Qualification); + } + } + return education; } @@ -588,7 +642,11 @@ public static class StructuredCvProfileJson job.IsCurrent = string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase); } - var metadataWithoutDates = metadata.Select(line => line.Replace(dateValue ?? string.Empty, string.Empty).Trim(' ', '|', ',', '-')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList(); + 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) job.Company = metadataWithoutDates[0].NullIfWhitespace(); if (metadataWithoutDates.Count > 1) job.Location = metadataWithoutDates[1].NullIfWhitespace(); @@ -625,7 +683,11 @@ public static class StructuredCvProfileJson education.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace(); } - var metadataWithoutDates = metadata.Select(line => line.Replace(dateValue ?? string.Empty, string.Empty).Trim(' ', '|', ',', '-')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList(); + 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) education.Institution = metadataWithoutDates[0].NullIfWhitespace(); if (metadataWithoutDates.Count > 1) education.Location = metadataWithoutDates[1].NullIfWhitespace(); diff --git a/job-tracker-ui/src/admin-system-page.test.tsx b/job-tracker-ui/src/admin-system-page.test.tsx index 8fbec06..c3560ec 100644 --- a/job-tracker-ui/src/admin-system-page.test.tsx +++ b/job-tracker-ui/src/admin-system-page.test.tsx @@ -117,6 +117,7 @@ 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('OCR avg latency')).toBeTruthy(); expect(screen.getByText('88.4 ms')).toBeTruthy(); }); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index aec47c8..1769666 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -434,6 +434,10 @@ export const translations = { adminSystemLastProbe: "Last probe", adminSystemLastSuccessfulProbe: "Last successful probe", adminSystemLastSummarizationSuccess: "Last summarization success", + adminSystemOllamaConfigured: "Ollama configured", + adminSystemOllamaReachable: "Ollama reachable", + adminSystemOllamaModel: "Ollama model", + adminSystemOllamaModelAvailable: "Ollama model ready", adminSystemRequests: "Requests", adminSystemCacheHits: "Cache hits", adminSystemCacheMisses: "Cache misses", @@ -443,6 +447,7 @@ export const translations = { adminSystemOcrRequests: "OCR requests", adminSystemOcrAvgLatency: "OCR avg latency", adminSystemOcrUnavailable: "OCR unavailable", + adminSystemOllamaOff: "Ollama off", adminSystemAiProbeFailed: "Failed to run AI service probe.", correspondenceNoMessages: "No messages yet.", correspondenceMe: "Me", @@ -1339,6 +1344,10 @@ export const translations = { adminSystemLastProbe: "Siste probe", adminSystemLastSuccessfulProbe: "Siste vellykkede probe", adminSystemLastSummarizationSuccess: "Siste vellykkede oppsummering", + adminSystemOllamaConfigured: "Ollama konfigurert", + adminSystemOllamaReachable: "Ollama tilgjengelig", + adminSystemOllamaModel: "Ollama-modell", + adminSystemOllamaModelAvailable: "Ollama-modell klar", adminSystemRequests: "Forespørsler", adminSystemCacheHits: "Cache-treff", adminSystemCacheMisses: "Cache-miss", @@ -1348,6 +1357,7 @@ export const translations = { adminSystemOcrRequests: "OCR-forespørsler", adminSystemOcrAvgLatency: "OCR snittlatens", adminSystemOcrUnavailable: "OCR utilgjengelig", + adminSystemOllamaOff: "Ollama av", adminSystemAiProbeFailed: "Kunne ikke kjøre AI-tjenesteprobe.", correspondenceNoMessages: "Ingen meldinger ennå.", correspondenceMe: "Meg", diff --git a/job-tracker-ui/src/pages/AdminSystemPage.tsx b/job-tracker-ui/src/pages/AdminSystemPage.tsx index 15b418c..b559315 100644 --- a/job-tracker-ui/src/pages/AdminSystemPage.tsx +++ b/job-tracker-ui/src/pages/AdminSystemPage.tsx @@ -363,6 +363,10 @@ export default function AdminSystemPage() { + + + + diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 28345b0..ddf32dd 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -1,10 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Divider, FormControl, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material"; +import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Dialog, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined"; +import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined"; import { api } from "../api"; import GoogleAuthCard from "../components/GoogleAuthCard"; @@ -21,10 +22,11 @@ import { StructuredCvFieldMetadata, StructuredCvProfile, } from "../profileCv"; +import { JobApplication } from "../types"; -type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects"; -type CvSectionStyle = "balanced" | "concise" | "impact" | "ats"; +type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects"; +type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh"; type ExtractionRun = { id: number; @@ -40,6 +42,24 @@ type ExtractionRun = { errorMessage?: string; }; +type JobListResponse = { + items: JobApplication[]; + total: number; + page: number; + pageSize: number; +}; + +type RewriteTemplateOption = { + id: CvSectionStyle; + title: string; + eyebrow: string; + accent: string; + blurb: string; + sampleHeading: string; + sampleMeta: string; + sampleBullets: string[]; +}; + type MeResponse = { provider?: "local" | "google" | "external"; id?: string; @@ -61,6 +81,48 @@ type MeResponse = { const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown"; const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp"; +const REWRITE_TEMPLATES: RewriteTemplateOption[] = [ + { + id: "ats-minimal", + title: "ATS Minimal", + eyebrow: "Scanner-friendly", + accent: "#0f172a", + blurb: "Compact, direct, and easy for screening systems to parse.", + sampleHeading: "Senior Backend Engineer", + sampleMeta: "Acme Systems · Oslo · 2021 - Present", + sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."] + }, + { + id: "harvard", + title: "Harvard", + eyebrow: "Traditional", + accent: "#7f1d1d", + blurb: "Formal hierarchy and restrained tone for conservative hiring flows.", + sampleHeading: "Professional Summary", + sampleMeta: "Clear structure · precise dates · credible language", + sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."] + }, + { + id: "auckland", + title: "Auckland", + eyebrow: "Modern sidebar", + accent: "#0f766e", + blurb: "Sharper highlights with a more contemporary, design-forward rhythm.", + sampleHeading: "Selected Impact", + sampleMeta: "Focused strengths · compact highlights", + sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."] + }, + { + id: "edinburgh", + title: "Edinburgh", + eyebrow: "Editorial", + accent: "#5b21b6", + blurb: "More personality and stronger section contrast without losing clarity.", + sampleHeading: "Experience Highlights", + sampleMeta: "Premium spacing · stronger visual voice", + sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."] + }, +]; function initialsFrom(values: Array) { const joined = values.map((x) => (x ?? "").trim()).filter(Boolean); @@ -142,10 +204,13 @@ export default function ProfilePage() { const [headline, setHeadline] = useState(""); const [profileCvText, setProfileCvText] = useState(""); const [rewritingSection, setRewritingSection] = useState(false); - const [cvSection, setCvSection] = useState("Professional Summary"); - const [cvSectionStyle, setCvSectionStyle] = useState("balanced"); + const [cvSection, setCvSection] = useState(""); + const [cvSectionStyle, setCvSectionStyle] = useState("ats-minimal"); const [cvSectionTargetRole, setCvSectionTargetRole] = useState(""); + const [selectedRewriteJobId, setSelectedRewriteJobId] = useState(""); const [cvSectionDraft, setCvSectionDraft] = useState(""); + const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState(null); + const [savedJobs, setSavedJobs] = useState([]); const [parsingCvSections, setParsingCvSections] = useState(false); const [reprocessingCv, setReprocessingCv] = useState(false); const [structuredCv, setStructuredCv] = useState(emptyStructuredCv()); @@ -155,9 +220,10 @@ export default function ProfilePage() { const loadProfile = useCallback(async () => { try { - const [profileResponse, runsResponse] = await Promise.all([ + const [profileResponse, runsResponse, jobsResponse] = await Promise.all([ api.get("/auth/me"), api.get("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)), + api.get("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }).catch(() => ({ data: { items: [], total: 0, page: 1, pageSize: 100 } } as any)), ]); const r = profileResponse; setMe(r.data); @@ -169,10 +235,12 @@ export default function ProfilePage() { setProfileCvText(r.data?.profileCvText ?? ""); setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson)); setExtractionRuns(runsResponse.data ?? []); + setSavedJobs(jobsResponse.data?.items ?? []); setHeadline(window.localStorage.getItem("profileHeadline") ?? ""); } catch { setMe(null); setExtractionRuns([]); + setSavedJobs([]); } }, []); @@ -193,6 +261,8 @@ export default function ProfilePage() { : t("profileGoogleNotLinked"); const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing"); 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; return ( @@ -672,22 +742,24 @@ export default function ProfilePage() { ))} - + - {t("profileCvSectionTools")} + CV style rewrite studio {t("profileCvSectionToolsHelp")} - + + + {REWRITE_TEMPLATES.map((option) => { + const selected = option.id === cvSectionStyle; + return ( + setCvSectionStyle(option.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setCvSectionStyle(option.id); + } + }} + sx={{ + p: 1.5, + borderRadius: 3, + 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", + }} + > + + + {option.eyebrow} + {option.title} + + { event.stopPropagation(); setRewritePreviewTemplate(option); }}> + + + + + {option.sampleHeading} + {option.sampleMeta} + {option.sampleBullets.map((bullet) => ( + • {bullet} + ))} + + {option.blurb} + + ); + })} + + + {t("profileCvSectionLabel")} - - {t("profileCvSectionStyle")} - setSelectedRewriteJobId(String(e.target.value))}> + None + {savedJobs.map((job) => ( + {job.jobTitle} · {job.company?.name ?? "Unknown company"} + ))} - setCvSectionTargetRole(e.target.value)} fullWidth /> - - setCvSectionDraft(e.target.value)} - multiline - minRows={6} - fullWidth - placeholder={t("profileCvSectionDraftPlaceholder")} - /> - - - - + + + + + Rewrite preview + {selectedRewriteTemplate.title} · {cvSection || "Whole CV"} + + {cvSectionDraft.trim() ? : null} + + + {cvSectionDraft.trim() ? ( + {cvSectionDraft} + ) : ( + Choose a CV style, optionally aim it at a saved job, and generate a rewrite preview here. + )} + + + + + + + + + setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth> + {rewritePreviewTemplate?.title ?? "Template preview"} + + {rewritePreviewTemplate ? ( + + {rewritePreviewTemplate.eyebrow} + {rewritePreviewTemplate.sampleHeading} + {rewritePreviewTemplate.sampleMeta} + {rewritePreviewTemplate.sampleBullets.map((bullet) => ( + • {bullet} + ))} + {rewritePreviewTemplate.blurb} + + ) : null} + + diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index fff5476..5640bb7 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -108,6 +108,27 @@ beforeEach(() => { ], } as any); } + if (url === '/jobapplications') { + return Promise.resolve({ + data: { + items: [ + { + id: 42, + jobTitle: 'Senior Backend Engineer', + company: { id: 7, name: 'Acme Systems' }, + status: 'Waiting', + dateApplied: '2026-03-20', + daysSince: 10, + description: 'Build API integrations and platform workflows.', + responseReceived: false, + }, + ], + total: 1, + page: 1, + pageSize: 100, + }, + } as any); + } return Promise.resolve({ data: {} } as any); }); mockedApi.post.mockImplementation((url: string) => { @@ -125,6 +146,9 @@ beforeEach(() => { }, } as any); } + if (url === '/profile-cv/rewrite-section') { + return Promise.resolve({ data: { text: 'Professional Summary\nClearer, sharper positioning for backend platform roles.' } } as any); + } if (url === '/profile-cv/reprocess') { return Promise.resolve({ data: { reprocessed: true } } as any); } @@ -201,6 +225,30 @@ test('profile page keeps raw extraction collapsed until expanded', async () => { expect(copyButtons.some((button) => !button.hasAttribute('disabled'))).toBe(true); }); +test('profile page rewrite tools use selected template and saved job context', async () => { + renderPage(); + + expect(await screen.findByText(/cv style rewrite studio/i)).toBeInTheDocument(); + fireEvent.click(screen.getByText(/harvard/i)); + fireEvent.mouseDown(screen.getAllByRole('combobox')[1]); + fireEvent.click(await screen.findByText(/senior backend engineer · acme systems/i)); + + const rewriteButton = screen.getByRole('button', { name: /rewrite section/i }); + fireEvent.click(rewriteButton); + + await waitFor(() => { + expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-section', expect.objectContaining({ + sectionName: null, + style: 'harvard', + templateId: 'harvard', + jobApplicationId: 42, + })); + }); + + expect(await screen.findByText(/draft ready/i)).toBeInTheDocument(); + expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument(); +}); + test('saving profile persists structured cv json', async () => { renderPage();