feat: add server-backed profile CV builder pipeline

This commit is contained in:
2026-04-01 12:25:35 +02:00
parent 22d7dd3573
commit 0551a525a8
7 changed files with 625 additions and 23 deletions
@@ -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<OkObjectResult>(result);
var json = JsonSerializer.Serialize(ok.Value);
@@ -787,7 +792,7 @@ public sealed class ProfileCvControllerTests
private static ProfileCvController CreateController(UserManager<ApplicationUser> 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() }
};
+320 -19
View File
@@ -64,18 +64,42 @@ public sealed class ProfileCvController : ControllerBase
private readonly ICvAiClassifier _cvAiClassifier;
private readonly JobTrackerContext _db;
private readonly AppPaths _paths;
private readonly ILogger<ProfileCvController> _logger;
private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter;
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null)
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? 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<ProfileCvController>.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<CvPdfArtifact> 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<string> 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 ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", 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<IEnumerable<CvTemplateDescriptor>> GetTemplates()
{
return Ok(GetCvTemplateDescriptors());
}
[HttpPost("rewrite-preview")]
public async Task<ActionResult<ProfileCvPreviewDto>> 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<IActionResult> 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<ActionResult<object>> 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+)?(?<name>[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<CvTemplateDescriptor> 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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<TailoredCvCustomSection>();
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<string> { "summary", "skills", "experience", "education", "custom" },
}
});
}
private async Task<StructuredCvProfile> BuildStructuredCvAsync(string text, CancellationToken cancellationToken)
{
var parseSource = NormalizeTextForStructuredParsing(text);
@@ -601,7 +866,7 @@ public sealed class ProfileCvController : ControllerBase
private async Task<StructuredCvProfile?> 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;
+108 -1
View File
@@ -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
</html>";
}
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 ? $"<div class=\"monarch-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<div class=\"monarch-company\">Tailored toward {Encode(companyName)}</div>";
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — Monarch</title>
<style>
:root {{ --accent:{accent}; --ink:#1c1917; --muted:#57534e; --paper:#fffdf8; --panel:#f7efe6; --line:#d6c1a8; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#efe7dc; color:var(--ink); font-family:'Times New Roman', Georgia, serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
.monarch-shell {{ border:1px solid var(--line); padding:10mm; position:relative; }}
.monarch-shell::before {{ content:''; position:absolute; inset:6mm; border:1px solid color-mix(in srgb, var(--line) 70%, white); pointer-events:none; }}
.monarch-header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; align-items:center; margin-bottom:8mm; }}
.monarch-kicker {{ display:inline-block; text-transform:uppercase; letter-spacing:.3em; font-size:8pt; color:var(--accent); margin-bottom:2mm; }}
.monarch-name {{ margin:0; font-size:28pt; line-height:1.05; }}
.monarch-headline {{ margin-top:2mm; font-size:11pt; color:var(--muted); max-width:130mm; }}
.monarch-company {{ margin-top:2mm; font-size:9pt; color:var(--accent); text-transform:uppercase; letter-spacing:.16em; }}
.monarch-photo {{ width:30mm; height:38mm; border:1px solid var(--line); background:var(--panel); overflow:hidden; }}
.monarch-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
.monarch-summary {{ margin-bottom:5mm; padding:4mm 5mm; background:var(--panel); border-left:3px solid var(--accent); font-size:10pt; color:var(--muted); }}
{BaseSectionCss(accent, "harvard")}
.section-title {{ text-transform:uppercase; letter-spacing:.12em; font-size:10pt; }}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<section class=""monarch-shell"">
<header class=""monarch-header"">
<div>
<span class=""monarch-kicker"">Executive CV</span>
<h1 class=""monarch-name"">{Encode(candidateName)}</h1>
<div class=""monarch-headline"">{Encode(document.Headline ?? jobTitle)}</div>
{companyMarkup}
</div>
{photoMarkup}
</header>
{(!string.IsNullOrWhiteSpace(jobTitle) ? $"<div class=\"monarch-summary\">Primary role target: {Encode(jobTitle)}</div>" : string.Empty)}
{body}
</section>
</main>
</body>
</html>";
}
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 ? $"<div class=\"fjord-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>{Encode(companyName)}</span>";
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — Fjord</title>
<style>
:root {{ --accent:{accent}; --ink:#102a43; --muted:#486581; --panel:#e6f1f3; --line:#9fb3c8; --paper:#fbfdff; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#d9e8ef; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:0; }}
.fjord-grid {{ display:grid; grid-template-columns:72mm 1fr; min-height:297mm; }}
.fjord-rail {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 15%, white)); color:white; padding:16mm 8mm; }}
.fjord-name {{ margin:0; font-size:21pt; line-height:1.08; }}
.fjord-headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
.fjord-meta {{ margin-top:4mm; font-size:8.5pt; display:flex; flex-direction:column; gap:1.2mm; opacity:.9; }}
.fjord-photo {{ width:28mm; height:28mm; border-radius:50%; overflow:hidden; border:2px solid rgba(255,255,255,.65); margin-top:6mm; }}
.fjord-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
.fjord-main {{ padding:14mm 14mm 14mm 10mm; }}
{BaseSectionCss(accent, "sidebar")}
.section {{ margin-top:5mm; }}
.skills {{ gap:1.5mm; }}
.skill-pill {{ background:var(--panel); border-color:transparent; color:var(--ink); }}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<section class=""fjord-grid"">
<aside class=""fjord-rail"">
<h1 class=""fjord-name"">{Encode(candidateName)}</h1>
<div class=""fjord-headline"">{Encode(document.Headline ?? jobTitle)}</div>
<div class=""fjord-meta""><span>{Encode(jobTitle)}</span>{companyMarkup}<span>Template: Fjord</span></div>
{photoMarkup}
</aside>
<section class=""fjord-main"">{body}</section>
</section>
</main>
</body>
</html>";
}
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("<article class=\"entry\">");
items.Append($"<div class=\"entry-title\">{Encode(entry.Qualification)}</div>");
var title = string.IsNullOrWhiteSpace(entry.QualificationLevel)
? entry.Qualification
: $"{entry.Qualification} ({entry.QualificationLevel})";
items.Append($"<div class=\"entry-title\">{Encode(title)}</div>");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>");
items.Append("</article>");
+23
View File
@@ -8,6 +8,8 @@ public sealed class StructuredCvProfile
public List<string> Summary { get; set; } = new();
public List<StructuredCvJob> Jobs { get; set; } = new();
public List<StructuredCvEducation> Education { get; set; } = new();
public List<StructuredCvCertification> Certifications { get; set; } = new();
public List<StructuredCvProject> Projects { get; set; } = new();
public List<string> Skills { get; set; } = new();
public List<StructuredCvLanguage> Languages { get; set; } = new();
public List<string> 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<string> 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<string> 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<string> Bullets { get; set; } = new();
public List<string> Skills { get; set; } = new();
}
public sealed class StructuredCvLanguage
{
public string? Name { get; set; }
+164
View File
@@ -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<StructuredCvCertification>())
.Select(NormalizeCertification)
.Where(certification => !string.IsNullOrWhiteSpace(certification.Name)
|| !string.IsNullOrWhiteSpace(certification.Issuer)
|| certification.Details.Count > 0)
.ToList();
profile.Projects = (profile.Projects ?? new List<StructuredCvProject>())
.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<StructuredCvLanguage>())
.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<string>();
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<string>();
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<StructuredCvCertification> 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<StructuredCvProject> 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<string> SplitBlocks(string content)
{
var normalized = content.Replace("\r\n", "\n").Trim();
+1
View File
@@ -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; }
+2 -1
View File
@@ -128,7 +128,7 @@ public static class TailoredCvDraftJson
var block = new List<string>();
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);