fix(career): preserve reviewed profile values
Keep extraction heuristics out of manual save, version, and import paths so reviewed locations, URLs, dates, and languages round-trip unchanged.
This commit is contained in:
@@ -51,7 +51,7 @@ public sealed class CareerProfileController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
|
||||
|
||||
var profile = StructuredCvProfileJson.Normalize(request?.Profile);
|
||||
var profile = StructuredCvProfileJson.NormalizeForPersistence(request?.Profile);
|
||||
var error = CareerProfileValidator.Validate(profile);
|
||||
if (error is not null) return BadRequest(error);
|
||||
|
||||
@@ -59,7 +59,7 @@ public sealed class CareerProfileController : ControllerBase
|
||||
|
||||
// Keep the derived projection in sync for legacy readers. CvText (the raw imported text) is
|
||||
// part of the career profile and is set here too; identity fields are never touched.
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(saved);
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved);
|
||||
if (request?.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText;
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
@@ -100,7 +100,7 @@ public sealed class CareerProfileController : ControllerBase
|
||||
var restored = await _career.RestoreVersionAsync(user.Id, version, cancellationToken);
|
||||
if (restored is null) return NotFound($"Version {version} was not found.");
|
||||
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(restored);
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored);
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ namespace JobTrackerApi.Controllers
|
||||
|
||||
private async Task<TailoredCvDraft> UpsertGeneratedTailoredCvDraftAsync(JobApplication job, ApplicationUser user, string? mode, CancellationToken cancellationToken)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var structured = StructuredCvProfileJson.DeserializePersisted(user.ProfileCvStructureJson);
|
||||
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, job.JobUrl }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
var structuredCvContext = BuildStructuredCvContext(user);
|
||||
@@ -1312,7 +1312,7 @@ Canonical profile:
|
||||
// Builds CV text grouped by section so match coverage can show *where* the evidence sits.
|
||||
private static Dictionary<string, string> BuildCvSections(ApplicationUser? user)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
|
||||
var structured = StructuredCvProfileJson.DeserializePersisted(user?.ProfileCvStructureJson);
|
||||
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Add(string name, IEnumerable<string?> values)
|
||||
@@ -1752,7 +1752,7 @@ Candidate CV/profile:
|
||||
return BadRequest("Add your profile CV text on the Profile page before generating a tailored CV draft.");
|
||||
}
|
||||
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var structured = StructuredCvProfileJson.DeserializePersisted(user.ProfileCvStructureJson);
|
||||
if (structured.Summary.Count == 0 && structured.Jobs.Count == 0 && structured.Skills.Count == 0)
|
||||
{
|
||||
return BadRequest("Build and review your canonical structured CV on the Profile page before generating a tailored draft.");
|
||||
|
||||
@@ -253,7 +253,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted);
|
||||
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(merged);
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged);
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
|
||||
@@ -323,7 +323,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var structuredCv = StructuredCvProfileJson.DeserializePersisted(user.ProfileCvStructureJson);
|
||||
var sourceText = string.IsNullOrWhiteSpace(request.SourceText)
|
||||
? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim())
|
||||
: request.SourceText.Trim();
|
||||
@@ -427,7 +427,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var structuredCv = StructuredCvProfileJson.DeserializePersisted(user.ProfileCvStructureJson);
|
||||
var sourceText = string.IsNullOrWhiteSpace(request.SourceText)
|
||||
? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim())
|
||||
: request.SourceText.Trim();
|
||||
|
||||
@@ -49,6 +49,158 @@ public static class StructuredCvProfileJson
|
||||
return JsonSerializer.Serialize(Normalize(profile), SerializerOptions);
|
||||
}
|
||||
|
||||
// Stored Career Profile values have already passed extraction review. Persistence therefore
|
||||
// performs structural cleanup only: trim/dedupe empty values, but never reinterpret a user's
|
||||
// website path, free-form date, location, role, institution or language. Extraction continues
|
||||
// to use Normalize/Serialize above and keeps its stricter heuristics.
|
||||
public static StructuredCvProfile DeserializePersisted(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return NormalizeForPersistence(new StructuredCvProfile());
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var sections = JsonSerializer.Deserialize<List<StructuredCvSection>>(json, SerializerOptions) ?? new List<StructuredCvSection>();
|
||||
return FromSections(sections);
|
||||
}
|
||||
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return NormalizeForPersistence(new StructuredCvProfile());
|
||||
var profile = JsonSerializer.Deserialize<StructuredCvProfile>(json, SerializerOptions) ?? new StructuredCvProfile();
|
||||
return NormalizeForPersistence(profile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return NormalizeForPersistence(new StructuredCvProfile());
|
||||
}
|
||||
}
|
||||
|
||||
public static string SerializePersisted(StructuredCvProfile? profile)
|
||||
=> JsonSerializer.Serialize(NormalizeForPersistence(profile), SerializerOptions);
|
||||
|
||||
public static StructuredCvProfile NormalizeForPersistence(StructuredCvProfile? profile)
|
||||
{
|
||||
profile ??= new StructuredCvProfile();
|
||||
profile.Version = string.IsNullOrWhiteSpace(profile.Version) ? "1" : profile.Version.Trim();
|
||||
profile.Metadata ??= new StructuredCvMetadata();
|
||||
profile.Metadata.Fields ??= new Dictionary<string, StructuredCvFieldMetadata>();
|
||||
|
||||
profile.Contact ??= new StructuredCvContact();
|
||||
profile.Contact.FullName = TrimOrNull(profile.Contact.FullName);
|
||||
profile.Contact.Headline = TrimOrNull(profile.Contact.Headline);
|
||||
profile.Contact.Email = TrimOrNull(profile.Contact.Email);
|
||||
profile.Contact.Phone = TrimOrNull(profile.Contact.Phone);
|
||||
profile.Contact.Location = TrimOrNull(profile.Contact.Location);
|
||||
profile.Contact.Website = TrimOrNull(profile.Contact.Website);
|
||||
profile.Contact.LinkedIn = TrimOrNull(profile.Contact.LinkedIn);
|
||||
|
||||
profile.Summary = CleanList(profile.Summary);
|
||||
profile.Jobs = (profile.Jobs ?? new List<StructuredCvJob>())
|
||||
.Select(job =>
|
||||
{
|
||||
job ??= new StructuredCvJob();
|
||||
job.Id = TrimOrNull(job.Id);
|
||||
job.Title = TrimOrNull(job.Title);
|
||||
job.Company = TrimOrNull(job.Company);
|
||||
job.Location = TrimOrNull(job.Location);
|
||||
job.Start = TrimOrNull(job.Start);
|
||||
job.End = TrimOrNull(job.End);
|
||||
job.StartDate = TrimOrNull(job.StartDate);
|
||||
job.EndDate = TrimOrNull(job.EndDate);
|
||||
job.Bullets = CleanList(job.Bullets);
|
||||
job.Skills = CleanList(job.Skills);
|
||||
return job;
|
||||
})
|
||||
.Where(job => job.Title is not null || job.Company is not null || job.Location is not null
|
||||
|| job.Start is not null || job.End is not null || job.Bullets.Count > 0 || job.Skills.Count > 0)
|
||||
.ToList();
|
||||
profile.Education = (profile.Education ?? new List<StructuredCvEducation>())
|
||||
.Select(education =>
|
||||
{
|
||||
education ??= new StructuredCvEducation();
|
||||
education.Id = TrimOrNull(education.Id);
|
||||
education.Qualification = TrimOrNull(education.Qualification);
|
||||
education.QualificationLevel = TrimOrNull(education.QualificationLevel);
|
||||
education.Institution = TrimOrNull(education.Institution);
|
||||
education.Location = TrimOrNull(education.Location);
|
||||
education.Start = TrimOrNull(education.Start);
|
||||
education.End = TrimOrNull(education.End);
|
||||
education.StartDate = TrimOrNull(education.StartDate);
|
||||
education.EndDate = TrimOrNull(education.EndDate);
|
||||
education.Details = CleanList(education.Details);
|
||||
return education;
|
||||
})
|
||||
.Where(education => education.Qualification is not null || education.QualificationLevel is not null
|
||||
|| education.Institution is not null || education.Location is not null || education.Start is not null
|
||||
|| education.End is not null || education.Details.Count > 0)
|
||||
.ToList();
|
||||
profile.Certifications = (profile.Certifications ?? new List<StructuredCvCertification>())
|
||||
.Select(certification =>
|
||||
{
|
||||
certification ??= new StructuredCvCertification();
|
||||
certification.Id = TrimOrNull(certification.Id);
|
||||
certification.Name = TrimOrNull(certification.Name);
|
||||
certification.Issuer = TrimOrNull(certification.Issuer);
|
||||
certification.Location = TrimOrNull(certification.Location);
|
||||
certification.Date = TrimOrNull(certification.Date);
|
||||
certification.DateNormalized = TrimOrNull(certification.DateNormalized);
|
||||
certification.Details = CleanList(certification.Details);
|
||||
return certification;
|
||||
})
|
||||
.Where(certification => certification.Name is not null || certification.Issuer is not null
|
||||
|| certification.Location is not null || certification.Date is not null || certification.Details.Count > 0)
|
||||
.ToList();
|
||||
profile.Projects = (profile.Projects ?? new List<StructuredCvProject>())
|
||||
.Select(project =>
|
||||
{
|
||||
project ??= new StructuredCvProject();
|
||||
project.Id = TrimOrNull(project.Id);
|
||||
project.Name = TrimOrNull(project.Name);
|
||||
project.Role = TrimOrNull(project.Role);
|
||||
project.Location = TrimOrNull(project.Location);
|
||||
project.Start = TrimOrNull(project.Start);
|
||||
project.End = TrimOrNull(project.End);
|
||||
project.StartDate = TrimOrNull(project.StartDate);
|
||||
project.EndDate = TrimOrNull(project.EndDate);
|
||||
project.Bullets = CleanList(project.Bullets);
|
||||
project.Skills = CleanList(project.Skills);
|
||||
return project;
|
||||
})
|
||||
.Where(project => project.Name is not null || project.Role is not null || project.Location is not null
|
||||
|| project.Start is not null || project.End is not null || project.Bullets.Count > 0 || project.Skills.Count > 0)
|
||||
.ToList();
|
||||
profile.Skills = CleanList(profile.Skills);
|
||||
profile.Languages = (profile.Languages ?? new List<StructuredCvLanguage>())
|
||||
.Select(language =>
|
||||
{
|
||||
language ??= new StructuredCvLanguage();
|
||||
language.Name = TrimOrNull(language.Name);
|
||||
language.Level = TrimOrNull(language.Level);
|
||||
language.Notes = TrimOrNull(language.Notes);
|
||||
return language;
|
||||
})
|
||||
.Where(language => language.Name is not null)
|
||||
.ToList();
|
||||
profile.Interests = CleanList(profile.Interests);
|
||||
profile.Awards = CleanList(profile.Awards);
|
||||
profile.Publications = CleanList(profile.Publications);
|
||||
profile.Organisations = CleanList(profile.Organisations);
|
||||
profile.References = CleanList(profile.References);
|
||||
profile.OtherSections = (profile.OtherSections ?? new List<StructuredCvOtherSection>())
|
||||
.Select(section => new StructuredCvOtherSection
|
||||
{
|
||||
Title = TrimOrNull(section?.Title),
|
||||
Items = CleanList(section?.Items),
|
||||
})
|
||||
.Where(section => section.Title is not null || section.Items.Count > 0)
|
||||
.ToList();
|
||||
|
||||
var normalizedSections = NormalizeSections(profile.Sections);
|
||||
profile.Sections = normalizedSections.Count > 0 ? normalizedSections : BuildSections(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
public static StructuredCvProfile Merge(StructuredCvProfile? preferred, StructuredCvProfile? fallback)
|
||||
{
|
||||
var primary = Normalize(preferred);
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
AssignStableIds(profile);
|
||||
NormalizeDates(profile);
|
||||
|
||||
var json = StructuredCvProfileJson.Serialize(profile);
|
||||
var json = StructuredCvProfileJson.SerializePersisted(profile);
|
||||
|
||||
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
@@ -109,7 +109,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
// Backfill a pre-Phase-3 profile from its blob, once, before reading relationally.
|
||||
if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
|
||||
{
|
||||
var fromBlob = StructuredCvProfileJson.Deserialize(profile.ProfileJson);
|
||||
var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
|
||||
AssignStableIds(fromBlob);
|
||||
NormalizeDates(fromBlob);
|
||||
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
|
||||
@@ -142,7 +142,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
if (experiences.Count == 0 && education.Count == 0 && skills.Count == 0 && projects.Count == 0
|
||||
&& certifications.Count == 0 && languages.Count == 0 && !string.IsNullOrWhiteSpace(profile.ProfileJson))
|
||||
{
|
||||
return StructuredCvProfileJson.Deserialize(profile.ProfileJson);
|
||||
return StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
|
||||
}
|
||||
|
||||
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
|
||||
@@ -172,7 +172,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
|
||||
// Re-save the old snapshot as a new version. Non-destructive: the current state stays in
|
||||
// history, so a restore can itself be undone by restoring the version before it.
|
||||
var restored = StructuredCvProfileJson.Deserialize(target.ProfileJson);
|
||||
var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson);
|
||||
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,26 +25,47 @@ public static class CareerProfileValidator
|
||||
|
||||
foreach (var j in p.Jobs)
|
||||
{
|
||||
if (Over(j.Title, MaxShortField) || Over(j.Company, MaxShortField) || Over(j.Location, MaxShortField))
|
||||
if (Over(j.Id, MaxShortField) || Over(j.Title, MaxShortField) || Over(j.Company, MaxShortField)
|
||||
|| Over(j.Location, MaxShortField) || Over(j.Start, MaxShortField) || Over(j.End, MaxShortField))
|
||||
return "An experience field exceeds the allowed length.";
|
||||
if (j.Bullets.Count > MaxListEntries || j.Skills.Count > MaxListEntries) return "An experience has too many bullets/skills.";
|
||||
if (j.Bullets.Any(b => Over(b, MaxLongField))) return "An experience bullet is too long.";
|
||||
if (j.Bullets.Any(b => Over(b, MaxLongField)) || j.Skills.Any(s => Over(s, MaxShortField))) return "An experience bullet or skill is too long.";
|
||||
}
|
||||
foreach (var e in p.Education)
|
||||
{
|
||||
if (Over(e.Qualification, MaxShortField) || Over(e.Institution, MaxShortField)) return "An education field exceeds the allowed length.";
|
||||
if (Over(e.Id, MaxShortField) || Over(e.Qualification, MaxShortField) || Over(e.QualificationLevel, MaxShortField)
|
||||
|| Over(e.Institution, MaxShortField) || Over(e.Location, MaxShortField)
|
||||
|| Over(e.Start, MaxShortField) || Over(e.End, MaxShortField)) return "An education field exceeds the allowed length.";
|
||||
if (e.Details.Count > MaxListEntries) return "An education entry has too many details.";
|
||||
if (e.Details.Any(detail => Over(detail, MaxLongField))) return "An education detail is too long.";
|
||||
}
|
||||
foreach (var pr in p.Projects)
|
||||
{
|
||||
if (Over(pr.Name, MaxShortField) || Over(pr.Role, MaxShortField)) return "A project field exceeds the allowed length.";
|
||||
if (Over(pr.Id, MaxShortField) || Over(pr.Name, MaxShortField) || Over(pr.Role, MaxShortField)
|
||||
|| Over(pr.Location, MaxShortField) || Over(pr.Start, MaxShortField) || Over(pr.End, MaxShortField)) return "A project field exceeds the allowed length.";
|
||||
if (pr.Bullets.Count > MaxListEntries || pr.Skills.Count > MaxListEntries) return "A project has too many bullets/skills.";
|
||||
if (pr.Bullets.Any(bullet => Over(bullet, MaxLongField)) || pr.Skills.Any(skill => Over(skill, MaxShortField))) return "A project bullet or skill is too long.";
|
||||
}
|
||||
foreach (var certification in p.Certifications)
|
||||
{
|
||||
if (Over(certification.Id, MaxShortField) || Over(certification.Name, MaxShortField)
|
||||
|| Over(certification.Issuer, MaxShortField) || Over(certification.Location, MaxShortField)
|
||||
|| Over(certification.Date, MaxShortField)) return "A certification field exceeds the allowed length.";
|
||||
if (certification.Details.Count > MaxListEntries) return "A certification has too many details.";
|
||||
if (certification.Details.Any(detail => Over(detail, MaxLongField))) return "A certification detail is too long.";
|
||||
}
|
||||
foreach (var language in p.Languages)
|
||||
{
|
||||
if (Over(language.Name, MaxShortField) || Over(language.Level, MaxShortField) || Over(language.Notes, MaxLongField))
|
||||
return "A language field exceeds the allowed length.";
|
||||
}
|
||||
foreach (var s in p.Skills)
|
||||
if (Over(s, MaxShortField)) return "A skill entry is too long.";
|
||||
|
||||
if (Over(p.Contact.FullName, MaxShortField) || Over(p.Contact.Email, MaxShortField)
|
||||
|| Over(p.Contact.Headline, MaxShortField) || Over(p.Contact.Location, MaxShortField))
|
||||
|| Over(p.Contact.Headline, MaxShortField) || Over(p.Contact.Phone, MaxShortField)
|
||||
|| Over(p.Contact.Location, MaxShortField) || Over(p.Contact.Website, MaxShortField)
|
||||
|| Over(p.Contact.LinkedIn, MaxShortField))
|
||||
return "A contact field exceeds the allowed length.";
|
||||
|
||||
return null;
|
||||
|
||||
@@ -81,7 +81,7 @@ public sealed class CvProfileDiffService : ICvProfileDiffService
|
||||
|
||||
public StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? acceptedLowConfidenceIds = null)
|
||||
{
|
||||
var merged = StructuredCvProfileJson.Deserialize(StructuredCvProfileJson.Serialize(current ?? new StructuredCvProfile()));
|
||||
var merged = StructuredCvProfileJson.DeserializePersisted(StructuredCvProfileJson.SerializePersisted(current ?? new StructuredCvProfile()));
|
||||
extracted = FilterLowConfidence(extracted ?? new StructuredCvProfile(), acceptedLowConfidenceIds);
|
||||
|
||||
MergeContact(merged.Contact, extracted.Contact);
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace JobTrackerApi.Services
|
||||
|
||||
public static string BuildStructuredCvContext(ApplicationUser? user)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
|
||||
var structured = StructuredCvProfileJson.DeserializePersisted(user?.ProfileCvStructureJson);
|
||||
var blocks = new List<string>();
|
||||
|
||||
var contactLines = new List<string>();
|
||||
@@ -102,7 +102,7 @@ namespace JobTrackerApi.Services
|
||||
|
||||
public static string BuildCvSearchCorpus(ApplicationUser? user)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
|
||||
var structured = StructuredCvProfileJson.DeserializePersisted(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!);
|
||||
|
||||
Reference in New Issue
Block a user