Improve CV rewrite flow and parser accuracy

This commit is contained in:
2026-04-01 11:30:37 +02:00
parent f402213526
commit f22c6791a7
9 changed files with 581 additions and 55 deletions
@@ -74,7 +74,7 @@ public sealed class ProfileCvController : ControllerBase
_paths = paths;
}
public sealed record RewriteSectionRequest(string SectionName, string? Style, string? TargetRole);
public sealed record RewriteSectionRequest(string? SectionName, string? Style, string? TargetRole, int? JobApplicationId, string? TemplateId);
public sealed record ParseCvRequest(string? Text);
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
@@ -274,24 +274,59 @@ public sealed class ProfileCvController : ControllerBase
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before rewriting a section.");
var sectionName = string.IsNullOrWhiteSpace(request.SectionName) ? "Professional Summary" : request.SectionName.Trim();
var style = string.IsNullOrWhiteSpace(request.Style) ? "balanced" : request.Style.Trim();
var sourceText = string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim();
var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
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 style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim();
var templateId = string.IsNullOrWhiteSpace(request.TemplateId) ? "ats-minimal" : request.TemplateId.Trim();
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)
: 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 ?? "-"}"
: 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 rewritten = await _aiService.SummarizeSectionAsync(
$"Rewrite only the '{sectionName}' section of this CV. Preserve facts, avoid inventing employers or metrics, and output only the rewritten section text. Style: {style}. {(targetRole is not null ? $"Target role: {targetRole}." : "Make it broadly reusable for future tailoring.")}",
user.ProfileCvText,
900,
180);
$"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.",
rewriteSource,
sectionName is null ? 1800 : 900,
sectionName is null ? 400 : 180);
if (string.IsNullOrWhiteSpace(rewritten))
{
return BadRequest("The AI service could not rewrite that CV section right now.");
return StatusCode(StatusCodes.Status502BadGateway, "The AI service could not rewrite your CV right now.");
}
return Ok(new { sectionName, style, targetRole, text = rewritten.Trim() });
return Ok(new
{
sectionName,
style,
templateId,
targetRole = effectiveTargetRole,
jobApplicationId = jobContext?.Id,
text = rewritten.Trim()
});
}
[HttpPost("parse")]
@@ -338,6 +373,37 @@ public sealed class ProfileCvController : ControllerBase
return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText, structuredCv, sections = structuredCv.Sections, extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion });
}
private static string BuildRewriteSourceText(string? sectionName, string? sourceText, StructuredCvProfile structuredCv)
{
if (string.IsNullOrWhiteSpace(sectionName))
{
return !string.IsNullOrWhiteSpace(sourceText)
? sourceText.Trim()
: string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}"));
}
var matchingSection = structuredCv.Sections.FirstOrDefault(section => string.Equals(section.Name, sectionName, StringComparison.OrdinalIgnoreCase));
if (matchingSection is not null && !string.IsNullOrWhiteSpace(matchingSection.Content))
{
return $"## {matchingSection.Name}\n{matchingSection.Content}";
}
return !string.IsNullOrWhiteSpace(sourceText)
? sourceText.Trim()
: string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}"));
}
private static string DescribeRewriteTemplate(string templateId)
{
return templateId.ToLowerInvariant() switch
{
"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.",
};
}
private async Task<StructuredCvProfile> BuildStructuredCvAsync(string text, CancellationToken cancellationToken)
{
var parseSource = NormalizeTextForStructuredParsing(text);