Implement S03 follow-up draft context loop

This commit is contained in:
2026-03-24 11:05:41 +01:00
parent b5b430947b
commit 0cacb4e51b
10 changed files with 645 additions and 26 deletions
@@ -121,6 +121,9 @@ namespace JobTrackerApi.Controllers
private sealed record AttachmentContextResult(string Context, List<string> Signals, List<string> UsedFiles);
private sealed record CorrespondenceContextResult(string Context, List<string> Signals, List<string> Participants, List<string> ThreadIds);
private const string ApplicationAnswerDraftStart = "<<<APPLICATION_ANSWER_DRAFT>>>";
private const string ApplicationAnswerDraftEnd = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
private async Task<AttachmentContextResult?> BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken, string? attachmentIdsCsv = null)
{
HashSet<int>? allowedIds = null;
@@ -280,6 +283,73 @@ namespace JobTrackerApi.Controllers
return new CorrespondenceContextResult(context.ToString().Trim(), signals, participants, threadIds);
}
private static string? ExtractSavedApplicationAnswerDraft(string? notes)
{
var value = (notes ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(value)) return null;
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
if (startIndex >= 0 && endIndex > startIndex)
{
var between = value[(startIndex + ApplicationAnswerDraftStart.Length)..endIndex].Trim();
return string.IsNullOrWhiteSpace(between) ? null : between;
}
const string legacyPrefix = "Application answer draft:";
var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
if (legacyIndex >= 0)
{
var legacy = value[(legacyIndex + legacyPrefix.Length)..].Trim();
return string.IsNullOrWhiteSpace(legacy) ? null : legacy;
}
return null;
}
private static string BuildFollowUpSubject(JobApplication job, Correspondence? lastMessage)
{
var subject = (lastMessage?.Subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subject))
{
return subject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase)
? subject
: $"Re: {subject}";
}
return $"Following up on {job.JobTitle} application";
}
private static List<string> BuildFollowUpContextSignals(JobApplication job, Correspondence? lastMessage, CorrespondenceContextResult? correspondenceContext, SavedPackageMaterial savedPackageMaterial, string? savedApplicationAnswer)
{
var signals = new List<string>();
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) signals.Add($"Recruiter contact: {job.Company.RecruiterName.Trim()}");
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) signals.Add($"Recruiter email on file: {job.Company.RecruiterEmail.Trim()}");
if (lastMessage is not null)
{
signals.Add($"Latest correspondence: {lastMessage.Date:yyyy-MM-dd} — {lastMessage.Subject ?? "(no subject)"}");
}
if (correspondenceContext?.Participants.Count > 0)
{
signals.Add($"Thread participants: {string.Join(", ", correspondenceContext.Participants.Take(3))}");
}
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText)) signals.Add("Saved cover letter available");
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)) signals.Add("Saved recruiter message available");
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)) signals.Add("Saved tailored CV available");
if (!string.IsNullOrWhiteSpace(savedApplicationAnswer)) signals.Add("Saved application answer available");
if (correspondenceContext is not null)
{
foreach (var signal in correspondenceContext.Signals)
{
if (!signals.Contains(signal, StringComparer.OrdinalIgnoreCase)) signals.Add(signal);
}
}
return signals.Take(6).ToList();
}
private static bool IsExtractableAttachmentExtension(string? extension)
{
return extension?.Trim().ToLowerInvariant() switch
@@ -1547,7 +1617,7 @@ 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 FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
public sealed record FocusPlanDto(
List<string> ImmediatePriorities,
List<string> CvBulletIdeas,
@@ -2260,12 +2330,16 @@ Candidate master CV:
var currentUser = await GetCurrentUserAsync(cancellationToken);
var signerName = GetPreferredDisplayName(currentUser);
var greeting = BuildGreeting(job);
var subject = $"Following up on {job.JobTitle} application";
var subject = BuildFollowUpSubject(job, lastMessage);
var reference = lastMessage?.Subject ?? job.JobTitle;
var summary = job.ShortSummary;
var appliedDate = job.DateApplied.ToString("MMMM d, yyyy");
var tagHighlights = SplitTags(job.Tags).Take(4).ToList();
var companyName = job.Company?.Name ?? "your team";
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
var correspondenceContext = await BuildCorrespondenceContextAsync(id, cancellationToken);
var savedPackageMaterial = new SavedPackageMaterial(job.TailoredCvText, job.CoverLetterText, job.RecruiterMessageDraft, job.Notes);
var savedApplicationAnswer = ExtractSavedApplicationAnswerDraft(job.Notes);
var requestedMode = string.IsNullOrWhiteSpace(mode)
? (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) ? "post-interview"
@@ -2274,7 +2348,17 @@ Candidate master CV:
: job.Status == "Rejected" ? "feedback-request"
: "post-apply")
: mode.Trim().ToLowerInvariant();
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
var followUpContextSignals = BuildFollowUpContextSignals(job, lastMessage, correspondenceContext, savedPackageMaterial, savedApplicationAnswer);
var contextSummary = string.Join(" ", new[]
{
reason.Trim(),
lastMessage is not null ? $"Latest thread activity was on {lastMessage.Date:MMMM d, yyyy}." : "No imported thread activity exists yet.",
!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText) || !string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft) || !string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)
? "Saved application package material is available for reuse."
: "No saved application package material is available yet."
}.Where(x => !string.IsNullOrWhiteSpace(x)));
var aiContext = $@"Candidate name: {signerName}
Role: {job.JobTitle}
Company: {companyName}
@@ -2282,18 +2366,33 @@ Applied on: {appliedDate}
Current status: {job.Status}
Requested follow-up mode: {requestedMode}
Reason for follow-up: {reason}
Follow-up context summary: {contextSummary}
Last message subject: {lastMessage?.Subject ?? "None"}
Last message date: {(lastMessage is not null ? lastMessage.Date.ToString("MMMM d, yyyy") : "None")}
Last message from: {lastMessage?.ExternalFrom ?? lastMessage?.From ?? "None"}
Relevant skills/tags: {(tagHighlights.Count > 0 ? string.Join(", ", tagHighlights) : "None provided")}
Short fit summary: {summary ?? "None provided"}
Imported correspondence context:
{correspondenceContext?.Context ?? "No imported correspondence context available."}
Saved application package material:
Tailored CV: {savedPackageMaterial.TailoredCvText ?? "None saved"}
Cover letter: {savedPackageMaterial.CoverLetterText ?? "None saved"}
Recruiter message: {savedPackageMaterial.RecruiterMessageDraft ?? "None saved"}
Application answer: {savedApplicationAnswer ?? "None saved"}
Follow-up context signals:
{(followUpContextSignals.Count > 0 ? string.Join("\n", followUpContextSignals.Select(signal => $"- {signal}")) : "- No extra context signals available.")}
Job description:
{job.TranslatedDescription ?? job.Description ?? "No job description available."}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
var aiBody = await _summarizer.SummarizeSectionAsync(
$"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.",
$"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, use the imported correspondence and saved application package material when they sharpen specificity, and keep the manual-send boundary intact by returning draft text only. 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,
190,
70);
210,
80);
var fallbackIntro = requestedMode switch
{
@@ -2304,16 +2403,24 @@ Job description:
_ => $"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 strongestOverlap = !string.IsNullOrWhiteSpace(savedApplicationAnswer)
? savedApplicationAnswer!.Split(new[] { '.', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()
: null;
var fallbackBody = string.Join("\n\n", new[]
{
greeting,
fallbackIntro,
!string.IsNullOrWhiteSpace(summary)
? $"From the posting and my background, the strongest overlap seems to be {summary.Trim().TrimEnd('.')}."
: tagHighlights.Count > 0
? $"The role's focus on {string.Join(", ", tagHighlights.Take(2))} especially stood out to me, and it lines up well with my experience."
: null,
$"I would be glad to share any additional details that would be helpful as you move through next steps for {reference}.",
? $"The strongest overlap still looks like {summary.Trim().TrimEnd('.')}."
: !string.IsNullOrWhiteSpace(strongestOverlap)
? strongestOverlap
: tagHighlights.Count > 0
? $"The role's focus on {string.Join(", ", tagHighlights.Take(2))} especially stood out to me, and it lines up well with my experience."
: null,
lastMessage is not null && !string.IsNullOrWhiteSpace(lastMessage.Subject)
? $"I also wanted to keep the thread moving on {lastMessage.Subject.Trim()} if there is anything else you need from me."
: $"I would be glad to share any additional details that would be helpful as you move through next steps for {reference}.",
$"Thanks for your time,\n{signerName}"
}.Where(x => !string.IsNullOrWhiteSpace(x)));
@@ -2323,7 +2430,16 @@ Job description:
body = string.Join("\n\n", new[] { greeting, body, $"Thanks,\n{signerName}" }.Where(x => !string.IsNullOrWhiteSpace(x)));
}
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
return Ok(new FollowUpDraftDto(
subject,
body,
reason,
DateTime.Today,
contextSummary,
followUpContextSignals,
lastMessage?.Subject,
lastMessage?.ExternalFrom ?? lastMessage?.From,
lastMessage?.Date));
}
[HttpPost("{id:int}/send-followup")]