Add CV structure analysis groundwork
This commit is contained in:
@@ -37,6 +37,8 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed record RewriteSectionRequest(string SectionName, string? Style, string? TargetRole);
|
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")]
|
[HttpPost("upload")]
|
||||||
[RequestSizeLimit(MaxFileSizeBytes)]
|
[RequestSizeLimit(MaxFileSizeBytes)]
|
||||||
@@ -146,6 +148,22 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
return Ok(new { sectionName, style, targetRole, text = rewritten.Trim() });
|
return Ok(new { sectionName, style, targetRole, text = rewritten.Trim() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("parse")]
|
||||||
|
public async Task<ActionResult<object>> 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")]
|
[HttpPost("improve")]
|
||||||
public async Task<IActionResult> Improve()
|
public async Task<IActionResult> Improve()
|
||||||
{
|
{
|
||||||
@@ -174,6 +192,80 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText });
|
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<string, string>(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<string> Lines)>();
|
||||||
|
var currentName = "General";
|
||||||
|
var currentLines = new List<string>();
|
||||||
|
|
||||||
|
void Flush()
|
||||||
|
{
|
||||||
|
var content = string.Join("\n", currentLines).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
sections.Add((currentName, new List<string>(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<string> ExtractTextAsync(IFormFile file, string extension)
|
private static async Task<string> ExtractTextAsync(IFormFile file, string extension)
|
||||||
{
|
{
|
||||||
if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -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.",
|
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.",
|
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||||
profileCvSectionTools: "Section rewrite tools",
|
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.",
|
profileCvSectionToolsHelp: "Generate sharper versions of one CV section at a time before pasting them back into your master CV.",
|
||||||
profileCvSectionLabel: "Section",
|
profileCvSectionLabel: "Section",
|
||||||
profileCvSectionSummary: "Professional Summary",
|
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.",
|
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.",
|
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||||
profileCvSectionTools: "Verktøy for CV-seksjoner",
|
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.",
|
profileCvSectionToolsHelp: "Generer skarpere versjoner av én CV-seksjon om gangen før du limer dem tilbake i hoved-CV-en.",
|
||||||
profileCvSectionLabel: "Seksjon",
|
profileCvSectionLabel: "Seksjon",
|
||||||
profileCvSectionSummary: "Profesjonell oppsummering",
|
profileCvSectionSummary: "Profesjonell oppsummering",
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import CropImageDialog from "../components/CropImageDialog";
|
|||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
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 CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
||||||
type CvSectionStyle = "balanced" | "concise" | "impact" | "ats";
|
type CvSectionStyle = "balanced" | "concise" | "impact" | "ats";
|
||||||
|
|
||||||
@@ -94,6 +100,8 @@ export default function ProfilePage() {
|
|||||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("balanced");
|
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("balanced");
|
||||||
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||||
const [cvSectionDraft, setCvSectionDraft] = useState("");
|
const [cvSectionDraft, setCvSectionDraft] = useState("");
|
||||||
|
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||||
|
const [parsedCvSections, setParsedCvSections] = useState<ParsedCvSection[]>([]);
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
@@ -335,6 +343,47 @@ export default function ProfilePage() {
|
|||||||
disabled={!isLocal}
|
disabled={!isLocal}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<Box sx={{ mt: 2, 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.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructureOverview")}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureOverviewHelp")}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!isLocal || !profileCvText.trim() || parsingCvSections}
|
||||||
|
onClick={async () => {
|
||||||
|
setParsingCvSections(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ sections?: ParsedCvSection[] }>("/profile-cv/parse", { text: profileCvText });
|
||||||
|
setParsedCvSections(res.data?.sections ?? []);
|
||||||
|
toast(t("profileCvStructureParsed"), "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error");
|
||||||
|
} finally {
|
||||||
|
setParsingCvSections(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parsingCvSections ? t("profileCvStructureParsing") : t("profileCvStructureParse")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{parsedCvSections.length > 0 ? (
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
|
{parsedCvSections.map((section) => (
|
||||||
|
<Box key={section.name} sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 0.75 }}>
|
||||||
|
<Typography variant="overline">{section.name}</Typography>
|
||||||
|
<Chip size="small" label={t("profileCvSectionWordCount", { count: section.wordCount })} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", whiteSpace: "pre-wrap" }}>{section.content.slice(0, 280)}{section.content.length > 280 ? "…" : ""}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureEmpty")}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
<Box sx={{ mt: 2, 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.5 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user