Compare commits
5 Commits
4db8c08958
...
56fed05d70
| Author | SHA1 | Date | |
|---|---|---|---|
| 56fed05d70 | |||
| 173187dcbb | |||
| fe9cd4dda1 | |||
| 63473bae85 | |||
| 4f69d395be |
@@ -0,0 +1,146 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using JobTrackerApi.Controllers;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
// Phase 2.1-b — CV extraction coverage. The structured model and StructuredCvProfileJson.FromSections
|
||||||
|
// already map Projects, Certifications and Languages headings into the profile; the gap was upstream
|
||||||
|
// (the AI /cv/normalize prompt never emitted those headings, so the sections were dropped). These
|
||||||
|
// tests lock the C# side so that once the normalized markdown carries # Projects / # Certifications /
|
||||||
|
// # Languages, they reach the structured profile — and so a future change can't silently regress it.
|
||||||
|
//
|
||||||
|
// Content shapes mirror what the (fixed) normalizer produces for the benchmark CV
|
||||||
|
// (Connor Babbington): a Projects section, and languages stated as "Name: Level".
|
||||||
|
public sealed class CvExtractionCoverageTests
|
||||||
|
{
|
||||||
|
private static StructuredCvSection Section(string name, string content) =>
|
||||||
|
new() { Name = name, Content = content };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromSections_maps_a_Projects_heading_into_structured_projects()
|
||||||
|
{
|
||||||
|
var profile = StructuredCvProfileJson.FromSections(new[]
|
||||||
|
{
|
||||||
|
Section("Projects",
|
||||||
|
"JobTrack\nFull-stack job-application tracker (React, ASP.NET Core, SQLite, Docker).\n\n" +
|
||||||
|
"InboxIntel\nGmail analytics and safe bulk-cleanup tool in .NET 8 with PostgreSQL."),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(2, profile.Projects.Count);
|
||||||
|
Assert.Contains(profile.Projects, p => p.Name == "JobTrack");
|
||||||
|
Assert.Contains(profile.Projects, p => p.Name == "InboxIntel");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromSections_maps_a_Certifications_heading_into_structured_certifications()
|
||||||
|
{
|
||||||
|
var profile = StructuredCvProfileJson.FromSections(new[]
|
||||||
|
{
|
||||||
|
Section("Certifications",
|
||||||
|
"Extended Diploma NVQ Level 3 in ICT\n\nAZ-900 Azure Fundamentals"),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.NotEmpty(profile.Certifications);
|
||||||
|
Assert.Contains(profile.Certifications, c => (c.Name ?? "").Contains("NVQ", System.StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromSections_maps_a_Languages_heading_with_levels()
|
||||||
|
{
|
||||||
|
var profile = StructuredCvProfileJson.FromSections(new[]
|
||||||
|
{
|
||||||
|
Section("Languages", "English: Native\nNorwegian: B1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(2, profile.Languages.Count);
|
||||||
|
Assert.Contains(profile.Languages, l => l.Name == "English" && (l.Level ?? "").Contains("Native"));
|
||||||
|
Assert.Contains(profile.Languages, l => l.Name == "Norwegian" && (l.Level ?? "").Contains("B1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The benchmark CV has all four rich sections; confirm they coexist without one clobbering another.
|
||||||
|
[Fact]
|
||||||
|
public void FromSections_populates_projects_certifications_and_languages_together()
|
||||||
|
{
|
||||||
|
var profile = StructuredCvProfileJson.FromSections(new[]
|
||||||
|
{
|
||||||
|
Section("Skills", "C#\n.NET\nDocker"),
|
||||||
|
Section("Projects", "JobTrack\nJob-application tracker."),
|
||||||
|
Section("Certifications", "NVQ Level 3 in ICT"),
|
||||||
|
Section("Languages", "English: Native\nNorwegian: B1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.NotEmpty(profile.Skills);
|
||||||
|
Assert.Single(profile.Projects);
|
||||||
|
Assert.NotEmpty(profile.Certifications);
|
||||||
|
Assert.Equal(2, profile.Languages.Count);
|
||||||
|
}
|
||||||
|
[Fact]
|
||||||
|
public void Normalized_markdown_strips_skill_group_labels()
|
||||||
|
{
|
||||||
|
var profile = InvokeProfileBuilder("""
|
||||||
|
# Skills
|
||||||
|
Development: C#
|
||||||
|
DevOps & Infrastructure: Docker
|
||||||
|
Practices: CI/CD
|
||||||
|
""");
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "C#", "CI/CD", "Docker" }, profile.Skills);
|
||||||
|
Assert.DoesNotContain(profile.Skills, skill => skill.StartsWith("Development", System.StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalized_markdown_separates_glued_dates_from_job_title()
|
||||||
|
{
|
||||||
|
var profile = InvokeProfileBuilder("""
|
||||||
|
# Work Experience
|
||||||
|
2015–2023System Developer
|
||||||
|
Warwickshire County Council, UK
|
||||||
|
- Built APIs
|
||||||
|
""");
|
||||||
|
|
||||||
|
var job = Assert.Single(profile.Jobs);
|
||||||
|
Assert.Equal("2015", job.Start);
|
||||||
|
Assert.Equal("2023", job.End);
|
||||||
|
Assert.Contains("System Developer", job.Title ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Extraction_repairs_common_utf8_as_latin1_mojibake()
|
||||||
|
{
|
||||||
|
var method = typeof(ProfileCvController).GetMethod("RepairKnownMojibake", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||||
|
var repaired = Assert.IsType<string>(method.Invoke(null, new object[] { "Tønsberg 2015–2023" }));
|
||||||
|
|
||||||
|
Assert.Equal("Tønsberg 2015–2023", repaired);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Earlier_part_time_roles_become_separate_experience_entries()
|
||||||
|
{
|
||||||
|
var profile = InvokeProfileBuilder("""
|
||||||
|
# Work Experience
|
||||||
|
System Developer
|
||||||
|
Warwickshire County Council
|
||||||
|
2015–2023
|
||||||
|
- Built APIs
|
||||||
|
|
||||||
|
Earlier roles (part-time)
|
||||||
|
- Sales Assistant — Royal Vapes | 2013–2015
|
||||||
|
- Labourer — The Hodcarrier | 2014–2016
|
||||||
|
- Lifeguard — Nuffield Health | 2012–2014
|
||||||
|
""");
|
||||||
|
|
||||||
|
Assert.Contains(profile.Jobs, job => job.Title == "Sales Assistant" && job.Company == "Royal Vapes");
|
||||||
|
Assert.Contains(profile.Jobs, job => job.Title == "Labourer" && job.Company == "The Hodcarrier");
|
||||||
|
Assert.Contains(profile.Jobs, job => job.Title == "Lifeguard" && job.Company == "Nuffield Health");
|
||||||
|
Assert.Equal(4, profile.Jobs.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StructuredCvProfile InvokeProfileBuilder(string markdown)
|
||||||
|
{
|
||||||
|
var method = typeof(ProfileCvController).GetMethod("BuildStructuredCvFromNormalizedMarkdown", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||||
|
return Assert.IsType<StructuredCvProfile>(method.Invoke(null, new object[] { markdown }));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
// Phase 2.1-a — the diff engine. Pure comparison; applies nothing. These pin the conservative policy:
|
||||||
|
// clear matches only, updates only on non-empty differing values, no deletions, dedup, and the
|
||||||
|
// High/Medium/Low confidence markers that drive the review screen.
|
||||||
|
public sealed class CvProfileDiffServiceTests
|
||||||
|
{
|
||||||
|
private static readonly CvProfileDiffService Svc = new();
|
||||||
|
|
||||||
|
private static CvCategoryDiff Cat(CvImportDiff d, string name) => d.Categories.First(c => c.Category == name);
|
||||||
|
|
||||||
|
private static StructuredCvJob Job(string title, string company, string? start = null, string? end = null, params string[] bullets)
|
||||||
|
=> new() { Title = title, Company = company, Start = start, End = end, Bullets = bullets.ToList() };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_current_makes_everything_an_addition()
|
||||||
|
{
|
||||||
|
var current = new StructuredCvProfile();
|
||||||
|
var extracted = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Jobs = { Job("System Developer", "Warwickshire County Council", "2015", "2023", "Built full-stack apps.") },
|
||||||
|
Projects = { new StructuredCvProject { Name = "JobTrack", Bullets = { "Job tracker." } } },
|
||||||
|
Skills = { "C#", ".NET", "Docker" },
|
||||||
|
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" }, new StructuredCvLanguage { Name = "Norwegian", Level = "B1" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var diff = Svc.Diff(current, extracted);
|
||||||
|
|
||||||
|
Assert.True(diff.HasChanges);
|
||||||
|
Assert.Single(Cat(diff, "Experience").Added);
|
||||||
|
Assert.Single(Cat(diff, "Projects").Added);
|
||||||
|
Assert.Equal(3, Cat(diff, "Skills").Added.Count);
|
||||||
|
Assert.Equal(2, Cat(diff, "Languages").Added.Count);
|
||||||
|
Assert.Empty(Cat(diff, "Experience").Updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_matched_job_with_no_new_information_is_unchanged_not_duplicated()
|
||||||
|
{
|
||||||
|
var job = Job("System Developer", "Warwickshire County Council", "2015", "2023", "Built apps.");
|
||||||
|
var current = new StructuredCvProfile { Jobs = { job } };
|
||||||
|
var extracted = new StructuredCvProfile { Jobs = { Job("system developer", "WARWICKSHIRE COUNTY COUNCIL", "2015", "2023", "Built apps.") } };
|
||||||
|
|
||||||
|
var exp = Cat(Svc.Diff(current, extracted), "Experience");
|
||||||
|
|
||||||
|
Assert.Empty(exp.Added); // same company+title -> not a new entry
|
||||||
|
Assert.Empty(exp.Updated); // same dates + bullets -> nothing to update
|
||||||
|
Assert.Equal(1, exp.UnchangedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_matched_job_with_new_dates_is_an_update_not_an_add()
|
||||||
|
{
|
||||||
|
var current = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council") } };
|
||||||
|
var extracted = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council", "2015", "2023") } };
|
||||||
|
|
||||||
|
var exp = Cat(Svc.Diff(current, extracted), "Experience");
|
||||||
|
|
||||||
|
Assert.Empty(exp.Added);
|
||||||
|
Assert.Single(exp.Updated);
|
||||||
|
Assert.Contains(exp.Updated[0].FieldChanges, f => f.Field == "Dates");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_genuinely_new_job_is_an_addition()
|
||||||
|
{
|
||||||
|
var current = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council") } };
|
||||||
|
var extracted = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Jobs = { Job("System Developer", "Warwickshire County Council"), Job("Bartender", "The Hodcarrier", "2016", "2018") },
|
||||||
|
};
|
||||||
|
|
||||||
|
var exp = Cat(Svc.Diff(current, extracted), "Experience");
|
||||||
|
|
||||||
|
Assert.Single(exp.Added);
|
||||||
|
Assert.Equal("Bartender — The Hodcarrier", exp.Added[0].Label);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Skills_dedup_case_insensitively_and_only_new_ones_are_added()
|
||||||
|
{
|
||||||
|
var current = new StructuredCvProfile { Skills = { "C#", "SQL" } };
|
||||||
|
var extracted = new StructuredCvProfile { Skills = { "c#", ".NET", "sql", "Docker", "docker" } };
|
||||||
|
|
||||||
|
var skills = Cat(Svc.Diff(current, extracted), "Skills");
|
||||||
|
|
||||||
|
Assert.Equal(2, skills.Added.Count); // .NET and Docker only, deduped
|
||||||
|
Assert.Contains(skills.Added, s => s.Label == ".NET");
|
||||||
|
Assert.Contains(skills.Added, s => s.Label == "Docker");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_language_level_change_is_an_update_a_new_language_is_an_add()
|
||||||
|
{
|
||||||
|
var current = new StructuredCvProfile { Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } } };
|
||||||
|
var extracted = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Languages =
|
||||||
|
{
|
||||||
|
new StructuredCvLanguage { Name = "English", Level = "Native" },
|
||||||
|
new StructuredCvLanguage { Name = "Norwegian", Level = "B1" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var langs = Cat(Svc.Diff(current, extracted), "Languages");
|
||||||
|
Assert.Single(langs.Added);
|
||||||
|
Assert.Equal("Norwegian (B1)", langs.Added[0].Label);
|
||||||
|
Assert.Equal(1, langs.UnchangedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_language_without_a_level_is_low_confidence()
|
||||||
|
{
|
||||||
|
var extracted = new StructuredCvProfile { Languages = { new StructuredCvLanguage { Name = "French" } } };
|
||||||
|
var langs = Cat(Svc.Diff(new StructuredCvProfile(), extracted), "Languages");
|
||||||
|
Assert.Equal("Low", langs.Added[0].Confidence);
|
||||||
|
Assert.Equal(1, diffLow(langs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int diffLow(CvCategoryDiff c) => c.LowConfidenceCount;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Extraction_never_proposes_blanking_an_existing_value_and_never_deletes()
|
||||||
|
{
|
||||||
|
// current has a rich job; extraction found the same job but with an empty location and no bullets.
|
||||||
|
var current = new StructuredCvProfile { Jobs = { Job("Dev", "Acme", "2019", "2022", "Did things.") } };
|
||||||
|
current.Jobs[0].Location = "Oslo";
|
||||||
|
var extracted = new StructuredCvProfile { Jobs = { Job("Dev", "Acme", "2019", "2022") } }; // no location, no bullets
|
||||||
|
|
||||||
|
var exp = Cat(Svc.Diff(current, extracted), "Experience");
|
||||||
|
|
||||||
|
Assert.Empty(exp.Added);
|
||||||
|
Assert.Empty(exp.Updated); // empty extracted fields never overwrite
|
||||||
|
Assert.Equal(1, exp.UnchangedCount);
|
||||||
|
|
||||||
|
// And a job the extraction did NOT mention simply doesn't appear in the diff (never deleted).
|
||||||
|
var extracted2 = new StructuredCvProfile();
|
||||||
|
Assert.False(Svc.Diff(current, extracted2).HasChanges);
|
||||||
|
}
|
||||||
|
[Fact]
|
||||||
|
public void Merge_preserves_existing_items_and_adds_new_information()
|
||||||
|
{
|
||||||
|
var current = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Jobs = { Job("Developer", "Acme", "2020", "2022", "Curated bullet") },
|
||||||
|
Skills = { "C#" },
|
||||||
|
};
|
||||||
|
current.Jobs[0].Id = "keep-me";
|
||||||
|
current.Jobs[0].Location = "Oslo";
|
||||||
|
var extracted = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Jobs =
|
||||||
|
{
|
||||||
|
Job("developer", "ACME", "2020", "2024", "Curated bullet", "New extracted bullet"),
|
||||||
|
Job("Engineer", "New Co", "2024", "Present", "Built systems"),
|
||||||
|
},
|
||||||
|
Skills = { "c#", "Docker" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var merged = Svc.Merge(current, extracted);
|
||||||
|
|
||||||
|
Assert.Equal(2, merged.Jobs.Count);
|
||||||
|
Assert.Equal("keep-me", merged.Jobs[0].Id);
|
||||||
|
Assert.Equal("Oslo", merged.Jobs[0].Location);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_never_blanks_or_deletes_existing_data()
|
||||||
|
{
|
||||||
|
var current = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Contact = new StructuredCvContact { FullName = "Demo User", Email = "demo@example.com" },
|
||||||
|
Jobs = { Job("Developer", "Acme", "2020", "2024", "Keep this") },
|
||||||
|
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } },
|
||||||
|
};
|
||||||
|
var extracted = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Contact = new StructuredCvContact { FullName = "Demo User" },
|
||||||
|
Languages = { new StructuredCvLanguage { Name = "English" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var merged = Svc.Merge(current, extracted);
|
||||||
|
|
||||||
|
Assert.Equal("demo@example.com", merged.Contact.Email);
|
||||||
|
Assert.Single(merged.Jobs);
|
||||||
|
Assert.Equal("Native", merged.Languages[0].Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_requires_explicit_acceptance_for_each_low_confidence_change()
|
||||||
|
{
|
||||||
|
var extracted = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Languages =
|
||||||
|
{
|
||||||
|
new StructuredCvLanguage { Name = "French" },
|
||||||
|
new StructuredCvLanguage { Name = "Norwegian", Level = "B1" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var diff = Svc.Diff(new StructuredCvProfile(), extracted);
|
||||||
|
var french = Cat(diff, "Languages").Added.Single(x => x.Label == "French");
|
||||||
|
|
||||||
|
Assert.Equal("Languages|french", french.Id);
|
||||||
|
var defaultMerge = Svc.Merge(new StructuredCvProfile(), extracted);
|
||||||
|
var confirmedMerge = Svc.Merge(new StructuredCvProfile(), extracted, new HashSet<string> { french.Id });
|
||||||
|
|
||||||
|
Assert.DoesNotContain(defaultMerge.Languages, x => x.Name == "French");
|
||||||
|
Assert.Contains(defaultMerge.Languages, x => x.Name == "Norwegian");
|
||||||
|
Assert.Contains(confirmedMerge.Languages, x => x.Name == "French");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ public sealed class ProfileCvControllerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Upload_stores_cv_artifact_and_extraction_run_metadata()
|
public async Task Upload_waits_for_review_then_accept_merges_and_applies()
|
||||||
{
|
{
|
||||||
var user = new ApplicationUser { Id = "user-1" };
|
var user = new ApplicationUser { Id = "user-1" };
|
||||||
var userManager = CreateUserManager();
|
var userManager = CreateUserManager();
|
||||||
@@ -81,21 +81,55 @@ public sealed class ProfileCvControllerTests
|
|||||||
Assert.IsType<OkObjectResult>(result);
|
Assert.IsType<OkObjectResult>(result);
|
||||||
var artifact = await db.CvUploadArtifacts.SingleAsync();
|
var artifact = await db.CvUploadArtifacts.SingleAsync();
|
||||||
var run = await db.CvExtractionRuns.SingleAsync();
|
var run = await db.CvExtractionRuns.SingleAsync();
|
||||||
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
|
||||||
Assert.Equal("user-1", artifact.OwnerUserId);
|
Assert.Equal("user-1", artifact.OwnerUserId);
|
||||||
Assert.Equal("resume.md", artifact.OriginalFileName);
|
Assert.Equal("resume.md", artifact.OriginalFileName);
|
||||||
Assert.True(System.IO.File.Exists(artifact.StoragePath));
|
Assert.True(System.IO.File.Exists(artifact.StoragePath));
|
||||||
Assert.Equal("applied", run.Status);
|
Assert.Equal("pending_review", run.Status);
|
||||||
Assert.Equal("upload", run.Trigger);
|
Assert.Equal("upload", run.Trigger);
|
||||||
Assert.Equal(artifact.Id, run.ArtifactId);
|
Assert.Equal(artifact.Id, run.ArtifactId);
|
||||||
|
Assert.Null(user.ProfileCvStructureJson);
|
||||||
|
Assert.Null(user.CurrentCvExtractionRunId);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(await controller.GetRunDiff(run.Id));
|
||||||
|
Assert.IsType<OkObjectResult>(await controller.AcceptRun(run.Id));
|
||||||
|
|
||||||
|
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||||
|
Assert.Equal("applied", run.Status);
|
||||||
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
|
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
|
||||||
Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId);
|
Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId);
|
||||||
Assert.Equal(1, user.CurrentCvProfileVersion);
|
Assert.Equal(1, user.CurrentCvProfileVersion);
|
||||||
Assert.Equal(run.Id, parsed.Metadata.AppliedExtractionRunId);
|
Assert.Equal(run.Id, parsed.Metadata.AppliedExtractionRunId);
|
||||||
Assert.True(parsed.Metadata.ProfileVersion >= 1);
|
|
||||||
Assert.Contains(parsed.Metadata.Fields.Keys, key => key == "contact.fullName" || key == "summary");
|
Assert.Contains(parsed.Metadata.Fields.Keys, key => key == "contact.fullName" || key == "summary");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardRun_leaves_profile_unchanged()
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser { Id = "user-1", ProfileCvStructureJson = StructuredCvProfileJson.Serialize(new StructuredCvProfile { Skills = { "C#" } }) };
|
||||||
|
var userManager = CreateUserManager();
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
var aiService = new Mock<ISummarizerService>();
|
||||||
|
await using var db = CreateDb();
|
||||||
|
db.CvExtractionRuns.Add(new CvExtractionRun
|
||||||
|
{
|
||||||
|
OwnerUserId = user.Id,
|
||||||
|
Trigger = "upload",
|
||||||
|
Status = "pending_review",
|
||||||
|
ParserVersion = "test",
|
||||||
|
NormalizerVersion = "test",
|
||||||
|
LlmPromptVersion = "test",
|
||||||
|
StructuredProfileJson = StructuredCvProfileJson.Serialize(new StructuredCvProfile { Skills = { "Docker" } }),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var run = await db.CvExtractionRuns.SingleAsync();
|
||||||
|
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(await controller.DiscardRun(run.Id));
|
||||||
|
|
||||||
|
Assert.Equal("discarded", run.Status);
|
||||||
|
Assert.Equal(new[] { "C#" }, StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Skills);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetRuns_returns_latest_extraction_runs()
|
public async Task GetRuns_returns_latest_extraction_runs()
|
||||||
{
|
{
|
||||||
@@ -186,10 +220,10 @@ public sealed class ProfileCvControllerTests
|
|||||||
|
|
||||||
var run = await db.CvExtractionRuns.SingleAsync();
|
var run = await db.CvExtractionRuns.SingleAsync();
|
||||||
Assert.Equal("reprocess", run.Trigger);
|
Assert.Equal("reprocess", run.Trigger);
|
||||||
Assert.Equal("applied", run.Status);
|
Assert.Equal("pending_review", run.Status);
|
||||||
Assert.Equal(2, user.CurrentCvProfileVersion);
|
Assert.Equal(1, user.CurrentCvProfileVersion);
|
||||||
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
|
Assert.Null(user.CurrentCvExtractionRunId);
|
||||||
Assert.Equal("# Connor Babbington\n\n## Professional Summary\nRefined profile", user.ProfileCvText);
|
Assert.Null(user.ProfileCvText);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -268,9 +302,10 @@ public sealed class ProfileCvControllerTests
|
|||||||
var result = await controller.Upload(file);
|
var result = await controller.Upload(file);
|
||||||
|
|
||||||
Assert.IsType<OkObjectResult>(result);
|
Assert.IsType<OkObjectResult>(result);
|
||||||
Assert.Equal(reconstructed, user.ProfileCvText);
|
var savedRun = await db.CvExtractionRuns.SingleAsync();
|
||||||
|
Assert.Equal(reconstructed, savedRun.NormalizedText);
|
||||||
|
|
||||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
|
||||||
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
||||||
Assert.Single(structured.Summary);
|
Assert.Single(structured.Summary);
|
||||||
Assert.Single(structured.Jobs);
|
Assert.Single(structured.Jobs);
|
||||||
@@ -326,10 +361,11 @@ public sealed class ProfileCvControllerTests
|
|||||||
|
|
||||||
Assert.IsType<OkObjectResult>(result);
|
Assert.IsType<OkObjectResult>(result);
|
||||||
normalizer.Verify(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()), Times.Once);
|
normalizer.Verify(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
var savedRun = await db.CvExtractionRuns.SingleAsync();
|
||||||
|
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
|
||||||
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
||||||
Assert.Contains("# Skills", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("# Skills", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Contains("Warwickshire County Council", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("Warwickshire County Council", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -366,7 +402,8 @@ public sealed class ProfileCvControllerTests
|
|||||||
var result = await controller.Upload(file);
|
var result = await controller.Upload(file);
|
||||||
|
|
||||||
Assert.IsType<OkObjectResult>(result);
|
Assert.IsType<OkObjectResult>(result);
|
||||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
var savedRun = await db.CvExtractionRuns.SingleAsync();
|
||||||
|
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
|
||||||
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
||||||
Assert.Equal("connor.babbington@cesnimda.co.uk", structured.Contact.Email);
|
Assert.Equal("connor.babbington@cesnimda.co.uk", structured.Contact.Email);
|
||||||
Assert.Equal("+47 41 33 44 70", structured.Contact.Phone);
|
Assert.Equal("+47 41 33 44 70", structured.Contact.Phone);
|
||||||
@@ -965,8 +1002,10 @@ public sealed class ProfileCvControllerTests
|
|||||||
var result = await controller.Upload(file);
|
var result = await controller.Upload(file);
|
||||||
|
|
||||||
Assert.IsType<OkObjectResult>(result);
|
Assert.IsType<OkObjectResult>(result);
|
||||||
Assert.Contains("Built APIs", user.ProfileCvText);
|
var run = await db.CvExtractionRuns.SingleAsync();
|
||||||
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
|
Assert.Contains("Built APIs", run.NormalizedText);
|
||||||
|
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(run.StructuredProfileJson).Contact.FullName);
|
||||||
|
Assert.Equal("pending_review", run.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -72,8 +72,9 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
private readonly ICvProcessingQueue _cvProcessingQueue;
|
private readonly ICvProcessingQueue _cvProcessingQueue;
|
||||||
private readonly IAppEmailSender _emailSender;
|
private readonly IAppEmailSender _emailSender;
|
||||||
private readonly ICareerProfileService _careerProfileService;
|
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;
|
_users = users;
|
||||||
_aiService = aiService;
|
_aiService = aiService;
|
||||||
@@ -87,6 +88,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
|
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
|
||||||
_emailSender = emailSender ?? NoOpEmailSender.Instance;
|
_emailSender = emailSender ?? NoOpEmailSender.Instance;
|
||||||
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
|
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
|
||||||
|
_cvProfileDiffService = cvProfileDiffService ?? new CvProfileDiffService();
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class NoOpEmailSender : IAppEmailSender
|
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
|
// 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
|
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
|
||||||
// (Phase 4), not this foundation integration.
|
// (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 ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
||||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||||
|
|
||||||
@@ -159,45 +162,21 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted);
|
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.RawExtractedText = result.RawText;
|
||||||
run.NormalizedText = result.NormalizedText;
|
run.NormalizedText = result.NormalizedText;
|
||||||
run.StructuredProfileJson = structuredJson;
|
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
|
||||||
run.Status = "applied";
|
run.Status = "pending_review";
|
||||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
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);
|
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
imported = true,
|
imported = false,
|
||||||
|
pendingReview = true,
|
||||||
characters = result.NormalizedText.Length,
|
characters = result.NormalizedText.Length,
|
||||||
structuredCv = result.StructuredCv,
|
|
||||||
sections = result.StructuredCv.Sections,
|
|
||||||
artifactId = artifact.Id,
|
artifactId = artifact.Id,
|
||||||
extractionRunId = run.Id,
|
extractionRunId = run.Id,
|
||||||
profileVersion = result.StructuredCv.Metadata.ProfileVersion,
|
status = run.Status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -238,6 +217,71 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
return Ok(runs);
|
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")]
|
[HttpPost("reprocess")]
|
||||||
public async Task<IActionResult> 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.");
|
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 normalizedText = (await MaybeReconstructStructuredCvAsync(text, cancellationToken)).Trim();
|
||||||
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
|
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
|
||||||
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
|
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
|
||||||
@@ -916,7 +961,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
|
|
||||||
var normalizedText = rebuilt.Trim();
|
var normalizedText = rebuilt.Trim();
|
||||||
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
|
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
|
||||||
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
|
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "improve":
|
case "improve":
|
||||||
@@ -931,7 +976,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
|
|
||||||
var normalizedText = improved.Trim();
|
var normalizedText = improved.Trim();
|
||||||
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
|
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
|
||||||
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
|
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "reprocess":
|
case "reprocess":
|
||||||
@@ -951,7 +996,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
};
|
};
|
||||||
var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty);
|
var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty);
|
||||||
var result = await ExtractStructuredCvFromFileAsync(file, extension, cancellationToken);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
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.RawExtractedText = rawText;
|
||||||
run.NormalizedText = normalizedText;
|
run.NormalizedText = normalizedText;
|
||||||
run.StructuredProfileJson = structuredJson;
|
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(structuredCv);
|
||||||
run.Status = "applied";
|
run.Status = "pending_review";
|
||||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
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);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1606,6 +1625,15 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
{
|
{
|
||||||
var normalized = content.Replace("\r\n", "\n").Trim();
|
var normalized = content.Replace("\r\n", "\n").Trim();
|
||||||
var structured = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Work Experience", Content = normalized } }).Jobs;
|
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))
|
if (ArePlausibleJobs(structured, null))
|
||||||
{
|
{
|
||||||
return structured;
|
return structured;
|
||||||
@@ -1711,6 +1739,36 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
return jobs;
|
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)
|
private static string? TitleCasePreservingAcronyms(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
@@ -2124,11 +2182,12 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
private static bool LooksLikeNormalizedMarkdownCv(string text)
|
private static bool LooksLikeNormalizedMarkdownCv(string text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text)) return false;
|
if (string.IsNullOrWhiteSpace(text)) return false;
|
||||||
return Regex.IsMatch(text, @"(?im)^#\s+(Contact|Professional Summary|Work Experience|Education|Skills|Languages|Interests)\s*$");
|
return Regex.IsMatch(text, @"(?im)^#\s+(Contact|Professional Summary|Work Experience|Education|Skills|Languages|Interests|Projects|Certifications)\s*$");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static StructuredCvProfile BuildStructuredCvFromNormalizedMarkdown(string text)
|
private static StructuredCvProfile BuildStructuredCvFromNormalizedMarkdown(string text)
|
||||||
{
|
{
|
||||||
|
text = SeparateGluedDateAndTitle(text);
|
||||||
var sections = ParseSections(text)
|
var sections = ParseSections(text)
|
||||||
.Select(section => new StructuredCvSection
|
.Select(section => new StructuredCvSection
|
||||||
{
|
{
|
||||||
@@ -2140,6 +2199,16 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
|
|
||||||
var profile = StructuredCvProfileJson.FromSections(sections);
|
var profile = StructuredCvProfileJson.FromSections(sections);
|
||||||
profile.Sections = 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))
|
if (string.IsNullOrWhiteSpace(profile.Contact.FullName))
|
||||||
{
|
{
|
||||||
@@ -2175,11 +2244,33 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
private static List<string> OrderSkills(List<string> skills)
|
private static List<string> OrderSkills(List<string> skills)
|
||||||
{
|
{
|
||||||
return skills
|
return skills
|
||||||
|
.Select(CleanSkillGroupPrefix)
|
||||||
|
.Where(skill => skill.Length > 0)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(skill => skill, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(skill => skill, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.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)
|
private static List<string> CleanInterestItems(List<string> interests)
|
||||||
{
|
{
|
||||||
return interests
|
return interests
|
||||||
@@ -2229,7 +2320,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
|
|
||||||
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
|
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)
|
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
|
||||||
.Select(match => match.Groups[1].Value)
|
.Select(match => match.Groups[1].Value)
|
||||||
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)
|
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
|||||||
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
|
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
|
||||||
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||||
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
|
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
|
||||||
|
builder.Services.AddSingleton<ICvProfileDiffService, CvProfileDiffService>();
|
||||||
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
|
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
|
||||||
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
|
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
|
||||||
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
|
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
// Phase 2.1-a — the diff half of the review-and-merge workflow. Pure comparison of the user's current
|
||||||
|
// Career Profile against a freshly-extracted profile; produces structured, user-facing changes. It
|
||||||
|
// applies NOTHING — the merge engine consumes an accepted diff separately. No DB, no AI, no state.
|
||||||
|
//
|
||||||
|
// Conservative by design (the approved policy): items match only when they clearly refer to the same
|
||||||
|
// thing (company+title, institution+qualification, project/certification name, language/skill text),
|
||||||
|
// a field is only ever proposed as an *update* when the extracted value is non-empty and differs, and
|
||||||
|
// nothing the user already has is ever marked for deletion.
|
||||||
|
|
||||||
|
public enum CvChangeKind { Add, Update }
|
||||||
|
|
||||||
|
public sealed record CvFieldChange(string Field, string? OldValue, string? NewValue);
|
||||||
|
|
||||||
|
public sealed class CvItemChange
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = "";
|
||||||
|
public CvChangeKind Kind { get; init; }
|
||||||
|
public string Category { get; init; } = "";
|
||||||
|
public string Label { get; init; } = "";
|
||||||
|
public string Confidence { get; init; } = "Medium"; // High | Medium | Low
|
||||||
|
public List<CvFieldChange> FieldChanges { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CvCategoryDiff
|
||||||
|
{
|
||||||
|
public string Category { get; init; } = "";
|
||||||
|
public List<CvItemChange> Added { get; init; } = new();
|
||||||
|
public List<CvItemChange> Updated { get; init; } = new();
|
||||||
|
public int UnchangedCount { get; init; }
|
||||||
|
public int LowConfidenceCount => Added.Concat(Updated).Count(c => c.Confidence == "Low");
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CvImportDiff
|
||||||
|
{
|
||||||
|
public List<CvCategoryDiff> Categories { get; init; } = new();
|
||||||
|
public int TotalAdded => Categories.Sum(c => c.Added.Count);
|
||||||
|
public int TotalUpdated => Categories.Sum(c => c.Updated.Count);
|
||||||
|
public int TotalLowConfidence => Categories.Sum(c => c.LowConfidenceCount);
|
||||||
|
public bool HasChanges => TotalAdded > 0 || TotalUpdated > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICvProfileDiffService
|
||||||
|
{
|
||||||
|
CvImportDiff Diff(StructuredCvProfile current, StructuredCvProfile extracted);
|
||||||
|
StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? acceptedLowConfidenceIds = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CvProfileDiffService : ICvProfileDiffService
|
||||||
|
{
|
||||||
|
public CvImportDiff Diff(StructuredCvProfile current, StructuredCvProfile extracted)
|
||||||
|
{
|
||||||
|
current ??= new StructuredCvProfile();
|
||||||
|
extracted ??= new StructuredCvProfile();
|
||||||
|
|
||||||
|
return new CvImportDiff
|
||||||
|
{
|
||||||
|
Categories = new List<CvCategoryDiff>
|
||||||
|
{
|
||||||
|
DiffContact(current.Contact, extracted.Contact),
|
||||||
|
DiffSummary(current.Summary, extracted.Summary),
|
||||||
|
DiffList("Experience", current.Jobs, extracted.Jobs, JobKey, JobLabel, JobFields, JobConfidence),
|
||||||
|
DiffList("Education", current.Education, extracted.Education, EduKey, EduLabel, EduFields, EduConfidence),
|
||||||
|
DiffList("Projects", current.Projects, extracted.Projects, p => Norm(p.Name), p => p.Name ?? "Project", ProjectFields, p => Confidence(p.Name, p.Bullets.Count > 0)),
|
||||||
|
DiffList("Certifications", current.Certifications, extracted.Certifications, c => Norm(c.Name), c => c.Name ?? "Certification", CertFields, c => Confidence(c.Name, !string.IsNullOrWhiteSpace(c.Issuer))),
|
||||||
|
DiffLanguages(current.Languages, extracted.Languages),
|
||||||
|
DiffScalars("Skills", current.Skills, extracted.Skills),
|
||||||
|
DiffScalars("Interests", current.Interests, extracted.Interests),
|
||||||
|
}.Where(c => c is not null).Select(c => c!).ToList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? acceptedLowConfidenceIds = null)
|
||||||
|
{
|
||||||
|
var merged = StructuredCvProfileJson.Deserialize(StructuredCvProfileJson.Serialize(current ?? new StructuredCvProfile()));
|
||||||
|
extracted = FilterLowConfidence(extracted ?? new StructuredCvProfile(), acceptedLowConfidenceIds);
|
||||||
|
|
||||||
|
MergeContact(merged.Contact, extracted.Contact);
|
||||||
|
if (extracted.Summary.Any(x => !string.IsNullOrWhiteSpace(x))) merged.Summary = extracted.Summary.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
|
||||||
|
MergeList(merged.Jobs, extracted.Jobs, JobKey, MergeJob);
|
||||||
|
MergeList(merged.Education, extracted.Education, EduKey, MergeEducation);
|
||||||
|
MergeList(merged.Projects, extracted.Projects, p => Norm(p.Name), MergeProject);
|
||||||
|
MergeList(merged.Certifications, extracted.Certifications, c => Norm(c.Name), MergeCertification);
|
||||||
|
MergeLanguages(merged.Languages, extracted.Languages);
|
||||||
|
AppendUnique(merged.Skills, extracted.Skills);
|
||||||
|
AppendUnique(merged.Interests, extracted.Interests);
|
||||||
|
MergeList(merged.OtherSections, extracted.OtherSections, x => Norm(x.Title), (a, b) => AppendUnique(a.Items, b.Items));
|
||||||
|
if (extracted.Sections.Count > 0) merged.Sections = extracted.Sections;
|
||||||
|
foreach (var field in extracted.Metadata.Fields) merged.Metadata.Fields[field.Key] = field.Value;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StructuredCvProfile FilterLowConfidence(StructuredCvProfile source, IReadOnlySet<string>? accepted)
|
||||||
|
{
|
||||||
|
var filtered = JsonSerializer.Deserialize<StructuredCvProfile>(JsonSerializer.Serialize(source)) ?? new StructuredCvProfile();
|
||||||
|
bool Allowed(string category, string key, string confidence) => confidence != "Low" || (accepted?.Contains(ChangeId(category, key)) ?? false);
|
||||||
|
|
||||||
|
if (!Allowed("Contact", string.Empty, Confidence(filtered.Contact.FullName, !string.IsNullOrWhiteSpace(filtered.Contact.Email)))) filtered.Contact = new StructuredCvContact();
|
||||||
|
filtered.Jobs = filtered.Jobs.Where(x => Allowed("Experience", JobKey(x), JobConfidence(x))).ToList();
|
||||||
|
filtered.Education = filtered.Education.Where(x => Allowed("Education", EduKey(x), EduConfidence(x))).ToList();
|
||||||
|
filtered.Projects = filtered.Projects.Where(x => Allowed("Projects", Norm(x.Name), Confidence(x.Name, x.Bullets.Count > 0))).ToList();
|
||||||
|
filtered.Certifications = filtered.Certifications.Where(x => Allowed("Certifications", Norm(x.Name), Confidence(x.Name, !string.IsNullOrWhiteSpace(x.Issuer)))).ToList();
|
||||||
|
filtered.Languages = filtered.Languages.Where(x => Allowed("Languages", Norm(x.Name), string.IsNullOrWhiteSpace(x.Level) ? "Low" : "High")).ToList();
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeList<T>(List<T> current, IEnumerable<T> extracted, Func<T, string> key, Action<T, T> merge)
|
||||||
|
{
|
||||||
|
var currentByKey = current.Where(x => key(x).Length > 0).GroupBy(key).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
foreach (var incoming in extracted)
|
||||||
|
{
|
||||||
|
var k = key(incoming);
|
||||||
|
if (k.Length > 0 && currentByKey.TryGetValue(k, out var existing)) merge(existing, incoming);
|
||||||
|
else if (k.Length > 0 && !currentByKey.ContainsKey(k))
|
||||||
|
{
|
||||||
|
current.Add(incoming);
|
||||||
|
currentByKey[k] = incoming;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeContact(StructuredCvContact current, StructuredCvContact incoming)
|
||||||
|
{
|
||||||
|
SetIfPresent(incoming.FullName, v => current.FullName = v);
|
||||||
|
SetIfPresent(incoming.Headline, v => current.Headline = v);
|
||||||
|
SetIfPresent(incoming.Email, v => current.Email = v);
|
||||||
|
SetIfPresent(incoming.Phone, v => current.Phone = v);
|
||||||
|
SetIfPresent(incoming.Location, v => current.Location = v);
|
||||||
|
SetIfPresent(incoming.Website, v => current.Website = v);
|
||||||
|
SetIfPresent(incoming.LinkedIn, v => current.LinkedIn = v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeJob(StructuredCvJob current, StructuredCvJob incoming)
|
||||||
|
{
|
||||||
|
SetIfPresent(incoming.Location, v => current.Location = v);
|
||||||
|
SetIfPresent(incoming.Start, v => current.Start = v);
|
||||||
|
SetIfPresent(incoming.End, v => current.End = v);
|
||||||
|
current.IsCurrent = incoming.IsCurrent || current.IsCurrent;
|
||||||
|
AppendUnique(current.Bullets, incoming.Bullets);
|
||||||
|
AppendUnique(current.Skills, incoming.Skills);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeEducation(StructuredCvEducation current, StructuredCvEducation incoming)
|
||||||
|
{
|
||||||
|
SetIfPresent(incoming.QualificationLevel, v => current.QualificationLevel = v);
|
||||||
|
SetIfPresent(incoming.Location, v => current.Location = v);
|
||||||
|
SetIfPresent(incoming.Start, v => current.Start = v);
|
||||||
|
SetIfPresent(incoming.End, v => current.End = v);
|
||||||
|
AppendUnique(current.Details, incoming.Details);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeProject(StructuredCvProject current, StructuredCvProject incoming)
|
||||||
|
{
|
||||||
|
SetIfPresent(incoming.Role, v => current.Role = v);
|
||||||
|
SetIfPresent(incoming.Location, v => current.Location = v);
|
||||||
|
SetIfPresent(incoming.Start, v => current.Start = v);
|
||||||
|
SetIfPresent(incoming.End, v => current.End = v);
|
||||||
|
AppendUnique(current.Bullets, incoming.Bullets);
|
||||||
|
AppendUnique(current.Skills, incoming.Skills);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeCertification(StructuredCvCertification current, StructuredCvCertification incoming)
|
||||||
|
{
|
||||||
|
SetIfPresent(incoming.Issuer, v => current.Issuer = v);
|
||||||
|
SetIfPresent(incoming.Location, v => current.Location = v);
|
||||||
|
SetIfPresent(incoming.Date, v => current.Date = v);
|
||||||
|
AppendUnique(current.Details, incoming.Details);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeLanguages(List<StructuredCvLanguage> current, IEnumerable<StructuredCvLanguage> extracted)
|
||||||
|
{
|
||||||
|
var currentByName = current.Where(x => !string.IsNullOrWhiteSpace(x.Name)).GroupBy(x => Norm(x.Name)).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
foreach (var incoming in extracted.Where(x => !string.IsNullOrWhiteSpace(x.Name)))
|
||||||
|
{
|
||||||
|
var k = Norm(incoming.Name);
|
||||||
|
if (currentByName.TryGetValue(k, out var existing))
|
||||||
|
{
|
||||||
|
SetIfPresent(incoming.Level, v => existing.Level = v);
|
||||||
|
SetIfPresent(incoming.Notes, v => existing.Notes = v);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current.Add(incoming);
|
||||||
|
currentByName[k] = incoming;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendUnique(List<string> current, IEnumerable<string> incoming)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(current.Select(Norm).Where(x => x.Length > 0));
|
||||||
|
foreach (var value in incoming.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||||
|
if (seen.Add(Norm(value))) current.Add(value.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetIfPresent(string? value, Action<string> set)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) set(value.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- list diff (Experience / Education / Projects / Certifications) --------------------------
|
||||||
|
|
||||||
|
private static CvCategoryDiff DiffList<T>(
|
||||||
|
string category,
|
||||||
|
List<T> current,
|
||||||
|
List<T> extracted,
|
||||||
|
Func<T, string> key,
|
||||||
|
Func<T, string> label,
|
||||||
|
Func<T, T, List<CvFieldChange>> fieldChanges,
|
||||||
|
Func<T, string> confidence)
|
||||||
|
{
|
||||||
|
var currentByKey = new Dictionary<string, T>();
|
||||||
|
foreach (var item in current)
|
||||||
|
{
|
||||||
|
var k = key(item);
|
||||||
|
if (!string.IsNullOrEmpty(k)) currentByKey[k] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
var added = new List<CvItemChange>();
|
||||||
|
var updated = new List<CvItemChange>();
|
||||||
|
var unchanged = 0;
|
||||||
|
|
||||||
|
foreach (var item in extracted)
|
||||||
|
{
|
||||||
|
var k = key(item);
|
||||||
|
if (!string.IsNullOrEmpty(k) && currentByKey.TryGetValue(k, out var existing))
|
||||||
|
{
|
||||||
|
var changes = fieldChanges(existing, item);
|
||||||
|
if (changes.Count > 0)
|
||||||
|
updated.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Update, Category = category, Label = label(item), Confidence = confidence(item), FieldChanges = changes });
|
||||||
|
else
|
||||||
|
unchanged++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
added.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Add, Category = category, Label = label(item), Confidence = confidence(item) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CvCategoryDiff { Category = category, Added = added, Updated = updated, UnchangedCount = unchanged };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- contact (field-level) ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static CvCategoryDiff DiffContact(StructuredCvContact current, StructuredCvContact extracted)
|
||||||
|
{
|
||||||
|
var fields = new List<CvFieldChange>();
|
||||||
|
void Compare(string name, string? oldV, string? newV)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(newV) && !ValuesEqual(oldV, newV))
|
||||||
|
fields.Add(new CvFieldChange(name, oldV, newV));
|
||||||
|
}
|
||||||
|
Compare("Full name", current.FullName, extracted.FullName);
|
||||||
|
Compare("Headline", current.Headline, extracted.Headline);
|
||||||
|
Compare("Email", current.Email, extracted.Email);
|
||||||
|
Compare("Phone", current.Phone, extracted.Phone);
|
||||||
|
Compare("Location", current.Location, extracted.Location);
|
||||||
|
Compare("Website", current.Website, extracted.Website);
|
||||||
|
Compare("LinkedIn", current.LinkedIn, extracted.LinkedIn);
|
||||||
|
|
||||||
|
var isNew = string.IsNullOrWhiteSpace(current.FullName) && string.IsNullOrWhiteSpace(current.Email);
|
||||||
|
var changes = new List<CvItemChange>();
|
||||||
|
var added = new List<CvItemChange>();
|
||||||
|
var updated = new List<CvItemChange>();
|
||||||
|
if (fields.Count > 0)
|
||||||
|
{
|
||||||
|
var change = new CvItemChange
|
||||||
|
{
|
||||||
|
Id = "Contact",
|
||||||
|
Kind = isNew ? CvChangeKind.Add : CvChangeKind.Update,
|
||||||
|
Category = "Contact",
|
||||||
|
Label = extracted.FullName ?? "Contact details",
|
||||||
|
Confidence = Confidence(extracted.FullName, !string.IsNullOrWhiteSpace(extracted.Email)),
|
||||||
|
FieldChanges = fields,
|
||||||
|
};
|
||||||
|
(isNew ? added : updated).Add(change);
|
||||||
|
}
|
||||||
|
return new CvCategoryDiff { Category = "Contact", Added = added, Updated = updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CvCategoryDiff DiffSummary(List<string> current, List<string> extracted)
|
||||||
|
{
|
||||||
|
var cur = JoinLines(current);
|
||||||
|
var ext = JoinLines(extracted);
|
||||||
|
var added = new List<CvItemChange>();
|
||||||
|
var updated = new List<CvItemChange>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(ext) && !ValuesEqual(cur, ext))
|
||||||
|
{
|
||||||
|
var change = new CvItemChange
|
||||||
|
{
|
||||||
|
Id = "Professional summary",
|
||||||
|
Kind = string.IsNullOrWhiteSpace(cur) ? CvChangeKind.Add : CvChangeKind.Update,
|
||||||
|
Category = "Professional summary",
|
||||||
|
Label = "Professional summary",
|
||||||
|
Confidence = "Medium",
|
||||||
|
FieldChanges = new List<CvFieldChange> { new("Summary", Trunc(cur), Trunc(ext)) },
|
||||||
|
};
|
||||||
|
(string.IsNullOrWhiteSpace(cur) ? added : updated).Add(change);
|
||||||
|
}
|
||||||
|
return new CvCategoryDiff { Category = "Professional summary", Added = added, Updated = updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- languages & skills (scalar-ish, dedup by normalized name) -------------------------------
|
||||||
|
|
||||||
|
private static CvCategoryDiff DiffLanguages(List<StructuredCvLanguage> current, List<StructuredCvLanguage> extracted)
|
||||||
|
{
|
||||||
|
var currentByName = current.Where(l => !string.IsNullOrWhiteSpace(l.Name)).ToDictionary(l => Norm(l.Name), l => l);
|
||||||
|
var added = new List<CvItemChange>();
|
||||||
|
var updated = new List<CvItemChange>();
|
||||||
|
var unchanged = 0;
|
||||||
|
foreach (var lang in extracted)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(lang.Name)) continue;
|
||||||
|
var k = Norm(lang.Name);
|
||||||
|
if (currentByName.TryGetValue(k, out var existing))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(lang.Level) && !ValuesEqual(existing.Level, lang.Level))
|
||||||
|
updated.Add(new CvItemChange { Id = ChangeId("Languages", k), Kind = CvChangeKind.Update, Category = "Languages", Label = lang.Name!, Confidence = "Medium", FieldChanges = new List<CvFieldChange> { new("Level", existing.Level, lang.Level) } });
|
||||||
|
else
|
||||||
|
unchanged++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
added.Add(new CvItemChange { Id = ChangeId("Languages", k), Kind = CvChangeKind.Add, Category = "Languages", Label = string.IsNullOrWhiteSpace(lang.Level) ? lang.Name! : $"{lang.Name} ({lang.Level})", Confidence = string.IsNullOrWhiteSpace(lang.Level) ? "Low" : "High" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new CvCategoryDiff { Category = "Languages", Added = added, Updated = updated, UnchangedCount = unchanged };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CvCategoryDiff DiffScalars(string category, List<string> current, List<string> extracted)
|
||||||
|
{
|
||||||
|
var currentSet = new HashSet<string>(current.Select(Norm).Where(s => s.Length > 0));
|
||||||
|
var added = new List<CvItemChange>();
|
||||||
|
var seen = new HashSet<string>();
|
||||||
|
foreach (var item in extracted)
|
||||||
|
{
|
||||||
|
var k = Norm(item);
|
||||||
|
if (k.Length == 0 || !seen.Add(k)) continue;
|
||||||
|
if (!currentSet.Contains(k))
|
||||||
|
added.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Add, Category = category, Label = item.Trim(), Confidence = "High" });
|
||||||
|
}
|
||||||
|
return new CvCategoryDiff { Category = category, Added = added, UnchangedCount = currentSet.Count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keys / labels / field comparisons -------------------------------------------------------
|
||||||
|
|
||||||
|
private static string JobKey(StructuredCvJob j) => $"{Norm(j.Company)}|{Norm(j.Title)}";
|
||||||
|
private static string JobLabel(StructuredCvJob j) => string.Join(" — ", new[] { j.Title, j.Company }.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||||
|
private static List<CvFieldChange> JobFields(StructuredCvJob a, StructuredCvJob b)
|
||||||
|
{
|
||||||
|
var f = new List<CvFieldChange>();
|
||||||
|
AddIf(f, "Dates", DateRange(a.Start, a.End), DateRange(b.Start, b.End));
|
||||||
|
AddIf(f, "Location", a.Location, b.Location);
|
||||||
|
if (b.Bullets.Count > 0 && !ValuesEqual(JoinLines(a.Bullets), JoinLines(b.Bullets)))
|
||||||
|
f.Add(new CvFieldChange("Bullets", $"{a.Bullets.Count} line(s)", $"{b.Bullets.Count} line(s)"));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
private static string JobConfidence(StructuredCvJob j) => Confidence(j.Title, !string.IsNullOrWhiteSpace(j.Company) && (!string.IsNullOrWhiteSpace(j.Start) || j.Bullets.Count > 0));
|
||||||
|
|
||||||
|
private static string EduKey(StructuredCvEducation e) => $"{Norm(e.Institution)}|{Norm(e.Qualification)}";
|
||||||
|
private static string EduLabel(StructuredCvEducation e) => string.Join(" — ", new[] { e.Qualification, e.Institution }.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||||
|
private static List<CvFieldChange> EduFields(StructuredCvEducation a, StructuredCvEducation b)
|
||||||
|
{
|
||||||
|
var f = new List<CvFieldChange>();
|
||||||
|
AddIf(f, "Dates", DateRange(a.Start, a.End), DateRange(b.Start, b.End));
|
||||||
|
AddIf(f, "Location", a.Location, b.Location);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
private static string EduConfidence(StructuredCvEducation e) => Confidence(e.Qualification, !string.IsNullOrWhiteSpace(e.Institution));
|
||||||
|
|
||||||
|
private static List<CvFieldChange> ProjectFields(StructuredCvProject a, StructuredCvProject b)
|
||||||
|
{
|
||||||
|
var f = new List<CvFieldChange>();
|
||||||
|
if (b.Bullets.Count > 0 && !ValuesEqual(JoinLines(a.Bullets), JoinLines(b.Bullets)))
|
||||||
|
f.Add(new CvFieldChange("Details", $"{a.Bullets.Count} line(s)", $"{b.Bullets.Count} line(s)"));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CvFieldChange> CertFields(StructuredCvCertification a, StructuredCvCertification b)
|
||||||
|
{
|
||||||
|
var f = new List<CvFieldChange>();
|
||||||
|
AddIf(f, "Issuer", a.Issuer, b.Issuer);
|
||||||
|
AddIf(f, "Date", a.Date, b.Date);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static string ChangeId(string category, string key) => key.Length == 0 ? category : $"{category}|{key}";
|
||||||
|
|
||||||
|
private static void AddIf(List<CvFieldChange> f, string name, string? oldV, string? newV)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(newV) && !ValuesEqual(oldV, newV))
|
||||||
|
f.Add(new CvFieldChange(name, oldV, newV));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Confidence(string? primary, bool corroborated)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(primary)) return "Low";
|
||||||
|
return corroborated ? "High" : "Medium";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DateRange(string? start, string? end)
|
||||||
|
{
|
||||||
|
var s = (start ?? "").Trim();
|
||||||
|
var e = (end ?? "").Trim();
|
||||||
|
if (s.Length == 0 && e.Length == 0) return "";
|
||||||
|
return $"{s} - {e}".Trim(' ', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ValuesEqual(string? a, string? b) => Norm(a) == Norm(b);
|
||||||
|
|
||||||
|
private static string Norm(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return "";
|
||||||
|
var lowered = value.Trim().ToLowerInvariant();
|
||||||
|
return Regex.Replace(lowered, @"[^\p{L}\p{Nd}]+", " ").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string JoinLines(IEnumerable<string> lines) => string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim()));
|
||||||
|
private static string Trunc(string? s, int max = 140) => string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s[..max] + "…");
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Career Workspace + CV Builder UX refactor
|
||||||
|
|
||||||
|
> 2026-07-20. UX/product restructuring against v1.0.0. **Frontend-only** — no change to
|
||||||
|
> CareerProfiles, CvVariants, the CV generation pipeline, extraction APIs, AI services, permissions,
|
||||||
|
> or tenant isolation.
|
||||||
|
>
|
||||||
|
> This document is the plan. It is delivered **staged**: Phase 0 (the sidebar bug and the framing
|
||||||
|
> copy) is implemented and tested now; Phases 1–4 are scoped for incremental delivery because they
|
||||||
|
> require surgery on the 1376-line `CareerProfilePage` and touch the production CV/extraction flow,
|
||||||
|
> where a single large rewrite would risk the "do not break" list. Each later phase is independently
|
||||||
|
> shippable and verifiable.
|
||||||
|
|
||||||
|
## Current problems
|
||||||
|
|
||||||
|
1. **Sidebar double-highlight.** On `/career/builder/{id}`, both "Career Workspace" and "CV Builder"
|
||||||
|
light up. Root cause: `AppShell` used `pathname === to || pathname.startsWith(to + "/")` per item,
|
||||||
|
so `/career` matched every `/career/...` child. No "most specific wins" rule. *(Fixed — Phase 0.)*
|
||||||
|
2. **Too many competing concepts on one page.** `CareerWorkspacePage` is a thin shell around
|
||||||
|
`CareerProfilePage` (1376 lines), which bundles: a structured-CV editor, extraction-run history, a
|
||||||
|
"CV structure overview", rewrite **templates** with a PDF carousel (a second, template-driven CV
|
||||||
|
builder), and field-level review metadata. A user cannot tell which artifact is "their CV".
|
||||||
|
3. **Internal vocabulary leaks to users** — "structured CV", "JSON structure", "extraction schema",
|
||||||
|
"structure overview". These are implementation concepts.
|
||||||
|
4. **Extraction is opaque.** Upload runs, the profile changes, but the user never sees *what* changed
|
||||||
|
and cannot approve it. History is shown as a primary section instead of the *result* of an import.
|
||||||
|
5. **Two CV builders.** The template-driven rewrite/PDF flow inside the profile page overlaps the real
|
||||||
|
CV Builder (`/career/builder`), which already owns templates, layout, styling, variants and PDF.
|
||||||
|
|
||||||
|
## New information architecture
|
||||||
|
|
||||||
|
**Product rule:** *Career Profile* holds your information; *CV Builder* creates documents from it.
|
||||||
|
|
||||||
|
| Surface | Owns | Does NOT own |
|
||||||
|
|---|---|---|
|
||||||
|
| **Career Profile** (`/career`) | Personal info, professional summary, work experience, education, skills, projects, certifications, languages. The facts. | Templates, layout, styling, PDF, variants |
|
||||||
|
| **CV Builder** (`/career/builder`, `/career/builder/{id}`) | Templates, layout, styling, section order/visibility, variants, PDF generation | Career facts (it *reads* the profile) |
|
||||||
|
|
||||||
|
Career Profile page structure (target):
|
||||||
|
|
||||||
|
- **Header** — "Career Profile", subtitle *"This information powers your CVs, applications, cover
|
||||||
|
letters and AI assistance."*, profile completeness %, last updated, quick action → CV Builder.
|
||||||
|
- **Sections** (user-facing labels only): Personal information · Professional summary · Work
|
||||||
|
experience · Education · Skills · Projects · Certifications · Languages.
|
||||||
|
- **Import CV** — current source (filename, date, status) + `[Upload new CV]`; after extraction a
|
||||||
|
review screen (below). History moves to Settings → Advanced → Import history.
|
||||||
|
|
||||||
|
## Removed / relocated concepts
|
||||||
|
|
||||||
|
| Concept | Disposition |
|
||||||
|
|---|---|
|
||||||
|
| "Structured CV Editor" | Renamed and reframed to **Career Profile editor** (same fields, user vocabulary) |
|
||||||
|
| "CV Structure Overview" | Removed from the user surface; if needed for debugging, move under admin/developer tools |
|
||||||
|
| "Template-driven CV Builder" (rewrite templates + PDF carousel inside the profile page) | **Removed** — the CV Builder already provides templates, layouts, styling, sections, customization and PDF. One CV Builder only |
|
||||||
|
| Extraction run history as a primary section | **Relocated** to Settings → Advanced → Import history |
|
||||||
|
| Internal terms ("structured CV", "JSON", "schema") in labels/help text | Replaced with plain language |
|
||||||
|
|
||||||
|
## Route ownership (authoritative)
|
||||||
|
|
||||||
|
```
|
||||||
|
/career → Career Profile (Career Workspace nav item)
|
||||||
|
/career/builder → CV Builder (CV Builder nav item)
|
||||||
|
/career/builder/{id} → CV Builder (child of CV Builder, NOT Career Profile)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rule: the sidebar item whose `to` is the **longest prefix** the current path is at or under wins;
|
||||||
|
all others are inactive. A child route never activates a parent nav item.
|
||||||
|
|
||||||
|
## Implementation plan
|
||||||
|
|
||||||
|
### Phase 0 — Sidebar bug + framing (DONE, this change)
|
||||||
|
- `AppShell.activeNavTo(pathname, tos)` — exported pure function; longest-owning `to` wins. `selected`
|
||||||
|
now compares against the single computed `activeTo` across both nav lists.
|
||||||
|
- Breadcrumb/title in `App.tsx`: explicit `/career/builder` → "CV Builder" ownership before the
|
||||||
|
`/career` fallback (previously `/career/builder` showed "Career Workspace").
|
||||||
|
- Career Workspace header reframed to the "Career Profile" product framing.
|
||||||
|
- Tests: `sidebar-active-nav.test.ts` — asserts exactly one active item for `/career`,
|
||||||
|
`/career/builder`, `/career/builder/{id}`, and that no item double-highlights.
|
||||||
|
|
||||||
|
### Phase 1 — terminology + first component split (IN PROGRESS)
|
||||||
|
|
||||||
|
**Delivered 2026-07-20 (increment 1):**
|
||||||
|
|
||||||
|
*Terminology → user-facing* (`src/i18n/translations.ts`, no structural change):
|
||||||
|
| Internal term (before) | User-facing (after) |
|
||||||
|
|---|---|
|
||||||
|
| "Structured CV editor" | "Career information" |
|
||||||
|
| "CV structure overview" | "Profile sections" |
|
||||||
|
| "Summary bullets" | "Professional summary" |
|
||||||
|
| "Core skills" | "Skills" |
|
||||||
|
| "Analyze sections" | "Read sections" |
|
||||||
|
| "Original extraction" | "Original import" |
|
||||||
|
| hardcoded "Master career profile" | "Career profile" |
|
||||||
|
Help text de-jargoned; the "Career information" help now says *"The CV Builder uses this information
|
||||||
|
to create documents."*
|
||||||
|
|
||||||
|
*Component extracted:* `src/views/career/ProfileCompleteness.tsx` — the completeness meter + missing
|
||||||
|
chips + version-history accordion, pulled out of `CareerProfilePage`. Display-only, props in, no state
|
||||||
|
or API — the first step of the split.
|
||||||
|
|
||||||
|
*No API / data / model change.* The save path is untouched:
|
||||||
|
`api.put("/career/profile", { profile: structuredCv, cvText })`. A new test
|
||||||
|
(`profile-page.test.tsx` → "saving the career profile PUTs … unchanged (Phase 1 refactor invariant)")
|
||||||
|
pins exactly that call so the remaining extraction can't silently change it. Existing profile-page
|
||||||
|
tests were re-pointed to the new labels; all behavioural assertions (save, parse, field values) kept.
|
||||||
|
|
||||||
|
*Verified:* tsc clean, production build clean, 136 frontend tests pass (was 135; +1 invariant test).
|
||||||
|
Sidebar fix from the previous task still passes.
|
||||||
|
|
||||||
|
**Delivered 2026-07-20 (increment 2 — COMPLETED):**
|
||||||
|
|
||||||
|
*Sections extracted* into `src/views/career/CareerProfileSections.tsx` (one file, one component per
|
||||||
|
export), plus `FieldReviewNote` + `confidenceTone` moved there verbatim and shared with the parent:
|
||||||
|
|
||||||
|
| Component | Slice | Props |
|
||||||
|
|---|---|---|
|
||||||
|
| `PersonalInformationSection` | `contact` | `value`, `onChange`, `getMetadata` |
|
||||||
|
| `ProfessionalSummarySection` | `summary` | `value`, `onChange`, `getMetadata` |
|
||||||
|
| `SkillsSection` | `skills` | `value`, `onChange`, `getMetadata` |
|
||||||
|
| `InterestsSection` | `interests` | `value`, `onChange`, `getMetadata` |
|
||||||
|
| `LanguagesSection` | `languages` | `value`, `onChange`, `getMetadata` |
|
||||||
|
| `WorkExperienceSection` | `jobs` | `value`, `onChange` |
|
||||||
|
| `EducationSection` | `education` | `value`, `onChange` |
|
||||||
|
| `OtherSectionsSection` | `otherSections` | `value`, `onChange` |
|
||||||
|
|
||||||
|
Each section is presentational: it receives its slice + `onChange(next)`; the parent still holds
|
||||||
|
`structuredCv`, loads, saves, and owns every extraction/import action. `getMetadata` is a callback
|
||||||
|
(`getStructuredCvFieldMetadata` over the whole profile) so sections stay decoupled from the full shape.
|
||||||
|
No section makes an API call. `CareerProfilePage` dropped from 1376 → ~1200 lines.
|
||||||
|
|
||||||
|
**No `ProjectsSection` / `CertificationsSection` were created** — the editor never had those sections
|
||||||
|
(`StructuredCvProfile` has no editable projects/certifications UI here; such content lives in "Other
|
||||||
|
sections"). Inventing them would add functionality, which this refactor explicitly avoids. Flagged for
|
||||||
|
a product decision in a later phase.
|
||||||
|
|
||||||
|
*Duplicate concepts hidden* (not deleted) behind an **"Advanced CV tools"** toggle on the Career
|
||||||
|
Profile page, collapsed by default:
|
||||||
|
- **CV Structure Overview** ("Profile sections" parse block)
|
||||||
|
- **Template-driven CV Builder** (rewrite templates + PDF carousel)
|
||||||
|
|
||||||
|
Both stay mounted and fully functional — gated with `display: none` via `showAdvancedCvTools`, so the
|
||||||
|
underlying flows (and their tests) are intact and reachable, just out of the default workflow. The
|
||||||
|
real CV Builder at `/career/builder` is the single CV-generation surface.
|
||||||
|
|
||||||
|
*Tests* (all green): existing profile-page tests re-pointed to reveal advanced tools before touching
|
||||||
|
those controls; added "editing a field in an extracted section updates parent state and flows into
|
||||||
|
save" (proves render → edit → `PUT /career/profile { profile, cvText }`). The save-invariant test from
|
||||||
|
increment 1 still pins the exact payload.
|
||||||
|
|
||||||
|
*Verified:* tsc clean, production build clean, **137 frontend tests pass**. No API, save-payload,
|
||||||
|
extraction, or data-model change.
|
||||||
|
|
||||||
|
**Future removal plan (later phase, not now):** once Phase 2 (Import CV review) and the real CV
|
||||||
|
Builder cover every flow the hidden blocks serve, delete the structure-overview parse block and the
|
||||||
|
template-driven builder from `CareerProfilePage` entirely, and drop the `showAdvancedCvTools` toggle.
|
||||||
|
Until then they remain behind the toggle so no tested functionality is lost.
|
||||||
|
|
||||||
|
**Still open for Phase 1 polish (optional, low priority):** a dedicated `CareerProfileHeader`
|
||||||
|
component and per-section actionable empty-state copy ("No work experience added yet" → the Add button
|
||||||
|
already present). Neither is blocking; both are cosmetic.
|
||||||
|
|
||||||
|
### Phase 2 — Import CV review screen (DONE 2026-07-30)
|
||||||
|
Extraction runs now stop at `pending_review`; they no longer overwrite the career profile. A backend diff
|
||||||
|
and conservative merge preserve curated/unmatched data, stable item IDs and non-empty values. The
|
||||||
|
Career Profile shows additions/updates by category with Apply/Discard actions. Low-confidence changes
|
||||||
|
have stable change IDs, are excluded by default in the API, and require individual checkbox confirmation.
|
||||||
|
Projects, certifications, languages-from-prose, grouped skill prefixes, glued date/title text, common
|
||||||
|
mojibake, and nested earlier/part-time roles are covered by regression tests. Part-time roles are separate
|
||||||
|
experience entries. No schema migration was required; pending profiles already fit `CvExtractionRun`.
|
||||||
|
|
||||||
|
### Phase 3 — Remove the second CV builder (DONE 2026-07-30)
|
||||||
|
The hidden template/rewrite/PDF-carousel block and all of its state, helpers, saved-job fetch, preview
|
||||||
|
dialog, and obsolete tests were deleted from `CareerProfilePage`. Career Profile now links directly to
|
||||||
|
`/career/builder`, the single owner of templates, layout, variants, previews and PDF generation. The
|
||||||
|
raw import section parser remains under Advanced CV tools as a recovery path.
|
||||||
|
|
||||||
|
### Phase 4 — WYSIWYG for long-form fields (DONE 2026-07-30)
|
||||||
|
Reused the existing dependency-free Markdown toolbar for the professional summary, work and education details, custom/project sections, and cover letters. Native textarea undo/redo remains available and stored payloads remain clean strings/string arrays.
|
||||||
|
|
||||||
|
Rich editor (bold, lists, links, undo/redo) for professional summary, work descriptions, achievements,
|
||||||
|
projects, cover letters. **Store clean HTML or Markdown only** — no editor-specific state — and the
|
||||||
|
renderer consumes the same format. Choose a small dependency already compatible with the stack, or a
|
||||||
|
minimal contentEditable wrapper; decide at Phase 4 to avoid a premature dependency.
|
||||||
|
|
||||||
|
## Preserved (verified not touched)
|
||||||
|
CareerProfiles, CvVariants, CV generation, public CV pages, extraction APIs, AI services, permissions,
|
||||||
|
tenant isolation. Phases 1–4 are frontend-only; any that appears to need a backend change is a signal
|
||||||
|
to re-scope, not to change the model.
|
||||||
|
|
||||||
|
## Why staged
|
||||||
|
The removals and the review/WYSIWYG surfaces all require editing the 1376-line `CareerProfilePage` and
|
||||||
|
the live extraction/generation path. Delivering them as one change would put the v1.0.0 CV pipeline at
|
||||||
|
risk with no incremental verification. Each phase above is small enough to ship and verify on its own.
|
||||||
@@ -50,21 +50,21 @@ Goal: finish surfacing the pre-application workflow in the UI, and close the sec
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — UX improvements
|
## Phase 2 — UX improvements ✅ DONE (2026-07-30)
|
||||||
|
|
||||||
Goal: the guide's "users should always understand where they are, what they can do, what happens next."
|
Goal: the guide's "users should always understand where they are, what they can do, what happens next."
|
||||||
|
|
||||||
| # | Task | Priority | Difficulty | Dependencies | Expected value |
|
| # | Task | Priority | Difficulty | Dependencies | Expected value |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 2.1 | ~~Split `/profile` and `/career`~~ ✅ **DONE (2026-07-17, commit `66cc6a7`)** — `/profile` = identity + security + preferences; `/career` = master career profile. The two saves are now scoped (partial-update-safe `PUT /auth/profile`) so neither wipes the other; the inert CV-Builder tab and dead `careerView` prop are gone. **The master profile is the source of truth; generated docs reference snapshots (not built yet).** | **P1** | **M** | none | Fixed the guide's "everything should have one obvious place". Functional separation via the existing `careerOnly` fork + scoped saves. |
|
| 2.1 | ~~Split `/profile` and `/career`~~ ✅ **DONE (2026-07-17, commit `66cc6a7`)** — `/profile` = identity + security + preferences; `/career` = master career profile. The two saves are now scoped (partial-update-safe `PUT /auth/profile`) so neither wipes the other; the inert CV-Builder tab and dead `careerView` prop are gone. **The master profile is the source of truth; generated docs reference snapshots (not built yet).** | **P1** | **M** | none | Fixed the guide's "everything should have one obvious place". Functional separation via the existing `careerOnly` fork + scoped saves. |
|
||||||
| 2.2 | **Decompose `ProfilePage.tsx` (still ~1370 lines) into two real components** | **P1** | **M** | 2.1 | Phase 2 delivered the *functional* split via the `careerOnly` fork + scoped saves, but it is still one file behind a boolean. Extracting `ProfilePage` (identity/security) and a `CareerWorkspace` content component (master profile) is the remaining cleanup — mechanical, deferred so Phase 2 stayed low-risk. Blocks nothing; do before heavy Phase 3/4 edits. |
|
| 2.2 | ✅ **DONE (2026-07-30)** — decomposed identity/security and Career Workspace into separate `ProfilePage` and `CareerProfilePage` components, with career sections extracted into focused presentational components. | **P1** | **M** | 2.1 | Phase 2 delivered the *functional* split via the `careerOnly` fork + scoped saves, but it is still one file behind a boolean. Extracting `ProfilePage` (identity/security) and a `CareerWorkspace` content component (master profile) is the remaining cleanup — mechanical, deferred so Phase 2 stayed low-risk. Blocks nothing; do before heavy Phase 3/4 edits. |
|
||||||
| 2.3 | **Real onboarding flow** — `Signup → Verify → Profile → Import CV → Connect email → First job` replacing the 2-item checklist | **P1** | **M** | 2.1 | Currently a dismissible checkbox pair. **Surface the Gmail connect step** — the strongest differentiator is buried in `/settings/connected-accounts`. |
|
| 2.3 | ✅ **DONE (2026-07-30)** — `Signup → Verify → Profile → Import CV → Connect email → First job`; the dashboard progress flow reuses existing signup/verification screens, reads structured career data, detects Gmail/Outlook/IMAP connections, and disappears when complete. | **P1** | **M** | 2.1 | Currently a dismissible checkbox pair. **Surface the Gmail connect step** — the strongest differentiator is buried in `/settings/connected-accounts`. |
|
||||||
| 2.4 | **Dedicated `/register` screen** | **P2** | **S** | none | Endpoint exists but returns 403 by default and has no route; signup is hidden inside `LoginPage`. Required for Phase 7; harmless now. |
|
| 2.4 | ✅ **DONE (2026-07-30)** — dedicated `/register` route reuses the hardened auth form and clearly disables submission when registration is unavailable. | **P2** | **S** | none | Endpoint exists but returns 403 by default and has no route; signup is hidden inside `LoginPage`. Required for Phase 7; harmless now. |
|
||||||
| 2.5 | **Introduce a server-cache layer** (React Query or equivalent) | **P2** | **M** | none | Root cause of the 600–1400-line components and the hand-rolled `refreshToken` prop-threading. Pays for itself across Phases 3–6. Adopt incrementally, not as a rewrite. |
|
| 2.5 | ✅ **DONE** — incremental cache equivalents ship through `useViewResource` for shared server state and `useWorkspaceTabCache` for expensive workspace tabs; no dependency added. | **P2** | **M** | none | Root cause of the 600–1400-line components and the hand-rolled `refreshToken` prop-threading. Pays for itself across Phases 3–6. Adopt incrementally, not as a rewrite. |
|
||||||
| 2.6 | **Decompose `JobDetailsDialog.tsx` (1400 lines)** | **P2** | **M** | 2.5 | A dialog carrying an entire workspace. |
|
| 2.6 | ✅ **DONE (2026-07-30)** — extracted shared presentation cards and four intelligence tab panels into `JobDetailsPanels` and `JobInsightTabs`; the dialog remains the orchestration boundary. | **P2** | **M** | 2.5 | A dialog carrying an entire workspace. |
|
||||||
| 2.7 | **Interview prep hub screen** | **P2** | **S** | none | `/jobapplications/{id}/interview-prep` already works and is invisible. Cheap win — a shipped feature nobody can reach. |
|
| 2.7 | ✅ **DONE** — user-owned interview preparation is exposed in `ApplicationWorkspacePage`, reachable from workflow signals and reminders, with grouped editable prep items. | **P2** | **S** | none | `/jobapplications/{id}/interview-prep` already works and is invisible. Cheap win — a shipped feature nobody can reach. |
|
||||||
| 2.8 | **Retire `react-scripts` as test runner** (move to Jest+SWC or Vitest) | **P2** | **M** | none | Removes one of three frontend toolchains. Do not touch the router in the same change. |
|
| 2.8 | ✅ **DONE (2026-07-30)** — direct Jest + Babel configuration replaces `react-scripts`; packages were promoted from the existing lockfile without a new download. | **P2** | **M** | none | Removes one of three frontend toolchains. Do not touch the router in the same change. |
|
||||||
| 2.9 | **Expand analytics** — funnel, response rate, time-in-stage | **P3** | **M** | 1.3 | The paid feature at Teal/Huntr. Needs correct prospect-vs-applied accounting from 1.3 first. |
|
| 2.9 | ✅ **DONE** — dashboard analytics include funnel, response rate by source, top companies, and time-in-stage backed by `AnalyticsService`. | **P3** | **M** | 1.3 | The paid feature at Teal/Huntr. Needs correct prospect-vs-applied accounting from 1.3 first. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Phase 2.1 — CV extraction: current implementation, weaknesses, proposal
|
||||||
|
|
||||||
|
> 2026-07-20. Investigation deliverable for Phase 2.1. Grounded in the code and in a real benchmark
|
||||||
|
> CV (Connor Babbington, Systems Developer). No code changed yet — this is the "explain → identify →
|
||||||
|
> propose" step before any implementation.
|
||||||
|
|
||||||
|
## 1. Current implementation (verified in code)
|
||||||
|
|
||||||
|
Upload flow (`ProfileCvController`, `CvProcessingQueue`, `tools/summarizer/app.py`):
|
||||||
|
|
||||||
|
```
|
||||||
|
Upload file → CvUploadArtifact stored on disk, CvExtractionRun queued
|
||||||
|
↓ (background) ProcessQueuedRunAsync
|
||||||
|
Extract text (PDF/DOCX/image OCR)
|
||||||
|
↓
|
||||||
|
AI /cv/normalize → LLM rewrites messy text into markdown-sectioned CV text
|
||||||
|
↓
|
||||||
|
split into blocks, AI /cv/classify-block per block → {section, fields, confidence}
|
||||||
|
↓
|
||||||
|
assemble StructuredCvProfile (+ per-field metadata: confidence, method, sourceBlockId, reviewState)
|
||||||
|
↓
|
||||||
|
ApplyQueuedRunResultAsync → SaveVersionAsync + user.ProfileCvStructureJson = <new> ← REPLACE
|
||||||
|
↓
|
||||||
|
run.Status = "applied" (no user confirmation)
|
||||||
|
```
|
||||||
|
|
||||||
|
The structured model (`Models/StructuredCvProfile.cs`) already has `Contact, Summary, Jobs,
|
||||||
|
Education, Certifications, Projects, Skills, Languages` and per-field confidence metadata. Confidence
|
||||||
|
is **captured and displayed** (the review chips) but used for nothing else.
|
||||||
|
|
||||||
|
## 2. Weaknesses (code-verified, then benchmark-verified)
|
||||||
|
|
||||||
|
### Architectural (the big two)
|
||||||
|
|
||||||
|
- **A1 — Replace, not merge.** `ApplyQueuedRunResultAsync` overwrites the entire profile every run
|
||||||
|
(`SaveVersionAsync(structuredCv)` + `user.ProfileCvStructureJson = structuredJson`). Re-importing a
|
||||||
|
CV, or a slightly worse OCR pass, **discards** whatever the user curated. This is the exact failure
|
||||||
|
the vision names: the profile oscillates instead of getting richer. There is no dedup, no
|
||||||
|
field-level merge, no "keep the better value".
|
||||||
|
- **A2 — No review-before-apply.** The run auto-applies (`Status = "applied"`). The user never sees
|
||||||
|
"we found 4 experiences, 15 skills, ⚠ 1 language" and never approves. The confidence metadata that
|
||||||
|
would drive such a screen is already computed and then ignored.
|
||||||
|
|
||||||
|
### Extraction quality (verified against the benchmark CV)
|
||||||
|
|
||||||
|
- **Q1 — Projects and Certifications are dropped.** The `/cv/normalize` heading list
|
||||||
|
(`app.py:566-573`) and the `/cv/classify-block` section enum (`app.py:622`) include only
|
||||||
|
Contact / Summary / Work Experience / Education / Skills / Languages / Interests — **no Projects, no
|
||||||
|
Certifications** — even though the data model and `StructuredCvProfileJson` fully support them. The
|
||||||
|
benchmark CV's entire **Projects** section (JobTrack, InboxIntel, infra lab) is lost.
|
||||||
|
- **Q2 — Languages in prose are missed.** The benchmark states languages only inside the summary
|
||||||
|
("Native English speaker; Norwegian at B1"). There is no `# Languages` block for the classifier to
|
||||||
|
pick up, and normalize won't synthesise one from prose, so **English/Norwegian + levels are lost**
|
||||||
|
as structured languages.
|
||||||
|
- **Q3 — Grouped skills leak their category label.** "Development: C#, .NET, Python…" — the classifier
|
||||||
|
turns skill lines into items but nothing strips the "Development:" / "DevOps & Infrastructure:" /
|
||||||
|
"Practices:" prefixes, so a skill like "Development: C#" or a junk "Development" item can appear.
|
||||||
|
- **Q4 — Glued date/title runs.** Text extraction yields "2015–2023System Developer — Warwickshire
|
||||||
|
County Council, UK" with no space between the date range and the title. The classifier expects clean
|
||||||
|
`start`/`end`; a two-hop normalize→classify can mis-split or drop the date.
|
||||||
|
- **Q5 — Nested "Earlier roles (part-time)" list.** Three secondary jobs (Royal Vapes, The Hodcarrier,
|
||||||
|
Nuffield Health) sit as sub-bullets under a heading, not as standard entries. They are likely
|
||||||
|
mis-classified as bullets of the parent job or dropped.
|
||||||
|
- **Q6 — Two-hop LLM loses whole-CV context.** normalize (rewrites text, can hallucinate/omit) then
|
||||||
|
per-block classify (no cross-block view) means duplicate or mis-sectioned entries and no dedup.
|
||||||
|
- **Q7 — Encoding.** PDF text extraction returns mojibake for `ø`, en-dashes and apostrophes
|
||||||
|
(`T�nsberg`, `years�`, `2015�2023`) depending on the extractor. Downstream this corrupts company
|
||||||
|
names, locations and dates. (The DB round-trip for `æøå` is already fixed; this is the *extraction*
|
||||||
|
side.)
|
||||||
|
|
||||||
|
### Confidence
|
||||||
|
|
||||||
|
- **C1 — Confidence is display-only.** It is computed per field and shown as chips, but never used to
|
||||||
|
(a) gate what auto-applies, (b) flag low-confidence items for review, or (c) decide merge-vs-keep.
|
||||||
|
|
||||||
|
## 3. Proposed architecture
|
||||||
|
|
||||||
|
Keep the strengths (text extraction, per-field confidence metadata, the structured model) and add the
|
||||||
|
two missing layers plus targeted extraction fixes.
|
||||||
|
|
||||||
|
```
|
||||||
|
Extract → AI structured extraction (improved) → Validate → Normalize
|
||||||
|
→ DIFF against current profile (new)
|
||||||
|
→ REVIEW screen: "We found …" (new, confidence-aware)
|
||||||
|
→ MERGE on accept (new: field-level, dedup, confidence-gated) ← never a blind replace
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Merge engine (A1).** On accept, merge per entity: match experiences/education/projects by a stable
|
||||||
|
key (company+title+dates / institution+qualification / project name), update a field only when the
|
||||||
|
incoming confidence is high **and** differs, add genuinely new items, and never delete a curated
|
||||||
|
item the import didn't mention. Skills/languages dedup case-insensitively; language levels update
|
||||||
|
only on higher confidence.
|
||||||
|
- **Review screen (A2).** Reuse the diff: show counts and per-category adds/updates with ✓ (high) and
|
||||||
|
⚠ (low-confidence) markers, and Accept all / Review individually / Discard. Nothing writes until
|
||||||
|
accept. (This is also Phase 2's "Import CV review" screen from the workspace refactor — one build
|
||||||
|
serves both.)
|
||||||
|
- **Extraction fixes (Q1–Q7).** Add Projects + Certifications to both prompts and the block assembler;
|
||||||
|
synthesise Languages from summary prose (or a dedicated language pass); strip skill-group prefixes;
|
||||||
|
harden date/title splitting; handle the "earlier/part-time roles" pattern; fix extraction-time
|
||||||
|
encoding.
|
||||||
|
- **Confidence gating (C1).** Drive the review markers and the merge rules from the existing
|
||||||
|
per-field confidence — no new scoring needed to start, just *use* it.
|
||||||
|
|
||||||
|
## 4. Recommended increment order (small, verified, deployable each)
|
||||||
|
|
||||||
|
1. **2.1-a — Merge instead of replace + review gate** (highest value, the vision's core). Backend
|
||||||
|
diff+merge engine with tests; frontend review screen. Nothing auto-overwrites again.
|
||||||
|
2. **2.1-b — Extraction coverage: Projects, Certifications, Languages-from-prose** (Q1, Q2). Prompt +
|
||||||
|
assembler + parser, benchmarked on the CV. **— DELIVERED 2026-07-20.**
|
||||||
|
3. **2.1-c — Extraction cleanup: skill-group prefixes, glued dates, part-time roles, encoding**
|
||||||
|
(Q3–Q5, Q7).
|
||||||
|
4. **2.1-d — Confidence-driven review markers and merge gating** (C1), once the review screen exists.
|
||||||
|
|
||||||
|
Each ships independently and leaves production green.
|
||||||
|
|
||||||
|
## 5. Benchmark as regression fixture
|
||||||
|
|
||||||
|
Save the benchmark CV's expected structured output as a test fixture: 5 experiences (2 primary + 3
|
||||||
|
part-time) or a documented decision on the part-time roles, 3 projects, grouped skills flattened,
|
||||||
|
English (Native) + Norwegian (B1) languages, education entry, contact with Norwegian location intact.
|
||||||
|
Extraction changes are measured against it — without overfitting (the rules must generalise).
|
||||||
|
|
||||||
|
## 2.1-b delivered — extraction coverage (2026-07-20)
|
||||||
|
|
||||||
|
**What changed.** The gap was upstream only: the C# assembler (`StructuredCvProfileJson.FromSections`
|
||||||
|
+ `BuildStructuredCvFromNormalizedMarkdown`) already maps `Projects`, `Certifications` and `Languages`
|
||||||
|
headings — the AI `normalize` prompt just never emitted them, so they were dropped.
|
||||||
|
|
||||||
|
- `tools/summarizer/app.py` `/cv/normalize`: added `# Projects` and `# Certifications` headings with
|
||||||
|
shapes; added a **languages-from-prose** rule (extract "native English", "Norwegian B1" from the
|
||||||
|
summary even without a Languages section; ignore programming languages); added **skill-group prefix
|
||||||
|
stripping** ("Development:", "DevOps & Infrastructure:", "Practices:" are dropped, only the skills
|
||||||
|
remain).
|
||||||
|
- `/cv/classify-block`: added `Projects` and `Certifications` to the section enum + rules (fallback
|
||||||
|
path).
|
||||||
|
- `ProfileCvController.LooksLikeNormalizedMarkdownCv`: recognises `# Projects` / `# Certifications` so
|
||||||
|
a CV whose structured content is mostly those sections still takes the markdown path.
|
||||||
|
|
||||||
|
**Verification.** 4 new backend tests (`CvExtractionCoverageTests`) lock the C# mapping of
|
||||||
|
Projects/Certifications/Languages; 1 new ai-service test (`test_classify_block_supports_projects_section`).
|
||||||
|
426 backend tests and 17 ai-service tests pass; `app.py` compiles. The LLM behaviour itself
|
||||||
|
(prompt → headings) could not be run here (no Ollama), but the C# side that consumes the headings is
|
||||||
|
proven, and the prompt change is additive/contract-safe.
|
||||||
|
|
||||||
|
**Deployment note.** These prompt changes live in the **ai-service container**, which `deploy.sh`
|
||||||
|
does **not** rebuild by default. Deploy with `DEPLOY_BUILD_AI_SERVICE=true ./deploy/deploy.sh` (or
|
||||||
|
rebuild `ai-service` manually) or the extraction change won't take effect. No database or backend
|
||||||
|
schema change.
|
||||||
|
|
||||||
|
**Not done here (moved to 2.1-c):** deterministic C# safety-nets for skill-prefix stripping and
|
||||||
|
glued-date splitting, and the "earlier/part-time roles" pattern. 2.1-b relies on the prompt for those;
|
||||||
|
2.1-c hardens them deterministically.
|
||||||
|
|
||||||
|
## 6. Open product decisions (need a call before building)
|
||||||
|
|
||||||
|
- **Merge matching keys** — how aggressively to treat two experiences as "the same" (company+title vs
|
||||||
|
fuzzy). Conservative (fewer merges, some dupes) vs aggressive (cleaner, risk of wrong merges).
|
||||||
|
- **Part-time/earlier roles** — separate experience entries, or a sub-list on the primary role?
|
||||||
|
- **Auto-apply threshold** — does anything ever apply without review (e.g. an empty profile's first
|
||||||
|
import), or is review always required?
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
testEnvironment: "jsdom",
|
||||||
|
roots: ["<rootDir>/src"],
|
||||||
|
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
|
||||||
|
transform: {
|
||||||
|
"^.+\.[jt]sx?$": ["babel-jest", {
|
||||||
|
presets: [
|
||||||
|
["@babel/preset-env", { targets: { node: "current" } }],
|
||||||
|
["@babel/preset-react", { runtime: "automatic" }],
|
||||||
|
["@babel/preset-typescript", { allExtensions: true, isTSX: true }],
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
"\.(css|less|scss|sass)$": "identity-obj-proxy",
|
||||||
|
"\.(gif|ttf|eot|svg|png|jpg|jpeg|webp)$": "<rootDir>/test/fileMock.js",
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+381
-11162
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,6 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
"react-scripts": "5.0.1",
|
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"start": "next dev",
|
"start": "next dev",
|
||||||
"build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
|
"build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
|
||||||
"test": "react-scripts test"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
@@ -53,5 +52,14 @@
|
|||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/preset-env": "^7.29.2",
|
||||||
|
"@babel/preset-react": "^7.28.5",
|
||||||
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
|
"babel-jest": "^27.5.1",
|
||||||
|
"identity-obj-proxy": "^3.0.0",
|
||||||
|
"jest": "^27.5.1",
|
||||||
|
"jest-environment-jsdom": "^27.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
|||||||
if (path.startsWith("/trash")) return [t("home"), t("trash")];
|
if (path.startsWith("/trash")) return [t("home"), t("trash")];
|
||||||
if (path.startsWith("/settings")) return [t("home"), t("settings")];
|
if (path.startsWith("/settings")) return [t("home"), t("settings")];
|
||||||
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
|
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
|
||||||
|
if (path.startsWith("/career/builder")) return [t("home"), "Career Workspace", "CV Builder"];
|
||||||
if (path.startsWith("/career")) return [t("home"), "Career Workspace"];
|
if (path.startsWith("/career")) return [t("home"), "Career Workspace"];
|
||||||
if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"];
|
if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"];
|
||||||
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
|
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
|
||||||
@@ -104,6 +105,7 @@ function titleFor(path: string, t: (k: any) => string): string {
|
|||||||
if (path.startsWith("/trash")) return t("trash");
|
if (path.startsWith("/trash")) return t("trash");
|
||||||
if (path.startsWith("/settings")) return t("settings");
|
if (path.startsWith("/settings")) return t("settings");
|
||||||
if (path.startsWith("/profile")) return t("profile");
|
if (path.startsWith("/profile")) return t("profile");
|
||||||
|
if (path.startsWith("/career/builder")) return "CV Builder";
|
||||||
if (path.startsWith("/career")) return "Career Workspace";
|
if (path.startsWith("/career")) return "Career Workspace";
|
||||||
if (path.startsWith("/settings/connected-accounts")) return "Connected accounts";
|
if (path.startsWith("/settings/connected-accounts")) return "Connected accounts";
|
||||||
if (path.startsWith("/admin/audit")) return t("auditLog");
|
if (path.startsWith("/admin/audit")) return t("auditLog");
|
||||||
@@ -383,6 +385,7 @@ export default function App() {
|
|||||||
const router = useMemo(() => createBrowserRouter([
|
const router = useMemo(() => createBrowserRouter([
|
||||||
{ path: "/", element: <LandingPage />, errorElement: <RouteErrorPage /> },
|
{ path: "/", element: <LandingPage />, errorElement: <RouteErrorPage /> },
|
||||||
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
|
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
|
||||||
|
{ path: "/register", element: <LoginPage initialMode="register" />, errorElement: <RouteErrorPage /> },
|
||||||
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
|
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
|
||||||
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
|
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
|
||||||
{ path: "/verify-email", element: <VerifyEmailPage />, errorElement: <RouteErrorPage /> },
|
{ path: "/verify-email", element: <VerifyEmailPage />, errorElement: <RouteErrorPage /> },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField,
|
Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField,
|
||||||
Tooltip, Typography,
|
Tooltip, Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import RichTextField from "./RichTextField";
|
||||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||||
import RestoreIcon from "@mui/icons-material/Restore";
|
import RestoreIcon from "@mui/icons-material/Restore";
|
||||||
|
|
||||||
@@ -290,14 +291,12 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
|
|||||||
error={error}
|
error={error}
|
||||||
>
|
>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<TextField
|
<RichTextField
|
||||||
multiline
|
|
||||||
minRows={12}
|
minRows={12}
|
||||||
fullWidth
|
|
||||||
label="Cover letter"
|
label="Cover letter"
|
||||||
value={text}
|
value={text}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={setDraft}
|
||||||
placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below."
|
placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines }
|
|||||||
import Correspondence from "./Correspondence";
|
import Correspondence from "./Correspondence";
|
||||||
import Attachments from "./Attachments";
|
import Attachments from "./Attachments";
|
||||||
import AiWorkspacePanel from "./AiWorkspacePanel";
|
import AiWorkspacePanel from "./AiWorkspacePanel";
|
||||||
|
import JobInsightTabs from "./JobInsightTabs";
|
||||||
|
import { DraftCard, ListCard, MatchScoreCard, PaperRow, SectionChips, TwoColumnSection, WorkspaceDraftCard } from "./JobDetailsPanels";
|
||||||
import JobFlowBar from "./JobFlowBar";
|
import JobFlowBar from "./JobFlowBar";
|
||||||
import GradientButton from "./GradientButton";
|
import GradientButton from "./GradientButton";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
@@ -1189,92 +1191,23 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 5 && (
|
<JobInsightTabs
|
||||||
<Box>
|
tab={tab}
|
||||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
matchScore={matchScore}
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
|
loadingMatchScore={loadingMatchScore}
|
||||||
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
|
candidateFit={candidateFit}
|
||||||
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
|
loadingCandidateFit={loadingCandidateFit}
|
||||||
</Button>
|
regenerateCandidateFit={regenerateCandidateFit}
|
||||||
</Box>
|
fitLevel={fitLevel}
|
||||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
focusPlan={focusPlan}
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
loadingFocusPlan={loadingFocusPlan}
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
regenerateFocusPlan={regenerateFocusPlan}
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
interviewPrep={interviewPrep}
|
||||||
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
|
loadingInterviewPrep={loadingInterviewPrep}
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
regenerateInterviewPrep={regenerateInterviewPrep}
|
||||||
<Chip label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
|
readiness={readiness}
|
||||||
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
|
loadingReadiness={loadingReadiness}
|
||||||
</Box>
|
/>
|
||||||
</Box>
|
|
||||||
<DraftCard title={t("jobDetailsTailoredPitch")} content={candidateFit.tailoredPitch} />
|
|
||||||
<SectionChips title={t("jobDetailsStrongMatches")} items={candidateFit.strengths} color="success" />
|
|
||||||
<SectionChips title={t("jobDetailsPossibleGaps")} items={candidateFit.gaps} color="warning" outlined />
|
|
||||||
<TwoColumnSection leftTitle={t("jobDetailsWhatToMention")} leftItems={candidateFit.mention} rightTitle={t("jobDetailsWhatNotToOverstate")} rightItems={candidateFit.avoid} />
|
|
||||||
<TwoColumnSection leftTitle={t("jobDetailsImproveCv")} leftItems={candidateFit.cvImprovements} rightTitle={t("jobDetailsMissingKeywords")} rightItems={candidateFit.missingKeywords} />
|
|
||||||
<TwoColumnSection leftTitle={t("jobDetailsTabInterviewPrep")} leftItems={candidateFit.interviewPrep} rightTitle={t("jobDetailsCvGuidance")} rightItems={candidateFit.guidance.cv} />
|
|
||||||
<TwoColumnSection leftTitle={t("jobDetailsCoverLetterGuidance")} leftItems={candidateFit.guidance.coverLetter} rightTitle={t("jobDetailsRecruiterMessageGuidance")} rightItems={candidateFit.guidance.recruiterMessage} />
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
|
||||||
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={candidateFit.coverLetterDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
|
||||||
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={candidateFit.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsCandidateFitEmpty")}</Typography>}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 6 && (
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
|
||||||
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
|
|
||||||
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
|
|
||||||
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
|
|
||||||
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={focusPlan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={focusPlan.coverLetterAngles} />
|
|
||||||
<ListCard title={t("jobDetailsFollowUpApproach")} items={focusPlan.followUpApproach} />
|
|
||||||
</Box>
|
|
||||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoFocusPlan")}</Typography>}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 7 && (
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
|
||||||
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
|
|
||||||
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
|
|
||||||
<TwoColumnSection leftTitle={t("jobDetailsTalkingPoints")} leftItems={interviewPrep.talkingPoints} rightTitle={t("jobDetailsLikelyQuestions")} rightItems={interviewPrep.likelyQuestions} />
|
|
||||||
<ListCard title={t("jobDetailsWeakSpots")} items={interviewPrep.weakSpots} />
|
|
||||||
</Box>
|
|
||||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoInterviewPrep")}</Typography>}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 8 && (
|
|
||||||
<Box>
|
|
||||||
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
|
||||||
<Typography variant="h6">{t("jobDetailsApplicationReadiness")}</Typography>
|
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
||||||
<Chip label={t("jobDetailsReadyPercent", { count: readiness.score })} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
|
|
||||||
<Chip label={readiness.level} variant="outlined" />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<TwoColumnSection leftTitle={t("jobDetailsCompleted")} leftItems={readiness.completed} rightTitle={t("jobDetailsStillMissing")} rightItems={readiness.missing} />
|
|
||||||
<ListCard title={t("jobDetailsSmartReminders")} items={readiness.reminders} />
|
|
||||||
</Box>
|
|
||||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoReadiness")}</Typography>}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 9 && isAdmin && (
|
{tab === 9 && isAdmin && (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
@@ -1287,177 +1220,3 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
if (loading && !score) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", gap: 1.5 }}>
|
|
||||||
<CircularProgress size={18} />
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!score) return null;
|
|
||||||
|
|
||||||
const color: "success" | "warning" | "error" | "inherit" =
|
|
||||||
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
|
||||||
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
|
||||||
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
|
|
||||||
{score.hasEnoughSignal ? (
|
|
||||||
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
|
||||||
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
|
|
||||||
<CircularProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={score.score}
|
|
||||||
size={92}
|
|
||||||
thickness={4}
|
|
||||||
aria-hidden="true"
|
|
||||||
color={color === "inherit" ? "primary" : color}
|
|
||||||
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
|
|
||||||
/>
|
|
||||||
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800 }}>—</Typography>
|
|
||||||
)}
|
|
||||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
|
||||||
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
|
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
||||||
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
|
||||||
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
|
||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
|
||||||
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
|
|
||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
|
||||||
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
{score.sectionCoverage.length ? (
|
|
||||||
<Box sx={{ mt: 1.5 }}>
|
|
||||||
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
|
|
||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
|
||||||
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
|
||||||
<Typography variant="overline">{title}</Typography>
|
|
||||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
|
||||||
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
|
||||||
<ListCard title={leftTitle} items={leftItems} />
|
|
||||||
<ListCard title={rightTitle} items={rightItems} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="overline">{title}</Typography>
|
|
||||||
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
|
|
||||||
</Box>
|
|
||||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
|
||||||
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}>• {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
||||||
<Typography variant="overline">{title}</Typography>
|
|
||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
|
|
||||||
<Chip size="small" color={statusColor} label={statusLabel} />
|
|
||||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const [value, setValue] = React.useState(content);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setValue(content);
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
||||||
<Typography variant="overline">{title}</Typography>
|
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
||||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
|
||||||
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? t("jobDetailsSaving") : t("save")}</Button> : null}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
|
|
||||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
|
||||||
{type}
|
|
||||||
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
{at ? new Date(at).toLocaleString() : ""}
|
|
||||||
{note ? ` - ${note}` : ""}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Button, Chip, CircularProgress, TextField, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
import { MatchScore } from "../types";
|
||||||
|
|
||||||
|
function copyLines(items: string[]) {
|
||||||
|
void navigator.clipboard.writeText(items.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
if (loading && !score) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
|
<CircularProgress size={18} />
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!score) return null;
|
||||||
|
|
||||||
|
const color: "success" | "warning" | "error" | "inherit" =
|
||||||
|
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
||||||
|
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
|
||||||
|
{score.hasEnoughSignal ? (
|
||||||
|
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
||||||
|
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
|
||||||
|
<CircularProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={score.score}
|
||||||
|
size={92}
|
||||||
|
thickness={4}
|
||||||
|
aria-hidden="true"
|
||||||
|
color={color === "inherit" ? "primary" : color}
|
||||||
|
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800 }}>—</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||||
|
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
||||||
|
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||||
|
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||||
|
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{score.sectionCoverage.length ? (
|
||||||
|
<Box sx={{ mt: 1.5 }}>
|
||||||
|
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||||
|
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Typography variant="overline">{title}</Typography>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
||||||
|
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
|
<ListCard title={leftTitle} items={leftItems} />
|
||||||
|
<ListCard title={rightTitle} items={rightItems} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline">{title}</Typography>
|
||||||
|
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
|
||||||
|
</Box>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||||
|
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}>• {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||||
|
<Typography variant="overline">{title}</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<Chip size="small" color={statusColor} label={statusLabel} />
|
||||||
|
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [value, setValue] = React.useState(content);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setValue(content);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||||
|
<Typography variant="overline">{title}</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
||||||
|
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? t("jobDetailsSaving") : t("save")}</Button> : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
|
||||||
|
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||||
|
{type}
|
||||||
|
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{at ? new Date(at).toLocaleString() : ""}
|
||||||
|
{note ? ` - ${note}` : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Button, Chip, CircularProgress, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
import { CandidateFit, FocusPlanResponse, InterviewPrepResponse, MatchScore, ReadinessResponse } from "../types";
|
||||||
|
import { DraftCard, ListCard, MatchScoreCard, SectionChips, TwoColumnSection } from "./JobDetailsPanels";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tab: number;
|
||||||
|
matchScore: MatchScore | null;
|
||||||
|
loadingMatchScore: boolean;
|
||||||
|
candidateFit: CandidateFit | null;
|
||||||
|
loadingCandidateFit: boolean;
|
||||||
|
regenerateCandidateFit: () => void;
|
||||||
|
fitLevel: { label: string; color: "success" | "warning" | "default" } | null;
|
||||||
|
focusPlan: FocusPlanResponse | null;
|
||||||
|
loadingFocusPlan: boolean;
|
||||||
|
regenerateFocusPlan: () => void;
|
||||||
|
interviewPrep: InterviewPrepResponse | null;
|
||||||
|
loadingInterviewPrep: boolean;
|
||||||
|
regenerateInterviewPrep: () => void;
|
||||||
|
readiness: ReadinessResponse | null;
|
||||||
|
loadingReadiness: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function JobInsightTabs(props: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { tab, matchScore, loadingMatchScore, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
|
||||||
|
return <>
|
||||||
|
{tab === 5 && (
|
||||||
|
<Box>
|
||||||
|
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
|
||||||
|
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
|
||||||
|
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<Chip label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
|
||||||
|
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<DraftCard title={t("jobDetailsTailoredPitch")} content={candidateFit.tailoredPitch} />
|
||||||
|
<SectionChips title={t("jobDetailsStrongMatches")} items={candidateFit.strengths} color="success" />
|
||||||
|
<SectionChips title={t("jobDetailsPossibleGaps")} items={candidateFit.gaps} color="warning" outlined />
|
||||||
|
<TwoColumnSection leftTitle={t("jobDetailsWhatToMention")} leftItems={candidateFit.mention} rightTitle={t("jobDetailsWhatNotToOverstate")} rightItems={candidateFit.avoid} />
|
||||||
|
<TwoColumnSection leftTitle={t("jobDetailsImproveCv")} leftItems={candidateFit.cvImprovements} rightTitle={t("jobDetailsMissingKeywords")} rightItems={candidateFit.missingKeywords} />
|
||||||
|
<TwoColumnSection leftTitle={t("jobDetailsTabInterviewPrep")} leftItems={candidateFit.interviewPrep} rightTitle={t("jobDetailsCvGuidance")} rightItems={candidateFit.guidance.cv} />
|
||||||
|
<TwoColumnSection leftTitle={t("jobDetailsCoverLetterGuidance")} leftItems={candidateFit.guidance.coverLetter} rightTitle={t("jobDetailsRecruiterMessageGuidance")} rightItems={candidateFit.guidance.recruiterMessage} />
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
|
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={candidateFit.coverLetterDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
||||||
|
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={candidateFit.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsCandidateFitEmpty")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 6 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||||
|
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
|
||||||
|
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
|
||||||
|
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
|
||||||
|
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={focusPlan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={focusPlan.coverLetterAngles} />
|
||||||
|
<ListCard title={t("jobDetailsFollowUpApproach")} items={focusPlan.followUpApproach} />
|
||||||
|
</Box>
|
||||||
|
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoFocusPlan")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 7 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||||
|
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
|
||||||
|
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
|
||||||
|
<TwoColumnSection leftTitle={t("jobDetailsTalkingPoints")} leftItems={interviewPrep.talkingPoints} rightTitle={t("jobDetailsLikelyQuestions")} rightItems={interviewPrep.likelyQuestions} />
|
||||||
|
<ListCard title={t("jobDetailsWeakSpots")} items={interviewPrep.weakSpots} />
|
||||||
|
</Box>
|
||||||
|
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoInterviewPrep")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 8 && (
|
||||||
|
<Box>
|
||||||
|
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Typography variant="h6">{t("jobDetailsApplicationReadiness")}</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Chip label={t("jobDetailsReadyPercent", { count: readiness.score })} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
|
||||||
|
<Chip label={readiness.level} variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<TwoColumnSection leftTitle={t("jobDetailsCompleted")} leftItems={readiness.completed} rightTitle={t("jobDetailsStillMissing")} rightItems={readiness.missing} />
|
||||||
|
<ListCard title={t("jobDetailsSmartReminders")} items={readiness.reminders} />
|
||||||
|
</Box>
|
||||||
|
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoReadiness")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,68 +1,92 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Box, Button, IconButton, Paper, Stack, Typography } from "@mui/material";
|
import { Box, Button, LinearProgress, Paper, Stack, Typography } from "@mui/material";
|
||||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
|
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { getUserKeyFromToken } from "../themePrefs";
|
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
function dismissKey() {
|
type MeResponse = {
|
||||||
return `onboardingChecklistDismissed:${getUserKeyFromToken()}`;
|
email?: string | null;
|
||||||
}
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
type MeResponse = { profileCvText?: string | null };
|
displayName?: string | null;
|
||||||
|
};
|
||||||
|
type CareerProfileResponse = {
|
||||||
|
cvText?: string | null;
|
||||||
|
profile?: {
|
||||||
|
summary?: string[];
|
||||||
|
jobs?: unknown[];
|
||||||
|
education?: unknown[];
|
||||||
|
projects?: unknown[];
|
||||||
|
certifications?: unknown[];
|
||||||
|
languages?: unknown[];
|
||||||
|
otherSections?: unknown[];
|
||||||
|
skills?: string[];
|
||||||
|
interests?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type ConnectionStatus = { connected?: boolean };
|
||||||
|
|
||||||
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
|
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [hasCv, setHasCv] = useState<boolean | null>(null);
|
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
|
||||||
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
api.get<MeResponse>("/auth/me")
|
Promise.all([
|
||||||
.then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); })
|
api.get<MeResponse>("/auth/me"),
|
||||||
.catch(() => { if (active) setHasCv(false); });
|
api.get<CareerProfileResponse>("/career/profile"),
|
||||||
|
Promise.all(["/gmail/status", "/microsoft-graph/status", "/imap/status"].map((url) =>
|
||||||
|
api.get<ConnectionStatus>(url).then((r) => Boolean(r.data?.connected)).catch(() => false),
|
||||||
|
)),
|
||||||
|
]).then(([me, career, connections]) => {
|
||||||
|
if (!active) return;
|
||||||
|
const profile = career.data?.profile;
|
||||||
|
const structuredCount = [
|
||||||
|
...(profile?.summary ?? []),
|
||||||
|
...(profile?.jobs ?? []),
|
||||||
|
...(profile?.education ?? []),
|
||||||
|
...(profile?.projects ?? []),
|
||||||
|
...(profile?.certifications ?? []),
|
||||||
|
...(profile?.languages ?? []),
|
||||||
|
...(profile?.otherSections ?? []),
|
||||||
|
...(profile?.skills ?? []),
|
||||||
|
...(profile?.interests ?? []),
|
||||||
|
].length;
|
||||||
|
setStatus({
|
||||||
|
profile: Boolean(me.data?.displayName?.trim() || me.data?.firstName?.trim() || me.data?.lastName?.trim()),
|
||||||
|
cv: structuredCount > 0 || Boolean(career.data?.cvText?.trim()),
|
||||||
|
email: connections.some(Boolean),
|
||||||
|
});
|
||||||
|
}).catch(() => { if (active) setStatus({ profile: false, cv: false, email: false }); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allDone = hasCv === true && hasJobs;
|
if (!status) return null;
|
||||||
if (dismissed || allDone || hasCv === null) return null;
|
|
||||||
|
|
||||||
const dismiss = () => {
|
|
||||||
window.localStorage.setItem(dismissKey(), "1");
|
|
||||||
setDismissed(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") },
|
{ done: true, label: t("onboardingStepSignup"), actionLabel: "" },
|
||||||
|
{ done: true, label: t("onboardingStepVerify"), actionLabel: "" },
|
||||||
|
{ done: status.profile, label: t("onboardingStepProfile"), action: () => navigate("/profile"), actionLabel: t("onboardingStepProfileAction") },
|
||||||
|
{ done: status.cv, label: t("onboardingStepCv"), action: () => navigate("/career"), actionLabel: t("onboardingStepCvAction") },
|
||||||
|
{ done: status.email, label: t("onboardingStepEmail"), action: () => navigate("/settings/connected-accounts"), actionLabel: t("onboardingStepEmailAction") },
|
||||||
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
|
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
|
||||||
{ done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") },
|
|
||||||
];
|
];
|
||||||
|
const completed = steps.filter((step) => step.done).length;
|
||||||
|
if (completed === steps.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper sx={{ p: 2.25, mb: 2, borderRadius: 4, border: "1px solid", borderColor: alpha(theme.palette.primary.main, 0.25), background: alpha(theme.palette.primary.main, 0.04) }}>
|
||||||
sx={{
|
|
||||||
p: 2.25,
|
|
||||||
mb: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: "1px solid",
|
|
||||||
borderColor: alpha(theme.palette.primary.main, 0.25),
|
|
||||||
background: alpha(theme.palette.primary.main, 0.04),
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
|
|
||||||
<CloseIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
|
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("onboardingBody")}</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("onboardingBody")}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{completed} / {steps.length}</Typography>
|
||||||
|
<LinearProgress variant="determinate" value={(completed / steps.length) * 100} sx={{ my: 1.5, borderRadius: 2 }} />
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
{steps.map((step) => (
|
{steps.map((step) => (
|
||||||
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
|
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
@@ -72,7 +96,7 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
|
|||||||
{step.label}
|
{step.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{!step.done ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
|
{!step.done && step.action ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function RichTextField({
|
|||||||
placeholder,
|
placeholder,
|
||||||
minRows = 2,
|
minRows = 2,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
@@ -26,6 +27,7 @@ export default function RichTextField({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
minRows?: number;
|
minRows?: number;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ export default function RichTextField({
|
|||||||
|
|
||||||
const btn = (title: string, icon: React.ReactNode, before: string, after: string, ph: string) => (
|
const btn = (title: string, icon: React.ReactNode, before: string, after: string, ph: string) => (
|
||||||
<Tooltip title={title}>
|
<Tooltip title={title}>
|
||||||
<IconButton size="small" aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
|
<IconButton size="small" disabled={disabled} aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
|
||||||
{icon}
|
{icon}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -65,6 +67,7 @@ export default function RichTextField({
|
|||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
slotProps={{ htmlInput: { "aria-label": ariaLabel ?? label } }}
|
slotProps={{ htmlInput: { "aria-label": ariaLabel ?? label } }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -218,20 +218,20 @@ export const translations = {
|
|||||||
profileCvUploadFailed: "Failed to upload CV.",
|
profileCvUploadFailed: "Failed to upload CV.",
|
||||||
profileCvTextLabel: "Profile CV / master resume text",
|
profileCvTextLabel: "Profile CV / master resume text",
|
||||||
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.",
|
||||||
profileCvStructuredDefaultHint: "The structured CV stays front and center. Open the original extraction only when you need to verify or clean up parser output.",
|
profileCvStructuredDefaultHint: "Your career information stays front and center. Open the original import only when you need to check or clean up what was read from your file.",
|
||||||
profileCvRawPanelTitle: "Original extraction",
|
profileCvRawPanelTitle: "Original import",
|
||||||
profileCvRawPanelHelp: "Usually messy, but useful for checking what the parser actually pulled from the uploaded file.",
|
profileCvRawPanelHelp: "Usually messy, but useful for checking what was actually read from the uploaded file.",
|
||||||
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",
|
profileCvStructureOverview: "Profile sections",
|
||||||
profileCvStructureOverviewHelp: "Parse your current CV text into reusable sections so you can spot missing structure before tailoring.",
|
profileCvStructureOverviewHelp: "Read your current CV text into sections so you can see what your profile will contain.",
|
||||||
profileCvStructureParse: "Analyze sections",
|
profileCvStructureParse: "Read sections",
|
||||||
profileCvStructureParsing: "Analyzing sections...",
|
profileCvStructureParsing: "Reading sections...",
|
||||||
profileCvStructureParsed: "CV structure analyzed.",
|
profileCvStructureParsed: "Sections read from your CV.",
|
||||||
profileCvStructureParseFailed: "Failed to analyze CV structure.",
|
profileCvStructureParseFailed: "Couldn't read sections from your CV.",
|
||||||
profileCvStructureEmpty: "No parsed sections yet.",
|
profileCvStructureEmpty: "No sections read yet.",
|
||||||
profileCvStructuredEditor: "Structured CV editor",
|
profileCvStructuredEditor: "Career information",
|
||||||
profileCvStructuredEditorHelp: "Edit reusable CV data directly so generators and matching can work from stable fields instead of raw text alone.",
|
profileCvStructuredEditorHelp: "Your career facts. The CV Builder uses this information to create documents — edit it here and every CV stays up to date.",
|
||||||
profileCvExtractionHistory: "Extraction history",
|
profileCvExtractionHistory: "Extraction history",
|
||||||
profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.",
|
profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.",
|
||||||
profileCvExtractionHistoryEmpty: "No extraction runs yet.",
|
profileCvExtractionHistoryEmpty: "No extraction runs yet.",
|
||||||
@@ -249,8 +249,8 @@ export const translations = {
|
|||||||
profileCvContactLocation: "Location",
|
profileCvContactLocation: "Location",
|
||||||
profileCvContactWebsite: "Website",
|
profileCvContactWebsite: "Website",
|
||||||
profileCvContactLinkedIn: "LinkedIn",
|
profileCvContactLinkedIn: "LinkedIn",
|
||||||
profileCvStructuredSummary: "Summary bullets",
|
profileCvStructuredSummary: "Professional summary",
|
||||||
profileCvStructuredSkills: "Core skills",
|
profileCvStructuredSkills: "Skills",
|
||||||
profileCvStructuredInterests: "Interests",
|
profileCvStructuredInterests: "Interests",
|
||||||
profileCvStructuredLanguages: "Languages",
|
profileCvStructuredLanguages: "Languages",
|
||||||
profileCvStructuredJobs: "Work experience",
|
profileCvStructuredJobs: "Work experience",
|
||||||
@@ -382,13 +382,16 @@ export const translations = {
|
|||||||
dashboardHeroLabel: "Job search overview",
|
dashboardHeroLabel: "Job search overview",
|
||||||
onboardingTitle: "Get set up",
|
onboardingTitle: "Get set up",
|
||||||
onboardingBody: "A few steps to get the most out of Jobbjakt.",
|
onboardingBody: "A few steps to get the most out of Jobbjakt.",
|
||||||
onboardingDismiss: "Dismiss",
|
onboardingStepSignup: "Create your account",
|
||||||
onboardingStepCv: "Add your CV",
|
onboardingStepVerify: "Verify your email",
|
||||||
|
onboardingStepProfile: "Complete your profile",
|
||||||
|
onboardingStepProfileAction: "Open profile",
|
||||||
|
onboardingStepCv: "Import your CV",
|
||||||
onboardingStepCvAction: "Add CV",
|
onboardingStepCvAction: "Add CV",
|
||||||
onboardingStepJob: "Import your first job",
|
onboardingStepJob: "Import your first job",
|
||||||
onboardingStepJobAction: "Add job",
|
onboardingStepJobAction: "Add job",
|
||||||
onboardingStepMatch: "Check your CV match score on a job",
|
onboardingStepEmail: "Connect your email",
|
||||||
onboardingStepMatchAction: "Open jobs",
|
onboardingStepEmailAction: "Connect email",
|
||||||
dashboardResponseRate: "{rate}% response rate",
|
dashboardResponseRate: "{rate}% response rate",
|
||||||
dashboardMonthsShort: "{count} mo",
|
dashboardMonthsShort: "{count} mo",
|
||||||
dashboardAppliedCount: "{count} applied",
|
dashboardAppliedCount: "{count} applied",
|
||||||
@@ -1452,13 +1455,16 @@ export const translations = {
|
|||||||
dashboardHeroLabel: "Oversikt over jobbsøket",
|
dashboardHeroLabel: "Oversikt over jobbsøket",
|
||||||
onboardingTitle: "Kom i gang",
|
onboardingTitle: "Kom i gang",
|
||||||
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
|
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
|
||||||
onboardingDismiss: "Lukk",
|
onboardingStepSignup: "Opprett kontoen din",
|
||||||
onboardingStepCv: "Legg til CV-en din",
|
onboardingStepVerify: "Bekreft e-postadressen din",
|
||||||
|
onboardingStepProfile: "Fullfør profilen din",
|
||||||
|
onboardingStepProfileAction: "Åpne profil",
|
||||||
|
onboardingStepCv: "Importer CV-en din",
|
||||||
onboardingStepCvAction: "Legg til CV",
|
onboardingStepCvAction: "Legg til CV",
|
||||||
onboardingStepJob: "Importer din første jobb",
|
onboardingStepJob: "Importer din første jobb",
|
||||||
onboardingStepJobAction: "Legg til jobb",
|
onboardingStepJobAction: "Legg til jobb",
|
||||||
onboardingStepMatch: "Sjekk CV-matchscoren på en jobb",
|
onboardingStepEmail: "Koble til e-post",
|
||||||
onboardingStepMatchAction: "Åpne jobber",
|
onboardingStepEmailAction: "Koble til e-post",
|
||||||
dashboardResponseRate: "{rate}% svarrate",
|
dashboardResponseRate: "{rate}% svarrate",
|
||||||
dashboardMonthsShort: "{count} md",
|
dashboardMonthsShort: "{count} md",
|
||||||
dashboardAppliedCount: "{count} søkt",
|
dashboardAppliedCount: "{count} søkt",
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ function initialsFrom(s?: string) {
|
|||||||
|
|
||||||
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
|
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
|
||||||
|
|
||||||
|
// Which single nav item owns the current path. A child route must not light up its parent:
|
||||||
|
// /career/builder/5 belongs to "CV Builder" (/career/builder), not "Career Workspace" (/career),
|
||||||
|
// even though the old `pathname.startsWith(to + "/")` test matched both. Explicit ownership =
|
||||||
|
// the LONGEST `to` that the path is at or under wins; everything else is inactive. Exported so the
|
||||||
|
// rule is unit-testable without rendering the shell.
|
||||||
|
export function activeNavTo(pathname: string, tos: string[]): string | null {
|
||||||
|
let best: string | null = null;
|
||||||
|
for (const to of tos) {
|
||||||
|
const owns = pathname === to || pathname.startsWith(to + "/");
|
||||||
|
if (owns && (best === null || to.length > best.length)) best = to;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
// The nav rail stays a fixed dark navy regardless of the app's light/dark theme toggle --
|
// The nav rail stays a fixed dark navy regardless of the app's light/dark theme toggle --
|
||||||
// a deliberate signature element, not derived from theme tokens.
|
// a deliberate signature element, not derived from theme tokens.
|
||||||
const SIDEBAR_BG = "#0f172a";
|
const SIDEBAR_BG = "#0f172a";
|
||||||
@@ -129,6 +143,12 @@ export default function AppShell({
|
|||||||
};
|
};
|
||||||
}, [nav, navBottom]);
|
}, [nav, navBottom]);
|
||||||
|
|
||||||
|
// Compute the one active destination across BOTH nav lists, so the most specific route wins.
|
||||||
|
const activeTo = useMemo(
|
||||||
|
() => activeNavTo(pathname, [...nav, ...navBottom].map((i) => i.to)),
|
||||||
|
[pathname, nav, navBottom],
|
||||||
|
);
|
||||||
|
|
||||||
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
|
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
|
||||||
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
|
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
|
||||||
{groups.map(([section, rows]) => (
|
{groups.map(([section, rows]) => (
|
||||||
@@ -140,7 +160,7 @@ export default function AppShell({
|
|||||||
) : null}
|
) : null}
|
||||||
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
|
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
|
||||||
{rows.map((item) => {
|
{rows.map((item) => {
|
||||||
const selected = pathname === item.to || pathname.startsWith(item.to + "/");
|
const selected = item.to === activeTo;
|
||||||
return (
|
return (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
key={item.to}
|
key={item.to}
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ const mockedApi = api as jest.Mocked<typeof api>;
|
|||||||
|
|
||||||
let consoleErrorSpy: jest.SpyInstance;
|
let consoleErrorSpy: jest.SpyInstance;
|
||||||
|
|
||||||
function renderLoginPage() {
|
function renderLoginPage(initialMode: "login" | "register" = "login") {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<LoginPage />
|
<LoginPage initialMode={initialMode} />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
@@ -33,6 +33,19 @@ function renderLoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('LoginPage', () => {
|
describe('LoginPage', () => {
|
||||||
|
it('renders a dedicated registration screen and explains when registration is disabled', async () => {
|
||||||
|
mockedApi.get.mockResolvedValueOnce({
|
||||||
|
data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false, requireEmailVerification: true },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
renderLoginPage("register");
|
||||||
|
|
||||||
|
expect(await screen.findByText('Registration is currently unavailable.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('heading', { name: 'Create account' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Create account' })).toBeDisabled();
|
||||||
|
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const originalConsoleError = console.error.bind(console);
|
const originalConsoleError = console.error.bind(console);
|
||||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import OnboardingChecklist from "./components/OnboardingChecklist";
|
||||||
|
import { I18nProvider } from "./i18n/I18nProvider";
|
||||||
|
import { api } from "./api";
|
||||||
|
|
||||||
|
jest.mock("./api", () => ({
|
||||||
|
api: { get: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
function renderChecklist() {
|
||||||
|
return render(<MemoryRouter><I18nProvider><OnboardingChecklist hasJobs={false} /></I18nProvider></MemoryRouter>);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("uses structured career data and connected inbox status for onboarding progress", async () => {
|
||||||
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
|
if (url === "/auth/me") return Promise.resolve({ data: { firstName: "Ada" } } as any);
|
||||||
|
if (url === "/career/profile") return Promise.resolve({ data: { cvText: "", profile: { jobs: [{ title: "Engineer" }] } } } as any);
|
||||||
|
return Promise.resolve({ data: { connected: url === "/gmail/status" } } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderChecklist();
|
||||||
|
|
||||||
|
expect(await screen.findByText("5 / 6")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Add CV" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Open profile" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Connect email" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Add job" })).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/career/profile"));
|
||||||
|
});
|
||||||
@@ -7,17 +7,6 @@ import ProfilePage from './views/ProfilePage';
|
|||||||
import CareerProfilePage from './views/CareerProfilePage';
|
import CareerProfilePage from './views/CareerProfilePage';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
|
|
||||||
const revokeObjectURLMock = jest.fn();
|
|
||||||
Object.defineProperty(window.URL, 'createObjectURL', {
|
|
||||||
writable: true,
|
|
||||||
value: createObjectURLMock,
|
|
||||||
});
|
|
||||||
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
|
||||||
writable: true,
|
|
||||||
value: revokeObjectURLMock,
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('./api', () => ({
|
jest.mock('./api', () => ({
|
||||||
api: {
|
api: {
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
@@ -34,7 +23,7 @@ jest.mock('./components/CropImageDialog', () => () => null);
|
|||||||
|
|
||||||
const mockedApi = api as jest.Mocked<typeof api>;
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
const REWRITE_TEMPLATES_COUNT = 6;
|
let extractionRunsResponse: any[] = [];
|
||||||
|
|
||||||
const structuredCv = {
|
const structuredCv = {
|
||||||
version: '1',
|
version: '1',
|
||||||
@@ -95,6 +84,18 @@ function renderPage() {
|
|||||||
void ProfilePage;
|
void ProfilePage;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
extractionRunsResponse = [{
|
||||||
|
id: 12,
|
||||||
|
trigger: 'upload',
|
||||||
|
status: 'applied',
|
||||||
|
artifactFileName: 'resume.pdf',
|
||||||
|
startedAtUtc: '2026-03-28T12:00:00Z',
|
||||||
|
completedAtUtc: '2026-03-28T12:00:05Z',
|
||||||
|
appliedAtUtc: '2026-03-28T12:00:05Z',
|
||||||
|
parserVersion: 'm005-s01',
|
||||||
|
normalizerVersion: 'm005-s01',
|
||||||
|
llmPromptVersion: 'm005-s01',
|
||||||
|
}];
|
||||||
mockedApi.get.mockImplementation((url: string) => {
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
if (url === '/career/profile') {
|
if (url === '/career/profile') {
|
||||||
// Phase 3: /career reads the structured profile from the relational source of truth.
|
// Phase 3: /career reads the structured profile from the relational source of truth.
|
||||||
@@ -125,22 +126,18 @@ beforeEach(() => {
|
|||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
if (url === '/profile-cv/runs') {
|
if (url === '/profile-cv/runs') {
|
||||||
return Promise.resolve({
|
return Promise.resolve({ data: extractionRunsResponse } as any);
|
||||||
data: [
|
}
|
||||||
{
|
if (/^\/profile-cv\/runs\/\d+\/diff$/.test(url)) {
|
||||||
id: 12,
|
return Promise.resolve({ data: {
|
||||||
trigger: 'upload',
|
runId: 13,
|
||||||
status: 'applied',
|
status: 'pending_review',
|
||||||
artifactFileName: 'resume.pdf',
|
diff: { totalAdded: 3, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [
|
||||||
startedAtUtc: '2026-03-28T12:00:00Z',
|
{ category: 'Skills', added: [{ id: 'Skills|docker', label: 'Docker', confidence: 'High' }], updated: [], unchangedCount: 2, lowConfidenceCount: 0 },
|
||||||
completedAtUtc: '2026-03-28T12:00:05Z',
|
{ category: 'Languages', added: [{ id: 'Languages|french', label: 'French', confidence: 'Low' }], updated: [], unchangedCount: 1, lowConfidenceCount: 1 },
|
||||||
appliedAtUtc: '2026-03-28T12:00:05Z',
|
{ category: 'Experience', added: [{ id: 'Experience|new co engineer', label: 'Engineer - New Co', confidence: 'High' }], updated: [{ id: 'Experience|demo co developer', label: 'Developer - Demo Co', confidence: 'High' }], unchangedCount: 0, lowConfidenceCount: 0 },
|
||||||
parserVersion: 'm005-s01',
|
] },
|
||||||
normalizerVersion: 'm005-s01',
|
} } as any);
|
||||||
llmPromptVersion: 'm005-s01',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
}
|
}
|
||||||
if (url === '/jobapplications') {
|
if (url === '/jobapplications') {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -180,12 +177,6 @@ beforeEach(() => {
|
|||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
if (url === '/profile-cv/rewrite-preview') {
|
|
||||||
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
|
|
||||||
}
|
|
||||||
if (url === '/profile-cv/export-pdf') {
|
|
||||||
return Promise.resolve({ data: new Blob([`pdf-${payload?.templateId ?? 'ats-minimal'}`], { type: 'application/pdf' }), config } as any);
|
|
||||||
}
|
|
||||||
if (url === '/profile-cv/reprocess') {
|
if (url === '/profile-cv/reprocess') {
|
||||||
return Promise.resolve({ data: { reprocessed: true } } as any);
|
return Promise.resolve({ data: { reprocessed: true } } as any);
|
||||||
}
|
}
|
||||||
@@ -197,21 +188,21 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
createObjectURLMock.mockClear();
|
|
||||||
revokeObjectURLMock.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('profile page loads persisted structured cv and can re-parse it', async () => {
|
test('profile page loads persisted structured cv and can re-parse it', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument();
|
expect(screen.getByText(/profile sections/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/structured cv editor/i)).toBeInTheDocument();
|
expect(screen.getAllByText(/career information/i).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
|
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
|
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/current run/i)).toBeInTheDocument();
|
expect(screen.getByText(/current run/i)).toBeInTheDocument();
|
||||||
expect(screen.getAllByText(/original extraction/i).length).toBeGreaterThan(0);
|
expect(screen.queryByText(/template-driven cv builder/i)).not.toBeInTheDocument();
|
||||||
const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i });
|
expect(screen.getByRole('link', { name: /open cv builder/i })).toHaveAttribute('href', '/career/builder');
|
||||||
|
expect(screen.getAllByText(/original import/i).length).toBeGreaterThan(0);
|
||||||
|
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
|
||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
||||||
@@ -222,7 +213,10 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
|
|||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
||||||
|
|
||||||
const analyzeButton = screen.getByRole('button', { name: /analyze sections/i });
|
// Structure overview is hidden from the default workflow (Phase 1 increment 2); reveal it.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /advanced cv tools/i }));
|
||||||
|
|
||||||
|
const analyzeButton = screen.getByRole('button', { name: /read sections/i });
|
||||||
await waitFor(() => expect(analyzeButton).toBeEnabled());
|
await waitFor(() => expect(analyzeButton).toBeEnabled());
|
||||||
fireEvent.click(analyzeButton);
|
fireEvent.click(analyzeButton);
|
||||||
|
|
||||||
@@ -233,6 +227,51 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
|
|||||||
expect(screen.getAllByText(/core skills/i).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/core skills/i).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('saving the career profile PUTs the structured profile and cv text unchanged (Phase 1 refactor invariant)', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const saveButton = await screen.findByRole('button', { name: /save changes/i });
|
||||||
|
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.put).toHaveBeenCalledWith(
|
||||||
|
'/career/profile',
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Demo User' }) }),
|
||||||
|
cvText: 'Professional Summary\nBuilt backend systems',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing a field in an extracted section updates parent state and flows into save (Phase 1 increment 2)', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
// Renders existing data through the extracted PersonalInformationSection.
|
||||||
|
const nameField = await screen.findByLabelText(/full name/i);
|
||||||
|
expect(nameField).toHaveValue('Demo User');
|
||||||
|
|
||||||
|
// Editing the child field updates the parent's structuredCv (controlled input reflects it back).
|
||||||
|
fireEvent.change(nameField, { target: { value: 'Edited Name' } });
|
||||||
|
expect(nameField).toHaveValue('Edited Name');
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /save changes/i });
|
||||||
|
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
// The edited value reaches the unchanged save path.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.put).toHaveBeenCalledWith(
|
||||||
|
'/career/profile',
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Edited Name' }) }),
|
||||||
|
cvText: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('profile page can reprocess from stored artifact history', async () => {
|
test('profile page can reprocess from stored artifact history', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
@@ -249,57 +288,17 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/the structured cv stays front and center/i)).toBeInTheDocument();
|
expect(screen.getByText(/your career information stays front and center/i)).toBeInTheDocument();
|
||||||
|
|
||||||
const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i });
|
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
|
||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
|
expect(screen.queryByRole('button', { name: /copy cv text/i })).not.toBeInTheDocument();
|
||||||
expect(copyButton).toBeDisabled();
|
|
||||||
|
|
||||||
fireEvent.click(originalExtractionToggle);
|
fireEvent.click(originalExtractionToggle);
|
||||||
|
|
||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
||||||
const copyButtons = screen.getAllByRole('button', { name: /copy cv text/i });
|
expect(screen.getByRole('button', { name: /copy cv text/i })).toBeEnabled();
|
||||||
expect(copyButtons.some((button) => !button.hasAttribute('disabled'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('profile page rewrite tools use selected template and saved job context', async () => {
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
|
|
||||||
fireEvent.click(screen.getByText(/harvard/i));
|
|
||||||
fireEvent.change(screen.getByLabelText(/prompt-based cv brief/i), { target: { value: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.' } });
|
|
||||||
fireEvent.change(screen.getByLabelText(/target role/i), { target: { value: 'Senior Platform Engineer' } });
|
|
||||||
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
|
|
||||||
fireEvent.click(rewriteButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-preview', expect.objectContaining({
|
|
||||||
sectionName: null,
|
|
||||||
style: 'harvard',
|
|
||||||
templateId: 'harvard',
|
|
||||||
jobApplicationId: null,
|
|
||||||
promptBackground: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.',
|
|
||||||
targetRole: 'Senior Platform Engineer',
|
|
||||||
language: 'English',
|
|
||||||
tone: 'Concise and direct',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument();
|
|
||||||
|
|
||||||
const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i });
|
|
||||||
fireEvent.click(buildCarouselButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const exportCalls = mockedApi.post.mock.calls.filter(([url]) => url === '/profile-cv/export-pdf');
|
|
||||||
expect(exportCalls.length).toBe(REWRITE_TEMPLATES_COUNT);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('saving the master profile (career) persists the structured profile via /career/profile', async () => {
|
test('saving the master profile (career) persists the structured profile via /career/profile', async () => {
|
||||||
@@ -334,3 +333,28 @@ test('/career shows the profile completeness overview', async () => {
|
|||||||
expect(await screen.findByText(/profile completeness/i)).toBeInTheDocument();
|
expect(await screen.findByText(/profile completeness/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/70%/)).toBeInTheDocument();
|
expect(screen.getByText(/70%/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pending CV extraction can be reviewed and applied', async () => {
|
||||||
|
extractionRunsResponse = [{
|
||||||
|
id: 13,
|
||||||
|
trigger: 'upload',
|
||||||
|
status: 'pending_review',
|
||||||
|
artifactFileName: 'new-resume.pdf',
|
||||||
|
startedAtUtc: '2026-03-29T12:00:00Z',
|
||||||
|
completedAtUtc: '2026-03-29T12:00:05Z',
|
||||||
|
parserVersion: 'm005-s01',
|
||||||
|
normalizerVersion: 'm005-s01',
|
||||||
|
llmPromptVersion: 'm005-s01',
|
||||||
|
}];
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/3 additions/i)).toBeInTheDocument();
|
||||||
|
const lowConfidence = screen.getByRole('checkbox', { name: /include low-confidence languages: french/i });
|
||||||
|
expect(lowConfidence).not.toBeChecked();
|
||||||
|
fireEvent.click(lowConfidence);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/13/accept', {
|
||||||
|
acceptedLowConfidenceIds: ['Languages|french'],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { activeNavTo } from "./layout/AppShell";
|
||||||
|
|
||||||
|
// Route ownership under test (the real sidebar entries):
|
||||||
|
// /career -> Career Workspace
|
||||||
|
// /career/builder -> CV Builder
|
||||||
|
// A child route must activate exactly one nav item — the most specific owner.
|
||||||
|
const TOS = ["/dashboard", "/jobs", "/career", "/career/builder", "/settings"];
|
||||||
|
|
||||||
|
describe("sidebar active nav ownership", () => {
|
||||||
|
test("/career activates Career Workspace only", () => {
|
||||||
|
expect(activeNavTo("/career", TOS)).toBe("/career");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/career/builder activates CV Builder only, not Career Workspace", () => {
|
||||||
|
expect(activeNavTo("/career/builder", TOS)).toBe("/career/builder");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/career/builder/{id} activates CV Builder only (the reported bug)", () => {
|
||||||
|
// Previously /career matched via startsWith('/career/') AND /career/builder matched — both lit up.
|
||||||
|
expect(activeNavTo("/career/builder/42", TOS)).toBe("/career/builder");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a plain child of /career (not /builder) still belongs to Career Workspace", () => {
|
||||||
|
expect(activeNavTo("/career/anything-else", TOS)).toBe("/career");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unrelated routes are unaffected and exact matches win", () => {
|
||||||
|
expect(activeNavTo("/jobs", TOS)).toBe("/jobs");
|
||||||
|
expect(activeNavTo("/settings/connected-accounts", TOS)).toBe("/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a path owned by no nav item activates nothing", () => {
|
||||||
|
expect(activeNavTo("/admin/system", TOS)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exactly one item is ever active (no double highlight)", () => {
|
||||||
|
for (const path of ["/career", "/career/builder", "/career/builder/7", "/jobs"]) {
|
||||||
|
const active = activeNavTo(path, TOS);
|
||||||
|
const matches = TOS.filter((t) => t === active);
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,15 @@ export default function CareerWorkspacePage() {
|
|||||||
<Box sx={{ display: "grid", gap: 2 }}>
|
<Box sx={{ display: "grid", gap: 2 }}>
|
||||||
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}>
|
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>Career Workspace</Typography>
|
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>Career Profile</Typography>
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs.
|
This information powers your CVs, applications, cover letters and AI assistance.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} onClick={() => navigate("/career/builder")}>Open CV Builder</Button>
|
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} onClick={() => navigate("/career/builder")}>Open CV Builder</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
<Alert severity="info" sx={{ borderRadius: 3 }}>
|
<Alert severity="info" sx={{ borderRadius: 3 }}>
|
||||||
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it.
|
Your career profile holds your information. The CV Builder creates documents from it — job-specific CVs stay separate and never overwrite your profile.
|
||||||
</Alert>
|
</Alert>
|
||||||
<Paper sx={{ borderRadius: 4, p: { xs: 1.5, md: 2.5 }, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
<Paper sx={{ borderRadius: 4, p: { xs: 1.5, md: 2.5 }, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||||
<CareerProfilePage />
|
<CareerProfilePage />
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type AuthConfig = {
|
|||||||
requireEmailVerification: boolean;
|
requireEmailVerification: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage({ initialMode = "login" }: { initialMode?: "login" | "register" }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -40,7 +40,7 @@ export default function LoginPage() {
|
|||||||
const [resendingVerification, setResendingVerification] = useState(false);
|
const [resendingVerification, setResendingVerification] = useState(false);
|
||||||
const [verificationResent, setVerificationResent] = useState(false);
|
const [verificationResent, setVerificationResent] = useState(false);
|
||||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
|
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
|
||||||
const [registerMode, setRegisterMode] = useState(false);
|
const [registerMode, setRegisterMode] = useState(initialMode === "register");
|
||||||
|
|
||||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
|
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
|
||||||
{t("signInTitle")}
|
{registerMode ? t("createAccount") : t("signInTitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||||
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
|
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
|
||||||
@@ -146,11 +146,11 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
{initialMode === "login" ? <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||||
<Tab label={t("emailAndPassword")} />
|
<Tab label={t("emailAndPassword")} />
|
||||||
<Tab label={t("google")} />
|
<Tab label={t("google")} />
|
||||||
<Tab label={t("microsoft")} />
|
<Tab label={t("microsoft")} />
|
||||||
</Tabs>
|
</Tabs> : null}
|
||||||
|
|
||||||
{tab === 0 && (
|
{tab === 0 && (
|
||||||
<Box
|
<Box
|
||||||
@@ -158,6 +158,7 @@ export default function LoginPage() {
|
|||||||
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
|
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
|
||||||
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
|
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
|
||||||
>
|
>
|
||||||
|
{registerMode && cfg && !allowReg ? <Alert severity="info">Registration is currently unavailable.</Alert> : null}
|
||||||
{cfg?.requireEmailVerification && emailNotVerified && (
|
{cfg?.requireEmailVerification && emailNotVerified && (
|
||||||
<Alert
|
<Alert
|
||||||
severity="warning"
|
severity="warning"
|
||||||
@@ -230,20 +231,20 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
||||||
{allowReg && (
|
{(allowReg || initialMode === "register") && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
disableRipple
|
disableRipple
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => { setRegisterMode((v) => !v); setFieldErrors({}); }}
|
onClick={() => { if (initialMode === "register") navigate("/login"); else setRegisterMode((v) => !v); setFieldErrors({}); }}
|
||||||
sx={{ px: 0, minWidth: 0, fontWeight: 700 }}
|
sx={{ px: 0, minWidth: 0, fontWeight: 700 }}
|
||||||
>
|
>
|
||||||
{registerMode ? t("backToLogin") : t("createAccount")}
|
{registerMode ? t("backToLogin") : t("createAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" variant="contained" disableRipple disabled={loading} sx={{ ml: "auto" }}>
|
<Button type="submit" variant="contained" disableRipple disabled={loading || (registerMode && cfg !== null && !allowReg)} sx={{ ml: "auto" }}>
|
||||||
{registerMode ? t("createAccount") : t("signInTitle")}
|
{registerMode ? t("createAccount") : t("signInTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Button, Chip, TextField, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
import RichTextField from "../../components/RichTextField";
|
||||||
|
import { useI18n } from "../../i18n/I18nProvider";
|
||||||
|
import {
|
||||||
|
joinLines,
|
||||||
|
splitLines,
|
||||||
|
StructuredCvContact,
|
||||||
|
StructuredCvEducation,
|
||||||
|
StructuredCvFieldMetadata,
|
||||||
|
StructuredCvJob,
|
||||||
|
StructuredCvLanguage,
|
||||||
|
StructuredCvOtherSection,
|
||||||
|
} from "../../profileCv";
|
||||||
|
|
||||||
|
// Career Profile editing sections, extracted from CareerProfilePage (Phase 1 increment 2).
|
||||||
|
//
|
||||||
|
// Each section is presentational: it receives its slice of the profile as `value` and reports edits
|
||||||
|
// through `onChange(next)`. The parent still owns `structuredCv`, all loading, the save
|
||||||
|
// (PUT /career/profile { profile, cvText }), and every extraction/import action. No section makes an
|
||||||
|
// API call or holds profile state — so save payloads and extraction behaviour are unchanged.
|
||||||
|
//
|
||||||
|
// `getMetadata` is the parent's field-review lookup (getStructuredCvFieldMetadata over the whole
|
||||||
|
// profile), passed as a callback so sections stay decoupled from the full profile shape.
|
||||||
|
type MetadataLookup = (path: string) => StructuredCvFieldMetadata | undefined;
|
||||||
|
|
||||||
|
// Field-review chip + tone, moved verbatim from CareerProfilePage so the sections and the parent
|
||||||
|
// share one definition. Behaviour (thresholds, labels, source snippet) is unchanged.
|
||||||
|
function confidenceTone(confidence?: number) {
|
||||||
|
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
|
||||||
|
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
|
||||||
|
if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const };
|
||||||
|
return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
|
||||||
|
if (!metadata) return null;
|
||||||
|
const tone = confidenceTone(metadata.confidence);
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
|
||||||
|
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
|
||||||
|
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
|
||||||
|
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
|
||||||
|
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
|
||||||
|
{metadata.sourceSnippet ? (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{metadata.sourceSnippet}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersonalInformationSection({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
getMetadata,
|
||||||
|
}: {
|
||||||
|
value: StructuredCvContact;
|
||||||
|
onChange: (next: StructuredCvContact) => void;
|
||||||
|
getMetadata: MetadataLookup;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const set = (patch: Partial<StructuredCvContact>) => onChange({ ...value, ...patch });
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<TextField label={t("profileCvContactFullName")} value={value.fullName ?? ""} onChange={(e) => set({ fullName: e.target.value || undefined })} fullWidth />
|
||||||
|
<FieldReviewNote metadata={getMetadata("contact.fullName")} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<TextField label={t("profileCvContactHeadline")} value={value.headline ?? ""} onChange={(e) => set({ headline: e.target.value || undefined })} fullWidth />
|
||||||
|
<FieldReviewNote metadata={getMetadata("contact.headline")} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<TextField label={t("profileCvContactEmail")} value={value.email ?? ""} onChange={(e) => set({ email: e.target.value || undefined })} fullWidth />
|
||||||
|
<FieldReviewNote metadata={getMetadata("contact.email")} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<TextField label={t("profileCvContactPhone")} value={value.phone ?? ""} onChange={(e) => set({ phone: e.target.value || undefined })} fullWidth />
|
||||||
|
<FieldReviewNote metadata={getMetadata("contact.phone")} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<TextField label={t("profileCvContactLocation")} value={value.location ?? ""} onChange={(e) => set({ location: e.target.value || undefined })} fullWidth />
|
||||||
|
<FieldReviewNote metadata={getMetadata("contact.location")} />
|
||||||
|
</Box>
|
||||||
|
<TextField label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
|
||||||
|
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared free-text list editor (one item per line) for Summary / Skills / Interests.
|
||||||
|
function LinesField({ label, value, onChange, metadata, minRows }: { label: string; value: string[]; onChange: (next: string[]) => void; metadata?: StructuredCvFieldMetadata; minRows: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<TextField label={label} value={joinLines(value)} onChange={(e) => onChange(splitLines(e.target.value))} helperText={t("profileCvStructuredListHelp")} multiline minRows={minRows} fullWidth />
|
||||||
|
<FieldReviewNote metadata={metadata} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfessionalSummarySection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<RichTextField label={t("profileCvStructuredSummary")} value={joinLines(value)} onChange={(text) => onChange(splitLines(text))} minRows={5} />
|
||||||
|
<FieldReviewNote metadata={getMetadata("summary")} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return <LinesField label={t("profileCvStructuredSkills")} value={value} onChange={onChange} metadata={getMetadata("skills")} minRows={5} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InterestsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return <LinesField label={t("profileCvStructuredInterests")} value={value} onChange={onChange} metadata={getMetadata("interests")} minRows={4} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguagesSection({ value, onChange, getMetadata }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const update = (index: number, patch: Partial<StructuredCvLanguage>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
|
||||||
|
<Button variant="outlined" size="small" onClick={() => onChange([...value, { name: "", level: "", notes: "" }])}>{t("profileCvStructuredAddLanguage")}</Button>
|
||||||
|
</Box>
|
||||||
|
<FieldReviewNote metadata={getMetadata("languages")} />
|
||||||
|
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||||
|
{value.map((language, index) => (
|
||||||
|
<Box key={`language-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr auto" }, gap: 1 }}>
|
||||||
|
<TextField label={t("profileCvLanguageName")} value={language.name ?? ""} onChange={(e) => update(index, { name: e.target.value || undefined })} fullWidth />
|
||||||
|
<TextField label={t("profileCvLanguageLevel")} value={language.level ?? ""} onChange={(e) => update(index, { level: e.target.value || undefined })} fullWidth />
|
||||||
|
<TextField label={t("profileCvLanguageNotes")} value={language.notes ?? ""} onChange={(e) => update(index, { notes: e.target.value || undefined })} fullWidth />
|
||||||
|
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkExperienceSection({ value, onChange }: { value: StructuredCvJob[]; onChange: (next: StructuredCvJob[]) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const update = (index: number, patch: Partial<StructuredCvJob>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredJobs")}</Typography>
|
||||||
|
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }])}>{t("profileCvStructuredAddJob")}</Button>
|
||||||
|
</Box>
|
||||||
|
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||||
|
{value.map((job, index) => (
|
||||||
|
<Box key={`job-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
|
||||||
|
<TextField label={t("profileCvJobTitle")} value={job.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
|
||||||
|
<TextField label={t("profileCvJobCompany")} value={job.company ?? ""} onChange={(e) => update(index, { company: e.target.value || undefined })} fullWidth />
|
||||||
|
<TextField label={t("profileCvJobLocation")} value={job.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||||
|
<TextField label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
|
||||||
|
<TextField label={t("profileCvJobEnd")} value={job.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || job.isCurrent })} fullWidth />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(text) => update(index, { bullets: splitLines(text) })} minRows={5} /></Box>
|
||||||
|
<TextField label={t("profileCvJobSkills")} value={joinLines(job.skills)} onChange={(e) => update(index, { skills: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||||
|
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EducationSection({ value, onChange }: { value: StructuredCvEducation[]; onChange: (next: StructuredCvEducation[]) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const update = (index: number, patch: Partial<StructuredCvEducation>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEducation")}</Typography>
|
||||||
|
<Button variant="outlined" size="small" onClick={() => onChange([...value, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }])}>{t("profileCvStructuredAddEducation")}</Button>
|
||||||
|
</Box>
|
||||||
|
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||||
|
{value.map((education, index) => (
|
||||||
|
<Box key={`education-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
|
||||||
|
<TextField label={t("profileCvEducationQualification")} value={education.qualification ?? ""} onChange={(e) => update(index, { qualification: e.target.value || undefined })} fullWidth />
|
||||||
|
<TextField label={t("profileCvEducationInstitution")} value={education.institution ?? ""} onChange={(e) => update(index, { institution: e.target.value || undefined })} fullWidth />
|
||||||
|
<TextField label={t("profileCvEducationLocation")} value={education.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||||
|
<TextField label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
|
||||||
|
<TextField label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined })} fullWidth />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(text) => update(index, { details: splitLines(text) })} minRows={4} /></Box>
|
||||||
|
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OtherSectionsSection({ value, onChange }: { value: StructuredCvOtherSection[]; onChange: (next: StructuredCvOtherSection[]) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const update = (index: number, patch: Partial<StructuredCvOtherSection>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredOtherSections")}</Typography>
|
||||||
|
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", items: [] }])}>{t("profileCvStructuredAddOtherSection")}</Button>
|
||||||
|
</Box>
|
||||||
|
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||||
|
{value.map((section, index) => (
|
||||||
|
<Box key={`other-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr auto" }, gap: 1 }}>
|
||||||
|
<TextField label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
|
||||||
|
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
|
||||||
|
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(text) => update(index, { items: splitLines(text) })} minRows={4} /></Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Chip, LinearProgress, Typography } from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
|
||||||
|
// Display-only slice of the Career Profile page: the completeness meter, the "missing" chips, and the
|
||||||
|
// version-history accordion. Extracted from CareerProfilePage as the first step of the Phase 1
|
||||||
|
// component split. It owns no state and makes no API calls — the parent still holds `completeness`
|
||||||
|
// and `versions` and performs the restore — so save/load behaviour is unchanged.
|
||||||
|
export type CareerCompletenessView = {
|
||||||
|
percent: number;
|
||||||
|
missing: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CareerVersionView = {
|
||||||
|
version: number;
|
||||||
|
source: string;
|
||||||
|
createdAtUtc: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfileCompleteness({
|
||||||
|
completeness,
|
||||||
|
versions,
|
||||||
|
loading,
|
||||||
|
onRestore,
|
||||||
|
}: {
|
||||||
|
completeness: CareerCompletenessView | null;
|
||||||
|
versions: CareerVersionView[];
|
||||||
|
loading: boolean;
|
||||||
|
onRestore: (version: number) => void;
|
||||||
|
}) {
|
||||||
|
if (!completeness) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2, flexWrap: "wrap", mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Profile completeness</Typography>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={completeness.percent}
|
||||||
|
color={completeness.percent >= 80 ? "success" : completeness.percent >= 40 ? "primary" : "warning"}
|
||||||
|
sx={{ height: 8, borderRadius: 999 }}
|
||||||
|
/>
|
||||||
|
{completeness.missing.length > 0 ? (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", alignItems: "center", mt: 1.25 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>Missing:</Typography>
|
||||||
|
{completeness.missing.map((label) => (
|
||||||
|
<Chip key={label} size="small" label={label} sx={{ height: 22, fontWeight: 700 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>Your career profile is complete.</Typography>
|
||||||
|
)}
|
||||||
|
{versions.length > 1 ? (
|
||||||
|
<Accordion disableGutters elevation={0} sx={{ mt: 1.5, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, minHeight: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>Version history ({versions.length})</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ px: 0, pt: 0 }}>
|
||||||
|
<Box sx={{ display: "grid", gap: 0.75 }}>
|
||||||
|
{versions.slice(0, 12).map((v) => (
|
||||||
|
<Box key={v.version} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""}
|
||||||
|
</Typography>
|
||||||
|
{!v.isCurrent ? (
|
||||||
|
<Button size="small" variant="text" disabled={loading} onClick={() => onRestore(v.version)}>Restore</Button>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = "test-file-stub";
|
||||||
+19
-3
@@ -569,6 +569,8 @@ Rules for normalized_text:
|
|||||||
# Work Experience
|
# Work Experience
|
||||||
# Education
|
# Education
|
||||||
# Skills
|
# Skills
|
||||||
|
# Projects
|
||||||
|
# Certifications
|
||||||
# Languages
|
# Languages
|
||||||
# Interests
|
# Interests
|
||||||
- Under # Contact, put one plain value per line, no labels unless unavoidable:
|
- Under # Contact, put one plain value per line, no labels unless unavoidable:
|
||||||
@@ -591,7 +593,19 @@ Rules for normalized_text:
|
|||||||
Institution, Location line
|
Institution, Location line
|
||||||
2016 - 2019 line
|
2016 - 2019 line
|
||||||
- detail
|
- detail
|
||||||
- Under # Skills and # Languages, use one bullet per item.
|
- Under # Projects, for each project use this exact shape (blank line between projects):
|
||||||
|
Project name
|
||||||
|
- one short description line covering what it is and the tech used
|
||||||
|
- Under # Certifications, one certification per line: name, then issuer and year if stated.
|
||||||
|
- Under # Skills, use one bullet per item. If skills are grouped with a category label such as
|
||||||
|
"Development:", "DevOps & Infrastructure:" or "Practices:", DROP the category label and list only
|
||||||
|
the individual skills as separate bullets. Never keep the category word as a skill.
|
||||||
|
- Under # Languages, one language per line as "Name: Level" (e.g. "English: Native", "Norwegian: B1").
|
||||||
|
IMPORTANT: languages are often stated only inside the summary or profile text (e.g. "native English
|
||||||
|
speaker", "Norwegian at B1"). When you see a spoken/written human language and any proficiency
|
||||||
|
(native, fluent, C1, B2, B1, A2, conversational, basic), add it here even if there is no dedicated
|
||||||
|
languages section in the source. Do NOT treat programming languages (C#, Python, JavaScript, SQL) as
|
||||||
|
human languages.
|
||||||
- Remove OCR/layout noise.
|
- Remove OCR/layout noise.
|
||||||
- Do not output placeholders like Not specified.
|
- Do not output placeholders like Not specified.
|
||||||
- If uncertain, omit the field/line rather than invent.
|
- If uncertain, omit the field/line rather than invent.
|
||||||
@@ -619,7 +633,7 @@ async def classify_cv_block(req: CvClassifyBlockRequest):
|
|||||||
You classify one CV text block into structured JSON.
|
You classify one CV text block into structured JSON.
|
||||||
Return ONLY valid JSON with this exact shape:
|
Return ONLY valid JSON with this exact shape:
|
||||||
{{
|
{{
|
||||||
"section": "Contact|Professional Summary|Work Experience|Education|Skills|Languages|Interests|Other",
|
"section": "Contact|Professional Summary|Work Experience|Education|Skills|Projects|Certifications|Languages|Interests|Other",
|
||||||
"confidence": 0.0,
|
"confidence": 0.0,
|
||||||
"reason": "short reason",
|
"reason": "short reason",
|
||||||
"title": string|null,
|
"title": string|null,
|
||||||
@@ -636,7 +650,9 @@ Rules:
|
|||||||
- Preserve facts only.
|
- Preserve facts only.
|
||||||
- section must be one of the listed values.
|
- section must be one of the listed values.
|
||||||
- Use Work Experience only for job/employment blocks.
|
- Use Work Experience only for job/employment blocks.
|
||||||
- Use Education only for degree/course/certification blocks.
|
- Use Education only for degree/diploma/course blocks.
|
||||||
|
- Use Projects for personal/side/portfolio project blocks (put the project name in title and details in bullets).
|
||||||
|
- Use Certifications for named certifications/licences (put the certification name in title).
|
||||||
- For Contact blocks, keep title/company/start/end null and bullets/summary/skills empty.
|
- For Contact blocks, keep title/company/start/end null and bullets/summary/skills empty.
|
||||||
- For Professional Summary blocks, prefer summary for concise summary lines and keep bullets empty unless the source is already bullet-like.
|
- For Professional Summary blocks, prefer summary for concise summary lines and keep bullets empty unless the source is already bullet-like.
|
||||||
- For Skills blocks, prefer skills for normalized skill items and keep title/company/start/end null.
|
- For Skills blocks, prefer skills for normalized skill items and keep title/company/start/end null.
|
||||||
|
|||||||
@@ -134,6 +134,38 @@ def test_classify_block_returns_structured_json(monkeypatch):
|
|||||||
assert payload["skills"] == ["Python", "SQL"]
|
assert payload["skills"] == ["Python", "SQL"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_block_supports_projects_section(monkeypatch):
|
||||||
|
# Phase 2.1-b: Projects and Certifications are now valid classified sections so project blocks
|
||||||
|
# (e.g. the benchmark CV's JobTrack/InboxIntel) are no longer dropped into "Other".
|
||||||
|
module = load_app_module(monkeypatch)
|
||||||
|
|
||||||
|
def fake_generate_json(prompt: str):
|
||||||
|
assert "Projects" in prompt # the enum now advertises Projects to the model
|
||||||
|
return {
|
||||||
|
"section": "Projects",
|
||||||
|
"confidence": 0.83,
|
||||||
|
"reason": "project block",
|
||||||
|
"title": "JobTrack",
|
||||||
|
"company": None,
|
||||||
|
"location": None,
|
||||||
|
"start": None,
|
||||||
|
"end": None,
|
||||||
|
"bullets": ["Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker)."],
|
||||||
|
"summary": [],
|
||||||
|
"skills": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json)
|
||||||
|
client = TestClient(module.app)
|
||||||
|
|
||||||
|
response = client.post("/cv/classify-block", json={"block": "JobTrack - Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker)."})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["section"] == "Projects"
|
||||||
|
assert payload["title"] == "JobTrack"
|
||||||
|
|
||||||
|
|
||||||
def test_classify_block_defaults_missing_section_to_other(monkeypatch):
|
def test_classify_block_defaults_missing_section_to_other(monkeypatch):
|
||||||
module = load_app_module(monkeypatch)
|
module = load_app_module(monkeypatch)
|
||||||
monkeypatch.setattr(module, "_ollama_generate_json", lambda prompt: {"bullets": []})
|
monkeypatch.setattr(module, "_ollama_generate_json", lambda prompt: {"bullets": []})
|
||||||
|
|||||||
Reference in New Issue
Block a user