Compare commits

...

7 Commits

Author SHA1 Message Date
cesnimda 56fed05d70 feat: complete phase 2 UX improvements
CI and Deploy / test (push) Successful in 2m42s
CI and Deploy / deploy (push) Failing after 9s
2026-07-30 21:35:52 +02:00
cesnimda 173187dcbb feat(cv): Phase 2.1-b — extract Projects, Certifications, and languages-from-prose
The structured model and StructuredCvProfileJson.FromSections already map
Projects/Certifications/Languages headings, but the AI normalize prompt
never emitted them, so on the benchmark CV the entire Projects section and
the in-summary languages (English Native, Norwegian B1) were silently
dropped. This closes that gap upstream — no backend schema or data change.

ai-service (tools/summarizer/app.py):
- /cv/normalize: added # Projects and # Certifications headings; a
  languages-from-prose rule (pull "native English", "Norwegian at B1" out
  of the summary even with no Languages section; ignore programming
  languages); and skill-group prefix stripping ("Development:",
  "DevOps & Infrastructure:", "Practices:" dropped, only the skills kept).
- /cv/classify-block: Projects and Certifications added to the section
  enum + rules (fallback path).

Backend:
- LooksLikeNormalizedMarkdownCv now recognises # Projects / # Certifications
  so those CVs still take the markdown assembly path.

Tests:
- CvExtractionCoverageTests (4) lock the C# mapping of Projects,
  Certifications and Languages sections into the structured profile.
- ai-service test_classify_block_supports_projects_section (1).
426 backend tests, 17 ai-service tests pass; app.py compiles.

The LLM behaviour (prompt -> headings) needs Ollama to observe and was not
run here; the C# side that consumes the headings is proven and the prompt
change is additive. Merge-not-replace + the review screen are the next
increment (2.1-a, approved: always-review, conservative merge).

Deployment: these prompts live in the ai-service container, which
deploy.sh does not rebuild by default -- deploy with
DEPLOY_BUILD_AI_SERVICE=true or the change won't take effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:40:04 +02:00
cesnimda fe9cd4dda1 refactor(career): Phase 1 increment 2 — extract editor sections, hide duplicate CV concepts
UI-only. No change to APIs, save payloads, extraction behaviour, or data
models. The parent CareerProfilePage still owns loading, state, saving,
and all extraction/import actions; the new sections are presentational
(value + onChange, plus a getMetadata callback for review chips).

Extracted into src/views/career/CareerProfileSections.tsx:
PersonalInformation, ProfessionalSummary, Skills, Interests, Languages,
WorkExperience, Education, OtherSections. FieldReviewNote + confidenceTone
moved there verbatim and shared with the parent. CareerProfilePage went
from 1376 to ~1200 lines.

No Projects/Certifications sections were created -- the editor never had
them (they are not editable structured fields here). Inventing them would
add functionality, which this refactor avoids; noted for a product
decision later.

Hid the duplicate CV concepts behind an "Advanced CV tools" toggle,
collapsed by default: the CV Structure Overview parse block and the
Template-driven CV builder. Both stay mounted and functional (gated with
display:none), so no tested functionality is removed -- the real CV
Builder at /career/builder is the single generation surface. Future
removal plan documented.

Tests: added "editing a field in an extracted section updates parent
state and flows into save" (render -> edit -> PUT /career/profile
{profile,cvText}); existing parse/rewrite tests reveal the advanced tools
first. The increment-1 save-invariant test still pins the payload.

Verified: tsc clean, production build clean, 137 frontend tests pass.
Backend untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:00:26 +02:00
cesnimda 63473bae85 refactor(career): Phase 1 increment — user-facing terminology + component split
UI-only restructuring of the Career Profile surface. No change to
database models, CareerProfiles schema, CvVariants, extraction APIs, AI
services, CV rendering, or public CV.

Terminology -> user-facing (i18n strings):
- "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 frames it as the
source the CV Builder consumes.

Component split (first step): extract ProfileCompleteness (completeness
meter + missing chips + version history) into src/views/career/. Display
only, props in, no state or API.

Save path untouched: api.put("/career/profile", { profile, cvText }). A
new test pins that exact call as the refactor invariant so the remaining
section extraction cannot silently change save behaviour. Existing
profile-page tests re-pointed to the new labels; every behavioural
assertion (save, parse, field values) kept.

Verified: tsc clean, production build clean, 136 frontend tests pass
(135 + 1 invariant). Sidebar fix from the previous task still passes.
Backend untouched.

The remaining Phase 1 work (per-section editor components, hiding the
template-driven builder and structure-overview blocks, actionable
per-section empty states) is staged in docs/career-workspace-ux-refactor.md
because it touches the live extraction test surface and is best verified
by driving the authenticated UI. This increment is a clean, non-regressing
checkpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:50:52 +02:00
cesnimda 4f69d395be fix(nav): only the most-specific sidebar item is active
On /career/builder/{id} both "Career Workspace" (/career) and "CV Builder"
(/career/builder) highlighted, because AppShell tested each item with
`pathname === to || pathname.startsWith(to + "/")` — so /career matched
every /career/... child. No "most specific wins" rule.

Add AppShell.activeNavTo(pathname, tos): the longest `to` that the path is
at or under wins, across both nav lists; every other item is inactive. A
child route never lights up a parent nav item. `selected` now compares
against that single computed activeTo. Exported as a pure function so the
ownership rule is unit-tested directly (sidebar-active-nav.test.ts):
exactly one active item for /career, /career/builder and
/career/builder/{id}, and no double-highlight.

