diff --git a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs new file mode 100644 index 0000000..353153a --- /dev/null +++ b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs @@ -0,0 +1,352 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Phase 5.3 — Application Intelligence. These tests pin the two properties that matter most: the +// results are OWNERSHIP-SCOPED, and nothing here writes to the user's data. Career matching in +// particular reads the master CareerProfile and must leave it byte-identical. +public sealed class ApplicationIntelligenceTests +{ + private const string Advert = """ + We are hiring a Senior Backend Developer (full-time) to join our platform team in Oslo. + + You will: + - Build and operate REST APIs used by every product surface + - Own services end to end, from design through production support + - Work closely with product and design on new features + + We expect: + - Strong experience with C# and .NET + - Solid SQL and Docker knowledge + - Experience with unit tests and CI/CD + """; + + private static (JobTrackerContext db, ApplicationIntelligenceService intelligence, ApplicationTimelineService timeline) New(string userId) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var currentUser = new Mock(); + currentUser.SetupGet(s => s.UserId).Returns(userId); + var db = new JobTrackerContext(options, currentUser.Object); + return (db, new ApplicationIntelligenceService(db, new JobCvMatchService()), new ApplicationTimelineService(db)); + } + + private static async Task SeedJobAsync(JobTrackerContext db, string owner, Action? tweak = null) + { + var company = new Company { OwnerUserId = owner, Name = "Acme" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + var job = new JobApplication + { + OwnerUserId = owner, + CompanyId = company.Id, + JobTitle = "Senior Backend Developer", + Status = "Applied", + Location = "Oslo", + Description = Advert, + }; + tweak?.Invoke(job); + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static async Task SeedProfileAsync(JobTrackerContext db, string owner) + { + var profile = new CareerProfile + { + OwnerUserId = owner, + Experiences = + { + new CareerExperience + { + OwnerUserId = owner, Title = "Backend Developer", Company = "Initech", Start = "2021", IsCurrent = true, + BulletsJson = """["Built REST APIs in C# and .NET","Ran the SQL migration programme"]""", + }, + new CareerExperience + { + OwnerUserId = owner, Title = "Barista", Company = "Coffee Co", Start = "2018", End = "2020", + BulletsJson = """["Served customers"]""", + }, + }, + Projects = + { + new CareerProject + { + OwnerUserId = owner, Name = "Deploy pipeline", Role = "Author", + BulletsJson = """["Docker based CI/CD for six services"]""", + }, + }, + Skills = { new CareerSkill { OwnerUserId = owner, Name = "C#", Category = "Languages" } }, + }; + db.CareerProfiles.Add(profile); + await db.SaveChangesAsync(); + return profile; + } + + // ---------- Milestone 1: timeline ---------- + + [Fact] + public async Task Timeline_turns_raw_events_into_readable_summaries() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-5) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", OldValue = "Applied", NewValue = "Interview", At = DateTime.Now.AddDays(-1) }); + await db.SaveChangesAsync(); + + var result = await timeline.GetAsync("user-1", job.Id, null, false, default); + + Assert.NotNull(result); + var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList(); + Assert.Contains("Moved from Applied to Interview", summaries); + Assert.Contains("Application created", summaries); + } + + [Fact] + public async Task Timeline_groups_by_day_newest_first() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-3) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-3).AddHours(2) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "ReplyReceived", At = DateTime.Now }); + await db.SaveChangesAsync(); + + var result = await timeline.GetAsync("user-1", job.Id, null, false, default); + + Assert.Equal(2, result!.Days.Count); + Assert.True(result.Days[0].Date > result.Days[1].Date); + Assert.Equal(2, result.Days[1].Events.Count); + Assert.Equal("Today", result.Days[0].Label); + } + + [Fact] + public async Task Timeline_detects_milestones_and_ignores_routine_events() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Interview", At = DateTime.Now }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Waiting", At = DateTime.Now.AddDays(-1) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-2) }); + await db.SaveChangesAsync(); + + var result = await timeline.GetAsync("user-1", job.Id, null, false, default); + + Assert.Single(result!.Milestones); + Assert.Equal("Moved to Interview", result.Milestones[0].Summary); + } + + [Fact] + public async Task Timeline_filters_by_category_but_keeps_the_milestone_spine() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Offer", At = DateTime.Now }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now }); + await db.SaveChangesAsync(); + + var result = await timeline.GetAsync("user-1", job.Id, "ai", false, default); + + Assert.Single(result!.Days.SelectMany(d => d.Events)); + Assert.Equal("ai", result.Days[0].Events[0].Category); + // Filtering the detail must not hide what actually happened. + Assert.Single(result.Milestones); + Assert.Equal(2, result.TotalEvents); + } + + [Fact] + public async Task Timeline_is_not_readable_for_another_users_application() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await timeline.GetAsync("user-1", other.Id, null, false, default)); + } + + // ---------- Milestone 2: job analysis ---------- + + [Fact] + public async Task Analysis_extracts_structure_from_the_advert() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + + var a = await intelligence.AnalyzeAsync("user-1", job.Id, default); + + Assert.NotNull(a); + Assert.Equal("Senior Backend Developer", a!.Role); + Assert.Equal("Acme", a.Company); + Assert.Equal("Oslo", a.Location); + Assert.Equal("Full-time", a.EmploymentType); + Assert.Equal("Senior", a.Seniority); + Assert.Contains("C#", a.Technologies); + Assert.Contains(".NET", a.Technologies); + Assert.Contains("Docker", a.Technologies); + Assert.NotEmpty(a.ImportantRequirements); + Assert.NotEmpty(a.Responsibilities); + Assert.True(a.HasJobDescription); + } + + [Fact] + public async Task Analysis_reports_what_the_advert_does_not_say() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1", j => { j.Salary = null; j.JobUrl = null; }); + + var a = await intelligence.AnalyzeAsync("user-1", job.Id, default); + + Assert.Contains(a!.MissingInformation, m => m.Contains("Salary", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(a.MissingInformation, m => m.Contains("link", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Analysis_degrades_gracefully_without_an_advert() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1", j => j.Description = null); + + var a = await intelligence.AnalyzeAsync("user-1", job.Id, default); + + Assert.False(a!.HasJobDescription); + Assert.Empty(a.Responsibilities); + Assert.Contains("advert", a.Summary, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Analysis_is_deterministic() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + + var first = await intelligence.AnalyzeAsync("user-1", job.Id, default); + var second = await intelligence.AnalyzeAsync("user-1", job.Id, default); + + Assert.Equal(first!.Keywords, second!.Keywords); + Assert.Equal(first.Summary, second.Summary); + } + + [Fact] + public async Task Analysis_is_not_readable_for_another_users_application() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await intelligence.AnalyzeAsync("user-1", other.Id, default)); + } + + // ---------- Milestone 3: career matching ---------- + + [Fact] + public async Task Match_scores_the_profile_against_the_advert_with_evidence() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + + var m = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.NotNull(m); + Assert.True(m!.HasCareerProfile); + Assert.True(m.Score > 0); + Assert.Contains("C#", m.MatchedSkills); + // The relevant-experience list is evidence drawn FROM the profile, not a copy of it. + Assert.Contains(m.RelevantExperience, e => e.Title == "Backend Developer"); + Assert.DoesNotContain(m.RelevantExperience, e => e.Title == "Barista"); + Assert.NotEmpty(m.Suggestions); + } + + [Fact] + public async Task Match_never_writes_to_the_career_profile() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + var profile = await SeedProfileAsync(db, "user-1"); + var bulletsBefore = profile.Experiences[0].BulletsJson; + var experienceCountBefore = profile.Experiences.Count; + var versionBefore = profile.Version; + + await intelligence.MatchAsync("user-1", job.Id, default); + + var after = await db.CareerProfiles.Include(p => p.Experiences).FirstAsync(p => p.OwnerUserId == "user-1"); + Assert.Equal(experienceCountBefore, after.Experiences.Count); + Assert.Equal(bulletsBefore, after.Experiences.First(e => e.Title == "Backend Developer").BulletsJson); + Assert.Equal(versionBefore, after.Version); + } + + [Fact] + public async Task Match_asks_for_a_profile_before_scoring_anything() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + + var m = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.False(m!.HasCareerProfile); + Assert.Equal(0, m.Score); + Assert.Contains(m.Suggestions, s => s.Contains("career profile", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Match_only_reads_the_requesting_users_profile() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-2"); // someone else's profile must not be scored + + var m = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.False(m!.HasCareerProfile); + } + + [Fact] + public async Task Match_is_not_readable_for_another_users_application() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await intelligence.MatchAsync("user-1", other.Id, default)); + } + + [Fact] + public async Task Match_counts_ai_suggestions_without_generating_any() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + db.AiInteractions.Add(new AiInteraction + { + OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "career-match", + Title = "Career match", Provider = "p", ResultJson = "{}", CreatedAtUtc = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + var interactionsBefore = await db.AiInteractions.CountAsync(); + + var m = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.Equal(1, m!.AiSuggestionCount); + // Reading the match must not itself call the AI or append history. + Assert.Equal(interactionsBefore, await db.AiInteractions.CountAsync()); + } +} diff --git a/JobTrackerApi/Controllers/ApplicationIntelligenceController.cs b/JobTrackerApi/Controllers/ApplicationIntelligenceController.cs new file mode 100644 index 0000000..4d1b7ae --- /dev/null +++ b/JobTrackerApi/Controllers/ApplicationIntelligenceController.cs @@ -0,0 +1,63 @@ +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +// Phase 5.3 — Application Intelligence. Three read-only endpoints on the application: +// timeline (interprets JobEvent), analysis (reads the advert), match (reads the master profile). +// +// None of them write anything. The AI narrative for analysis and match stays on the existing +// /api/jobapplications/{id}/ai routes, which are suggestion-only and versioned by AiInteraction. +// docs/architecture/application-workspace.md. +[ApiController] +[Route("api/jobapplications/{jobId:int}")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class ApplicationIntelligenceController : ControllerBase +{ + private readonly UserManager _users; + private readonly IApplicationTimelineService _timeline; + private readonly IApplicationIntelligenceService _intelligence; + + public ApplicationIntelligenceController( + UserManager users, + IApplicationTimelineService timeline, + IApplicationIntelligenceService intelligence) + { + _users = users; + _timeline = timeline; + _intelligence = intelligence; + } + + [HttpGet("timeline")] + public async Task> GetTimeline( + int jobId, [FromQuery] string? category, [FromQuery] bool milestonesOnly, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _timeline.GetAsync(userId, jobId, category, milestonesOnly, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("analysis")] + public async Task> GetAnalysis(int jobId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _intelligence.AnalyzeAsync(userId, jobId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("match")] + public async Task> GetMatch(int jobId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _intelligence.MatchAsync(userId, jobId, ct); + return result is null ? NotFound() : Ok(result); + } + + private async Task CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id; +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 3ff81e8..b94aa4c 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -43,6 +43,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/ApplicationIntelligenceService.cs b/JobTrackerApi/Services/ApplicationIntelligenceService.cs new file mode 100644 index 0000000..bcf4620 --- /dev/null +++ b/JobTrackerApi/Services/ApplicationIntelligenceService.cs @@ -0,0 +1,365 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services.JobImport; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +// Phase 5.3 Milestones 2 and 3 — job analysis and career matching. +// +// DETERMINISTIC and READ-ONLY. Both endpoints derive their answer from data the user already owns +// (the advert on the JobApplication, the master CareerProfile) using the existing SkillTagger and +// JobCvMatchService. The same input always gives the same number, so the score is something a user +// can trust and re-check. +// +// The AI narrative is deliberately NOT here: it stays in AiWorkspaceService's "job-analysis" and +// "career-match" modules, which are suggestion-only, append-only (AiInteraction is the version +// history), and require the user to act on them. Nothing in this file writes to the CareerProfile, +// a CvVariant, or the JobApplication. docs/architecture/application-workspace.md. +public sealed record JobAnalysisDto( + string? Role, + string? Company, + string? Location, + string? EmploymentType, + string? Seniority, + string? Salary, + IReadOnlyList Technologies, + IReadOnlyList Skills, + IReadOnlyList Responsibilities, + IReadOnlyList Keywords, + string Summary, + IReadOnlyList ImportantRequirements, + IReadOnlyList InterviewTopics, + IReadOnlyList MissingInformation, + bool HasJobDescription, + int AiSuggestionCount); + +public sealed record MatchEvidenceDto(string Title, string? Subtitle, IReadOnlyList Matched); + +public sealed record CareerMatchDto( + int Score, + string Band, + bool HasEnoughSignal, + bool HasCareerProfile, + IReadOnlyList MatchedSkills, + IReadOnlyList MissingSkills, + IReadOnlyList RelevantExperience, + IReadOnlyList RelevantProjects, + IReadOnlyList Suggestions, + int AiSuggestionCount); + +public interface IApplicationIntelligenceService +{ + Task AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); + Task MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); +} + +public sealed class ApplicationIntelligenceService : IApplicationIntelligenceService +{ + private const int MaxEvidence = 5; + + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + // Bullet lines in an advert: "-", "*", "•", or "1." at the start of a line. + private static readonly Regex BulletRx = new(@"^\s*(?:[-*•·–]|\d+[.)])\s+(?.+)$", + RegexOptions.Multiline | RegexOptions.Compiled); + + private static readonly (string Label, Regex Pattern)[] EmploymentTypes = + { + ("Full-time", new Regex(@"\bfull[-\s]?time\b|\bfast stilling\b|\bheltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + ("Part-time", new Regex(@"\bpart[-\s]?time\b|\bdeltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + ("Contract", new Regex(@"\bcontract\b|\bfreelance\b|\bconsultan(t|cy)\b|\bengasjement\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + ("Internship", new Regex(@"\bintern(ship)?\b|\btrainee\b|\bpraktikant\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + ("Temporary", new Regex(@"\btemporary\b|\bfixed[-\s]?term\b|\bvikariat\b|\bmidlertidig\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + }; + + private static readonly (string Label, Regex Pattern)[] Seniorities = + { + ("Lead / Principal", new Regex(@"\b(lead|principal|staff|head of|director)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + ("Senior", new Regex(@"\bsenior\b|\bsr\.?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + ("Junior / Entry", new Regex(@"\bjunior\b|\bjr\.?\b|\bentry[-\s]?level\b|\bgraduate\b|\bnyutdannet\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + ("Mid-level", new Regex(@"\bmid[-\s]?level\b|\bintermediate\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + }; + + // A salary line: a currency figure, or an explicit salary/lønn mention with numbers nearby. + private static readonly Regex SalaryRx = new( + @"(?:(?:[£$€]|\bNOK\b|\bkr\b|\bUSD\b|\bGBP\b|\bEUR\b)\s?[\d][\d\s.,]{2,}(?:\s?[-–]\s?[\d][\d\s.,]{2,})?(?:\s?(?:k|per\s+(?:year|annum|month|hour)|p\.?a\.?))?)|(?:\b(?:salary|lønn|compensation)\b[^.\n]{0,60}?[\d][\d\s.,]{2,}[^.\n]{0,20})", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex ResponsibilityRx = new( + @"\b(you will|you'll|responsib|the role|day[-\s]to[-\s]day|arbeidsoppgaver|du vil)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex RequirementRx = new( + @"\b(require|must have|essential|we expect|you have|experience (?:with|in)|proficien|kvalifikasjon|vi ser etter)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly JobTrackerContext _db; + private readonly IJobCvMatchService _match; + + public ApplicationIntelligenceService(JobTrackerContext db, IJobCvMatchService match) + { + _db = db; + _match = match; + } + + // ---------- Milestone 2: job analysis ---------- + + public async Task AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + if (job is null) return null; + + var description = job.Description ?? string.Empty; + var hasDescription = !string.IsNullOrWhiteSpace(description); + var haystack = $"{job.JobTitle}\n{description}"; + + // Same tagger the job importer and the CV match use, so the vocabulary is consistent everywhere. + var tags = SkillTagger.Detect(haystack); + var technologies = tags.Where(IsTechnology).ToList(); + var skills = tags.Where(t => !IsTechnology(t)).ToList(); + + var bullets = BulletRx.Matches(description) + .Select(m => Tidy(m.Groups["text"].Value)) + .Where(b => b.Length > 12) + .ToList(); + + var responsibilities = Pick(bullets, description, ResponsibilityRx); + var requirements = Pick(bullets, description, RequirementRx); + + var aiCount = await _db.AiInteractions.AsNoTracking() + .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "job-analysis", ct); + + return new JobAnalysisDto( + Role: Blank(job.JobTitle), + Company: Blank(job.Company?.Name), + Location: Blank(job.Location), + EmploymentType: FirstMatch(EmploymentTypes, haystack), + Seniority: FirstMatch(Seniorities, haystack), + Salary: Blank(job.Salary) ?? (hasDescription ? Tidy(SalaryRx.Match(description).Value) is { Length: > 0 } s ? s : null : null), + Technologies: technologies, + Skills: skills, + Responsibilities: responsibilities, + Keywords: tags.ToList(), + Summary: BuildSummary(job, technologies, hasDescription), + ImportantRequirements: requirements, + InterviewTopics: technologies.Concat(skills).Take(8).ToList(), + MissingInformation: MissingInformation(job, hasDescription), + HasJobDescription: hasDescription, + AiSuggestionCount: aiCount); + } + + private static string BuildSummary(JobApplication job, IReadOnlyList technologies, bool hasDescription) + { + if (!hasDescription) + { + return "No advert text saved yet, so this analysis is limited to the fields on the application. Paste the advert to get requirements, technologies and interview topics."; + } + + var sb = new StringBuilder(); + sb.Append(job.JobTitle); + if (!string.IsNullOrWhiteSpace(job.Company?.Name)) sb.Append(" at ").Append(job.Company!.Name); + if (!string.IsNullOrWhiteSpace(job.Location)) sb.Append(" · ").Append(job.Location); + sb.Append('.'); + + if (technologies.Count > 0) + { + sb.Append(" The advert leans on ") + .Append(string.Join(", ", technologies.Take(5))) + .Append('.'); + } + + return sb.ToString(); + } + + private static List MissingInformation(JobApplication job, bool hasDescription) + { + var missing = new List(); + if (!hasDescription) missing.Add("The advert text itself"); + if (string.IsNullOrWhiteSpace(job.Salary)) missing.Add("Salary or compensation range"); + if (string.IsNullOrWhiteSpace(job.Location)) missing.Add("Location or remote policy"); + if (string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) missing.Add("A named contact to follow up with"); + if (string.IsNullOrWhiteSpace(job.JobUrl)) missing.Add("A link back to the original posting"); + return missing; + } + + // ---------- Milestone 3: career matching ---------- + + public async Task MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + if (job is null) return null; + + // READ ONLY. The master profile is the single source of truth and nothing here writes to it. + var profile = await _db.CareerProfiles.AsNoTracking() + .Include(p => p.Experiences) + .Include(p => p.Projects) + .Include(p => p.Skills) + .FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct); + + var aiCount = await _db.AiInteractions.AsNoTracking() + .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "career-match", ct); + + if (profile is null) + { + return new CareerMatchDto(0, "No profile", false, false, + Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty(), + new[] { "Build your career profile first — matching compares the advert against it." }, + aiCount); + } + + // Feed the profile to the SAME deterministic matcher the CV builder uses, so one job scores + // identically whichever surface asks. + var sections = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Experience"] = string.Join("\n", profile.Experiences.Select(ExperienceText)), + ["Projects"] = string.Join("\n", profile.Projects.Select(ProjectText)), + ["Skills"] = string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}")), + }; + + var result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections); + + var relevantExperience = profile.Experiences + .Select(e => new { Entry = e, Hits = HitsFor(ExperienceText(e), result.MatchedKeywords) }) + .Where(x => x.Hits.Count > 0) + .OrderByDescending(x => x.Hits.Count) + .Take(MaxEvidence) + .Select(x => new MatchEvidenceDto( + x.Entry.Title ?? "Untitled role", + string.Join(" · ", new[] { x.Entry.Company, Period(x.Entry.Start, x.Entry.End, x.Entry.IsCurrent) }.Where(v => !string.IsNullOrWhiteSpace(v))), + x.Hits)) + .ToList(); + + var relevantProjects = profile.Projects + .Select(p => new { Entry = p, Hits = HitsFor(ProjectText(p), result.MatchedKeywords) }) + .Where(x => x.Hits.Count > 0) + .OrderByDescending(x => x.Hits.Count) + .Take(MaxEvidence) + .Select(x => new MatchEvidenceDto( + x.Entry.Name ?? "Untitled project", + Blank(x.Entry.Role), + x.Hits)) + .ToList(); + + return new CareerMatchDto( + result.Score, + result.Band, + result.HasEnoughSignal, + HasCareerProfile: true, + MatchedSkills: result.MatchedKeywords, + MissingSkills: result.MissingKeywords, + RelevantExperience: relevantExperience, + RelevantProjects: relevantProjects, + Suggestions: Suggestions(result, relevantExperience.Count), + AiSuggestionCount: aiCount); + } + + // Suggestions describe what the USER could change. They never edit anything themselves. + private static List Suggestions(JobCvMatchResult result, int experienceHits) + { + var suggestions = new List(); + + if (!result.HasEnoughSignal) + { + suggestions.Add("The advert is too short to score reliably — paste the full text for a real match."); + return suggestions; + } + + if (result.MissingKeywords.Count > 0) + { + suggestions.Add($"The advert asks for {string.Join(", ", result.MissingKeywords.Take(4))} — add it to your profile if you have it."); + } + + if (experienceHits == 0) + { + suggestions.Add("No experience entry matched the advert. Rewrite your bullets in the advert's vocabulary where it is honest to do so."); + } + + if (result.Score < 50) + { + suggestions.Add("A CV variant tailored to this advert would lift the match — the builder starts from your master profile."); + } + else if (result.Score < 80) + { + suggestions.Add("Solid match. Lead with the matched skills in your cover letter's opening paragraph."); + } + else + { + suggestions.Add("Strong match. Focus your effort on the cover letter and interview prep rather than the CV."); + } + + return suggestions; + } + + // ---------- shared ---------- + + private static string ExperienceText(CareerExperience e) => + $"{e.Title} {e.Company} {e.Location} {ReadJsonArray(e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}"; + + private static string ProjectText(CareerProject p) => + $"{p.Name} {p.Role} {ReadJsonArray(p.BulletsJson)} {ReadJsonArray(p.SkillsJson)}"; + + private static string ReadJsonArray(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return string.Empty; + try + { + var items = JsonSerializer.Deserialize>(json, Json); + return items is null ? string.Empty : string.Join(" ", items); + } + catch (JsonException) + { + // A malformed blob must not break the whole match — treat it as no text. + return string.Empty; + } + } + + // Which of the job's matched keywords this specific entry is the evidence for. + private static List HitsFor(string text, IReadOnlyList matchedKeywords) => + matchedKeywords.Where(k => SkillTagger.MatchesTag(k, text)).ToList(); + + private static string? Period(string? start, string? end, bool isCurrent) + { + if (string.IsNullOrWhiteSpace(start)) return isCurrent ? "Current" : null; + return isCurrent ? $"{start} – present" : string.IsNullOrWhiteSpace(end) ? start : $"{start} – {end}"; + } + + // Pull the bullets nearest the paragraph that introduces requirements/responsibilities. Falls back + // to "all bullets" when the advert has no such heading, which is common enough. + private static List Pick(List bullets, string description, Regex cue) + { + if (bullets.Count == 0) return new List(); + if (!cue.IsMatch(description)) return bullets.Take(MaxEvidence).ToList(); + + var cued = bullets.Where(b => cue.IsMatch(b)).ToList(); + return (cued.Count > 0 ? cued : bullets).Take(MaxEvidence).ToList(); + } + + private static bool IsTechnology(string tag) => tag switch + { + "Communication" or "Collaboration" or "Problem Solving" or "Leadership" or "Ownership" + or "Adaptability" or "Attention to Detail" or "Agile" => false, + _ => true, + }; + + private static string? FirstMatch((string Label, Regex Pattern)[] table, string text) + { + foreach (var (label, pattern) in table) + { + if (pattern.IsMatch(text)) return label; + } + return null; + } + + private static string Tidy(string? value) => + string.IsNullOrWhiteSpace(value) ? string.Empty : Regex.Replace(value.Trim(), @"\s+", " "); + + private static string? Blank(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/JobTrackerApi/Services/ApplicationTimelineService.cs b/JobTrackerApi/Services/ApplicationTimelineService.cs new file mode 100644 index 0000000..cb3a01b --- /dev/null +++ b/JobTrackerApi/Services/ApplicationTimelineService.cs @@ -0,0 +1,175 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +// Phase 5.3 Milestone 1 — timeline intelligence. +// +// A READ-ONLY interpretation layer over JobEvent. JobEvent stays the source of historical truth: this +// writes nothing, stores nothing, and adds no table. It turns rows like +// ("StatusChanged", "Applied", "Interview") into a sentence, tags each event with a category and +// whether it is a milestone, and groups the result by day so the workspace can render a real +// timeline instead of a flat list. docs/architecture/application-workspace.md. +public sealed record TimelineEventDto( + int Id, + string Type, + string Category, + string Summary, + string? Detail, + bool IsMilestone, + DateTime At); + +public sealed record TimelineDayDto(DateTime Date, string Label, IReadOnlyList Events); + +public sealed record TimelineDto( + IReadOnlyList Days, + IReadOnlyList Milestones, + IReadOnlyList Categories, + int TotalEvents); + +public interface IApplicationTimelineService +{ + Task GetAsync(string ownerUserId, int jobApplicationId, string? category, bool milestonesOnly, CancellationToken ct); +} + +public sealed class ApplicationTimelineService : IApplicationTimelineService +{ + // Event categories, so the UI can filter without knowing every raw Type. + public const string CategoryLifecycle = "lifecycle"; + public const string CategoryStage = "stage"; + public const string CategoryFollowUp = "follow-up"; + public const string CategoryCommunication = "communication"; + public const string CategoryAi = "ai"; + + private readonly JobTrackerContext _db; + + public ApplicationTimelineService(JobTrackerContext db) + { + _db = db; + } + + public async Task GetAsync(string ownerUserId, int jobApplicationId, string? category, bool milestonesOnly, CancellationToken ct) + { + var owns = await _db.JobApplications.AsNoTracking() + .AnyAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + if (!owns) return null; + + var events = await _db.JobEvents.AsNoTracking() + .Where(e => e.JobApplicationId == jobApplicationId) + .OrderByDescending(e => e.At) + .ThenByDescending(e => e.Id) + .ToListAsync(ct); + + var projected = events.Select(Describe).ToList(); + + var filtered = projected + .Where(e => string.IsNullOrWhiteSpace(category) || string.Equals(e.Category, category, StringComparison.OrdinalIgnoreCase)) + .Where(e => !milestonesOnly || e.IsMilestone) + .ToList(); + + var days = filtered + .GroupBy(e => e.At.Date) + .OrderByDescending(g => g.Key) + .Select(g => new TimelineDayDto(g.Key, DayLabel(g.Key), g.ToList())) + .ToList(); + + return new TimelineDto( + days, + // Milestones ignore the active filter: they are the "what actually happened" spine and stay + // visible while the user narrows the detail below. + projected.Where(e => e.IsMilestone).ToList(), + projected.Select(e => e.Category).Distinct().OrderBy(c => c, StringComparer.Ordinal).ToList(), + projected.Count); + } + + // One JobEvent row -> a sentence a human can read, plus its category and milestone flag. + private static TimelineEventDto Describe(JobEvent e) + { + var type = (e.Type ?? string.Empty).Trim(); + var (category, summary, isMilestone) = type switch + { + "Created" => (CategoryLifecycle, "Application created", true), + "Deleted" => (CategoryLifecycle, "Application moved to trash", false), + "Restored" => (CategoryLifecycle, "Application restored from trash", false), + "Undo" => (CategoryLifecycle, "Change undone", false), + "StatusChanged" => (CategoryStage, StatusSummary(e), IsMilestoneStatus(e.NewValue)), + "FollowUpSet" => (CategoryFollowUp, FollowUpSummary(e), false), + "ResponseUpdated" => (CategoryCommunication, ResponseSummary(e), false), + "ReplyReceived" => (CategoryCommunication, "Reply received", true), + "AiRefreshed" => (CategoryAi, "AI suggestions refreshed", false), + _ => (CategoryLifecycle, string.IsNullOrWhiteSpace(type) ? "Activity recorded" : Humanize(type), false), + }; + + // The note is the user's own words, so it always wins as the detail line. + var detail = !string.IsNullOrWhiteSpace(e.Note) ? e.Note!.Trim() : null; + + return new TimelineEventDto(e.Id, type, category, summary, detail, isMilestone, e.At); + } + + private static string StatusSummary(JobEvent e) + { + var from = Clean(e.OldValue); + var to = Clean(e.NewValue); + if (to is null) return "Status changed"; + return from is null ? $"Moved to {to}" : $"Moved from {from} to {to}"; + } + + private static string FollowUpSummary(JobEvent e) + { + var to = Clean(e.NewValue); + if (to is null) return "Follow-up cleared"; + return DateTime.TryParse(to, out var parsed) + ? $"Follow-up scheduled for {parsed:d MMMM yyyy}" + : $"Follow-up scheduled for {to}"; + } + + private static string ResponseSummary(JobEvent e) + { + var to = Clean(e.NewValue); + return to is null ? "Response status updated" : $"Response marked {to}"; + } + + // The stages that actually mean something happened, as opposed to routine housekeeping. + private static bool IsMilestoneStatus(string? status) + { + var s = (status ?? string.Empty).Trim(); + if (s.Length == 0) return false; + return s.Contains("applied", StringComparison.OrdinalIgnoreCase) + || s.Contains("interview", StringComparison.OrdinalIgnoreCase) + || s.Contains("offer", StringComparison.OrdinalIgnoreCase) + || s.Contains("rejected", StringComparison.OrdinalIgnoreCase) + || s.Contains("accepted", StringComparison.OrdinalIgnoreCase) + || s.Contains("declined", StringComparison.OrdinalIgnoreCase); + } + + private static string DayLabel(DateTime date) + { + var today = DateTime.Now.Date; + if (date == today) return "Today"; + if (date == today.AddDays(-1)) return "Yesterday"; + return date.Year == today.Year ? date.ToString("dddd d MMMM") : date.ToString("d MMMM yyyy"); + } + + private static string? Clean(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + // "StatusChanged" -> "Status changed", so an unknown future type still reads as a sentence. + private static string Humanize(string type) + { + var chars = new List(type.Length + 4); + for (var i = 0; i < type.Length; i++) + { + if (i > 0 && char.IsUpper(type[i])) + { + chars.Add(' '); + chars.Add(char.ToLowerInvariant(type[i])); + } + else + { + chars.Add(type[i]); + } + } + return new string(chars.ToArray()); + } +} diff --git a/docs/architecture/application-workspace.md b/docs/architecture/application-workspace.md index 2846908..a648a0f 100644 --- a/docs/architecture/application-workspace.md +++ b/docs/architecture/application-workspace.md @@ -18,6 +18,8 @@ The workspace **owns no data and duplicates none**. It is an aggregate read plus | Section | Backed by (existing system) | |---|---| +| Timeline | `JobEvent` — interpreted, never replaced | +| Analysis / Match | the advert and the master `CareerProfile`, read deterministically | | Checklist | `ApplicationChecklistItem` — completion state only, seeded from the readiness signals | | CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile` | | Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history | @@ -158,6 +160,68 @@ its own parallel checklist. It **projects** the persisted checklist: So the division is: **the checklist is the workflow the user drives, readiness is the calculation and health indicator derived from it.** One system, two projections. +## Application intelligence (Phase 5.3) + +Three read-only reads that answer "how suitable is this job", "how does my experience match", "what am +I missing", "what happened previously". All deterministic, all owned by nothing: + +| Endpoint | Reads | Owns | +|---|---|---| +| `GET /{id}/timeline` | `JobEvent` | nothing | +| `GET /{id}/analysis` | `JobApplication.Description` | nothing | +| `GET /{id}/match` | `CareerProfile` + the advert | nothing | + +No new table, no new column. `ApplicationTimelineService` and `ApplicationIntelligenceService` write +nothing at all. + +### The AI boundary + +**The deterministic answer and the AI narrative are separate on purpose.** + +- The three endpoints above never call the AI. Opening the Analysis or Match section costs nothing + and cannot change anything — the page renders a computed answer. +- The narrative lives where it already did: `AiWorkspaceService`'s `job-analysis` and `career-match` + modules, reached from `AiWorkspacePanel`, generated only when the user asks. +- Every generation is appended as an `AiInteraction` — that append-only history *is* the versioning, + and the user restores, compares or deletes from it. +- AI output is a suggestion. Nothing in this phase writes to the `CareerProfile`, a `CvVariant`, a + cover letter, or the `JobApplication`. `Match_never_writes_to_the_career_profile` pins that. + +So a user gets a trustworthy number for free, and pays for prose only when they want it. + +### Timeline + +`JobEvent` stays the source of historical truth; the service is an interpretation layer over it. Each +row gains a readable summary (`("StatusChanged", "Applied", "Interview")` → "Moved from Applied to +Interview"), a category (`lifecycle`, `stage`, `follow-up`, `communication`, `ai`) and a milestone +flag. Events group by day with relative labels. + +Milestones are the stages that mean something happened — applied, interview, offer, rejected, +accepted, declined — plus creation and replies received. They are returned **unfiltered**: narrowing +the detail below must not hide what actually happened. + +An unrecognised future `Type` degrades to a humanised sentence rather than disappearing. + +### Job analysis + +Structured extraction from the advert, reusing the existing `SkillTagger` so the vocabulary matches +the job importer and the CV match. Facts (role, company, location, employment type, seniority, +salary), lists (technologies, skills, responsibilities, requirements, keywords, interview topics), +and — deliberately — **what the advert does not say**, which is usually the more useful half. + +With no advert saved it degrades to the fields on the application and says so. + +### Career matching + +Feeds the master `CareerProfile` into the same `JobCvMatchService` the CV builder uses, so one +application scores identically whichever surface asks. Returns the score and band, matched and +missing skills, and — the part that makes it actionable — **which experience and project entries are +the evidence** for each matched keyword, ranked by hit count. + +Suggestions describe what the user could change. They never change it. + +With no profile it returns score 0 and asks the user to build one, rather than implying a bad match. + ## Extension points - **New section**: add to `WORKSPACE_SECTIONS` and render it; nav is data-driven. @@ -170,5 +234,7 @@ health indicator derived from it.** One system, two projections. 1. ✅ Workspace foundation — route, nav shell, aggregate overview, next recommended action. 2. ✅ Checklist and progress tracking — persisted items, auto-completion from the readiness signals, custom items, reordering, dismissal; readiness refactored into a projection of it. -3. Timeline and activity history. 4. Job analysis. 5. Career matching. 6. CV integration. +3. ✅ Application intelligence — timeline interpretation, structured job analysis, career matching + (Phase 5.3, all three deterministic and read-only). +4. CV integration. 7. Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements. diff --git a/job-tracker-ui/src/application-intelligence.test.tsx b/job-tracker-ui/src/application-intelligence.test.tsx new file mode 100644 index 0000000..eda9deb --- /dev/null +++ b/job-tracker-ui/src/application-intelligence.test.tsx @@ -0,0 +1,180 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { + ApplicationAnalysis, ApplicationMatch, ApplicationTimeline, +} from "./components/ApplicationIntelligence"; +import { api } from "./api"; + +jest.mock("./api", () => ({ + api: { + get: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.", +})); + +const mockedApi = api as jest.Mocked; + +const timeline = { + days: [ + { + date: "2026-07-19", + label: "Today", + events: [ + { id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" }, + { id: 3, type: "AiRefreshed", category: "ai", summary: "AI suggestions refreshed", detail: null, isMilestone: false, at: "2026-07-19T08:00:00Z" }, + ], + }, + ], + milestones: [ + { id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" }, + ], + categories: ["ai", "stage"], + totalEvents: 2, +}; + +const analysis = { + role: "Senior Backend Developer", + company: "Acme", + location: "Oslo", + employmentType: "Full-time", + seniority: "Senior", + salary: null, + technologies: ["C#", ".NET"], + skills: ["Collaboration"], + responsibilities: ["Build and operate REST APIs"], + keywords: ["C#", ".NET", "Collaboration"], + summary: "Senior Backend Developer at Acme · Oslo.", + importantRequirements: ["Strong experience with C# and .NET"], + interviewTopics: ["C#", ".NET"], + missingInformation: ["Salary or compensation range"], + hasJobDescription: true, + aiSuggestionCount: 0, +}; + +const match = { + score: 72, + band: "Good", + hasEnoughSignal: true, + hasCareerProfile: true, + matchedSkills: ["C#", "SQL"], + missingSkills: ["Kubernetes"], + relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 – present", matched: ["C#"] }], + relevantProjects: [], + suggestions: ["Solid match. Lead with the matched skills."], + aiSuggestionCount: 0, +}; + +beforeEach(() => jest.clearAllMocks()); + +// ---------- Timeline ---------- + +test("timeline renders grouped days, milestones and readable summaries", async () => { + mockedApi.get.mockResolvedValue({ data: timeline } as any); + + render(); + + expect(await screen.findByText("Milestones")).toBeInTheDocument(); + expect(screen.getByText("Today")).toBeInTheDocument(); + expect(screen.getAllByText("Moved from Applied to Interview").length).toBeGreaterThan(0); + expect(screen.getByText("AI suggestions refreshed")).toBeInTheDocument(); +}); + +test("timeline filters by category", async () => { + mockedApi.get.mockResolvedValue({ data: timeline } as any); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "AI" })); + + await waitFor(() => + expect(mockedApi.get).toHaveBeenLastCalledWith( + "/jobapplications/7/timeline", + { params: { category: "ai", milestonesOnly: undefined } }, + )); +}); + +test("timeline shows an empty state when nothing has happened", async () => { + mockedApi.get.mockResolvedValue({ data: { days: [], milestones: [], categories: [], totalEvents: 0 } } as any); + + render(); + + expect(await screen.findByText(/Nothing has happened yet/i)).toBeInTheDocument(); +}); + +test("timeline surfaces an error instead of rendering nothing", async () => { + mockedApi.get.mockRejectedValue(new Error("boom")); + + render(); + + expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument(); +}); + +// ---------- Analysis ---------- + +test("analysis renders the extracted structure", async () => { + mockedApi.get.mockResolvedValue({ data: analysis } as any); + + render(); + + expect(await screen.findByText("Senior Backend Developer at Acme · Oslo.")).toBeInTheDocument(); + expect(screen.getByText("Full-time")).toBeInTheDocument(); + expect(screen.getByText("Strong experience with C# and .NET")).toBeInTheDocument(); + expect(screen.getByText("Salary or compensation range")).toBeInTheDocument(); +}); + +test("analysis prompts for the advert when there is none", async () => { + mockedApi.get.mockResolvedValue({ + data: { ...analysis, hasJobDescription: false, technologies: [], responsibilities: [], importantRequirements: [] }, + } as any); + + render(); + + expect(await screen.findByText(/No advert text saved yet/i)).toBeInTheDocument(); +}); + +test("analysis shows a loading state before the data arrives", () => { + mockedApi.get.mockReturnValue(new Promise(() => {}) as any); + + const { container } = render(); + + expect(container.querySelectorAll(".MuiSkeleton-root").length).toBeGreaterThan(0); +}); + +// ---------- Match ---------- + +test("match renders the score, evidence and suggestions", async () => { + mockedApi.get.mockResolvedValue({ data: match } as any); + + render(); + + expect(await screen.findByText("72%")).toBeInTheDocument(); + expect(screen.getByText("Good")).toBeInTheDocument(); + expect(screen.getByText("Backend Developer")).toBeInTheDocument(); + expect(screen.getByText("Kubernetes")).toBeInTheDocument(); + expect(screen.getByText(/Solid match/i)).toBeInTheDocument(); +}); + +test("match asks for a career profile before showing a score", async () => { + mockedApi.get.mockResolvedValue({ + data: { + ...match, score: 0, band: "No profile", hasCareerProfile: false, + matchedSkills: [], missingSkills: [], relevantExperience: [], + suggestions: ["Build your career profile first."], + }, + } as any); + + render(); + + expect(await screen.findByText(/No career profile yet/i)).toBeInTheDocument(); + expect(screen.queryByText("0%")).not.toBeInTheDocument(); +}); + +test("match warns when the advert is too short to score", async () => { + mockedApi.get.mockResolvedValue({ data: { ...match, hasEnoughSignal: false } } as any); + + render(); + + expect(await screen.findByText(/too short to score reliably/i)).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index 747b288..dbb2631 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -84,7 +84,7 @@ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; mile { key: "portfolio", label: "Portfolio", milestone: 8 }, { key: "documents", label: "Documents" }, { key: "interview", label: "Interview Prep" }, - { key: "timeline", label: "Timeline", milestone: 3 }, + { key: "timeline", label: "Timeline" }, { key: "notes", label: "Notes", milestone: 3 }, { key: "communication", label: "Communication" }, ]; @@ -94,6 +94,81 @@ export const applicationWorkspaceApi = { api.get(`/jobapplications/${jobId}/workspace`).then((r) => r.data), }; +// Phase 5.3 — Application Intelligence. All three reads are deterministic and read-only; the AI +// narrative stays on the existing /ai routes, which require the user to ask for it. +export type TimelineEvent = { + id: number; + type: string; + category: string; + summary: string; + detail: string | null; + isMilestone: boolean; + at: string; +}; + +export type TimelineDay = { date: string; label: string; events: TimelineEvent[] }; + +export type Timeline = { + days: TimelineDay[]; + milestones: TimelineEvent[]; + categories: string[]; + totalEvents: number; +}; + +export type JobAnalysis = { + role: string | null; + company: string | null; + location: string | null; + employmentType: string | null; + seniority: string | null; + salary: string | null; + technologies: string[]; + skills: string[]; + responsibilities: string[]; + keywords: string[]; + summary: string; + importantRequirements: string[]; + interviewTopics: string[]; + missingInformation: string[]; + hasJobDescription: boolean; + aiSuggestionCount: number; +}; + +export type MatchEvidence = { title: string; subtitle: string | null; matched: string[] }; + +export type CareerMatch = { + score: number; + band: string; + hasEnoughSignal: boolean; + hasCareerProfile: boolean; + matchedSkills: string[]; + missingSkills: string[]; + relevantExperience: MatchEvidence[]; + relevantProjects: MatchEvidence[]; + suggestions: string[]; + aiSuggestionCount: number; +}; + +export const TIMELINE_CATEGORY_LABELS: Record = { + lifecycle: "Lifecycle", + stage: "Stage", + "follow-up": "Follow-up", + communication: "Communication", + ai: "AI", +}; + +export const applicationIntelligenceApi = { + timeline: (jobId: number, category?: string, milestonesOnly?: boolean) => + api + .get(`/jobapplications/${jobId}/timeline`, { + params: { category: category || undefined, milestonesOnly: milestonesOnly || undefined }, + }) + .then((r) => r.data), + analysis: (jobId: number) => + api.get(`/jobapplications/${jobId}/analysis`).then((r) => r.data), + match: (jobId: number) => api.get(`/jobapplications/${jobId}/match`).then((r) => r.data), +}; + export const applicationChecklistApi = { get: (jobId: number) => api.get(`/jobapplications/${jobId}/checklist`).then((r) => r.data), diff --git a/job-tracker-ui/src/components/ApplicationIntelligence.tsx b/job-tracker-ui/src/components/ApplicationIntelligence.tsx new file mode 100644 index 0000000..5d4cd84 --- /dev/null +++ b/job-tracker-ui/src/components/ApplicationIntelligence.tsx @@ -0,0 +1,344 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { + Alert, Box, Chip, Divider, LinearProgress, Paper, Skeleton, Stack, ToggleButton, + ToggleButtonGroup, Typography, +} from "@mui/material"; + +import { getApiErrorMessage } from "../api"; +import { + CareerMatch, JobAnalysis, TIMELINE_CATEGORY_LABELS, Timeline, applicationIntelligenceApi, +} from "../applicationWorkspace"; + +// Phase 5.3 — Application Intelligence sections for the workspace. +// +// Everything here renders a deterministic, read-only backend answer. No component triggers an AI +// generation: that stays an explicit user action in AiWorkspacePanel, so nothing on this page can +// silently spend a token or change the user's data. +// docs/architecture/application-workspace.md. + +// One loader for all three sections: same fetch/loading/empty/error shape, so the sections stay +// consistent and each one is just its own rendering. +function useIntelligence(load: () => Promise, deps: React.DependencyList) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const run = useCallback(load, deps); + + useEffect(() => { + let cancelled = false; + setLoading(true); + run() + .then((result) => { + if (cancelled) return; + setData(result); + setError(null); + }) + .catch((err) => { + if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section.")); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [run]); + + return { data, error, loading }; +} + +function SectionShell({ title, subtitle, loading, error, empty, emptyText, children }: { + title: string; + subtitle?: string; + loading: boolean; + error: string | null; + empty?: boolean; + emptyText?: string; + children: React.ReactNode; +}) { + return ( + + {title} + {subtitle && {subtitle}} + + {loading ? ( + {[0, 1, 2].map((i) => )} + ) : error ? ( + {error} + ) : empty ? ( + {emptyText} + ) : ( + children + )} + + ); +} + +function Chips({ label, values, color }: { label: string; values: string[]; color?: "success" | "warning" | "default" }) { + if (values.length === 0) return null; + return ( + + {label} + + {values.map((v) => ( + + ))} + + + ); +} + +function Bullets({ label, values }: { label: string; values: string[] }) { + if (values.length === 0) return null; + return ( + + {label} + + {values.map((v, i) => ( + {v} + ))} + + + ); +} + +// ---------- Timeline ---------- + +export function ApplicationTimeline({ jobId }: { jobId: number }) { + const [category, setCategory] = useState(""); + const { data, error, loading } = useIntelligence( + () => applicationIntelligenceApi.timeline(jobId, category || undefined), + [jobId, category], + ); + + const hasEvents = (data?.totalEvents ?? 0) > 0; + + return ( + + {hasEvents && (data?.milestones.length ?? 0) > 0 && ( + + Milestones + + {(data?.milestones ?? []).map((m) => ( + + {m.summary} + + {new Date(m.at).toLocaleDateString()} + + + ))} + + + )} + + + {/* SectionShell takes children as a prop, so this JSX is built before it decides whether to + render it — every access has to tolerate a null `data`. */} + + {(data?.categories.length ?? 0) > 1 && ( + setCategory(next ?? "")} + aria-label="Filter timeline by category" + sx={{ flexWrap: "wrap" }} + > + All + {(data?.categories ?? []).map((c) => ( + + {TIMELINE_CATEGORY_LABELS[c] ?? c} + + ))} + + )} + + {(data?.days.length ?? 0) === 0 ? ( + No events in this category. + ) : ( + (data?.days ?? []).map((day) => ( + + + {day.label} + + + {day.events.map((e) => ( + + + + {e.summary} + + {e.isMilestone && } + + {e.detail && ( + {e.detail} + )} + + ))} + + + )) + )} + + + + ); +} + +// ---------- Analysis ---------- + +export function ApplicationAnalysis({ jobId }: { jobId: number }) { + const { data, error, loading } = useIntelligence( + () => applicationIntelligenceApi.analysis(jobId), + [jobId], + ); + + const facts: [string, string | null][] = data + ? [ + ["Role", data.role], + ["Company", data.company], + ["Location", data.location], + ["Employment type", data.employmentType], + ["Seniority", data.seniority], + ["Salary", data.salary], + ] + : []; + + return ( + + + {data && !data.hasJobDescription && ( + + No advert text saved yet. Paste it into the application to get requirements, technologies + and interview topics. + + )} + + {data?.summary} + + + {facts.map(([k, v]) => ( + + {k} + {v ?? "—"} + + ))} + + + + + + + + + + + ); +} + +// ---------- Match ---------- + +export function ApplicationMatch({ jobId }: { jobId: number }) { + const { data, error, loading } = useIntelligence( + () => applicationIntelligenceApi.match(jobId), + [jobId], + ); + + return ( + + + {data && !data.hasCareerProfile ? ( + + No career profile yet. Matching compares the advert against your master profile — build it + once and every application scores against it. + + ) : ( + <> + + + {data?.score}% + + + + + + {data && !data.hasEnoughSignal && ( + + The advert is too short to score reliably. Paste the full text for a real match. + + )} + + + + + {(data?.relevantExperience.length ?? 0) > 0 && ( + + + Relevant experience + + + {(data?.relevantExperience ?? []).map((e, i) => ( + + {e.title} + {e.subtitle && {e.subtitle}} + + {e.matched.map((k) => )} + + + ))} + + + )} + + {(data?.relevantProjects.length ?? 0) > 0 && ( + + + Relevant projects + + + {(data?.relevantProjects ?? []).map((p, i) => ( + + {p.title} + {p.subtitle && {p.subtitle}} + + {p.matched.map((k) => )} + + + ))} + + + )} + + )} + + + + + ); +} diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index 3d98f93..4912623 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -19,6 +19,9 @@ import Attachments from "../components/Attachments"; import Correspondence from "../components/Correspondence"; import AiWorkspacePanel from "../components/AiWorkspacePanel"; import ApplicationChecklist from "../components/ApplicationChecklist"; +import { + ApplicationAnalysis, ApplicationMatch, ApplicationTimeline, +} from "../components/ApplicationIntelligence"; import { WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, } from "../applicationWorkspace"; @@ -92,6 +95,10 @@ export default function ApplicationWorkspacePage() { {section === "overview" && } {section === "job-details" && } + {/* Deterministic answer first, then the AI panel below it — the page never generates on load. */} + {section === "analysis" && jobId > 0 && } + {section === "match" && jobId > 0 && } + {section === "timeline" && jobId > 0 && } {(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && ( @@ -106,7 +113,7 @@ export default function ApplicationWorkspacePage() { {section === "checklist" && jobId > 0 && ( )} - {["cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && ( + {["cv", "cover-letter", "portfolio", "notes"].includes(section) && ( )}