feat: persist candidate fit and focus plan, stop re-running on every open

Extends the interview-prep persistence pattern (previous commit) to
the other two AI-generated per-job outputs that were re-running their
full AI call chain on every tab open: candidate-fit (4 AI calls) and
focus-plan (4 AI calls). Across all three tabs that's 9 AI calls fired
every single time a user revisits a job's AI workspace tabs.

Generalized into AiWorkspaceNote (OwnerUserId, JobApplicationId,
NoteType, ResultJson) rather than duplicating InterviewPrepNote's
per-field-column shape: CandidateFitDto and FocusPlanDto are irregular
and nested (up to 13 fields including a nested guidance object),
where per-field columns would be unreasonable. One table, keyed by
note type, serving both.

Same rules as interview prep: reuse across calls, regenerate when the
attachment selection changes, regenerate on explicit refresh. Frontend
gets the same "Regenerate" button on both tabs.

3 new tests (persist+reuse for both, refresh for candidate-fit).
Verified against the real dev DB.
This commit is contained in:
cesnimda
2026-07-12 15:41:58 +02:00
parent 5916f09852
commit 00a035ea20
6 changed files with 312 additions and 6 deletions
@@ -2192,7 +2192,7 @@ Canonical profile:
}
[HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
@@ -2202,6 +2202,13 @@ Canonical profile:
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
if (!refresh)
{
var cached = await TryGetCachedAiNoteAsync<CandidateFitDto>(userId, id, "candidate-fit", attachmentSignature, cancellationToken);
if (cached is not null) return Ok(cached);
}
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
@@ -2298,7 +2305,7 @@ Candidate CV/profile:
"Close with a clear expression of interest and availability."
});
return Ok(new CandidateFitDto(
var dto = new CandidateFitDto(
MatchSummary: matchSummary,
FitLevel: fitLevel,
MatchScore: matchScore,
@@ -2312,11 +2319,14 @@ Candidate CV/profile:
TailoredPitch: tailoredPitch,
Guidance: guidance,
CoverLetterDraft: coverLetterDraft,
RecruiterMessageDraft: recruiterMessageDraft));
RecruiterMessageDraft: recruiterMessageDraft);
await SaveAiNoteAsync(userId, id, "candidate-fit", attachmentSignature, dto, cancellationToken);
return Ok(dto);
}
[HttpGet("{id:int}/focus-plan")]
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
@@ -2326,6 +2336,13 @@ Candidate CV/profile:
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.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
@@ -2388,13 +2405,41 @@ Candidate master CV:
var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags);
return Ok(new FocusPlanDto(
var dto = new FocusPlanDto(
ImmediatePriorities: immediatePriorities,
CvBulletIdeas: cvBulletIdeas,
ProofPointsToLeadWith: proofPointsToLeadWith,
CoverLetterAngles: coverLetterAngles,
FollowUpApproach: followUpApproach,
StrategicSummary: strategicSummary));
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(
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType && x.AttachmentContextSignature == attachmentSignature,
cancellationToken);
if (existing is null) return null;
return JsonSerializer.Deserialize<T>(existing.ResultJson);
}
private async Task SaveAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, T dto, CancellationToken cancellationToken)
{
var note = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType,
cancellationToken);
if (note is null)
{
note = new AiWorkspaceNote { OwnerUserId = userId, JobApplicationId = jobApplicationId, NoteType = noteType };
_db.AiWorkspaceNotes.Add(note);
}
note.AttachmentContextSignature = attachmentSignature;
note.ResultJson = JsonSerializer.Serialize(dto);
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
}
[HttpGet("{id:int}/interview-prep")]