Improve CV rewrite flow and parser accuracy
This commit is contained in:
@@ -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<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900)).ReturnsAsync(string.Empty);
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), It.IsAny<string>(), 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<object>();
|
||||
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<string>)extractMethod!.Invoke(null, new object[] { formFile, extension })!;
|
||||
var text = await extractTask;
|
||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||
|
||||
var buildTask = (Task<StructuredCvProfile>)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<string, string?>
|
||||
{
|
||||
["Data:Root"] = tempRoot,
|
||||
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts")
|
||||
})
|
||||
.Build();
|
||||
|
||||
var env = new Mock<IHostEnvironment>();
|
||||
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
|
||||
return new AppPaths(config, env.Object);
|
||||
}
|
||||
}
|
||||
@@ -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<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Harvard template", StringComparison.Ordinal) && instruction.Contains("Senior Backend Engineer", StringComparison.Ordinal)), It.Is<string>(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<OkObjectResult>(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]
|
||||
|
||||
@@ -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<StructuredCvProfile> BuildStructuredCvAsync(string text, CancellationToken cancellationToken)
|
||||
{
|
||||
var parseSource = NormalizeTextForStructuredParsing(text);
|
||||
|
||||
@@ -12,6 +12,13 @@ public static class StructuredCvProfileJson
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> 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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -363,6 +363,10 @@ export default function AdminSystemPage() {
|
||||
<DetailRow label={t("adminSystemDevice")} value={status?.ai.device || "-"} />
|
||||
<DetailRow label={t("adminSystemGpuAvailable")} value={status?.ai.gpuAvailable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemGpuName")} value={status?.ai.gpuName || "-"} />
|
||||
<DetailRow label={t("adminSystemOllamaConfigured")} value={status?.ai.ollamaConfigured ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemOllamaReachable")} value={status?.ai.ollamaReachable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemOllamaModel")} value={status?.ai.ollamaModel || "-"} />
|
||||
<DetailRow label={t("adminSystemOllamaModelAvailable")} value={status?.ai.ollamaModelAvailable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
|
||||
<DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
|
||||
<DetailRow label={t("adminSystemLastProbe")} value={formatDate(status?.ai.lastProbeAt)} />
|
||||
|
||||
@@ -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<string | undefined>) {
|
||||
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<CvSectionOption>("Professional Summary");
|
||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("balanced");
|
||||
const [cvSection, setCvSection] = useState<CvSectionOption>("");
|
||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
|
||||
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
||||
const [cvSectionDraft, setCvSectionDraft] = useState("");
|
||||
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
||||
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
|
||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||
const [reprocessingCv, setReprocessingCv] = useState(false);
|
||||
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(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<MeResponse>("/auth/me"),
|
||||
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
|
||||
api.get<JobListResponse>("/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 (
|
||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||
@@ -672,22 +742,24 @@ export default function ProfilePage() {
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ mt: 2, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.03) 0%, rgba(15,23,42,0) 100%)" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvSectionTools")}</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>CV style rewrite studio</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvSectionToolsHelp")}</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!isLocal || !profileCvText.trim() || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
variant="contained"
|
||||
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRewritingSection(true);
|
||||
try {
|
||||
const res = await api.post<{ text?: string }>("/profile-cv/rewrite-section", {
|
||||
sectionName: cvSection,
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
});
|
||||
setCvSectionDraft(res.data?.text ?? "");
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
@@ -701,10 +773,59 @@ export default function ProfilePage() {
|
||||
{rewritingSection ? t("profileCvSectionRewriting") : t("profileCvSectionRewrite")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1.2fr" }, gap: 1.5, mb: 1.5 }}>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
|
||||
{REWRITE_TEMPLATES.map((option) => {
|
||||
const selected = option.id === cvSectionStyle;
|
||||
return (
|
||||
<Paper
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => 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",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1, mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 800 }}>{option.eyebrow}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{option.title}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={(event) => { event.stopPropagation(); setRewritePreviewTemplate(option); }}>
|
||||
<ZoomInOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 148 }}>
|
||||
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 700, mb: 0.5 }}>{option.sampleHeading}</Typography>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mb: 1 }}>{option.sampleMeta}</Typography>
|
||||
{option.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="caption" sx={{ display: "block", color: "text.primary", mb: 0.5 }}>• {bullet}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{option.blurb}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.5 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
||||
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
||||
<MenuItem value="">Whole CV</MenuItem>
|
||||
<MenuItem value="Professional Summary">{t("profileCvSectionSummary")}</MenuItem>
|
||||
<MenuItem value="Core Skills">{t("profileCvSectionSkills")}</MenuItem>
|
||||
<MenuItem value="Experience Highlights">{t("profileCvSectionExperience")}</MenuItem>
|
||||
@@ -712,31 +833,56 @@ export default function ProfilePage() {
|
||||
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("profileCvSectionStyle")}</InputLabel>
|
||||
<Select value={cvSectionStyle} label={t("profileCvSectionStyle")} onChange={(e) => setCvSectionStyle(e.target.value as CvSectionStyle)}>
|
||||
<MenuItem value="balanced">{t("profileCvSectionStyleBalanced")}</MenuItem>
|
||||
<MenuItem value="concise">{t("profileCvSectionStyleConcise")}</MenuItem>
|
||||
<MenuItem value="impact">{t("profileCvSectionStyleImpact")}</MenuItem>
|
||||
<MenuItem value="ats">{t("profileCvSectionStyleAts")}</MenuItem>
|
||||
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : undefined} />
|
||||
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||
<InputLabel>Saved job context</InputLabel>
|
||||
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{savedJobs.map((job) => (
|
||||
<MenuItem key={job.id} value={String(job.id)}>{job.jobTitle} · {job.company?.name ?? "Unknown company"}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth />
|
||||
</Box>
|
||||
<TextField
|
||||
label={t("profileCvSectionDraft")}
|
||||
value={cvSectionDraft}
|
||||
onChange={(e) => setCvSectionDraft(e.target.value)}
|
||||
multiline
|
||||
minRows={6}
|
||||
fullWidth
|
||||
placeholder={t("profileCvSectionDraftPlaceholder")}
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
|
||||
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim())}>{t("profileCvSectionAppend")}</Button>
|
||||
<Button variant="contained" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => replaceCvSection(prev, cvSection, cvSectionDraft))}>{t("profileCvSectionReplace")}</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Rewrite preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · {cvSection || "Whole CV"}</Typography>
|
||||
</Box>
|
||||
{cvSectionDraft.trim() ? <Chip size="small" color="success" label="Draft ready" /> : null}
|
||||
</Box>
|
||||
<Box sx={{ minHeight: 180, borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
|
||||
{cvSectionDraft.trim() ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{cvSectionDraft}</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a CV style, optionally aim it at a saved job, and generate a rewrite preview here.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
|
||||
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim() : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionAppend") : "Use as full CV"}</Button>
|
||||
<Button variant="contained" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? replaceCvSection(prev, cvSection, cvSectionDraft) : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionReplace") : "Replace full CV"}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={Boolean(rewritePreviewTemplate)} onClose={() => setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{rewritePreviewTemplate?.title ?? "Template preview"}</DialogTitle>
|
||||
<DialogContent>
|
||||
{rewritePreviewTemplate ? (
|
||||
<Box sx={{ p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${rewritePreviewTemplate.accent}12 0%, rgba(255,255,255,0) 100%)` }}>
|
||||
<Typography variant="overline" sx={{ color: rewritePreviewTemplate.accent, fontWeight: 800 }}>{rewritePreviewTemplate.eyebrow}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 0.5 }}>{rewritePreviewTemplate.sampleHeading}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{rewritePreviewTemplate.sampleMeta}</Typography>
|
||||
{rewritePreviewTemplate.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="body2" sx={{ mb: 0.75 }}>• {bullet}</Typography>
|
||||
))}
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1.5 }}>{rewritePreviewTemplate.blurb}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user