Also give the breadcrumb/title in App.tsx explicit /career/builder ->
"CV Builder" ownership (it previously showed "Career Workspace"), and
reframe the Career Workspace header to the "Career Profile" product
framing: "This information powers your CVs, applications, cover letters
and AI assistance."

Frontend only — no change to CareerProfiles, CvVariants, CV generation,
extraction APIs, AI, permissions or tenant isolation. Plan for the deeper
information-architecture work is in docs/career-workspace-ux-refactor.md,
staged so the 1376-line CareerProfilePage and the live CV/extraction
pipeline are refactored incrementally with verification, not in one risky
rewrite.

Verified: tsc clean, frontend build clean, 135 frontend tests pass
(128 + 7 new nav tests). Backend untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:29:50 +02:00
cesnimda 4db8c08958 fix(security): evaluate tenant CurrentUserId live, not at construction
CI and Deploy / test (push) Successful in 2m38s
CI and Deploy / deploy (push) Failing after 7s
Production POST /api/cv/variants returned 200 but GET /api/cv/variants/{id}
returned 404, with the query logged as `... FROM CvVariants WHERE FALSE`
(no parameters). The created row had a correct OwnerUserId; the read was
excluded by the global query filter because CurrentUserId was null at
query time. Reproduced locally: it affected EVERY tenant-filtered read
(CV list returned 0 after creating 5, JobApplications returned total 0),
not just CV -- writes worked, reads came back empty.

Root cause: the "local" JwtBearer OnTokenValidated resolves the
request-scoped JobTrackerContext (to run LocalSessionValidator) BEFORE the
authentication middleware assigns HttpContext.User. JobTrackerContext
captured CurrentUserId in its constructor from ICurrentUserService.UserId,
which reads HttpContext.User -- still unauthenticated at that point -- so
CurrentUserId froze to null. That same scoped instance is reused by the
controller, so `CurrentUserId != null && OwnerUserId == CurrentUserId`
compiled to WHERE FALSE for the whole request. POST worked because
CreateAsync sets OwnerUserId from the controller-resolved user, and
inserts are not filtered.

Fix: make CurrentUserId a computed property that reads
ICurrentUserService.UserId live, so the query filters see the
authenticated user at query-execution time. Deny-on-null is preserved
(still null for an unauthenticated principal). LocalSessionValidator is
unaffected -- it already uses IgnoreQueryFilters and queries by explicit
sid.

Verified on a real MariaDB 11 container end to end: create then read a
variant returns 200, the variant list returns all rows, and
GET /api/jobapplications reads normally. Added
CurrentUserIdLiveEvaluationTests pinning the live-evaluation behaviour
(both fail against a constructor snapshot). 422 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:20:09 +02:00
cesnimda 474654b1c1 fix(cv): add LongTailJson to reconciler CareerProfiles schema
Production GET /api/cv/outline returned 500 "Unknown column
'c.LongTailJson'". CareerProfiles is reconciler-owned, but the
reconciler's CREATE TABLE (both the SQLite and MySQL branches) only
listed Id, OwnerUserId, ProfileJson, Version, CreatedAtUtc, UpdatedAtUtc.
LongTailJson was added to the CareerProfile model in Phase 3 but neither
CREATE was updated and no column-repair existed, so:

- existing databases (prod): the MySQL CREATE is guarded on
  !HasMySqlTable, so it never runs once the table exists, and nothing
  adds the column -> LoadStructuredAsync selects a column that isn't
  there.
- fresh databases: the CREATE itself omitted the column, so even a brand
  new MariaDB/SQLite was missing it. The 420 tests never caught this
  because they build tables from the EF model, not the reconciler DDL.
  The release audit missed it because it never exercised /api/cv/outline.

Add LongTailJson to both CREATE statements and add an additive repair
(EnsureColumn / EnsureMySqlColumn) for existing tables. DEFAULT ''
backfills existing rows and matches the non-nullable model property.
This is the sanctioned reconciler repair path, not a manual ALTER, and
preserves existing data (ADD COLUMN is non-destructive).

