diff --git a/JobTrackerApi.Tests/CareerProfileControllerTests.cs b/JobTrackerApi.Tests/CareerProfileControllerTests.cs index ff82ca7..da6d4ac 100644 --- a/JobTrackerApi.Tests/CareerProfileControllerTests.cs +++ b/JobTrackerApi.Tests/CareerProfileControllerTests.cs @@ -60,6 +60,46 @@ public sealed class CareerProfileControllerTests Assert.Equal(new[] { "C#", "SQL" }, dto.Profile.Skills); } + [Fact] + public async Task Put_then_get_preserves_reviewed_free_form_fields() + { + var (controller, _, user) = Build(); + var profile = Sample(); + profile.Contact.Location = "Oslo, Norway and Remote across Europe"; + profile.Contact.Website = "https://example.test/portfolio/ada?view=full"; + profile.Contact.LinkedIn = "https://www.linkedin.com/in/ada-lovelace"; + profile.Jobs[0].Location = "Oslo, Norway and Remote across Europe"; + profile.Jobs[0].Start = "Spring 2020"; + profile.Jobs.Add(new StructuredCvJob { Location = "Remote across Europe", Start = "Before 2020" }); + profile.Languages.Add(new StructuredCvLanguage + { + Name = "Norwegian Sign Language", + Level = "Professional working proficiency", + Notes = "Used with distributed teams", + }); + + var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None); + var saved = Assert.IsType(Assert.IsType(put.Result).Value).Profile; + var get = await controller.Get(CancellationToken.None); + var reloaded = Assert.IsType(Assert.IsType(get.Result).Value).Profile; + + foreach (var actual in new[] { saved, reloaded }) + { + Assert.Equal("Oslo, Norway and Remote across Europe", actual.Contact.Location); + Assert.Equal("https://example.test/portfolio/ada?view=full", actual.Contact.Website); + Assert.Equal("https://www.linkedin.com/in/ada-lovelace", actual.Contact.LinkedIn); + Assert.Equal("Oslo, Norway and Remote across Europe", actual.Jobs[0].Location); + Assert.Equal("Spring 2020", actual.Jobs[0].Start); + Assert.Equal("Remote across Europe", actual.Jobs[1].Location); + Assert.Equal("Before 2020", actual.Jobs[1].Start); + Assert.Equal("Norwegian Sign Language", actual.Languages[0].Name); + Assert.Equal("Professional working proficiency", actual.Languages[0].Level); + } + + var projected = StructuredCvProfileJson.DeserializePersisted(user.ProfileCvStructureJson); + Assert.Equal("https://example.test/portfolio/ada?view=full", projected.Contact.Website); + } + [Fact] public async Task Get_reports_completeness_with_missing_sections() { @@ -86,6 +126,19 @@ public sealed class CareerProfileControllerTests Assert.IsType(put.Result); } + [Fact] + public async Task Put_rejects_an_over_limit_reviewed_contact_value_instead_of_discarding_it() + { + var (controller, _, _) = Build(); + var profile = Sample(); + profile.Contact.Website = $"https://example.test/{new string('a', 600)}"; + + var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None); + + var badRequest = Assert.IsType(put.Result); + Assert.Equal("A contact field exceeds the allowed length.", badRequest.Value); + } + [Fact] public async Task Put_accepts_an_empty_work_in_progress_profile() { diff --git a/JobTrackerApi.Tests/CvProfileDiffServiceTests.cs b/JobTrackerApi.Tests/CvProfileDiffServiceTests.cs index 676f89e..3abd170 100644 --- a/JobTrackerApi.Tests/CvProfileDiffServiceTests.cs +++ b/JobTrackerApi.Tests/CvProfileDiffServiceTests.cs @@ -150,7 +150,8 @@ public sealed class CvProfileDiffServiceTests Skills = { "C#" }, }; current.Jobs[0].Id = "keep-me"; - current.Jobs[0].Location = "Oslo"; + current.Jobs[0].Location = "Oslo, Norway and Remote across Europe"; + current.Contact.Website = "https://example.test/portfolio/ada?view=full"; var extracted = new StructuredCvProfile { Jobs = @@ -165,7 +166,8 @@ public sealed class CvProfileDiffServiceTests Assert.Equal(2, merged.Jobs.Count); Assert.Equal("keep-me", merged.Jobs[0].Id); - Assert.Equal("Oslo", merged.Jobs[0].Location); + Assert.Equal("Oslo, Norway and Remote across Europe", merged.Jobs[0].Location); + Assert.Equal("https://example.test/portfolio/ada?view=full", merged.Contact.Website); Assert.Equal("2024", merged.Jobs[0].End); Assert.Equal(new[] { "Curated bullet", "New extracted bullet" }, merged.Jobs[0].Bullets); Assert.Equal(new[] { "C#", "Docker" }, merged.Skills); diff --git a/JobTrackerApi/Controllers/CareerProfileController.cs b/JobTrackerApi/Controllers/CareerProfileController.cs index 73b23dc..012c6e5 100644 --- a/JobTrackerApi/Controllers/CareerProfileController.cs +++ b/JobTrackerApi/Controllers/CareerProfileController.cs @@ -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))); diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index c3d6145..490a5bc 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -103,7 +103,7 @@ namespace JobTrackerApi.Controllers private async Task 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 BuildCvSections(ApplicationUser? user) { - var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson); + var structured = StructuredCvProfileJson.DeserializePersisted(user?.ProfileCvStructureJson); var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); void Add(string name, IEnumerable 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."); diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index b7adc1c..226b555 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -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(); diff --git a/JobTrackerApi/Models/StructuredCvProfileJson.cs b/JobTrackerApi/Models/StructuredCvProfileJson.cs index 23d53f5..ef7a425 100644 --- a/JobTrackerApi/Models/StructuredCvProfileJson.cs +++ b/JobTrackerApi/Models/StructuredCvProfileJson.cs @@ -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>(json, SerializerOptions) ?? new List(); + return FromSections(sections); + } + + if (doc.RootElement.ValueKind != JsonValueKind.Object) return NormalizeForPersistence(new StructuredCvProfile()); + var profile = JsonSerializer.Deserialize(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(); + + 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()) + .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()) + .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()) + .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()) + .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()) + .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()) + .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); diff --git a/JobTrackerApi/Services/CareerProfileService.cs b/JobTrackerApi/Services/CareerProfileService.cs index e663e0c..14726f0 100644 --- a/JobTrackerApi/Services/CareerProfileService.cs +++ b/JobTrackerApi/Services/CareerProfileService.cs @@ -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); } diff --git a/JobTrackerApi/Services/CareerProfileValidator.cs b/JobTrackerApi/Services/CareerProfileValidator.cs index cce03de..ad9edfa 100644 --- a/JobTrackerApi/Services/CareerProfileValidator.cs +++ b/JobTrackerApi/Services/CareerProfileValidator.cs @@ -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; diff --git a/JobTrackerApi/Services/CvImportDiff.cs b/JobTrackerApi/Services/CvImportDiff.cs index 9b2e5ab..ed40069 100644 --- a/JobTrackerApi/Services/CvImportDiff.cs +++ b/JobTrackerApi/Services/CvImportDiff.cs @@ -81,7 +81,7 @@ public sealed class CvProfileDiffService : ICvProfileDiffService public StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet? 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); diff --git a/JobTrackerApi/Services/JobApplicationHelpers.cs b/JobTrackerApi/Services/JobApplicationHelpers.cs index 5284c3f..7a67d09 100644 --- a/JobTrackerApi/Services/JobApplicationHelpers.cs +++ b/JobTrackerApi/Services/JobApplicationHelpers.cs @@ -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(); var contactLines = new List(); @@ -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(); if (!string.IsNullOrWhiteSpace(user?.ProfileCvText)) parts.Add(user.ProfileCvText!); if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) parts.Add(structured.Contact.Headline!); diff --git a/docs/architecture/career-profile-model.md b/docs/architecture/career-profile-model.md index e0897c7..cf9c62e 100644 --- a/docs/architecture/career-profile-model.md +++ b/docs/architecture/career-profile-model.md @@ -159,6 +159,17 @@ Existing read paths (CV rendering, tailoring, match-score, cover letters) read 4. The eventual removal of the blob (once every reader is migrated to read relational) is a later phase and out of scope here. +### Reviewed-value persistence boundary (2026-08-15) + +Extraction payloads continue through `StructuredCvProfileJson.Normalize`, which applies heuristics +to reject parser noise and infer locations, URLs, roles, dates and languages. Once a profile reaches +the editable Career surface, it is reviewed user data: `NormalizeForPersistence`, +`SerializePersisted` and `DeserializePersisted` only trim/dedupe structural values and must not +reinterpret them. This separation prevents a manual website path, free-form date or location such +as "Remote across Europe" from changing on save, version restore, import merge or a legacy +projection read. `CareerProfileValidator` rejects oversized reviewed values explicitly instead of +silently discarding them. + This mirrors the additive, non-destructive philosophy of Phase 0/ADR-002: introduce the new model, keep the old surface working via a derived projection, flip readers later. diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index c60199c..02b2950 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -198,3 +198,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un | V-164 | Career/Profile focused Jest; CV extraction/diff backend tests; AI-sidecar pytest; production frontend build; ingestion execution-path review | Repository root / `job-tracker-ui` / `tools/summarizer` | Reproduce and fix career-field resets while assessing the proposed Ollama accuracy pipeline | PASS — Career 17/17 including active-poll preservation; backend 8/8; sidecar 22/22; build passes. Polling now fetches run status only. Existing pipeline is confirmed as local parser/OCR → Ollama-first normalize/classify → deterministic C# validation/diff/review | Synthetic/JSDOM/fake model only; no real private CV, live Ollama model comparison, provider or production call. Repository `.venv` lacked pytest; global Python passed | Repository correction verified; model benchmark remains external/runtime work | | V-165 | CV renderer/resolver/template tests; Builder helper/editor/list Jest; optimized frontend build; real headless Chromium DOM/PDF probe; diff check | Repository root / `job-tracker-ui` | Verify professional multi-page CV rendering, physical preview metrics, custom-section ordering and stored-output safety under pathological content | PASS — backend 25/25; frontend 21/21; build passes. A 14-role/75-skill fixture with oversized name/email/URL produced zero horizontal offenders and a 9-page 173,196-byte PDF with extractable final-page text. Custom sections share persisted order; partial pages use ceiling count; stale preview/save/export races are gated | Synthetic data and local Chromium only; authenticated 375/768/1440 app journey, real private CV, production browser binary and DOCX remain unverified | Renderer/PDF repository scope verified; application-browser and production gates remain | | V-166 | Focused/full backend and frontend tests; optimized frontend build/TypeScript; diff review | Repository root / `job-tracker-ui` | Verify an administrator can identify the deployed application version without exposing build metadata in the normal-user UI/API bootstrap | PASS — focused Auth/System 36/36 and AppShell 2/2; backend 640/640; frontend 53 suites/221 tests; production build passes. Configured version/commit reaches admin `/api/auth/me`, normal users receive no configured metadata, and the responsive header badge is absent unless the admin-owned prop is supplied | Local/JSDOM evidence only; remote CI and deployed version comparison remain. Jest retains the documented force-exit/open-handle notice | Priority repository increment verified; ship proof remains | +| V-167 | Failing Career round-trip reproduction; affected/full backend; focused Career/Profile Jest; persistence-consumer and diff review | Repository root / `job-tracker-ui` | Preserve every reviewed Career text value across save/get/version/projection/import use without weakening extraction cleanup or write bounds | PASS — pre-fix location became `Oslo, Norway and` and the test failed; post-fix affected backend 112/112, backend 642/642 and Career/Profile UI 17/17 pass. Website path/query, remote location, free-form date, custom language and incomplete WIP entry round-trip; oversized website is rejected explicitly | Local SQLite/JSDOM only; no real private CV/model/provider/production data. One initial Jest command used nonexistent paths and was corrected; the correct files passed | Reviewed/extracted normalization boundary verified locally | diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 8687ccf..ad6ef63 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -10,9 +10,9 @@ Updated: 2026-08-15 - **Production-verified work:** None. - **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require documented production access and unfinished dependencies. Real provider, SMTP/MariaDB and production environments are unavailable; DEP-001 awaits approved merge/live verification. The in-app browser is available for local UI checks. - **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages. -- **Immediate order:** admin version indicator full gate/ship proof; Career lossless manual persistence; CV contact contrast; JOBS-002 parity/regression; cross-app contrast/accessibility; PRODUCT-001; VER-001. External-only work remains skipped, not allowed to stall this queue. +- **Immediate order:** CV contact contrast; JOBS-002 parity/regression; cross-app contrast/accessibility; PRODUCT-001; VER-001. The admin version indicator is pushed in `a6cffe0`; Career lossless manual persistence is locally complete. External-only work remains skipped, not allowed to stall this queue. - **Status counts:** 7 `VERIFIED LOCALLY`; 22 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 4 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`. -- **Test status:** backend 640/640; frontend 53/53 suites and 221/221 tests; optimized production build/TypeScript pass. The version indicator also has focused Auth/System 36/36 and AppShell 2/2 evidence. Prior AI sidecar 22/22, Playwright 6/6, pathological nine-page Chromium/PDF proof and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded. +- **Test status:** backend 642/642; affected Career/import/profile/job paths 112/112; Career/Profile UI 17/17; prior frontend 53/53 suites and 221/221 tests plus optimized production build/TypeScript pass. The version indicator also has focused Auth/System 36/36 and AppShell 2/2 evidence. Prior AI sidecar 22/22, Playwright 6/6, pathological nine-page Chromium/PDF proof and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded. - **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default. - **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred. - **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap. diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md index 43b7c81..5257fb7 100644 --- a/docs/work-programmes/master-work-plan.md +++ b/docs/work-programmes/master-work-plan.md @@ -55,8 +55,8 @@ This queue records the highest-value work that can proceed without production cr | Order | Immediate work | Owning package(s) | Current state and finish line | |---:|---|---|---| | 1 | Admin-only deployed-version indicator in the application header | DEP-001, VER-001 | Implemented with authenticated API and shell tests. The badge shows the CI deployment version and exposes the commit SHA in its accessible label/tooltip only for administrators; full regression, remote CI and deployment smoke remain. | -| 2 | Lossless Career field persistence | CAREER-001 | Next implementable correction. Manual website/location/contact edits must round-trip exactly enough for user intent; extraction cleanup must not silently rewrite already reviewed values. Add controller and UI regression tests. | -| 3 | CV contact/header/sidebar contrast correction | CAREER-002 | Queued. Apply renderer-scoped theme ownership for contact/headline text and rerun pathological HTML/PDF proof without shrinking typography. | +| 2 | Lossless Career field persistence | CAREER-001 | Implemented and locally verified. Manual website/location/contact/date/language values now use a reviewed-data persistence boundary; extraction heuristics remain isolated to extraction. Full remote/production smoke remains. | +| 3 | CV contact/header/sidebar contrast correction | CAREER-002 | Next implementable correction. Apply renderer-scoped theme ownership for contact/headline text and rerun pathological HTML/PDF proof without shrinking typography. | | 4 | Dedicated Job Details parity and JOBS-002 closure | JOBS-002 | In progress. Finish any remaining legacy follow-up/application-package parity, dirty-edit behavior, tenant authorization and 375/768/1440 theme/keyboard/history/error/long-data verification. | | 5 | Cross-application contrast/accessibility pass | UX-002, UX-003, VER-001 | Queued after the scoped Career/CV corrections. Audit semantic alerts, secondary text, focus, loading/empty/error states and remaining hardcoded colors before documenting larger redesigns. | | 6 | Honest Free/Pro homepage and upgrade surfaces | PRODUCT-001 | Not started. Inventory existing claims first; do not invent pricing, limits or trial terms before billing configuration is real. | @@ -630,9 +630,9 @@ This queue records the highest-value work that can proceed without production cr - **Required production verification:** synthetic account smoke. - **Status:** `IMPLEMENTED — NOT VERIFIED`. - **Blocker:** browser session was already finalized; three-width/theme/keyboard/Norwegian checks and production synthetic-account smoke remain. -- **Evidence:** `docs/verification/career-001-career-workspace.md`; V-117–V-119 and V-164; focused Career/Profile 17/17, extraction backend 8/8, sidecar 22/22 and production build. State-aware actions/recent CVs are implemented, extraction polling no longer overwrites unsaved form state, and the Apply/Discard gate is unchanged. -- **Commit:** `268b3a0` (`feat(career): clarify workspace actions`) plus the CAREER-002 polling checkpoint recorded in V-164. -- **Remaining work:** correct lossless persistence for manually reviewed website/location/contact values, then run the browser and production gates. Live model-quality benchmarking and deeper builder interaction belong to CAREER-002. +- **Evidence:** `docs/verification/career-001-career-workspace.md`; V-117–V-119, V-164 and V-167; focused Career/Profile UI 17/17, affected backend 112/112, full backend 642/642, extraction backend 8/8, sidecar 22/22 and production build. State-aware actions/recent CVs are implemented, extraction polling no longer overwrites unsaved form state, reviewed values round-trip without extraction reinterpretation, and the Apply/Discard gate is unchanged. +- **Commit:** `268b3a0` (`feat(career): clarify workspace actions`) plus the V-164 polling and V-167 persistence checkpoints. +- **Remaining work:** browser and production gates. Live model-quality benchmarking and deeper builder interaction belong to CAREER-002. ### CAREER-002 — CV Builder interaction redesign and external research diff --git a/docs/work-programmes/session-handoff.md b/docs/work-programmes/session-handoff.md index 8d80703..d14873f 100644 --- a/docs/work-programmes/session-handoff.md +++ b/docs/work-programmes/session-handoff.md @@ -2,17 +2,17 @@ Updated: 2026-08-15 -- **Exact current task:** finish and ship the prioritized admin-only deployed-version indicator, then correct lossless Career manual-field persistence and CV contact contrast before returning to JOBS-002 closure. -- **Last completed step:** exposed configured build version/commit metadata through `/api/auth/me` only for administrators and added a compact responsive header badge with accessible version/commit text. -- **Files currently modified:** auth bootstrap DTO/controller/test, application shell/App bootstrap/test, and master programme tracking/evidence. -- **Commands already run:** focused Auth/System 36/36; focused AppShell 2/2; full backend 640/640; full frontend 53 suites/221 tests; optimized frontend build/TypeScript; diff check. -- **Test results:** all listed local gates pass. Jest retains the documented force-exit/open-handle notice; prior CV pathological proof and recorded baselines remain valid. +- **Exact current task:** commit/push lossless Career manual-field persistence, then correct CV header/sidebar contact contrast before returning to JOBS-002 closure. +- **Last completed step:** separated strict extraction normalization from lossless reviewed-profile persistence and routed Career save/version/restore/import-merge/legacy consumers through the correct boundary. +- **Files currently modified:** structured profile persistence/validation, Career and CV/job projection consumers, Career/import regressions, architecture and work-programme evidence. +- **Commands already run:** failing Career round-trip reproduction; affected backend 112/112; full backend 642/642; Career/Profile UI 17/17; diff check. +- **Test results:** all corrected commands pass. One initial Jest invocation named two nonexistent test paths and returned “No tests found”; the correct Career/Profile files then passed 17/17. Jest retains the documented force-exit/open-handle notice. - **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed. - **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed. - **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred. - **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred. -- **Uncommitted changes:** V-166 admin version indicator and tracking update; no dependency/schema/config/migration change. +- **Uncommitted changes:** V-167 Career reviewed-value persistence and tracking update; no dependency/schema/config/migration change. V-166 is committed/pushed as `a6cffe0`. - **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007. -- **Exact next action:** review, commit and push V-166; then fix the Career manual-value normalization boundary and CV contact contrast with focused regressions. +- **Exact next action:** review, commit and push V-167; then fix CV contact/header/sidebar contrast with renderer and PDF regressions. - **Work that can continue independently:** the immediate queue in the master plan: Career lossless persistence, CV contrast, JOBS-002 closure, cross-app contrast/accessibility, PRODUCT-001 and VER-001. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates. - **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved.