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;
|
||||||
using JobTrackerApi.Services.JobImport;
|
using JobTrackerApi.Services.JobImport;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Identity;
|
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)
|
private static List<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
|
||||||
{
|
{
|
||||||
var normalized = (status ?? string.Empty).Trim();
|
var normalized = (status ?? string.Empty).Trim();
|
||||||
@@ -1332,11 +1425,34 @@ namespace JobTrackerApi.Controllers
|
|||||||
string? CoverLetterDraft,
|
string? CoverLetterDraft,
|
||||||
string? RecruiterMessageDraft);
|
string? RecruiterMessageDraft);
|
||||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
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 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 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);
|
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")]
|
[HttpGet("{id:int}/candidate-fit")]
|
||||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, CancellationToken cancellationToken)
|
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 strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
|
||||||
var gaps = 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}
|
var jobContext = $@"Job title: {job.JobTitle}
|
||||||
Company: {job.Company?.Name}
|
Company: {job.Company?.Name}
|
||||||
Status: {job.Status}
|
Status: {job.Status}
|
||||||
@@ -1375,7 +1492,7 @@ Job description and notes:
|
|||||||
{jobText}
|
{jobText}
|
||||||
|
|
||||||
Candidate CV/profile:
|
Candidate CV/profile:
|
||||||
{cvText}";
|
{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
||||||
|
|
||||||
var matchSummary = await _summarizer.SummarizeSectionAsync(
|
var matchSummary = await _summarizer.SummarizeSectionAsync(
|
||||||
"Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.",
|
"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 matchedTags = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList();
|
||||||
var missingTags = 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}
|
var context = $@"Job title: {job.JobTitle}
|
||||||
Company: {job.Company?.Name}
|
Company: {job.Company?.Name}
|
||||||
Status: {job.Status}
|
Status: {job.Status}
|
||||||
@@ -1496,7 +1614,7 @@ Job description and notes:
|
|||||||
{jobText}
|
{jobText}
|
||||||
|
|
||||||
Candidate master CV:
|
Candidate master CV:
|
||||||
{cvText}";
|
{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
||||||
|
|
||||||
var strategicSummary = await _summarizer.SummarizeSectionAsync(
|
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.",
|
"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")]
|
[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
|
var job = await _db.JobApplications
|
||||||
.Include(j => j.Company)
|
.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.");
|
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}
|
var packageContext = $@"Job title: {job.JobTitle}
|
||||||
Company: {job.Company?.Name}
|
Company: {job.Company?.Name}
|
||||||
Status: {job.Status}
|
Status: {job.Status}
|
||||||
|
Generation mode: {mode ?? "default"}
|
||||||
|
Cover-letter style: {coverLetterStyle ?? "balanced"}
|
||||||
|
|
||||||
Job context:
|
Job context:
|
||||||
{jobText}
|
{jobText}
|
||||||
|
|
||||||
Candidate master CV:
|
Candidate master CV:
|
||||||
{cvText}";
|
{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
||||||
|
|
||||||
var tailoredCvText = await _summarizer.SummarizeSectionAsync(
|
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,
|
packageContext,
|
||||||
256,
|
256,
|
||||||
120) ?? cvText;
|
120) ?? cvText;
|
||||||
|
|
||||||
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
|
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,
|
packageContext,
|
||||||
260,
|
260,
|
||||||
110);
|
110);
|
||||||
|
|
||||||
var applicationAnswerDraft = await _summarizer.SummarizeSectionAsync(
|
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,
|
packageContext,
|
||||||
170,
|
170,
|
||||||
70);
|
70);
|
||||||
|
|
||||||
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
|
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,
|
packageContext,
|
||||||
140,
|
140,
|
||||||
55);
|
55);
|
||||||
@@ -1705,7 +1829,9 @@ Candidate master CV:
|
|||||||
CoverLetterDraft: coverLetterDraft,
|
CoverLetterDraft: coverLetterDraft,
|
||||||
ApplicationAnswerDraft: applicationAnswerDraft,
|
ApplicationAnswerDraft: applicationAnswerDraft,
|
||||||
RecruiterMessageDraft: recruiterMessageDraft,
|
RecruiterMessageDraft: recruiterMessageDraft,
|
||||||
KeyPoints: keyPoints));
|
KeyPoints: keyPoints,
|
||||||
|
AttachmentSignals: attachmentContext?.Signals ?? new List<string>(),
|
||||||
|
AttachmentFilesUsed: attachmentContext?.UsedFiles ?? new List<string>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("analytics-overview")]
|
[HttpGet("analytics-overview")]
|
||||||
@@ -1940,6 +2066,7 @@ Candidate master CV:
|
|||||||
: job.Status == "Rejected" ? "feedback-request"
|
: job.Status == "Rejected" ? "feedback-request"
|
||||||
: "post-apply")
|
: "post-apply")
|
||||||
: mode.Trim().ToLowerInvariant();
|
: mode.Trim().ToLowerInvariant();
|
||||||
|
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken);
|
||||||
var aiContext = $@"Candidate name: {signerName}
|
var aiContext = $@"Candidate name: {signerName}
|
||||||
Role: {job.JobTitle}
|
Role: {job.JobTitle}
|
||||||
Company: {companyName}
|
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")}
|
Relevant skills/tags: {(tagHighlights.Count > 0 ? string.Join(", ", tagHighlights) : "None provided")}
|
||||||
Short fit summary: {summary ?? "None provided"}
|
Short fit summary: {summary ?? "None provided"}
|
||||||
Job description:
|
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(
|
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, 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;
|
_aiService = aiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record RewriteSectionRequest(string SectionName, string? Style, string? TargetRole);
|
||||||
|
|
||||||
[HttpPost("upload")]
|
[HttpPost("upload")]
|
||||||
[RequestSizeLimit(MaxFileSizeBytes)]
|
[RequestSizeLimit(MaxFileSizeBytes)]
|
||||||
public async Task<IActionResult> Upload([FromForm] IFormFile file)
|
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 });
|
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")]
|
[HttpPost("improve")]
|
||||||
public async Task<IActionResult> Improve()
|
public async Task<IActionResult> Improve()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Last updated: 2026-03-23
|
|||||||
- [x] Add AI service latency/OCR telemetry to the system page
|
- [x] Add AI service latency/OCR telemetry to the system page
|
||||||
- [x] Add frontend test coverage for AI service status rendering
|
- [x] Add frontend test coverage for AI service status rendering
|
||||||
- [x] Add CV text improvement flow powered by the AI service
|
- [x] Add CV text improvement flow powered by the AI service
|
||||||
- [ ] Extend AI extraction to job attachment ingestion
|
- [x] Extend AI extraction to job attachment ingestion for package/follow-up context
|
||||||
- [ ] Consider full internal service/class rename from `Summarizer*` to `AiService*`
|
- [ ] Consider full internal service/class rename from `Summarizer*` to `AiService*`
|
||||||
|
|
||||||
## Build / UI Issues
|
## Build / UI Issues
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type FollowUpDraft = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
|
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
|
||||||
|
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -97,6 +98,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||||
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
|
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
|
||||||
|
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
|
||||||
const [tailoredCvText, setTailoredCvText] = useState("");
|
const [tailoredCvText, setTailoredCvText] = useState("");
|
||||||
const [draftRecipient, setDraftRecipient] = useState("");
|
const [draftRecipient, setDraftRecipient] = useState("");
|
||||||
const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply");
|
const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply");
|
||||||
@@ -279,6 +281,15 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
<MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
|
<MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||||
|
<InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel>
|
||||||
|
<Select value={coverLetterStyle} label={t("jobDetailsCoverLetterStyle")} onChange={(e) => setCoverLetterStyle(e.target.value as CoverLetterStyle)}>
|
||||||
|
<MenuItem value="balanced">{t("jobDetailsCoverLetterStyleBalanced")}</MenuItem>
|
||||||
|
<MenuItem value="concise">{t("jobDetailsCoverLetterStyleConcise")}</MenuItem>
|
||||||
|
<MenuItem value="formal">{t("jobDetailsCoverLetterStyleFormal")}</MenuItem>
|
||||||
|
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
<Button size="small" variant="outlined" onClick={async () => {
|
<Button size="small" variant="outlined" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
|
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
|
||||||
@@ -292,7 +303,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
setGeneratingPackage(true);
|
setGeneratingPackage(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode } });
|
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle } });
|
||||||
setApplicationPackage(res.data);
|
setApplicationPackage(res.data);
|
||||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
setTailoredCvText(res.data.tailoredCvText ?? "");
|
||||||
toast(t("jobDetailsPackageGenerated"), "success");
|
toast(t("jobDetailsPackageGenerated"), "success");
|
||||||
@@ -369,6 +380,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
}
|
}
|
||||||
}} saving={savingApplicationDrafts} />
|
}} saving={savingApplicationDrafts} />
|
||||||
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage.keyPoints} />
|
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage.keyPoints} />
|
||||||
|
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage.attachmentSignals.length > 0 ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage.attachmentFilesUsed.length > 0 ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -525,13 +537,16 @@ function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { le
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListCard({ title, items }: { title: string; items: string[] }) {
|
function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||||
<Typography variant="overline">{title}</Typography>
|
<Box>
|
||||||
|
<Typography variant="overline">{title}</Typography>
|
||||||
|
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
|
||||||
|
</Box>
|
||||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||||
|
|||||||
@@ -190,6 +190,27 @@ export const translations = {
|
|||||||
profileCvTextLabel: "Profile CV / master resume text",
|
profileCvTextLabel: "Profile CV / master resume text",
|
||||||
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
||||||
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||||
|
profileCvSectionTools: "Section rewrite tools",
|
||||||
|
profileCvSectionToolsHelp: "Generate sharper versions of one CV section at a time before pasting them back into your master CV.",
|
||||||
|
profileCvSectionLabel: "Section",
|
||||||
|
profileCvSectionSummary: "Professional Summary",
|
||||||
|
profileCvSectionSkills: "Core Skills",
|
||||||
|
profileCvSectionExperience: "Experience Highlights",
|
||||||
|
profileCvSectionAchievements: "Selected Achievements",
|
||||||
|
profileCvSectionProjects: "Projects",
|
||||||
|
profileCvSectionStyle: "Rewrite style",
|
||||||
|
profileCvSectionStyleBalanced: "Balanced",
|
||||||
|
profileCvSectionStyleConcise: "Concise",
|
||||||
|
profileCvSectionStyleImpact: "Impact-focused",
|
||||||
|
profileCvSectionStyleAts: "ATS-friendly",
|
||||||
|
profileCvSectionTargetRole: "Target role (optional)",
|
||||||
|
profileCvSectionRewrite: "Rewrite section",
|
||||||
|
profileCvSectionRewriting: "Rewriting section...",
|
||||||
|
profileCvSectionRewritten: "CV section draft generated.",
|
||||||
|
profileCvSectionRewriteFailed: "Failed to rewrite the CV section.",
|
||||||
|
profileCvSectionDraft: "Section draft",
|
||||||
|
profileCvSectionDraftPlaceholder: "Your rewritten section will appear here.",
|
||||||
|
profileCvSectionAppend: "Append to CV text",
|
||||||
profileSaveChanges: "Save changes",
|
profileSaveChanges: "Save changes",
|
||||||
profileUpdated: "Profile updated.",
|
profileUpdated: "Profile updated.",
|
||||||
profileUpdateFailed: "Failed to update profile.",
|
profileUpdateFailed: "Failed to update profile.",
|
||||||
@@ -630,6 +651,11 @@ export const translations = {
|
|||||||
jobDetailsGenerationAts: "ATS focused",
|
jobDetailsGenerationAts: "ATS focused",
|
||||||
jobDetailsGenerationAchievement: "Achievement focused",
|
jobDetailsGenerationAchievement: "Achievement focused",
|
||||||
jobDetailsGenerationInterview: "Interview focused",
|
jobDetailsGenerationInterview: "Interview focused",
|
||||||
|
jobDetailsCoverLetterStyle: "Cover letter style",
|
||||||
|
jobDetailsCoverLetterStyleBalanced: "Balanced",
|
||||||
|
jobDetailsCoverLetterStyleConcise: "Concise",
|
||||||
|
jobDetailsCoverLetterStyleFormal: "Formal",
|
||||||
|
jobDetailsCoverLetterStyleBold: "Bold",
|
||||||
jobDetailsResume: "Resume",
|
jobDetailsResume: "Resume",
|
||||||
jobDetailsCoverLetter: "Cover letter",
|
jobDetailsCoverLetter: "Cover letter",
|
||||||
jobDetailsPortfolio: "Portfolio",
|
jobDetailsPortfolio: "Portfolio",
|
||||||
@@ -681,6 +707,8 @@ export const translations = {
|
|||||||
jobDetailsRecruiterMessageSaved: "Recruiter message saved to this job.",
|
jobDetailsRecruiterMessageSaved: "Recruiter message saved to this job.",
|
||||||
jobDetailsRecruiterMessageSaveFailed: "Failed to save recruiter message.",
|
jobDetailsRecruiterMessageSaveFailed: "Failed to save recruiter message.",
|
||||||
jobDetailsKeyPoints: "Key points to emphasize",
|
jobDetailsKeyPoints: "Key points to emphasize",
|
||||||
|
jobDetailsAttachmentSignals: "Attachment-derived signals",
|
||||||
|
jobDetailsNoAttachmentSignals: "No reusable attachment signals were found yet.",
|
||||||
jobDetailsReason: "Reason",
|
jobDetailsReason: "Reason",
|
||||||
jobDetailsFollowUpMode: "Follow-up mode",
|
jobDetailsFollowUpMode: "Follow-up mode",
|
||||||
jobDetailsFollowUpModePostApply: "Post-apply check-in",
|
jobDetailsFollowUpModePostApply: "Post-apply check-in",
|
||||||
@@ -934,6 +962,27 @@ export const translations = {
|
|||||||
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
|
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
|
||||||
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
|
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
|
||||||
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||||
|
profileCvSectionTools: "Verktøy for CV-seksjoner",
|
||||||
|
profileCvSectionToolsHelp: "Generer skarpere versjoner av én CV-seksjon om gangen før du limer dem tilbake i hoved-CV-en.",
|
||||||
|
profileCvSectionLabel: "Seksjon",
|
||||||
|
profileCvSectionSummary: "Profesjonell oppsummering",
|
||||||
|
profileCvSectionSkills: "Kjernekompetanse",
|
||||||
|
profileCvSectionExperience: "Erfaringshøydepunkter",
|
||||||
|
profileCvSectionAchievements: "Utvalgte prestasjoner",
|
||||||
|
profileCvSectionProjects: "Prosjekter",
|
||||||
|
profileCvSectionStyle: "Omskrivingsstil",
|
||||||
|
profileCvSectionStyleBalanced: "Balansert",
|
||||||
|
profileCvSectionStyleConcise: "Kortfattet",
|
||||||
|
profileCvSectionStyleImpact: "Effektfokusert",
|
||||||
|
profileCvSectionStyleAts: "ATS-vennlig",
|
||||||
|
profileCvSectionTargetRole: "Målrolle (valgfritt)",
|
||||||
|
profileCvSectionRewrite: "Omskriv seksjon",
|
||||||
|
profileCvSectionRewriting: "Omskriver seksjon...",
|
||||||
|
profileCvSectionRewritten: "CV-seksjon generert.",
|
||||||
|
profileCvSectionRewriteFailed: "Kunne ikke omskrive CV-seksjonen.",
|
||||||
|
profileCvSectionDraft: "Seksjonsutkast",
|
||||||
|
profileCvSectionDraftPlaceholder: "Den omskrevne seksjonen vises her.",
|
||||||
|
profileCvSectionAppend: "Legg til i CV-teksten",
|
||||||
profileSaveChanges: "Lagre endringer",
|
profileSaveChanges: "Lagre endringer",
|
||||||
profileUpdated: "Profil oppdatert.",
|
profileUpdated: "Profil oppdatert.",
|
||||||
profileUpdateFailed: "Kunne ikke oppdatere profil.",
|
profileUpdateFailed: "Kunne ikke oppdatere profil.",
|
||||||
@@ -1374,6 +1423,11 @@ export const translations = {
|
|||||||
jobDetailsGenerationAts: "ATS-fokusert",
|
jobDetailsGenerationAts: "ATS-fokusert",
|
||||||
jobDetailsGenerationAchievement: "Prestasjonfokusert",
|
jobDetailsGenerationAchievement: "Prestasjonfokusert",
|
||||||
jobDetailsGenerationInterview: "Intervjufokusert",
|
jobDetailsGenerationInterview: "Intervjufokusert",
|
||||||
|
jobDetailsCoverLetterStyle: "Stil for søknadsbrev",
|
||||||
|
jobDetailsCoverLetterStyleBalanced: "Balansert",
|
||||||
|
jobDetailsCoverLetterStyleConcise: "Kortfattet",
|
||||||
|
jobDetailsCoverLetterStyleFormal: "Formell",
|
||||||
|
jobDetailsCoverLetterStyleBold: "Modig",
|
||||||
jobDetailsResume: "CV",
|
jobDetailsResume: "CV",
|
||||||
jobDetailsCoverLetter: "Søknadsbrev",
|
jobDetailsCoverLetter: "Søknadsbrev",
|
||||||
jobDetailsPortfolio: "Portefølje",
|
jobDetailsPortfolio: "Portefølje",
|
||||||
@@ -1425,6 +1479,8 @@ export const translations = {
|
|||||||
jobDetailsRecruiterMessageSaved: "Melding til rekrutterer lagret på denne jobben.",
|
jobDetailsRecruiterMessageSaved: "Melding til rekrutterer lagret på denne jobben.",
|
||||||
jobDetailsRecruiterMessageSaveFailed: "Kunne ikke lagre melding til rekrutterer.",
|
jobDetailsRecruiterMessageSaveFailed: "Kunne ikke lagre melding til rekrutterer.",
|
||||||
jobDetailsKeyPoints: "Nøkkelpunkter å fremheve",
|
jobDetailsKeyPoints: "Nøkkelpunkter å fremheve",
|
||||||
|
jobDetailsAttachmentSignals: "Signal fra vedlegg",
|
||||||
|
jobDetailsNoAttachmentSignals: "Ingen gjenbrukbare signaler fra vedlegg ble funnet ennå.",
|
||||||
jobDetailsReason: "Årsak",
|
jobDetailsReason: "Årsak",
|
||||||
jobDetailsFollowUpMode: "Oppfølgingsmodus",
|
jobDetailsFollowUpMode: "Oppfølgingsmodus",
|
||||||
jobDetailsFollowUpModePostApply: "Oppfølging etter søknad",
|
jobDetailsFollowUpModePostApply: "Oppfølging etter søknad",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Alert, Avatar, Box, Button, Chip, Divider, LinearProgress, Paper, TextField, Typography } from "@mui/material";
|
import { Alert, Avatar, Box, Button, Chip, Divider, FormControl, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material";
|
||||||
|
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
||||||
@@ -11,6 +11,9 @@ import CropImageDialog from "../components/CropImageDialog";
|
|||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
|
type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
||||||
|
type CvSectionStyle = "balanced" | "concise" | "impact" | "ats";
|
||||||
|
|
||||||
type MeResponse = {
|
type MeResponse = {
|
||||||
provider?: "local" | "google" | "external";
|
provider?: "local" | "google" | "external";
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -64,7 +67,11 @@ export default function ProfilePage() {
|
|||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [headline, setHeadline] = useState("");
|
const [headline, setHeadline] = useState("");
|
||||||
const [profileCvText, setProfileCvText] = useState("");
|
const [profileCvText, setProfileCvText] = useState("");
|
||||||
|
const [rewritingSection, setRewritingSection] = useState(false);
|
||||||
|
const [cvSection, setCvSection] = useState<CvSectionOption>("Professional Summary");
|
||||||
|
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("balanced");
|
||||||
|
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||||
|
const [cvSectionDraft, setCvSectionDraft] = useState("");
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
@@ -306,6 +313,71 @@ export default function ProfilePage() {
|
|||||||
disabled={!isLocal}
|
disabled={!isLocal}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvSectionTools")}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvSectionToolsHelp")}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!isLocal || !profileCvText.trim() || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||||
|
onClick={async () => {
|
||||||
|
setRewritingSection(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ text?: string }>("/profile-cv/rewrite-section", {
|
||||||
|
sectionName: cvSection,
|
||||||
|
style: cvSectionStyle,
|
||||||
|
targetRole: cvSectionTargetRole.trim() || null,
|
||||||
|
});
|
||||||
|
setCvSectionDraft(res.data?.text ?? "");
|
||||||
|
toast(t("profileCvSectionRewritten"), "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
||||||
|
} finally {
|
||||||
|
setRewritingSection(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rewritingSection ? t("profileCvSectionRewriting") : t("profileCvSectionRewrite")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1.2fr" }, gap: 1.5, mb: 1.5 }}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
||||||
|
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
||||||
|
<MenuItem value="Professional Summary">{t("profileCvSectionSummary")}</MenuItem>
|
||||||
|
<MenuItem value="Core Skills">{t("profileCvSectionSkills")}</MenuItem>
|
||||||
|
<MenuItem value="Experience Highlights">{t("profileCvSectionExperience")}</MenuItem>
|
||||||
|
<MenuItem value="Selected Achievements">{t("profileCvSectionAchievements")}</MenuItem>
|
||||||
|
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>{t("profileCvSectionStyle")}</InputLabel>
|
||||||
|
<Select value={cvSectionStyle} label={t("profileCvSectionStyle")} onChange={(e) => setCvSectionStyle(e.target.value as CvSectionStyle)}>
|
||||||
|
<MenuItem value="balanced">{t("profileCvSectionStyleBalanced")}</MenuItem>
|
||||||
|
<MenuItem value="concise">{t("profileCvSectionStyleConcise")}</MenuItem>
|
||||||
|
<MenuItem value="impact">{t("profileCvSectionStyleImpact")}</MenuItem>
|
||||||
|
<MenuItem value="ats">{t("profileCvSectionStyleAts")}</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth />
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label={t("profileCvSectionDraft")}
|
||||||
|
value={cvSectionDraft}
|
||||||
|
onChange={(e) => setCvSectionDraft(e.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={6}
|
||||||
|
fullWidth
|
||||||
|
placeholder={t("profileCvSectionDraftPlaceholder")}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
|
||||||
|
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim())}>{t("profileCvSectionAppend")}</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{cvWordCount} words
|
{cvWordCount} words
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export interface ApplicationPackageResponse {
|
|||||||
applicationAnswerDraft?: string | null;
|
applicationAnswerDraft?: string | null;
|
||||||
recruiterMessageDraft?: string | null;
|
recruiterMessageDraft?: string | null;
|
||||||
keyPoints: string[];
|
keyPoints: string[];
|
||||||
|
attachmentSignals: string[];
|
||||||
|
attachmentFilesUsed: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveApplicationDraftsRequest {
|
export interface SaveApplicationDraftsRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user