Verified on a real MariaDB 11 container: an existing 6-column
CareerProfiles gains LongTailJson on startup (repair path), a fresh DB
gets it from the CREATE (longtext), and GET /api/cv/outline returns 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:19:52 +02:00
36 changed files with 3021 additions and 12492 deletions
+13 -2
View File
@@ -6,11 +6,22 @@ namespace JobTrackerApi.Data
{
public class JobTrackerContext : IdentityDbContext<ApplicationUser>
{
public string? CurrentUserId { get; }
private readonly JobTrackerApi.Services.ICurrentUserService _currentUser;
// Evaluated live on each access, NOT captured in the constructor. The "local" JwtBearer
// OnTokenValidated resolves this request-scoped DbContext to run LocalSessionValidator BEFORE
// the authentication middleware assigns HttpContext.User. A constructor snapshot therefore froze
// CurrentUserId to null for the whole request, and the same scoped instance is reused by the
// controller — so every tenant-filtered read compiled to `WHERE FALSE` and returned nothing
// (created rows 404'd on read, lists came back empty) even though writes set OwnerUserId
// correctly from the controller-resolved user. Reading it live means the query filters see the
// authenticated user at query-execution time. Deny-on-null is preserved: it is still null for an
// unauthenticated principal.
public string? CurrentUserId => _currentUser.UserId;
public JobTrackerContext(DbContextOptions<JobTrackerContext> options, JobTrackerApi.Services.ICurrentUserService currentUser) : base(options)
{
CurrentUserId = currentUser.UserId;
_currentUser = currentUser;
}
public DbSet<Company> Companies => Set<Company>();
@@ -0,0 +1,69 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
// Regression guard for the production tenant-read failure: the "local" JwtBearer OnTokenValidated
// resolves the request-scoped JobTrackerContext to run session validation BEFORE the auth middleware
// sets HttpContext.User. When CurrentUserId was captured in the DbContext constructor, that froze it
// to null for the whole request, and every global-query-filtered read returned WHERE FALSE — created
// rows 404'd on read and lists came back empty, while writes (which set OwnerUserId from the
// controller-resolved user) still succeeded.
//
// The fix makes CurrentUserId read ICurrentUserService.UserId live on each access. These tests fail
// if it reverts to a constructor snapshot.
public sealed class CurrentUserIdLiveEvaluationTests
{
private static (JobTrackerContext db, Mock<ICurrentUserService> user) New(string? initialUserId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
var user = new Mock<ICurrentUserService>();
user.SetupGet(s => s.UserId).Returns(initialUserId);
return (new JobTrackerContext(options, user.Object), user);
}
[Fact]
public void CurrentUserId_reflects_the_user_resolved_after_construction()
{
// Constructed while unauthenticated (UserId null), exactly as OnTokenValidated does.
var (db, user) = New(initialUserId: null);
Assert.Null(db.CurrentUserId);
// The auth middleware then assigns the principal; UserId becomes non-null on the SAME instance.
user.SetupGet(s => s.UserId).Returns("user-1");
Assert.Equal("user-1", db.CurrentUserId);
}
[Fact]
public async Task A_row_created_while_unauthenticated_context_is_readable_once_the_user_resolves()
{
var (db, user) = New(initialUserId: null);
// Write happens with an explicit owner (mirrors CreateAsync taking the controller-resolved id),
// while the context was built before the user resolved.
db.CvVariants.Add(new CvVariant
{
OwnerUserId = "user-1",
Name = "Test",
PublicSlug = "slug-1",
SettingsJson = "{}",
Version = 1,
});
await db.SaveChangesAsync();
// Before the user resolves, the tenant filter denies (deny-on-null preserved).
Assert.Empty(await db.CvVariants.ToListAsync());
// Once the middleware sets the user, the same context reads the row — not WHERE FALSE.
user.SetupGet(s => s.UserId).Returns("user-1");
var visible = await db.CvVariants.ToListAsync();
Assert.Single(visible);
Assert.Equal("user-1", visible[0].OwnerUserId);
}
}
@@ -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
20152023System 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 20152023", repaired);
}
[Fact]
public void Earlier_part_time_roles_become_separate_experience_entries()
{
var profile = InvokeProfileBuilder("""
# Work Experience
System Developer
Warwickshire County Council
20152023
- Built APIs
Earlier roles (part-time)
- Sales Assistant Royal Vapes | 20132015
- Labourer The Hodcarrier | 20142016
- Lifeguard Nuffield Health | 20122014
""");
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");
}
}
+55 -16
View File
@@ -40,7 +40,7 @@ public sealed class ProfileCvControllerTests
}
[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 userManager = CreateUserManager();
@@ -81,21 +81,55 @@ public sealed class ProfileCvControllerTests
Assert.IsType<OkObjectResult>(result);
var artifact = await db.CvUploadArtifacts.SingleAsync();
var run = await db.CvExtractionRuns.SingleAsync();
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("user-1", artifact.OwnerUserId);
Assert.Equal("resume.md", artifact.OriginalFileName);
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(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(artifact.Id, user.CurrentCvUploadArtifactId);
Assert.Equal(1, user.CurrentCvProfileVersion);
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");
}
[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]
public async Task GetRuns_returns_latest_extraction_runs()
{
@@ -186,10 +220,10 @@ public sealed class ProfileCvControllerTests
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("reprocess", run.Trigger);
Assert.Equal("applied", run.Status);
Assert.Equal(2, user.CurrentCvProfileVersion);
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
Assert.Equal("# Connor Babbington\n\n## Professional Summary\nRefined profile", user.ProfileCvText);
Assert.Equal("pending_review", run.Status);
Assert.Equal(1, user.CurrentCvProfileVersion);
Assert.Null(user.CurrentCvExtractionRunId);
Assert.Null(user.ProfileCvText);
}
[Fact]
@@ -268,9 +302,10 @@ public sealed class ProfileCvControllerTests
var result = await controller.Upload(file);
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.Single(structured.Summary);
Assert.Single(structured.Jobs);
@@ -326,10 +361,11 @@ public sealed class ProfileCvControllerTests
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);
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.Contains("# Skills", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Warwickshire County Council", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("# Skills", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Warwickshire County Council", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -366,7 +402,8 @@ public sealed class ProfileCvControllerTests
var result = await controller.Upload(file);
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@cesnimda.co.uk", structured.Contact.Email);
Assert.Equal("+47 41 33 44 70", structured.Contact.Phone);
@@ -965,8 +1002,10 @@ public sealed class ProfileCvControllerTests
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
Assert.Contains("Built APIs", user.ProfileCvText);
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Contains("Built APIs", run.NormalizedText);
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(run.StructuredProfileJson).Contact.FullName);
Assert.Equal("pending_review", run.Status);
}
[Fact]
+155 -64
View File
@@ -72,8 +72,9 @@ public sealed class ProfileCvController : ControllerBase
private readonly ICvProcessingQueue _cvProcessingQueue;
private readonly IAppEmailSender _emailSender;
private readonly ICareerProfileService _careerProfileService;
private readonly ICvProfileDiffService _cvProfileDiffService;
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null)
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null, ICvProfileDiffService? cvProfileDiffService = null)
{
_users = users;
_aiService = aiService;
@@ -87,6 +88,7 @@ public sealed class ProfileCvController : ControllerBase
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
_emailSender = emailSender ?? NoOpEmailSender.Instance;
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
_cvProfileDiffService = cvProfileDiffService ?? new CvProfileDiffService();
}
private sealed class NoOpEmailSender : IAppEmailSender
@@ -120,6 +122,7 @@ public sealed class ProfileCvController : ControllerBase
// in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
// (Phase 4), not this foundation integration.
public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds);
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
@@ -159,45 +162,21 @@ public sealed class ProfileCvController : ControllerBase
try
{
var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted);
result.StructuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, result.StructuredCv, "upload", HttpContext.RequestAborted);
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.RawExtractedText = result.RawText;
run.NormalizedText = result.NormalizedText;
run.StructuredProfileJson = structuredJson;
run.Status = "applied";
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
run.AppliedAtUtc = run.CompletedAtUtc;
user.ProfileCvText = result.NormalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvUploadArtifactId = artifact.Id;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = result.StructuredCv.Metadata.ProfileVersion;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return BadRequest(run.ErrorMessage);
}
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new
{
imported = true,
imported = false,
pendingReview = true,
characters = result.NormalizedText.Length,
structuredCv = result.StructuredCv,
sections = result.StructuredCv.Sections,
artifactId = artifact.Id,
extractionRunId = run.Id,
profileVersion = result.StructuredCv.Metadata.ProfileVersion,
status = run.Status,
});
}
catch (Exception ex)
@@ -238,6 +217,71 @@ public sealed class ProfileCvController : ControllerBase
return Ok(runs);
}
[HttpGet("runs/{id:int}/diff")]
public async Task<IActionResult> GetRunDiff([FromRoute] int id)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
return Ok(new { runId = run.Id, run.Status, diff = _cvProfileDiffService.Diff(current, extracted) });
}
[HttpPost("runs/{id:int}/accept")]
public async Task<IActionResult> AcceptRun([FromRoute] int id, [FromBody] AcceptCvRunRequest? request = null)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
var diff = _cvProfileDiffService.Diff(current, extracted);
var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
merged.Metadata.AppliedExtractionRunId = run.Id;
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted);
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(merged);
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
run.Status = "applied";
run.AppliedAtUtc = DateTimeOffset.UtcNow;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description)));
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged });
}
[HttpPost("runs/{id:int}/discard")]
public async Task<IActionResult> DiscardRun([FromRoute] int id)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
run.Status = "discarded";
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return NoContent();
}
[HttpPost("reprocess")]
public async Task<IActionResult> Reprocess()
{
@@ -807,6 +851,7 @@ public sealed class ProfileCvController : ControllerBase
throw new InvalidOperationException("The uploaded CV file could not be read or was empty.");
}
text = RepairKnownMojibake(text);
var normalizedText = (await MaybeReconstructStructuredCvAsync(text, cancellationToken)).Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
@@ -916,7 +961,7 @@ public sealed class ProfileCvController : ControllerBase
var normalizedText = rebuilt.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "improve":
@@ -931,7 +976,7 @@ public sealed class ProfileCvController : ControllerBase
var normalizedText = improved.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "reprocess":
@@ -951,7 +996,7 @@ public sealed class ProfileCvController : ControllerBase
};
var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty);
var result = await ExtractStructuredCvFromFileAsync(file, extension, cancellationToken);
await ApplyQueuedRunResultAsync(run, user, result.RawText, result.NormalizedText, result.StructuredCv, artifact.Id, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, result.RawText, result.NormalizedText, result.StructuredCv, cancellationToken);
break;
}
default:
@@ -971,39 +1016,13 @@ public sealed class ProfileCvController : ControllerBase
}
}
private async Task ApplyQueuedRunResultAsync(CvExtractionRun run, ApplicationUser user, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
private async Task CompleteQueuedRunForReviewAsync(CvExtractionRun run, string rawText, string normalizedText, StructuredCvProfile structuredCv, CancellationToken cancellationToken)
{
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, run.Trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.RawExtractedText = rawText;
run.NormalizedText = normalizedText;
run.StructuredProfileJson = structuredJson;
run.Status = "applied";
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(structuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
run.AppliedAtUtc = run.CompletedAtUtc;
user.ProfileCvText = normalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
if (artifactId.HasValue)
{
user.CurrentCvUploadArtifactId = artifactId.Value;
}
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(cancellationToken);
throw new InvalidOperationException(run.ErrorMessage);
}
await _db.SaveChangesAsync(cancellationToken);
}
@@ -1606,6 +1625,15 @@ public sealed class ProfileCvController : ControllerBase
{
var normalized = content.Replace("\r\n", "\n").Trim();
var structured = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Work Experience", Content = normalized } }).Jobs;
var earlierRoles = ParseEarlierRoles(normalized);
foreach (var role in earlierRoles)
{
if (!structured.Any(job => string.Equals(job.Title, role.Title, StringComparison.OrdinalIgnoreCase)
&& string.Equals(job.Company, role.Company, StringComparison.OrdinalIgnoreCase)))
{
structured.Add(role);
}
}
if (ArePlausibleJobs(structured, null))
{
return structured;
@@ -1711,6 +1739,36 @@ public sealed class ProfileCvController : ControllerBase
return jobs;
}
private static List<StructuredCvJob> ParseEarlierRoles(string content)
{
var heading = Regex.Match(content, @"(?im)^\s*(?:[-*]\s*)?Earlier roles(?:\s*\(part[- ]?time\))?\s*:?\s*$");
if (!heading.Success) return new List<StructuredCvJob>();
var roles = new List<StructuredCvJob>();
foreach (var rawLine in content[(heading.Index + heading.Length)..].Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (rawLine.StartsWith('#')) break;
var line = rawLine.Trim().TrimStart('-', '*', '•', ' ');
var dates = Regex.Match(line, @"(?<start>\d{4})\s*[-–—]\s*(?<end>\d{4}|Present|Current)", RegexOptions.IgnoreCase);
if (!dates.Success) continue;
var identity = Regex.Replace(line, @"\s*[|,(]?\s*\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current)\s*\)?\s*$", string.Empty, RegexOptions.IgnoreCase).Trim();
var parts = Regex.Split(identity, @"\s+(?:—||\||at)\s+", RegexOptions.IgnoreCase);
if (parts.Length != 2 || parts.Any(string.IsNullOrWhiteSpace)) continue;
roles.Add(new StructuredCvJob
{
Title = parts[0].Trim(),
Company = parts[1].Trim(),
Start = dates.Groups["start"].Value,
End = dates.Groups["end"].Value,
IsCurrent = dates.Groups["end"].Value.Equals("Present", StringComparison.OrdinalIgnoreCase)
|| dates.Groups["end"].Value.Equals("Current", StringComparison.OrdinalIgnoreCase),
});
}
return roles;
}
private static string? TitleCasePreservingAcronyms(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
@@ -2124,11 +2182,12 @@ public sealed class ProfileCvController : ControllerBase
private static bool LooksLikeNormalizedMarkdownCv(string text)
{
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)
{
text = SeparateGluedDateAndTitle(text);
var sections = ParseSections(text)
.Select(section => new StructuredCvSection
{
@@ -2140,6 +2199,16 @@ public sealed class ProfileCvController : ControllerBase
var profile = StructuredCvProfileJson.FromSections(sections);
profile.Sections = sections;
var workExperience = sections.FirstOrDefault(section => section.Name == "Work Experience")?.Content;
if (!string.IsNullOrWhiteSpace(workExperience))
{
profile.Jobs.RemoveAll(job => (job.Title ?? string.Empty).StartsWith("Earlier roles", StringComparison.OrdinalIgnoreCase));
foreach (var role in ParseEarlierRoles(workExperience))
{
if (!profile.Jobs.Any(job => string.Equals(job.Title, role.Title, StringComparison.OrdinalIgnoreCase)
&& string.Equals(job.Company, role.Company, StringComparison.OrdinalIgnoreCase))) profile.Jobs.Add(role);
}
}
if (string.IsNullOrWhiteSpace(profile.Contact.FullName))
{
@@ -2175,11 +2244,33 @@ public sealed class ProfileCvController : ControllerBase
private static List<string> OrderSkills(List<string> skills)
{
return skills
.Select(CleanSkillGroupPrefix)
.Where(skill => skill.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(skill => skill, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string CleanSkillGroupPrefix(string skill)
=> Regex.Replace(skill.Trim(), @"^(?:Development|DevOps(?:\s*&\s*Infrastructure)?|Infrastructure|Practices|Tools|Technologies|Technical Skills)\s*:\s*", string.Empty, RegexOptions.IgnoreCase).Trim();
private static string SeparateGluedDateAndTitle(string text)
=> Regex.Replace(text, @"(?<date>\b\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current))(?<title>[\p{L}][^\r\n]*)", "${title}\n${date}", RegexOptions.IgnoreCase);
private static string RepairKnownMojibake(string text)
=> text
.Replace("ø", "ø", StringComparison.Ordinal)
.Replace("Ø", "Ø", StringComparison.Ordinal)
.Replace("æ", "æ", StringComparison.Ordinal)
.Replace("Æ", "Æ", StringComparison.Ordinal)
.Replace("Ã¥", "å", StringComparison.Ordinal)
.Replace("Ã…", "Å", StringComparison.Ordinal)
.Replace("–", "", StringComparison.Ordinal)
.Replace("—", "—", StringComparison.Ordinal)
.Replace("’", "", StringComparison.Ordinal)
.Replace("“", "“", StringComparison.Ordinal)
.Replace("”", "”", StringComparison.Ordinal);
private static List<string> CleanInterestItems(List<string> interests)
{
return interests
@@ -2229,7 +2320,7 @@ public sealed class ProfileCvController : ControllerBase
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
{
var raw = Encoding.UTF8.GetString(bytes);
var raw = Encoding.Latin1.GetString(bytes);
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
.Select(match => match.Groups[1].Value)
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)
+1
View File
@@ -39,6 +39,7 @@ builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddSingleton<ICvProfileDiffService, CvProfileDiffService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
+427
View File
@@ -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] + "…");
}
@@ -773,12 +773,20 @@ public static class StartupInitializationExtensions
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ProfileJson" TEXT NOT NULL,
"LongTailJson" TEXT NOT NULL DEFAULT '',
"Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL
);
""");
// LongTailJson was added to the CareerProfile model (Phase 3) but this CREATE and
// the MySQL one were never updated to match, and no column-repair existed — so a
// CareerProfiles table created before this line lacks the column and /api/cv/outline
// (CareerProfileService.LoadStructuredAsync) fails with "Unknown column LongTailJson".
// Additive repair for existing databases.
EnsureColumn(c, "CareerProfiles", "LongTailJson", """ALTER TABLE "CareerProfiles" ADD COLUMN "LongTailJson" TEXT NOT NULL DEFAULT '';""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
@@ -1620,6 +1628,7 @@ public static class StartupInitializationExtensions
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`ProfileJson` longtext NOT NULL,
`LongTailJson` longtext NOT NULL,
`Version` int NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
@@ -1628,6 +1637,11 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
// Additive repair for a CareerProfiles table created before LongTailJson was added
// to the model. Without it, /api/cv/outline fails with "Unknown column LongTailJson".
// DEFAULT '' backfills existing rows and matches the non-nullable model property.
EnsureMySqlColumn(conn, "CareerProfiles", "LongTailJson", "ALTER TABLE `CareerProfiles` ADD COLUMN `LongTailJson` longtext NOT NULL DEFAULT '';");
if (!HasMySqlTable(conn, "CareerProfileVersions") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmd = conn.CreateCommand();
+192
View File
@@ -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 14 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 14 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.
+9 -9
View File
@@ -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."
| # | 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.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.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.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.5 | **Introduce a server-cache layer** (React Query or equivalent) | **P2** | **M** | none | Root cause of the 6001400-line components and the hand-rolled `refreshToken` prop-threading. Pays for itself across Phases 36. Adopt incrementally, not as a rewrite. |
| 2.6 | **Decompose `JobDetailsDialog.tsx` (1400 lines)** | **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.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.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.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 | **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 | **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 | **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 6001400-line components and the hand-rolled `refreshToken` prop-threading. Pays for itself across Phases 36. Adopt incrementally, not as a rewrite. |
| 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 | **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 | **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 | **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. |
---
+160
View File
@@ -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 "20152023System 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
(`Tnsberg`, `years`, `20152023`) 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 (Q1Q7).** 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**
(Q3Q5, 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?
+18
View File
@@ -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",
},
};
+381 -11162
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -26,7 +26,6 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3",
"react-scripts": "5.0.1",
"typescript": "^5.9.3",
"web-vitals": "^2.1.4"
},
@@ -34,7 +33,7 @@
"dev": "next dev",
"start": "next dev",
"build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
"test": "react-scripts test"
"test": "jest"
},
"eslintConfig": {
"extends": [
@@ -53,5 +52,14 @@
"last 1 firefox 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"
}
}
+3
View File
@@ -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("/settings")) return [t("home"), t("settings")];
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("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"];
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("/settings")) return t("settings");
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("/settings/connected-accounts")) return "Connected accounts";
if (path.startsWith("/admin/audit")) return t("auditLog");
@@ -383,6 +385,7 @@ export default function App() {
const router = useMemo(() => createBrowserRouter([
{ path: "/", element: <LandingPage />, errorElement: <RouteErrorPage /> },
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/register", element: <LoginPage initialMode="register" />, errorElement: <RouteErrorPage /> },
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, 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,
Tooltip, Typography,
} from "@mui/material";
import RichTextField from "./RichTextField";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import RestoreIcon from "@mui/icons-material/Restore";
@@ -290,14 +291,12 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
error={error}
>
<Stack spacing={2}>
<TextField
multiline
<RichTextField
minRows={12}
fullWidth
label="Cover letter"
value={text}
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."
/>
@@ -29,6 +29,8 @@ import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines }
import Correspondence from "./Correspondence";
import Attachments from "./Attachments";
import AiWorkspacePanel from "./AiWorkspacePanel";
import JobInsightTabs from "./JobInsightTabs";
import { DraftCard, ListCard, MatchScoreCard, PaperRow, SectionChips, TwoColumnSection, WorkspaceDraftCard } from "./JobDetailsPanels";
import JobFlowBar from "./JobFlowBar";
import GradientButton from "./GradientButton";
import { useI18n } from "../i18n/I18nProvider";
@@ -1189,92 +1191,23 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box>
)}
{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>
)}
<JobInsightTabs
tab={tab}
matchScore={matchScore}
loadingMatchScore={loadingMatchScore}
candidateFit={candidateFit}
loadingCandidateFit={loadingCandidateFit}
regenerateCandidateFit={regenerateCandidateFit}
fitLevel={fitLevel}
focusPlan={focusPlan}
loadingFocusPlan={loadingFocusPlan}
regenerateFocusPlan={regenerateFocusPlan}
interviewPrep={interviewPrep}
loadingInterviewPrep={loadingInterviewPrep}
regenerateInterviewPrep={regenerateInterviewPrep}
readiness={readiness}
loadingReadiness={loadingReadiness}
/>
{tab === 9 && isAdmin && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
@@ -1287,177 +1220,3 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</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 { 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 RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import CloseIcon from "@mui/icons-material/Close";
import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
function dismissKey() {
return `onboardingChecklistDismissed:${getUserKeyFromToken()}`;
}
type MeResponse = { profileCvText?: string | null };
type MeResponse = {
email?: string | null;
firstName?: string | null;
lastName?: 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 }) {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useI18n();
const [hasCv, setHasCv] = useState<boolean | null>(null);
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
useEffect(() => {
let active = true;
api.get<MeResponse>("/auth/me")
.then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); })
.catch(() => { if (active) setHasCv(false); });
Promise.all([
api.get<MeResponse>("/auth/me"),
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; };
}, []);
const allDone = hasCv === true && hasJobs;
if (dismissed || allDone || hasCv === null) return null;
const dismiss = () => {
window.localStorage.setItem(dismissKey(), "1");
setDismissed(true);
};
if (!status) return null;
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: 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 (
<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),
position: "relative",
}}
>
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
<CloseIcon fontSize="small" />
</IconButton>
<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) }}>
<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}>
{steps.map((step) => (
<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}
</Typography>
</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>
))}
</Stack>
@@ -19,6 +19,7 @@ export default function RichTextField({
placeholder,
minRows = 2,
ariaLabel,
disabled = false,
}: {
value: string;
onChange: (v: string) => void;
@@ -26,6 +27,7 @@ export default function RichTextField({
placeholder?: string;
minRows?: number;
ariaLabel?: string;
disabled?: boolean;
}) {
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) => (
<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}
</IconButton>
</Tooltip>
@@ -65,6 +67,7 @@ export default function RichTextField({
fullWidth
size="small"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
slotProps={{ htmlInput: { "aria-label": ariaLabel ?? label } }}
/>
+28 -22
View File
@@ -218,20 +218,20 @@ export const translations = {
profileCvUploadFailed: "Failed to upload CV.",
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.",
profileCvStructuredDefaultHint: "The structured CV stays front and center. Open the original extraction only when you need to verify or clean up parser output.",
profileCvRawPanelTitle: "Original extraction",
profileCvRawPanelHelp: "Usually messy, but useful for checking what the parser actually pulled from the uploaded file.",
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 import",
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.",
profileCvSectionTools: "Section rewrite tools",
profileCvStructureOverview: "CV structure overview",
profileCvStructureOverviewHelp: "Parse your current CV text into reusable sections so you can spot missing structure before tailoring.",
profileCvStructureParse: "Analyze sections",
profileCvStructureParsing: "Analyzing sections...",
profileCvStructureParsed: "CV structure analyzed.",
profileCvStructureParseFailed: "Failed to analyze CV structure.",
profileCvStructureEmpty: "No parsed sections yet.",
profileCvStructuredEditor: "Structured CV editor",
profileCvStructuredEditorHelp: "Edit reusable CV data directly so generators and matching can work from stable fields instead of raw text alone.",
profileCvStructureOverview: "Profile sections",
profileCvStructureOverviewHelp: "Read your current CV text into sections so you can see what your profile will contain.",
profileCvStructureParse: "Read sections",
profileCvStructureParsing: "Reading sections...",
profileCvStructureParsed: "Sections read from your CV.",
profileCvStructureParseFailed: "Couldn't read sections from your CV.",
profileCvStructureEmpty: "No sections read yet.",
profileCvStructuredEditor: "Career information",
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",
profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.",
profileCvExtractionHistoryEmpty: "No extraction runs yet.",
@@ -249,8 +249,8 @@ export const translations = {
profileCvContactLocation: "Location",
profileCvContactWebsite: "Website",
profileCvContactLinkedIn: "LinkedIn",
profileCvStructuredSummary: "Summary bullets",
profileCvStructuredSkills: "Core skills",
profileCvStructuredSummary: "Professional summary",
profileCvStructuredSkills: "Skills",
profileCvStructuredInterests: "Interests",
profileCvStructuredLanguages: "Languages",
profileCvStructuredJobs: "Work experience",
@@ -382,13 +382,16 @@ export const translations = {
dashboardHeroLabel: "Job search overview",
onboardingTitle: "Get set up",
onboardingBody: "A few steps to get the most out of Jobbjakt.",
onboardingDismiss: "Dismiss",
onboardingStepCv: "Add your CV",
onboardingStepSignup: "Create your account",
onboardingStepVerify: "Verify your email",
onboardingStepProfile: "Complete your profile",
onboardingStepProfileAction: "Open profile",
onboardingStepCv: "Import your CV",
onboardingStepCvAction: "Add CV",
onboardingStepJob: "Import your first job",
onboardingStepJobAction: "Add job",
onboardingStepMatch: "Check your CV match score on a job",
onboardingStepMatchAction: "Open jobs",
onboardingStepEmail: "Connect your email",
onboardingStepEmailAction: "Connect email",
dashboardResponseRate: "{rate}% response rate",
dashboardMonthsShort: "{count} mo",
dashboardAppliedCount: "{count} applied",
@@ -1452,13 +1455,16 @@ export const translations = {
dashboardHeroLabel: "Oversikt over jobbsøket",
onboardingTitle: "Kom i gang",
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
onboardingDismiss: "Lukk",
onboardingStepCv: "Legg til CV-en din",
onboardingStepSignup: "Opprett kontoen din",
onboardingStepVerify: "Bekreft e-postadressen din",
onboardingStepProfile: "Fullfør profilen din",
onboardingStepProfileAction: "Åpne profil",
onboardingStepCv: "Importer CV-en din",
onboardingStepCvAction: "Legg til CV",
onboardingStepJob: "Importer din første jobb",
onboardingStepJobAction: "Legg til jobb",
onboardingStepMatch: "Sjekk CV-matchscoren på en jobb",
onboardingStepMatchAction: "Åpne jobber",
onboardingStepEmail: "Koble til e-post",
onboardingStepEmailAction: "Koble til e-post",
dashboardResponseRate: "{rate}% svarrate",
dashboardMonthsShort: "{count} md",
dashboardAppliedCount: "{count} søkt",
+21 -1
View File
@@ -47,6 +47,20 @@ function initialsFrom(s?: string) {
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 --
// a deliberate signature element, not derived from theme tokens.
const SIDEBAR_BG = "#0f172a";
@@ -129,6 +143,12 @@ export default function AppShell({
};
}, [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[]]>) => (
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
{groups.map(([section, rows]) => (
@@ -140,7 +160,7 @@ export default function AppShell({
) : null}
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
{rows.map((item) => {
const selected = pathname === item.to || pathname.startsWith(item.to + "/");
const selected = item.to === activeTo;
return (
<ListItemButton
key={item.to}
+15 -2
View File
@@ -20,12 +20,12 @@ const mockedApi = api as jest.Mocked<typeof api>;
let consoleErrorSpy: jest.SpyInstance;
function renderLoginPage() {
function renderLoginPage(initialMode: "login" | "register" = "login") {
return render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<I18nProvider>
<ToastProvider>
<LoginPage />
<LoginPage initialMode={initialMode} />
</ToastProvider>
</I18nProvider>
</MemoryRouter>,
@@ -33,6 +33,19 @@ function renderLoginPage() {
}
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(() => {
const originalConsoleError = console.error.bind(console);
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"));
});
+109 -85
View File
@@ -7,17 +7,6 @@ import ProfilePage from './views/ProfilePage';
import CareerProfilePage from './views/CareerProfilePage';
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', () => ({
api: {
get: jest.fn(),
@@ -34,7 +23,7 @@ jest.mock('./components/CropImageDialog', () => () => null);
const mockedApi = api as jest.Mocked<typeof api>;
const REWRITE_TEMPLATES_COUNT = 6;
let extractionRunsResponse: any[] = [];
const structuredCv = {
version: '1',
@@ -95,6 +84,18 @@ function renderPage() {
void ProfilePage;
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) => {
if (url === '/career/profile') {
// Phase 3: /career reads the structured profile from the relational source of truth.
@@ -125,22 +126,18 @@ beforeEach(() => {
} as any);
}
if (url === '/profile-cv/runs') {
return Promise.resolve({
data: [
{
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',
},
],
} as any);
return Promise.resolve({ data: extractionRunsResponse } as any);
}
if (/^\/profile-cv\/runs\/\d+\/diff$/.test(url)) {
return Promise.resolve({ data: {
runId: 13,
status: 'pending_review',
diff: { totalAdded: 3, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [
{ category: 'Skills', added: [{ id: 'Skills|docker', label: 'Docker', confidence: 'High' }], updated: [], unchangedCount: 2, lowConfidenceCount: 0 },
{ category: 'Languages', added: [{ id: 'Languages|french', label: 'French', confidence: 'Low' }], updated: [], unchangedCount: 1, lowConfidenceCount: 1 },
{ 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 },
] },
} } as any);
}
if (url === '/jobapplications') {
return Promise.resolve({
@@ -180,12 +177,6 @@ beforeEach(() => {
},
} 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') {
return Promise.resolve({ data: { reprocessed: true } } as any);
}
@@ -197,21 +188,21 @@ beforeEach(() => {
afterEach(() => {
jest.clearAllMocks();
createObjectURLMock.mockClear();
revokeObjectURLMock.mockClear();
});
test('profile page loads persisted structured cv and can re-parse it', async () => {
renderPage();
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument();
expect(screen.getByText(/structured cv editor/i)).toBeInTheDocument();
expect(screen.getByText(/profile sections/i)).toBeInTheDocument();
expect(screen.getAllByText(/career information/i).length).toBeGreaterThan(0);
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
expect(screen.getByText(/current run/i)).toBeInTheDocument();
expect(screen.getAllByText(/original extraction/i).length).toBeGreaterThan(0);
const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i });
expect(screen.queryByText(/template-driven cv builder/i)).not.toBeInTheDocument();
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(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
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(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());
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);
});
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 () => {
renderPage();
@@ -249,57 +288,17 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
renderPage();
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');
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
expect(copyButton).toBeDisabled();
expect(screen.queryByRole('button', { name: /copy cv text/i })).not.toBeInTheDocument();
fireEvent.click(originalExtractionToggle);
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
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(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));
expect(screen.getByRole('button', { name: /copy cv text/i })).toBeEnabled();
});
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(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 }}>
<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>
<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" }}>
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>
</Box>
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} onClick={() => navigate("/career/builder")}>Open CV Builder</Button>
</Paper>
<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>
<Paper sx={{ borderRadius: 4, p: { xs: 1.5, md: 2.5 }, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<CareerProfilePage />
+9 -8
View File
@@ -21,7 +21,7 @@ type AuthConfig = {
requireEmailVerification: boolean;
};
export default function LoginPage() {
export default function LoginPage({ initialMode = "login" }: { initialMode?: "login" | "register" }) {
const { toast } = useToast();
const { t } = useI18n();
const navigate = useNavigate();
@@ -40,7 +40,7 @@ export default function LoginPage() {
const [resendingVerification, setResendingVerification] = useState(false);
const [verificationResent, setVerificationResent] = useState(false);
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@]+$/;
@@ -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)" }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
{t("signInTitle")}
{registerMode ? t("createAccount") : t("signInTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{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("google")} />
<Tab label={t("microsoft")} />
</Tabs>
</Tabs> : null}
{tab === 0 && (
<Box
@@ -158,6 +158,7 @@ export default function LoginPage() {
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{registerMode && cfg && !allowReg ? <Alert severity="info">Registration is currently unavailable.</Alert> : null}
{cfg?.requireEmailVerification && emailNotVerified && (
<Alert
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" }}>
{allowReg && (
{(allowReg || initialMode === "register") && (
<Button
type="button"
variant="text"
size="small"
disableRipple
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 }}
>
{registerMode ? t("backToLogin") : t("createAccount")}
</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")}
</Button>
</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>
);
}
+1
View File
@@ -0,0 +1 @@
module.exports = "test-file-stub";
+19 -3
View File
@@ -569,6 +569,8 @@ Rules for normalized_text:
# Work Experience
# Education
# Skills
# Projects
# Certifications
# Languages
# Interests
- Under # Contact, put one plain value per line, no labels unless unavoidable:
@@ -591,7 +593,19 @@ Rules for normalized_text:
Institution, Location line
2016 - 2019 line
- 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.
- Do not output placeholders like Not specified.
- 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.
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,
"reason": "short reason",
"title": string|null,
@@ -636,7 +650,9 @@ Rules:
- Preserve facts only.
- section must be one of the listed values.
- 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 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.
+32
View File
@@ -134,6 +134,38 @@ def test_classify_block_returns_structured_json(monkeypatch):
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):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "_ollama_generate_json", lambda prompt: {"bullets": []})