From ae3505b877f033c04465adb3bcdc749aff0241c9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:48:32 +0200 Subject: [PATCH] feat: deterministic email-driven status suggestions New EmailStatusClassifier scans a message subject/body for outcome signals (interview invite, offer, rejection) and suggests a canonical pipeline status. Priority-ordered so a rejection that mentions the prior interview still classifies as Rejected. Deterministic - no AI - so it is instant, reproducible, and safe. - GET /api/jobapplications/{id}/status-suggestion reads the job's latest inbound correspondence (incl. Gmail imports) and suggests a forward status move, suppressed when already in/past that stage - always human-confirmed via the existing PATCH .../status - 7 classifier unit tests + 2 endpoint integration tests; backend green (133) Co-Authored-By: Claude Fable 5 --- .../EmailStatusClassifierTests.cs | 61 ++++++++++++++++++ .../JobApplicationsEndpointBehaviorTests.cs | 62 +++++++++++++++++++ .../Controllers/JobApplicationsController.cs | 51 +++++++++++++++ .../Services/EmailStatusClassifier.cs | 61 ++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 JobTrackerApi.Tests/EmailStatusClassifierTests.cs create mode 100644 JobTrackerApi/Services/EmailStatusClassifier.cs diff --git a/JobTrackerApi.Tests/EmailStatusClassifierTests.cs b/JobTrackerApi.Tests/EmailStatusClassifierTests.cs new file mode 100644 index 0000000..1939932 --- /dev/null +++ b/JobTrackerApi.Tests/EmailStatusClassifierTests.cs @@ -0,0 +1,61 @@ +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class EmailStatusClassifierTests +{ + [Fact] + public void Detects_rejection() + { + var s = EmailStatusClassifier.Classify("Your application", "Thank you for your time. Unfortunately, we have decided not to proceed with your application."); + Assert.NotNull(s); + Assert.Equal("Rejected", s!.SuggestedStatus); + } + + [Fact] + public void Detects_offer() + { + var s = EmailStatusClassifier.Classify("Great news", "We are pleased to offer you the position of Backend Engineer."); + Assert.NotNull(s); + Assert.Equal("Offer", s!.SuggestedStatus); + } + + [Fact] + public void Detects_interview_invite() + { + var s = EmailStatusClassifier.Classify("Next steps", "We would like to invite you to interview next week. What is your availability for a call?"); + Assert.NotNull(s); + Assert.Equal("Interview", s!.SuggestedStatus); + } + + [Fact] + public void Rejection_wins_over_interview_mention() + { + // A rejection email that references the interview the candidate had must classify as Rejected. + var s = EmailStatusClassifier.Classify( + "Update on your application", + "Thank you for taking the time to interview with us. Unfortunately, we will not be moving forward."); + Assert.NotNull(s); + Assert.Equal("Rejected", s!.SuggestedStatus); + } + + [Fact] + public void Weak_interview_cue_is_low_confidence() + { + var s = EmailStatusClassifier.Classify("Coding challenge", "Please complete this take-home assessment."); + Assert.NotNull(s); + Assert.Equal("Interview", s!.SuggestedStatus); + Assert.Equal("low", s.Confidence); + } + + [Fact] + public void Returns_null_for_neutral_email() + { + Assert.Null(EmailStatusClassifier.Classify("Re: question", "Thanks for the info, that answers my question about the parking.")); + } + + [Fact] + public void Handles_empty_input() + => Assert.Null(EmailStatusClassifier.Classify(null, null)); +} diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index 0592b72..95c0b8d 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -56,6 +56,68 @@ public sealed class JobApplicationsEndpointBehaviorTests Assert.Contains("Profile page", badRequest.Value?.ToString()); } + [Fact] + public async Task Status_suggestion_from_latest_inbound_rejection() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + db.Correspondences.Add(new Correspondence + { + JobApplicationId = job.Id, + From = "Company", + Direction = "inbound", + Subject = "Update", + Content = "Unfortunately, we have decided not to proceed.", + Date = DateTime.Now, + }); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(dto.HasSuggestion); + Assert.Equal("Rejected", dto.SuggestedStatus); + } + + [Fact] + public async Task Status_suggestion_suppressed_when_already_in_stage() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Rejected" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + db.Correspondences.Add(new Correspondence + { + JobApplicationId = job.Id, + From = "Company", + Direction = "inbound", + Content = "Unfortunately, we will not be moving forward.", + Date = DateTime.Now, + }); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.False(dto.HasSuggestion); + } + [Fact] public async Task Match_score_scores_job_against_profile_cv() { diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 87a1ec5..4f0ba63 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1604,6 +1604,57 @@ Canonical profile: return NoContent(); } + public sealed record StatusSuggestionDto( + bool HasSuggestion, + string? SuggestedStatus, + string? CurrentStatus, + string? Signal, + string? Confidence, + DateTime? MessageDate, + string? MessageSubject); + + /// + /// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview + /// invite or rejection). Deterministic and always human-confirmed via PATCH .../status. + /// + [HttpGet("{id:int}/status-suggestion")] + public async Task> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null); + + var latestInbound = await _db.Correspondences + .AsNoTracking() + .Where(c => c.JobApplicationId == id + && c.Direction != "outbound" + && c.From != "Me") + .OrderByDescending(c => c.Date) + .FirstOrDefaultAsync(cancellationToken); + if (latestInbound is null) return Ok(none); + + var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content); + if (suggestion is null) return Ok(none); + + // Don't nag when the job is already in (or past) the suggested stage. + var currentOrder = JobPipeline.OrderOf(job.Status); + var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus); + if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder) + { + return Ok(none); + } + + return Ok(new StatusSuggestionDto( + HasSuggestion: true, + SuggestedStatus: suggestion.SuggestedStatus, + CurrentStatus: job.Status, + Signal: suggestion.Signal, + Confidence: suggestion.Confidence, + MessageDate: latestInbound.Date, + MessageSubject: latestInbound.Subject)); + } + [HttpPost("{id:int}/refresh-ai")] public async Task> RefreshAi([FromRoute] int id, CancellationToken cancellationToken) diff --git a/JobTrackerApi/Services/EmailStatusClassifier.cs b/JobTrackerApi/Services/EmailStatusClassifier.cs new file mode 100644 index 0000000..b5108c1 --- /dev/null +++ b/JobTrackerApi/Services/EmailStatusClassifier.cs @@ -0,0 +1,61 @@ +namespace JobTrackerApi.Services +{ + public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence); + + /// + /// Deterministic email → pipeline-status classifier. Scans subject/body for outcome signals and + /// suggests a canonical status. No AI: instant, reproducible, and safe (the user always confirms). + /// Priority matters — a rejection email often still mentions "interview", so rejection wins. + /// + public static class EmailStatusClassifier + { + // Ordered highest-priority first. Each stage lists lowercase phrases to look for. + private static readonly (string Status, string Confidence, string[] Phrases)[] Rules = + { + ("Rejected", "high", new[] + { + "regret to inform", "we regret", "unfortunately, we", "not moving forward", + "not be moving forward", "decided not to proceed", "will not be proceeding", + "not to proceed", "not been selected", "will not be progressing", + "unable to offer", "position has been filled", "no longer being considered", + "decided to move forward with other", "pursue other candidates", + "not to move forward", "were not successful", "was not successful", + }), + ("Offer", "high", new[] + { + "pleased to offer", "delighted to offer", "happy to offer", "offer of employment", + "job offer", "we would like to offer", "formal offer", "extend an offer", + "offer letter", "excited to offer", + }), + ("Interview", "medium", new[] + { + "invite you to interview", "invite you to an interview", "schedule an interview", + "would like to invite you", "phone screen", "phone interview", "video interview", + "technical interview", "next steps in the", "your availability for a call", + "availability for an interview", "set up a call", "set up an interview", + "meet the team", "book a time", "invitation to interview", "interview invitation", + "like to speak with you", "move to the interview", + }), + }; + + // Weaker single-word cues only fire when no strong phrase matched (kept low-confidence). + private static readonly string[] InterviewWeakCues = { "interview", "assessment", "coding challenge", "take-home" }; + + public static EmailStatusSuggestion? Classify(string? subject, string? body) + { + var text = $"{subject}\n{body}".ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(text)) return null; + + foreach (var (status, confidence, phrases) in Rules) + { + var hit = phrases.FirstOrDefault(p => text.Contains(p, StringComparison.Ordinal)); + if (hit is not null) return new EmailStatusSuggestion(status, hit, confidence); + } + + var weak = InterviewWeakCues.FirstOrDefault(c => text.Contains(c, StringComparison.Ordinal)); + if (weak is not null) return new EmailStatusSuggestion("Interview", weak, "low"); + + return null; + } + } +}