Add typed structured CV extraction
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user