feat(ai): queue strategy snapshots

This commit is contained in:
cesnimda
2026-08-09 12:51:46 +02:00
parent 5eb9b3cb96
commit a62122640c
12 changed files with 790 additions and 158 deletions
@@ -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);