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 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-03 03:48:32 +02:00
parent 695fbd6d21
commit ae3505b877
4 changed files with 235 additions and 0 deletions
@@ -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));
}
@@ -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<OkObjectResult>(result.Result);
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(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<OkObjectResult>(result.Result);
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
Assert.False(dto.HasSuggestion);
}
[Fact]
public async Task Match_score_scores_job_against_profile_cv()
{
@@ -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);
/// <summary>
/// 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.
/// </summary>
[HttpGet("{id:int}/status-suggestion")]
public async Task<ActionResult<StatusSuggestionDto>> 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<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
@@ -0,0 +1,61 @@
namespace JobTrackerApi.Services
{
public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence);
/// <summary>
/// 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.
/// </summary>
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;
}
}
}