feat(ai): queue strategy snapshots
This commit is contained in:
@@ -1524,100 +1524,6 @@ Candidate CV/profile:
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/focus-plan")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.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 attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
|
||||
if (!refresh)
|
||||
{
|
||||
var cached = await TryGetCachedAiNoteAsync<FocusPlanDto>(userId, id, "focus-plan", attachmentSignature, cancellationToken);
|
||||
if (cached is not null) return Ok(cached);
|
||||
}
|
||||
|
||||
var user = await _db.Users.AsNoTracking().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 structuredCvContext = BuildStructuredCvContext(user);
|
||||
|
||||
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
||||
var context = $@"Job title: {job.JobTitle}
|
||||
Company: {job.Company?.Name}
|
||||
Status: {job.Status}
|
||||
Job description and notes:
|
||||
{jobText}
|
||||
|
||||
Candidate master CV:
|
||||
{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(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.",
|
||||
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);
|
||||
|
||||
var dto = new FocusPlanDto(
|
||||
ImmediatePriorities: immediatePriorities,
|
||||
CvBulletIdeas: cvBulletIdeas,
|
||||
ProofPointsToLeadWith: proofPointsToLeadWith,
|
||||
CoverLetterAngles: coverLetterAngles,
|
||||
FollowUpApproach: followUpApproach,
|
||||
StrategicSummary: strategicSummary);
|
||||
|
||||
await SaveAiNoteAsync(userId, id, "focus-plan", attachmentSignature, dto, cancellationToken);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
private async Task<T?> TryGetCachedAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, CancellationToken cancellationToken) where T : class
|
||||
{
|
||||
var existing = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
|
||||
|
||||
Reference in New Issue
Block a user