From 8f04637cff49d829aff8904c5116d83d6a701fc6 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 23 Mar 2026 23:07:02 +0100 Subject: [PATCH] Add CV structure analysis groundwork --- .../Controllers/ProfileCvController.cs | 92 +++++++++++++++++++ job-tracker-ui/src/i18n/translations.ts | 16 ++++ job-tracker-ui/src/pages/ProfilePage.tsx | 49 ++++++++++ 3 files changed, 157 insertions(+) diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index a05aec0..adac1cc 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -37,6 +37,8 @@ public sealed class ProfileCvController : ControllerBase } public sealed record RewriteSectionRequest(string SectionName, string? Style, string? TargetRole); + public sealed record ParseCvRequest(string? Text); + public sealed record ParsedCvSectionDto(string Name, string Content, int WordCount); [HttpPost("upload")] [RequestSizeLimit(MaxFileSizeBytes)] @@ -146,6 +148,22 @@ public sealed class ProfileCvController : ControllerBase return Ok(new { sectionName, style, targetRole, text = rewritten.Trim() }); } + [HttpPost("parse")] + public async Task> Parse([FromBody] ParseCvRequest? request) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var source = string.IsNullOrWhiteSpace(request?.Text) ? user.ProfileCvText : request!.Text; + if (string.IsNullOrWhiteSpace(source)) return BadRequest("Add or import CV text before parsing sections."); + + var sections = ParseSections(source) + .Select(section => new ParsedCvSectionDto(section.Name, section.Content, CountWords(section.Content))) + .ToList(); + + return Ok(new { sections, totalWords = CountWords(source) }); + } + [HttpPost("improve")] public async Task Improve() { @@ -174,6 +192,80 @@ public sealed class ProfileCvController : ControllerBase return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText }); } + private static int CountWords(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return 0; + return text.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + } + + private static List<(string Name, string Content)> ParseSections(string source) + { + var lines = source.Replace("\r\n", "\n").Split('\n'); + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["professional summary"] = "Professional Summary", + ["summary"] = "Professional Summary", + ["profile"] = "Professional Summary", + ["core skills"] = "Core Skills", + ["skills"] = "Core Skills", + ["technical skills"] = "Core Skills", + ["experience"] = "Experience Highlights", + ["experience highlights"] = "Experience Highlights", + ["work experience"] = "Experience Highlights", + ["selected achievements"] = "Selected Achievements", + ["achievements"] = "Selected Achievements", + ["projects"] = "Projects", + ["education"] = "Education", + ["certifications"] = "Certifications", + ["certificates"] = "Certifications", + }; + + var sections = new List<(string Name, List Lines)>(); + var currentName = "General"; + var currentLines = new List(); + + void Flush() + { + var content = string.Join("\n", currentLines).Trim(); + if (!string.IsNullOrWhiteSpace(content)) + { + sections.Add((currentName, new List(currentLines))); + } + currentLines.Clear(); + } + + foreach (var raw in lines) + { + var line = raw.Trim(); + var normalized = line.TrimEnd(':').Trim(); + var looksLikeHeading = normalized.Length > 0 + && normalized.Length <= 40 + && !normalized.Contains('.') + && aliases.ContainsKey(normalized.ToLowerInvariant()); + + if (looksLikeHeading) + { + Flush(); + currentName = aliases[normalized.ToLowerInvariant()]; + continue; + } + + currentLines.Add(raw); + } + + Flush(); + + if (sections.Count == 0) + { + return new List<(string Name, string Content)> { ("General", source.Trim()) }; + } + + return sections + .Select(section => (section.Name, string.Join("\n", section.Lines).Trim())) + .Where(section => !string.IsNullOrWhiteSpace(section.Item2)) + .ToList(); + } + private static async Task ExtractTextAsync(IFormFile file, string extension) { if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)) diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 052965d..133ee74 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -191,6 +191,14 @@ export const translations = { profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.", profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.", profileCvSectionTools: "Section rewrite tools", + profileCvStructureOverview: "CV structure overview", + profileCvStructureOverviewHelp: "Parse your current CV text into reusable sections so you can spot missing structure before tailoring.", + profileCvStructureParse: "Analyze sections", + profileCvStructureParsing: "Analyzing sections...", + profileCvStructureParsed: "CV structure analyzed.", + profileCvStructureParseFailed: "Failed to analyze CV structure.", + profileCvStructureEmpty: "No parsed sections yet.", + profileCvSectionWordCount: "{count} words", profileCvSectionToolsHelp: "Generate sharper versions of one CV section at a time before pasting them back into your master CV.", profileCvSectionLabel: "Section", profileCvSectionSummary: "Professional Summary", @@ -991,6 +999,14 @@ export const translations = { profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.", profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.", profileCvSectionTools: "Verktøy for CV-seksjoner", + profileCvStructureOverview: "Oversikt over CV-struktur", + profileCvStructureOverviewHelp: "Analyser gjeldende CV-tekst til gjenbrukbare seksjoner slik at du ser manglende struktur før du tilpasser den.", + profileCvStructureParse: "Analyser seksjoner", + profileCvStructureParsing: "Analyserer seksjoner...", + profileCvStructureParsed: "CV-strukturen er analysert.", + profileCvStructureParseFailed: "Kunne ikke analysere CV-strukturen.", + profileCvStructureEmpty: "Ingen analyserte seksjoner ennå.", + profileCvSectionWordCount: "{count} ord", profileCvSectionToolsHelp: "Generer skarpere versjoner av én CV-seksjon om gangen før du limer dem tilbake i hoved-CV-en.", profileCvSectionLabel: "Seksjon", profileCvSectionSummary: "Profesjonell oppsummering", diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 7b79bf4..1c4a110 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -11,6 +11,12 @@ import CropImageDialog from "../components/CropImageDialog"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; +type ParsedCvSection = { + name: string; + content: string; + wordCount: number; +}; + type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects"; type CvSectionStyle = "balanced" | "concise" | "impact" | "ats"; @@ -94,6 +100,8 @@ export default function ProfilePage() { const [cvSectionStyle, setCvSectionStyle] = useState("balanced"); const [cvSectionTargetRole, setCvSectionTargetRole] = useState(""); const [cvSectionDraft, setCvSectionDraft] = useState(""); + const [parsingCvSections, setParsingCvSections] = useState(false); + const [parsedCvSections, setParsedCvSections] = useState([]); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); @@ -335,6 +343,47 @@ export default function ProfilePage() { disabled={!isLocal} fullWidth /> + + + + {t("profileCvStructureOverview")} + {t("profileCvStructureOverviewHelp")} + + + + {parsedCvSections.length > 0 ? ( + + {parsedCvSections.map((section) => ( + + + {section.name} + + + {section.content.slice(0, 280)}{section.content.length > 280 ? "…" : ""} + + ))} + + ) : ( + {t("profileCvStructureEmpty")} + )} +