Add attachment-aware AI drafting and CV section tools
This commit is contained in:
@@ -5,6 +5,7 @@ using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.JobImport;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
@@ -76,6 +77,98 @@ namespace JobTrackerApi.Controllers
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record AttachmentContextResult(string Context, List<string> Signals, List<string> UsedFiles);
|
||||
|
||||
private async Task<AttachmentContextResult?> BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var attachments = await _db.Attachments
|
||||
.AsNoTracking()
|
||||
.Where(a => a.JobApplicationId == jobId)
|
||||
.OrderByDescending(a => a.UploadDate)
|
||||
.Take(4)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (attachments.Count == 0) return null;
|
||||
|
||||
var metadata = attachments
|
||||
.Select(a => $"- {a.FileName} ({a.FileType}, {Math.Max(1, a.FileSize / 1024)} KB)")
|
||||
.ToList();
|
||||
|
||||
var extractedSections = new List<string>();
|
||||
var usedFiles = new List<string>();
|
||||
|
||||
foreach (var attachment in attachments.Take(3))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.FilePath) || !System.IO.File.Exists(attachment.FilePath)) continue;
|
||||
if (attachment.FileSize <= 0 || attachment.FileSize > 5 * 1024 * 1024) continue;
|
||||
|
||||
var ext = Path.GetExtension(attachment.FileName ?? string.Empty);
|
||||
if (!IsExtractableAttachmentExtension(ext)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = System.IO.File.OpenRead(attachment.FilePath);
|
||||
var extracted = await _summarizer.ExtractTextAsync(stream, attachment.FileName ?? "attachment", attachment.FileType, cancellationToken);
|
||||
var text = extracted?.Text?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text)) continue;
|
||||
|
||||
var condensed = text.Length > 1400
|
||||
? await _summarizer.SummarizeSectionAsync(
|
||||
"Extract the most relevant job-application signals from this attachment. Focus on skills, achievements, metrics, proof points, and wording that would help tailor a CV or cover letter. Return compact plain text only.",
|
||||
text,
|
||||
220,
|
||||
80) ?? text[..Math.Min(text.Length, 1400)]
|
||||
: text[..Math.Min(text.Length, 1400)];
|
||||
|
||||
extractedSections.Add($"Attachment: {attachment.FileName}\n{condensed.Trim()}");
|
||||
usedFiles.Add(attachment.FileName ?? "attachment");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort only; attachment context should never break generation.
|
||||
}
|
||||
}
|
||||
|
||||
var signals = new List<string>();
|
||||
if (usedFiles.Count > 0)
|
||||
{
|
||||
var signalContext = string.Join("\n\n", extractedSections);
|
||||
signals = await BuildListFromAiAsync(
|
||||
"List up to 4 concrete job-application signals from these attachments. Focus on evidence, achievements, quantified results, named tools, and wording worth reusing. Return one short signal per line with no numbering.",
|
||||
signalContext,
|
||||
cancellationToken,
|
||||
fallbackPrefix: usedFiles.First());
|
||||
}
|
||||
|
||||
var context = new StringBuilder();
|
||||
context.AppendLine("Attachment inventory:");
|
||||
foreach (var line in metadata) context.AppendLine(line);
|
||||
if (extractedSections.Count > 0)
|
||||
{
|
||||
context.AppendLine();
|
||||
context.AppendLine("Attachment-derived context:");
|
||||
context.AppendLine(string.Join("\n\n", extractedSections));
|
||||
}
|
||||
|
||||
return new AttachmentContextResult(context.ToString().Trim(), signals, usedFiles);
|
||||
}
|
||||
|
||||
private static bool IsExtractableAttachmentExtension(string? extension)
|
||||
{
|
||||
return extension?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
".pdf" => true,
|
||||
".docx" => true,
|
||||
".txt" => true,
|
||||
".md" => true,
|
||||
".png" => true,
|
||||
".jpg" => true,
|
||||
".jpeg" => true,
|
||||
".webp" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
|
||||
{
|
||||
var normalized = (status ?? string.Empty).Trim();
|
||||
@@ -1332,11 +1425,34 @@ namespace JobTrackerApi.Controllers
|
||||
string? CoverLetterDraft,
|
||||
string? RecruiterMessageDraft);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints);
|
||||
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed);
|
||||
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
||||
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);
|
||||
|
||||
private static string BuildPackageModeInstruction(string? mode)
|
||||
{
|
||||
return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"concise" => "Prioritize brevity, clarity, and easy scanning. Use tight phrasing and trim filler.",
|
||||
"ats" => "Prioritize ATS-friendly wording, direct skill alignment, standard section phrasing, and keyword coverage where accurate.",
|
||||
"achievement" => "Prioritize impact, outcomes, ownership, scope, and measurable achievements.",
|
||||
"interview" => "Prioritize talking points that are easy to defend in an interview and tie each claim to concrete examples.",
|
||||
_ => "Keep the output balanced, credible, and practical for real applications.",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildCoverLetterStyleInstruction(string? style)
|
||||
{
|
||||
return (style ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"concise" => "Keep the letter compact and efficient with minimal filler.",
|
||||
"formal" => "Use a polished, professional, slightly more formal tone without sounding stiff.",
|
||||
"bold" => "Use a confident, high-conviction tone while staying factual and credible.",
|
||||
_ => "Use a balanced, modern, professional tone.",
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/candidate-fit")]
|
||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1367,6 +1483,7 @@ namespace JobTrackerApi.Controllers
|
||||
var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
|
||||
var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
|
||||
|
||||
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken);
|
||||
var jobContext = $@"Job title: {job.JobTitle}
|
||||
Company: {job.Company?.Name}
|
||||
Status: {job.Status}
|
||||
@@ -1375,7 +1492,7 @@ Job description and notes:
|
||||
{jobText}
|
||||
|
||||
Candidate CV/profile:
|
||||
{cvText}";
|
||||
{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
||||
|
||||
var matchSummary = await _summarizer.SummarizeSectionAsync(
|
||||
"Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.",
|
||||
@@ -1489,6 +1606,7 @@ Candidate CV/profile:
|
||||
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 attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken);
|
||||
var context = $@"Job title: {job.JobTitle}
|
||||
Company: {job.Company?.Name}
|
||||
Status: {job.Status}
|
||||
@@ -1496,7 +1614,7 @@ Job description and notes:
|
||||
{jobText}
|
||||
|
||||
Candidate master CV:
|
||||
{cvText}";
|
||||
{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
||||
|
||||
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.",
|
||||
@@ -1636,7 +1754,7 @@ Candidate master CV:
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/generate-application-package")]
|
||||
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? coverLetterStyle, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
@@ -1660,36 +1778,42 @@ Candidate master CV:
|
||||
return BadRequest("This job does not have enough description or notes to generate an application package.");
|
||||
}
|
||||
|
||||
var packageModeInstruction = BuildPackageModeInstruction(mode);
|
||||
var coverLetterStyleInstruction = BuildCoverLetterStyleInstruction(coverLetterStyle);
|
||||
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken);
|
||||
|
||||
var packageContext = $@"Job title: {job.JobTitle}
|
||||
Company: {job.Company?.Name}
|
||||
Status: {job.Status}
|
||||
Generation mode: {mode ?? "default"}
|
||||
Cover-letter style: {coverLetterStyle ?? "balanced"}
|
||||
|
||||
Job context:
|
||||
{jobText}
|
||||
|
||||
Candidate master CV:
|
||||
{cvText}";
|
||||
{cvText}{(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.",
|
||||
$"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}",
|
||||
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.",
|
||||
$"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}",
|
||||
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.",
|
||||
$"Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words. {packageModeInstruction}",
|
||||
packageContext,
|
||||
170,
|
||||
70);
|
||||
|
||||
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.",
|
||||
$"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}",
|
||||
packageContext,
|
||||
140,
|
||||
55);
|
||||
@@ -1705,7 +1829,9 @@ Candidate master CV:
|
||||
CoverLetterDraft: coverLetterDraft,
|
||||
ApplicationAnswerDraft: applicationAnswerDraft,
|
||||
RecruiterMessageDraft: recruiterMessageDraft,
|
||||
KeyPoints: keyPoints));
|
||||
KeyPoints: keyPoints,
|
||||
AttachmentSignals: attachmentContext?.Signals ?? new List<string>(),
|
||||
AttachmentFilesUsed: attachmentContext?.UsedFiles ?? new List<string>()));
|
||||
}
|
||||
|
||||
[HttpGet("analytics-overview")]
|
||||
@@ -1940,6 +2066,7 @@ Candidate master CV:
|
||||
: job.Status == "Rejected" ? "feedback-request"
|
||||
: "post-apply")
|
||||
: mode.Trim().ToLowerInvariant();
|
||||
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken);
|
||||
var aiContext = $@"Candidate name: {signerName}
|
||||
Role: {job.JobTitle}
|
||||
Company: {companyName}
|
||||
@@ -1952,7 +2079,7 @@ Last message date: {(lastMessage is not null ? lastMessage.Date.ToString("MMMM d
|
||||
Relevant skills/tags: {(tagHighlights.Count > 0 ? string.Join(", ", tagHighlights) : "None provided")}
|
||||
Short fit summary: {summary ?? "None provided"}
|
||||
Job description:
|
||||
{job.TranslatedDescription ?? job.Description ?? "No job description available."}";
|
||||
{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.",
|
||||
|
||||
@@ -36,6 +36,8 @@ public sealed class ProfileCvController : ControllerBase
|
||||
_aiService = aiService;
|
||||
}
|
||||
|
||||
public sealed record RewriteSectionRequest(string SectionName, string? Style, string? TargetRole);
|
||||
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(MaxFileSizeBytes)]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFile file)
|
||||
@@ -119,6 +121,31 @@ public sealed class ProfileCvController : ControllerBase
|
||||
return Ok(new { rebuilt = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText });
|
||||
}
|
||||
|
||||
[HttpPost("rewrite-section")]
|
||||
public async Task<IActionResult> RewriteSection([FromBody] RewriteSectionRequest request)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before rewriting a section.");
|
||||
|
||||
var sectionName = string.IsNullOrWhiteSpace(request.SectionName) ? "Professional Summary" : request.SectionName.Trim();
|
||||
var style = string.IsNullOrWhiteSpace(request.Style) ? "balanced" : request.Style.Trim();
|
||||
var targetRole = string.IsNullOrWhiteSpace(request.TargetRole) ? null : request.TargetRole.Trim();
|
||||
|
||||
var rewritten = await _aiService.SummarizeSectionAsync(
|
||||
$"Rewrite only the '{sectionName}' section of this CV. Preserve facts, avoid inventing employers or metrics, and output only the rewritten section text. Style: {style}. {(targetRole is not null ? $"Target role: {targetRole}." : "Make it broadly reusable for future tailoring.")}",
|
||||
user.ProfileCvText,
|
||||
900,
|
||||
180);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rewritten))
|
||||
{
|
||||
return BadRequest("The AI service could not rewrite that CV section right now.");
|
||||
}
|
||||
|
||||
return Ok(new { sectionName, style, targetRole, text = rewritten.Trim() });
|
||||
}
|
||||
|
||||
[HttpPost("improve")]
|
||||
public async Task<IActionResult> Improve()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user