Add focus plans and stage-aware follow-up drafting

This commit is contained in:
cesnimda
2026-03-23 22:04:39 +01:00
parent 19b0424ef3
commit 8db620e45b
8 changed files with 345 additions and 25 deletions
@@ -55,6 +55,67 @@ namespace JobTrackerApi.Controllers
return "Hi there,";
}
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
{
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
var items = (raw ?? string.Empty)
.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim().TrimStart('-', '•', '*', ' '))
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.ToList();
if (items.Count > 0) return items;
return new List<string>
{
$"Lead with clear evidence tied to {fallbackPrefix}.",
"Use concrete outcomes, metrics, or scope whenever possible.",
"Keep the language specific to this role instead of generic.",
};
}
private static List<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
{
var normalized = (status ?? string.Empty).Trim();
var advice = new List<string>();
switch (normalized)
{
case "Applied":
advice.Add("Follow up briefly, reaffirm interest, and reference the date you applied.");
advice.Add("Mention one or two of the strongest overlaps from the posting instead of repeating your whole background.");
break;
case "Waiting":
advice.Add("Acknowledge that you are following up on next steps and keep the message light but specific.");
advice.Add("Use one proof point that shows why you remain a strong fit.");
break;
case "Interview":
case "Interviewing":
advice.Add("Focus on momentum, appreciation, and readiness for the next step.");
advice.Add("Reference a memorable point from the process, discussion, or role priorities if possible.");
break;
case "Offer":
advice.Add("Keep the tone warm and professional, and focus on clarifying next steps or timing.");
advice.Add("Avoid sounding pushy; frame the note around alignment and practical progress.");
break;
case "Rejected":
advice.Add("If appropriate, ask for feedback with a respectful and concise tone.");
advice.Add("Keep the door open for future opportunities instead of arguing the decision.");
break;
default:
advice.Add("Match the tone to the current stage and be specific about why you are following up now.");
advice.Add("Keep it concise, credible, and easy to respond to.");
break;
}
if (matchedTags.Any()) advice.Add($"Lead with relevant overlap such as {string.Join(", ", matchedTags.Take(2))}.");
if (missingTags.Any()) advice.Add($"Do not overstate areas like {string.Join(", ", missingTags.Take(2))}; frame them honestly.");
return advice.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList();
}
private static IEnumerable<string> SplitTags(string? s)
{
if (string.IsNullOrWhiteSpace(s)) yield break;
@@ -1245,6 +1306,13 @@ namespace JobTrackerApi.Controllers
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
public sealed record FocusPlanDto(
List<string> ImmediatePriorities,
List<string> CvBulletIdeas,
List<string> ProofPointsToLeadWith,
List<string> CoverLetterAngles,
List<string> FollowUpApproach,
string StrategicSummary);
public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt);
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
public sealed record CandidateFitChannelGuidanceDto(List<string> Cv, List<string> CoverLetter, List<string> Interview, List<string> RecruiterMessage);
@@ -1391,6 +1459,86 @@ Candidate CV/profile:
RecruiterMessageDraft: recruiterMessageDraft));
}
[HttpGet("{id:int}/focus-plan")]
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
{
return BadRequest("Add your profile CV text on the Profile page before generating a focus plan.");
}
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to generate a focus plan.");
}
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).Take(8).ToList();
var normalizedCv = cvText.ToLowerInvariant();
var matchedTags = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList();
var missingTags = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList();
var context = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name}
Status: {job.Status}
Job description and notes:
{jobText}
Candidate master CV:
{cvText}";
var strategicSummary = await _summarizer.SummarizeSectionAsync(
"Write a concise strategy summary for how the candidate should approach this role. Focus on what matters most in the posting, what evidence to lead with, and where to be careful.",
context,
220,
90) ?? "Focus on the strongest overlap with the posting, lead with evidence, and keep your outreach specific and credible.";
var immediatePriorities = new List<string>();
immediatePriorities.AddRange(matchedTags.Take(3).Select(x => $"Lead with your strongest evidence for {x}."));
immediatePriorities.AddRange(missingTags.Take(2).Select(x => $"Address {x} carefully: show adjacent experience or a credible ramp-up story."));
if (!string.IsNullOrWhiteSpace(job.ShortSummary)) immediatePriorities.Add($"Use the role summary as a framing line: {job.ShortSummary.Trim().TrimEnd('.')}. ");
immediatePriorities = immediatePriorities.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList();
var cvBulletIdeas = await BuildListFromAiAsync(
"Write 4 resume bullet ideas tailored to this job. Each bullet should be specific, factual in tone, and outcome-oriented. Return one bullet per line with no numbering.",
context,
cancellationToken,
fallbackPrefix: matchedTags.FirstOrDefault() ?? job.JobTitle);
var proofPointsToLeadWith = await BuildListFromAiAsync(
"Write 4 short proof points the candidate should lead with for this role. Use evidence, scope, outcomes, and credibility. Return one point per line with no numbering.",
context,
cancellationToken,
fallbackPrefix: job.Company?.Name ?? job.JobTitle);
var coverLetterAngles = await BuildListFromAiAsync(
"Write 4 short cover-letter angles for this role. Focus on why this role, why this company, and the most relevant strengths. Return one angle per line with no numbering.",
context,
cancellationToken,
fallbackPrefix: matchedTags.FirstOrDefault() ?? "relevant experience");
var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags);
return Ok(new FocusPlanDto(
ImmediatePriorities: immediatePriorities,
CvBulletIdeas: cvBulletIdeas,
ProofPointsToLeadWith: proofPointsToLeadWith,
CoverLetterAngles: coverLetterAngles,
FollowUpApproach: followUpApproach,
StrategicSummary: strategicSummary));
}
[HttpGet("{id:int}/interview-prep")]
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, CancellationToken cancellationToken)
{
@@ -1529,10 +1677,10 @@ Candidate master CV:
120) ?? cvText;
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
"Write a concise, tailored cover letter for this candidate and job. Keep it specific, credible, and directly aligned to the role.",
"Write a concise but high-quality cover letter for this candidate and job. Use the candidate CV as the source of evidence, mirror the priorities of the posting, mention concrete overlap instead of generic enthusiasm, and make the letter feel specific to this company and role. Keep it credible, polished, and directly aligned to the role.",
packageContext,
220,
90);
260,
110);
var applicationAnswerDraft = await _summarizer.SummarizeSectionAsync(
"Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words.",
@@ -1756,7 +1904,7 @@ Candidate master CV:
}
[HttpGet("{id:int}/followup-draft")]
public async Task<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, CancellationToken cancellationToken)
public async Task<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.AsNoTracking()
@@ -1785,10 +1933,19 @@ Candidate master CV:
var tagHighlights = SplitTags(job.Tags).Take(4).ToList();
var companyName = job.Company?.Name ?? "your team";
var requestedMode = string.IsNullOrWhiteSpace(mode)
? (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) ? "post-interview"
: job.Status == "Waiting" ? "waiting-update"
: job.Status == "Offer" ? "offer-checkin"
: job.Status == "Rejected" ? "feedback-request"
: "post-apply")
: mode.Trim().ToLowerInvariant();
var aiContext = $@"Candidate name: {signerName}
Role: {job.JobTitle}
Company: {companyName}
Applied on: {appliedDate}
Current status: {job.Status}
Requested follow-up mode: {requestedMode}
Reason for follow-up: {reason}
Last message subject: {lastMessage?.Subject ?? "None"}
Last message date: {(lastMessage is not null ? lastMessage.Date.ToString("MMMM d, yyyy") : "None")}
@@ -1798,15 +1955,24 @@ Job description:
{job.TranslatedDescription ?? job.Description ?? "No job description available."}";
var aiBody = await _summarizer.SummarizeSectionAsync(
"Write a concise, professional follow-up email in first person. Mention that the candidate applied on the provided date, reference the exact role and company, mention one or two concrete details from the role or fit summary, and close with polite interest in next steps. Keep it specific, warm, and under 140 words. Return only the email body.",
$"Write a concise, professional follow-up email in first person for the mode '{requestedMode}'. Mention that the candidate applied on the provided date, reference the exact role and company, mention one or two concrete details from the role or fit summary, and close with polite interest in next steps. Adjust the tone to the stage: post-apply should be light and interested, waiting-update should ask about progress, post-interview should thank them and reaffirm fit, offer-checkin should be warm and practical, feedback-request should be respectful and brief. Keep it specific, warm, and under 140 words. Return only the email body.",
aiContext,
180,
190,
70);
var fallbackIntro = requestedMode switch
{
"post-interview" => $"I wanted to thank you again for the conversation about the {job.JobTitle} role and follow up on next steps.",
"waiting-update" => $"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}, and see whether there are any updates on the process.",
"offer-checkin" => $"I wanted to check in on the latest status for the {job.JobTitle} role and any next steps you would like from me.",
"feedback-request" => $"Thank you for the update on the {job.JobTitle} process. If you're open to it, I would be grateful for any brief feedback that could help me improve.",
_ => $"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}. I'm still very interested in the opportunity at {companyName}.",
};
var fallbackBody = string.Join("\n\n", new[]
{
greeting,
$"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}. I'm still very interested in the opportunity at {companyName}.",
fallbackIntro,
!string.IsNullOrWhiteSpace(summary)
? $"From the posting and my background, the strongest overlap seems to be {summary.Trim().TrimEnd('.')}."
: tagHighlights.Count > 0