feat: complete phase 2 UX improvements
CI and Deploy / test (push) Successful in 2m42s
CI and Deploy / deploy (push) Failing after 9s

This commit is contained in:
cesnimda
2026-07-30 21:35:52 +02:00
parent 173187dcbb
commit 56fed05d70
26 changed files with 1979 additions and 12283 deletions
+154 -63
View File
@@ -72,8 +72,9 @@ public sealed class ProfileCvController : ControllerBase
private readonly ICvProcessingQueue _cvProcessingQueue;
private readonly IAppEmailSender _emailSender;
private readonly ICareerProfileService _careerProfileService;
private readonly ICvProfileDiffService _cvProfileDiffService;
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null)
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null, ICvProfileDiffService? cvProfileDiffService = null)
{
_users = users;
_aiService = aiService;
@@ -87,6 +88,7 @@ public sealed class ProfileCvController : ControllerBase
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
_emailSender = emailSender ?? NoOpEmailSender.Instance;
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
_cvProfileDiffService = cvProfileDiffService ?? new CvProfileDiffService();
}
private sealed class NoOpEmailSender : IAppEmailSender
@@ -120,6 +122,7 @@ public sealed class ProfileCvController : ControllerBase
// in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
// (Phase 4), not this foundation integration.
public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds);
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
@@ -159,45 +162,21 @@ public sealed class ProfileCvController : ControllerBase
try
{
var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted);
result.StructuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, result.StructuredCv, "upload", HttpContext.RequestAborted);
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.RawExtractedText = result.RawText;
run.NormalizedText = result.NormalizedText;
run.StructuredProfileJson = structuredJson;
run.Status = "applied";
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
run.AppliedAtUtc = run.CompletedAtUtc;
user.ProfileCvText = result.NormalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvUploadArtifactId = artifact.Id;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = result.StructuredCv.Metadata.ProfileVersion;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return BadRequest(run.ErrorMessage);
}
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new
{
imported = true,
imported = false,
pendingReview = true,
characters = result.NormalizedText.Length,
structuredCv = result.StructuredCv,
sections = result.StructuredCv.Sections,
artifactId = artifact.Id,
extractionRunId = run.Id,
profileVersion = result.StructuredCv.Metadata.ProfileVersion,
status = run.Status,
});
}
catch (Exception ex)
@@ -238,6 +217,71 @@ public sealed class ProfileCvController : ControllerBase
return Ok(runs);
}
[HttpGet("runs/{id:int}/diff")]
public async Task<IActionResult> GetRunDiff([FromRoute] int id)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
return Ok(new { runId = run.Id, run.Status, diff = _cvProfileDiffService.Diff(current, extracted) });
}
[HttpPost("runs/{id:int}/accept")]
public async Task<IActionResult> AcceptRun([FromRoute] int id, [FromBody] AcceptCvRunRequest? request = null)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
var diff = _cvProfileDiffService.Diff(current, extracted);
var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
merged.Metadata.AppliedExtractionRunId = run.Id;
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted);
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(merged);
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
run.Status = "applied";
run.AppliedAtUtc = DateTimeOffset.UtcNow;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description)));
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged });
}
[HttpPost("runs/{id:int}/discard")]
public async Task<IActionResult> DiscardRun([FromRoute] int id)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
run.Status = "discarded";
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return NoContent();
}
[HttpPost("reprocess")]
public async Task<IActionResult> Reprocess()
{
@@ -807,6 +851,7 @@ public sealed class ProfileCvController : ControllerBase
throw new InvalidOperationException("The uploaded CV file could not be read or was empty.");
}
text = RepairKnownMojibake(text);
var normalizedText = (await MaybeReconstructStructuredCvAsync(text, cancellationToken)).Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
@@ -916,7 +961,7 @@ public sealed class ProfileCvController : ControllerBase
var normalizedText = rebuilt.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "improve":
@@ -931,7 +976,7 @@ public sealed class ProfileCvController : ControllerBase
var normalizedText = improved.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "reprocess":
@@ -951,7 +996,7 @@ public sealed class ProfileCvController : ControllerBase
};
var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty);
var result = await ExtractStructuredCvFromFileAsync(file, extension, cancellationToken);
await ApplyQueuedRunResultAsync(run, user, result.RawText, result.NormalizedText, result.StructuredCv, artifact.Id, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, result.RawText, result.NormalizedText, result.StructuredCv, cancellationToken);
break;
}
default:
@@ -971,39 +1016,13 @@ public sealed class ProfileCvController : ControllerBase
}
}
private async Task ApplyQueuedRunResultAsync(CvExtractionRun run, ApplicationUser user, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
private async Task CompleteQueuedRunForReviewAsync(CvExtractionRun run, string rawText, string normalizedText, StructuredCvProfile structuredCv, CancellationToken cancellationToken)
{
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, run.Trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.RawExtractedText = rawText;
run.NormalizedText = normalizedText;
run.StructuredProfileJson = structuredJson;
run.Status = "applied";
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(structuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
run.AppliedAtUtc = run.CompletedAtUtc;
user.ProfileCvText = normalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
if (artifactId.HasValue)
{
user.CurrentCvUploadArtifactId = artifactId.Value;
}
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(cancellationToken);
throw new InvalidOperationException(run.ErrorMessage);
}
await _db.SaveChangesAsync(cancellationToken);
}
@@ -1606,6 +1625,15 @@ public sealed class ProfileCvController : ControllerBase
{
var normalized = content.Replace("\r\n", "\n").Trim();
var structured = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Work Experience", Content = normalized } }).Jobs;
var earlierRoles = ParseEarlierRoles(normalized);
foreach (var role in earlierRoles)
{
if (!structured.Any(job => string.Equals(job.Title, role.Title, StringComparison.OrdinalIgnoreCase)
&& string.Equals(job.Company, role.Company, StringComparison.OrdinalIgnoreCase)))
{
structured.Add(role);
}
}
if (ArePlausibleJobs(structured, null))
{
return structured;
@@ -1711,6 +1739,36 @@ public sealed class ProfileCvController : ControllerBase
return jobs;
}
private static List<StructuredCvJob> ParseEarlierRoles(string content)
{
var heading = Regex.Match(content, @"(?im)^\s*(?:[-*]\s*)?Earlier roles(?:\s*\(part[- ]?time\))?\s*:?\s*$");
if (!heading.Success) return new List<StructuredCvJob>();
var roles = new List<StructuredCvJob>();
foreach (var rawLine in content[(heading.Index + heading.Length)..].Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (rawLine.StartsWith('#')) break;
var line = rawLine.Trim().TrimStart('-', '*', '•', ' ');
var dates = Regex.Match(line, @"(?<start>\d{4})\s*[-–—]\s*(?<end>\d{4}|Present|Current)", RegexOptions.IgnoreCase);
if (!dates.Success) continue;
var identity = Regex.Replace(line, @"\s*[|,(]?\s*\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current)\s*\)?\s*$", string.Empty, RegexOptions.IgnoreCase).Trim();
var parts = Regex.Split(identity, @"\s+(?:—||\||at)\s+", RegexOptions.IgnoreCase);
if (parts.Length != 2 || parts.Any(string.IsNullOrWhiteSpace)) continue;
roles.Add(new StructuredCvJob
{
Title = parts[0].Trim(),
Company = parts[1].Trim(),
Start = dates.Groups["start"].Value,
End = dates.Groups["end"].Value,
IsCurrent = dates.Groups["end"].Value.Equals("Present", StringComparison.OrdinalIgnoreCase)
|| dates.Groups["end"].Value.Equals("Current", StringComparison.OrdinalIgnoreCase),
});
}
return roles;
}
private static string? TitleCasePreservingAcronyms(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
@@ -2129,6 +2187,7 @@ public sealed class ProfileCvController : ControllerBase
private static StructuredCvProfile BuildStructuredCvFromNormalizedMarkdown(string text)
{
text = SeparateGluedDateAndTitle(text);
var sections = ParseSections(text)
.Select(section => new StructuredCvSection
{
@@ -2140,6 +2199,16 @@ public sealed class ProfileCvController : ControllerBase
var profile = StructuredCvProfileJson.FromSections(sections);
profile.Sections = sections;
var workExperience = sections.FirstOrDefault(section => section.Name == "Work Experience")?.Content;
if (!string.IsNullOrWhiteSpace(workExperience))
{
profile.Jobs.RemoveAll(job => (job.Title ?? string.Empty).StartsWith("Earlier roles", StringComparison.OrdinalIgnoreCase));
foreach (var role in ParseEarlierRoles(workExperience))
{
if (!profile.Jobs.Any(job => string.Equals(job.Title, role.Title, StringComparison.OrdinalIgnoreCase)
&& string.Equals(job.Company, role.Company, StringComparison.OrdinalIgnoreCase))) profile.Jobs.Add(role);
}
}
if (string.IsNullOrWhiteSpace(profile.Contact.FullName))
{
@@ -2175,11 +2244,33 @@ public sealed class ProfileCvController : ControllerBase
private static List<string> OrderSkills(List<string> skills)
{
return skills
.Select(CleanSkillGroupPrefix)
.Where(skill => skill.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(skill => skill, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string CleanSkillGroupPrefix(string skill)
=> Regex.Replace(skill.Trim(), @"^(?:Development|DevOps(?:\s*&\s*Infrastructure)?|Infrastructure|Practices|Tools|Technologies|Technical Skills)\s*:\s*", string.Empty, RegexOptions.IgnoreCase).Trim();
private static string SeparateGluedDateAndTitle(string text)
=> Regex.Replace(text, @"(?<date>\b\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current))(?<title>[\p{L}][^\r\n]*)", "${title}\n${date}", RegexOptions.IgnoreCase);
private static string RepairKnownMojibake(string text)
=> text
.Replace("ø", "ø", StringComparison.Ordinal)
.Replace("Ø", "Ø", StringComparison.Ordinal)
.Replace("æ", "æ", StringComparison.Ordinal)
.Replace("Æ", "Æ", StringComparison.Ordinal)
.Replace("Ã¥", "å", StringComparison.Ordinal)
.Replace("Ã…", "Å", StringComparison.Ordinal)
.Replace("–", "", StringComparison.Ordinal)
.Replace("—", "—", StringComparison.Ordinal)
.Replace("’", "", StringComparison.Ordinal)
.Replace("“", "“", StringComparison.Ordinal)
.Replace("”", "”", StringComparison.Ordinal);
private static List<string> CleanInterestItems(List<string> interests)
{
return interests
@@ -2229,7 +2320,7 @@ public sealed class ProfileCvController : ControllerBase
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
{
var raw = Encoding.UTF8.GetString(bytes);
var raw = Encoding.Latin1.GetString(bytes);
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
.Select(match => match.Groups[1].Value)
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)