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(
|
||||
|
||||
@@ -14,14 +14,14 @@ public sealed class OperationsController(UserOperationStore operations) : Contro
|
||||
public async Task<ActionResult<IReadOnlyList<OperationDto>>> List([FromQuery] int limit = 25, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (limit is < 1 or > 100) return BadRequest(new { code = "invalid_limit", message = "Limit must be between 1 and 100." });
|
||||
return Ok((await operations.ListAsync(limit, cancellationToken)).Select(ToDto).ToList());
|
||||
return Ok((await operations.ListAsync(limit, cancellationToken)).Select(OperationDto.From).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<OperationDto>> Get(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var operation = await operations.GetAsync(id, cancellationToken);
|
||||
return operation is null ? NotFound() : Ok(ToDto(operation));
|
||||
return operation is null ? NotFound() : Ok(OperationDto.From(operation));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel")]
|
||||
@@ -30,7 +30,7 @@ public sealed class OperationsController(UserOperationStore operations) : Contro
|
||||
if (await operations.GetAsync(id, cancellationToken) is null) return NotFound();
|
||||
if (!await operations.RequestCancellationAsync(id, cancellationToken))
|
||||
return Conflict(new { code = "operation_not_cancellable", message = "This operation can no longer be cancelled." });
|
||||
return Ok(ToDto((await operations.GetAsync(id, cancellationToken))!));
|
||||
return Ok(OperationDto.From((await operations.GetAsync(id, cancellationToken))!));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/retry")]
|
||||
@@ -39,24 +39,9 @@ public sealed class OperationsController(UserOperationStore operations) : Contro
|
||||
if (await operations.GetAsync(id, cancellationToken) is null) return NotFound();
|
||||
if (!await operations.RetryAsync(id, cancellationToken))
|
||||
return Conflict(new { code = "operation_not_retryable", message = "Only failed or cancelled operations can be retried." });
|
||||
return Ok(ToDto((await operations.GetAsync(id, cancellationToken))!));
|
||||
return Ok(OperationDto.From((await operations.GetAsync(id, cancellationToken))!));
|
||||
}
|
||||
|
||||
private static OperationDto ToDto(UserOperation operation) => new(
|
||||
operation.Id,
|
||||
operation.TaskType,
|
||||
operation.Status,
|
||||
operation.SubjectType,
|
||||
operation.CreatedAtUtc,
|
||||
operation.StartedAtUtc,
|
||||
operation.CompletedAtUtc,
|
||||
operation.DeadlineAtUtc,
|
||||
operation.CancellationRequestedAtUtc,
|
||||
operation.ProgressStage,
|
||||
operation.ProgressPercent,
|
||||
operation.FailureCategory,
|
||||
!OperationStatuses.IsTerminal(operation.Status) && operation.CancellationRequestedAtUtc is null,
|
||||
operation.Status is OperationStatuses.Failed or OperationStatuses.Cancelled);
|
||||
}
|
||||
|
||||
public sealed record OperationDto(
|
||||
@@ -73,7 +58,24 @@ public sealed record OperationDto(
|
||||
int? ProgressPercent,
|
||||
string? FailureCategory,
|
||||
bool CanCancel,
|
||||
bool CanRetry);
|
||||
bool CanRetry)
|
||||
{
|
||||
public static OperationDto From(UserOperation operation) => new(
|
||||
operation.Id,
|
||||
operation.TaskType,
|
||||
operation.Status,
|
||||
operation.SubjectType,
|
||||
operation.CreatedAtUtc,
|
||||
operation.StartedAtUtc,
|
||||
operation.CompletedAtUtc,
|
||||
operation.DeadlineAtUtc,
|
||||
operation.CancellationRequestedAtUtc,
|
||||
operation.ProgressStage,
|
||||
operation.ProgressPercent,
|
||||
operation.FailureCategory,
|
||||
!OperationStatuses.IsTerminal(operation.Status) && operation.CancellationRequestedAtUtc is null,
|
||||
operation.Status is OperationStatuses.Failed or OperationStatuses.Cancelled);
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/notifications")]
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/jobapplications/{jobId:int}/focus-plan")]
|
||||
[Authorize(AuthenticationSchemes = "local", Policy = ProEntitlement.Policy)]
|
||||
public sealed class StrategySnapshotController(
|
||||
JobTrackerContext db,
|
||||
AiOperationAdmission admission,
|
||||
StrategySnapshotService snapshots) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<FocusPlanDto>> Get(int jobId, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<int> ids;
|
||||
try { ids = StrategySnapshotService.ParseAttachmentIds(attachmentIds); }
|
||||
catch (StrategySnapshotValidationException exception) { return Problem(exception); }
|
||||
var result = await snapshots.GetCachedAsync(jobId, StrategySnapshotService.NormalizeAttachmentIds(ids), cancellationToken);
|
||||
return result is null
|
||||
? NotFound(new { code = "strategy_not_generated", message = "No strategy snapshot has been generated for this context." })
|
||||
: Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("operations")]
|
||||
public async Task<IActionResult> Enqueue(int jobId, [FromBody] StrategySnapshotRequest? request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ids = StrategySnapshotService.ParseAttachmentIds(request?.AttachmentIds);
|
||||
await snapshots.ValidateRequestAsync(jobId, ids, cancellationToken);
|
||||
var signature = StrategySnapshotService.NormalizeAttachmentIds(ids);
|
||||
var subject = StrategySnapshotService.EncodeSubject(jobId, ids);
|
||||
var activeStatuses = new[] { OperationStatuses.Queued, OperationStatuses.Running, OperationStatuses.WaitingForRetry, OperationStatuses.WaitingForExternalFallback };
|
||||
var active = await db.UserOperations.AsNoTracking()
|
||||
.Where(item => item.TaskType == StrategySnapshotService.TaskType && item.SubjectId == subject && activeStatuses.Contains(item.Status))
|
||||
.OrderByDescending(item => item.CreatedAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (active is not null)
|
||||
{
|
||||
var statusUrl = $"/api/operations/{active.Id:D}";
|
||||
return Accepted(statusUrl, new StrategySnapshotOperationResponse(OperationDto.From(active), statusUrl, false));
|
||||
}
|
||||
var key = await snapshots.BuildIdempotencyKeyAsync(jobId, signature, cancellationToken);
|
||||
var result = await admission.EnqueueAsync(
|
||||
StrategySnapshotService.TaskType,
|
||||
key,
|
||||
"job_strategy",
|
||||
subject,
|
||||
AiOperationPriorities.Interactive,
|
||||
cancellationToken);
|
||||
return Accepted(result.StatusUrl, new StrategySnapshotOperationResponse(OperationDto.From(result.Operation), result.StatusUrl, result.Created));
|
||||
}
|
||||
catch (StrategySnapshotValidationException exception) { return Problem(exception); }
|
||||
catch (AiOperationAdmissionException exception)
|
||||
{
|
||||
if (exception.RetryAfterSeconds is int seconds) Response.Headers.RetryAfter = seconds.ToString();
|
||||
return StatusCode(exception.StatusCode, new { code = exception.Code, message = exception.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("operation")]
|
||||
public async Task<ActionResult<OperationDto>> LatestOperation(int jobId, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<int> ids;
|
||||
try { ids = StrategySnapshotService.ParseAttachmentIds(attachmentIds); }
|
||||
catch (StrategySnapshotValidationException exception) { return Problem(exception); }
|
||||
var subject = StrategySnapshotService.EncodeSubject(jobId, ids);
|
||||
var operation = await db.UserOperations.AsNoTracking()
|
||||
.Where(item => item.TaskType == StrategySnapshotService.TaskType && item.SubjectId == subject)
|
||||
.OrderByDescending(item => item.CreatedAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return operation is null ? NotFound() : Ok(OperationDto.From(operation));
|
||||
}
|
||||
|
||||
private ObjectResult Problem(StrategySnapshotValidationException exception) =>
|
||||
StatusCode(exception.StatusCode, new { code = exception.Code, message = exception.Message });
|
||||
}
|
||||
|
||||
public sealed record StrategySnapshotRequest(string? AttachmentIds);
|
||||
public sealed record StrategySnapshotOperationResponse(OperationDto Operation, string StatusUrl, bool Created);
|
||||
Reference in New Issue
Block a user