Add typed structured CV extraction

This commit is contained in:
2026-03-28 15:01:32 +01:00
parent 19a4da9382
commit 8f8a34ad9c
5 changed files with 1029 additions and 77 deletions
@@ -58,45 +58,99 @@ namespace JobTrackerApi.Controllers
return "Hi there,";
}
private sealed record CvSectionRecord(string? Name, string? Content, int? WordCount);
private static string BuildStructuredCvContext(ApplicationUser? user)
{
if (string.IsNullOrWhiteSpace(user?.ProfileCvStructureJson)) return string.Empty;
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
var blocks = new List<string>();
try
var contactLines = new List<string>();
if (!string.IsNullOrWhiteSpace(structured.Contact.FullName)) contactLines.Add($"Name: {structured.Contact.FullName}");
if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) contactLines.Add($"Headline: {structured.Contact.Headline}");
if (!string.IsNullOrWhiteSpace(structured.Contact.Email)) contactLines.Add($"Email: {structured.Contact.Email}");
if (!string.IsNullOrWhiteSpace(structured.Contact.Location)) contactLines.Add($"Location: {structured.Contact.Location}");
if (!string.IsNullOrWhiteSpace(structured.Contact.LinkedIn)) contactLines.Add($"LinkedIn: {structured.Contact.LinkedIn}");
if (contactLines.Count > 0) blocks.Add($"Contact:\n{string.Join("\n", contactLines)}");
if (structured.Summary.Count > 0)
{
var sections = JsonSerializer.Deserialize<List<CvSectionRecord>>(user.ProfileCvStructureJson);
if (sections is null || sections.Count == 0) return string.Empty;
blocks.Add($"Summary:\n- {string.Join("\n- ", structured.Summary.Take(4))}");
}
var preferredOrder = new[]
if (structured.Skills.Count > 0)
{
blocks.Add($"Skills:\n{string.Join(", ", structured.Skills.Take(16))}");
}
if (structured.Jobs.Count > 0)
{
var jobBlocks = structured.Jobs.Take(3).Select(job =>
{
"Professional Summary",
"Core Skills",
"Experience Highlights",
"Selected Achievements",
"Projects",
"Education",
"Certifications",
};
var ordered = preferredOrder
.Select(name => sections.FirstOrDefault(section => string.Equals(section.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase)))
.Where(section => section is not null)
.Concat(sections.Where(section => !preferredOrder.Contains(section.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase)))
.Where(section => !string.IsNullOrWhiteSpace(section?.Content))
.Take(6)
.Select(section => $"{section!.Name}:\n{section.Content!.Trim()}")
.ToList();
return ordered.Count > 0
? $"Structured CV sections:\n{string.Join("\n\n", ordered)}"
: string.Empty;
var header = string.Join(" | ", new[] { job.Title, job.Company, job.Location, FormatStructuredDateRange(job.Start, job.End, job.IsCurrent) }.Where(value => !string.IsNullOrWhiteSpace(value)));
var bullets = job.Bullets.Take(3).Select(bullet => $"- {bullet}");
return string.Join("\n", new[] { header }.Concat(bullets).Where(value => !string.IsNullOrWhiteSpace(value)));
}).Where(value => !string.IsNullOrWhiteSpace(value)).ToList();
if (jobBlocks.Count > 0) blocks.Add($"Work Experience:\n{string.Join("\n\n", jobBlocks)}");
}
catch
if (structured.Education.Count > 0)
{
return string.Empty;
var items = structured.Education.Take(3).Select(education => string.Join(" | ", new[] { education.Qualification, education.Institution, education.Location, FormatStructuredDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value))));
blocks.Add($"Education:\n- {string.Join("\n- ", items)}");
}
if (structured.Languages.Count > 0)
{
var items = structured.Languages.Take(5).Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value))));
blocks.Add($"Languages:\n- {string.Join("\n- ", items)}");
}
if (structured.OtherSections.Count > 0)
{
var items = structured.OtherSections.Take(2)
.Where(section => !string.IsNullOrWhiteSpace(section.Title) && section.Items.Count > 0)
.Select(section => $"{section.Title}: {string.Join("; ", section.Items.Take(4))}")
.ToList();
if (items.Count > 0) blocks.Add($"Other sections:\n- {string.Join("\n- ", items)}");
}
if (blocks.Count == 0 && structured.Sections.Count > 0)
{
blocks.AddRange(structured.Sections.Take(6).Select(section => $"{section.Name}:\n{section.Content}"));
}
return blocks.Count > 0
? $"Structured CV:\n{string.Join("\n\n", blocks)}"
: string.Empty;
}
private static string BuildCvSearchCorpus(ApplicationUser? user)
{
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText)) parts.Add(user.ProfileCvText!);
if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) parts.Add(structured.Contact.Headline!);
if (structured.Summary.Count > 0) parts.Add(string.Join("\n", structured.Summary));
if (structured.Skills.Count > 0) parts.Add(string.Join("\n", structured.Skills));
if (structured.Jobs.Count > 0)
{
parts.Add(string.Join("\n", structured.Jobs.SelectMany(job => new[] { job.Title, job.Company, job.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(job.Bullets).Concat(job.Skills))));
}
if (structured.Education.Count > 0)
{
parts.Add(string.Join("\n", structured.Education.SelectMany(education => new[] { education.Qualification, education.Institution, education.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(education.Details))));
}
if (structured.Languages.Count > 0)
{
parts.Add(string.Join("\n", structured.Languages.Select(language => string.Join(" ", new[] { language.Name, language.Level, language.Notes }.Where(value => !string.IsNullOrWhiteSpace(value))))));
}
return string.Join("\n", parts.Where(part => !string.IsNullOrWhiteSpace(part)));
}
private static string? FormatStructuredDateRange(string? start, string? end, bool isCurrent)
{
if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null;
if (string.IsNullOrWhiteSpace(start)) return end;
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
}
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
@@ -1729,7 +1783,7 @@ namespace JobTrackerApi.Controllers
return BadRequest("This job does not have enough description or notes to compare against your CV.");
}
var normalizedCv = cvText.ToLowerInvariant();
var normalizedCv = BuildCvSearchCorpus(user).ToLowerInvariant();
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();