Complete S02 application package drafting loop

This commit is contained in:
2026-03-24 10:36:05 +01:00
parent 3e5f796326
commit b5b430947b
14 changed files with 864 additions and 152 deletions
@@ -119,6 +119,7 @@ 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 async Task<AttachmentContextResult?> BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken, string? attachmentIdsCsv = null)
{
@@ -215,6 +216,70 @@ namespace JobTrackerApi.Controllers
return new AttachmentContextResult(context.ToString().Trim(), signals, usedFiles);
}
private async Task<CorrespondenceContextResult?> BuildCorrespondenceContextAsync(int jobId, CancellationToken cancellationToken)
{
var messages = await _db.Correspondences
.AsNoTracking()
.Where(message => message.JobApplicationId == jobId)
.OrderByDescending(message => message.Date)
.Take(6)
.ToListAsync(cancellationToken);
if (messages.Count == 0) return null;
messages = messages
.OrderBy(message => message.Date)
.ToList();
var participants = messages
.SelectMany(message => new[] { message.ExternalFrom, message.ExternalTo })
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(6)
.ToList();
var threadIds = messages
.Select(message => message.ExternalThreadId)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!.Trim())
.Distinct(StringComparer.Ordinal)
.Take(4)
.ToList();
var timeline = messages.Select(message =>
{
var content = (message.Content ?? string.Empty).Trim();
if (content.Length > 320)
{
content = content[..320].TrimEnd() + "…";
}
return $"- {message.Date:yyyy-MM-dd} | From={message.From} | Subject={message.Subject ?? "(no subject)"} | ExternalFrom={message.ExternalFrom ?? ""} | ExternalTo={message.ExternalTo ?? ""}\n {content}";
}).ToList();
var context = new StringBuilder();
context.AppendLine("Imported correspondence context:");
if (participants.Count > 0)
{
context.AppendLine($"Participants: {string.Join(", ", participants)}");
}
if (threadIds.Count > 0)
{
context.AppendLine($"Threads: {string.Join(", ", threadIds)}");
}
context.AppendLine("Timeline:");
context.AppendLine(string.Join("\n", timeline));
var signals = await BuildListFromAiAsync(
"List up to 4 concrete application-package signals from this imported correspondence. Focus on recruiter priorities, specific role language, next steps, constraints, and phrasing that should influence a tailored CV, cover letter, or recruiter message. Return one short signal per line with no numbering.",
context.ToString(),
cancellationToken,
fallbackPrefix: messages.Last().Subject ?? "imported correspondence");
return new CorrespondenceContextResult(context.ToString().Trim(), signals, participants, threadIds);
}
private static bool IsExtractableAttachmentExtension(string? extension)
{
return extension?.Trim().ToLowerInvariant() switch
@@ -1511,6 +1576,7 @@ namespace JobTrackerApi.Controllers
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
@@ -1826,9 +1892,7 @@ Candidate master CV:
if (!string.IsNullOrWhiteSpace(request.Notes))
{
job.Notes = string.IsNullOrWhiteSpace(job.Notes)
? request.Notes.Trim()
: $"{job.Notes.Trim()}\n\n{request.Notes.Trim()}";
job.Notes = request.Notes.Trim();
}
if (!string.IsNullOrWhiteSpace(request.RecruiterMessageDraft))
@@ -1858,7 +1922,7 @@ Candidate master CV:
return BadRequest("Add your profile CV text on the Profile page before generating an application package.");
}
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, job.JobUrl }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
@@ -1869,6 +1933,13 @@ Candidate master CV:
var coverLetterStyleInstruction = BuildCoverLetterStyleInstruction(coverLetterStyle);
var structuredCvContext = BuildStructuredCvContext(user);
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 recruiterContext = new StringBuilder();
recruiterContext.AppendLine($"Recruiter name: {job.Company?.RecruiterName ?? ""}");
recruiterContext.AppendLine($"Recruiter email: {job.Company?.RecruiterEmail ?? ""}");
recruiterContext.AppendLine($"Greeting baseline: {BuildGreeting(job)}");
var packageContext = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name}
@@ -1876,32 +1947,39 @@ Status: {job.Status}
Generation mode: {mode ?? "default"}
Cover-letter style: {coverLetterStyle ?? "balanced"}
Recruiter and company context:
{recruiterContext.ToString().Trim()}
Job context:
{jobText}
{(correspondenceContext is not null ? $"\n\nImported correspondence:\n{correspondenceContext.Context}" : string.Empty)}
{(!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText) || !string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText) || !string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)
? $"\n\nExisting saved job material:\nTailored CV draft: {savedPackageMaterial.TailoredCvText ?? ""}\nCover letter draft: {savedPackageMaterial.CoverLetterText ?? ""}\nRecruiter message draft: {savedPackageMaterial.RecruiterMessageDraft ?? ""}"
: string.Empty)}
Candidate master CV:
{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
var tailoredCvText = await _summarizer.SummarizeSectionAsync(
$"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job. {packageModeInstruction}",
$"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job. Use imported correspondence and recruiter language when it sharpens specificity, but do not invent facts. {packageModeInstruction}",
packageContext,
256,
120) ?? cvText;
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
$"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. {packageModeInstruction} {coverLetterStyleInstruction}",
$"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, incorporate relevant signals from imported correspondence when available, 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. {packageModeInstruction} {coverLetterStyleInstruction}",
packageContext,
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. {packageModeInstruction}",
$"Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words, use specific evidence from the CV and imported correspondence where helpful, and avoid generic filler. {packageModeInstruction}",
packageContext,
170,
70);
var coverLetterVariants = await BuildDraftVariantsAsync(
"Write a concise, job-specific cover letter for this candidate and role. Use concrete evidence from the CV and job context, avoid generic enthusiasm, and keep the tone credible and polished.",
"Write a concise, job-specific cover letter for this candidate and role. Use concrete evidence from the CV, recruiter context, and imported correspondence where relevant, avoid generic enthusiasm, and keep the tone credible and polished.",
packageContext,
cancellationToken,
"concise and efficient",
@@ -1909,13 +1987,13 @@ Candidate master CV:
"confident and high-conviction");
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
$"Write a short recruiter intro message for this candidate and role. Make it feel specific to the posting by mentioning the exact role, company, and one or two concrete overlaps from the candidate profile or job context. Keep it warm, direct, and concise. {packageModeInstruction}",
$"Write a short recruiter intro message for this candidate and role. Make it feel specific to the posting by mentioning the exact role, company, and one or two concrete overlaps from the candidate profile, job context, or imported correspondence. If recruiter details are available, use them naturally. Keep it warm, direct, and concise. {packageModeInstruction}",
packageContext,
140,
55);
var recruiterMessageVariants = await BuildDraftVariantsAsync(
"Write a short recruiter intro message for this candidate and role. Mention the exact role, company, and one or two concrete overlaps. Keep it natural, specific, and easy to respond to.",
"Write a short recruiter intro message for this candidate and role. Mention the exact role, company, recruiter context, and one or two concrete overlaps. Keep it natural, specific, and easy to respond to.",
packageContext,
cancellationToken,
"warm and conversational",
@@ -1924,10 +2002,34 @@ Candidate master CV:
var keyPoints = SkillTagger.Detect(jobText)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.Take(4)
.Select(x => $"Lead with evidence of {x}.")
.ToList();
if (correspondenceContext is not null)
{
foreach (var signal in correspondenceContext.Signals)
{
if (!keyPoints.Contains(signal, StringComparer.OrdinalIgnoreCase))
{
keyPoints.Add(signal);
}
}
}
if (attachmentContext is not null)
{
foreach (var signal in attachmentContext.Signals)
{
if (!keyPoints.Contains(signal, StringComparer.OrdinalIgnoreCase))
{
keyPoints.Add(signal);
}
}
}
keyPoints = keyPoints.Take(6).ToList();
return Ok(new GenerateApplicationPackageDto(
TailoredCvText: tailoredCvText,
CoverLetterDraft: coverLetterDraft,