feat: persist interview prep instead of regenerating on every open

Interview prep re-ran its AI call every time the tab opened -- flagged
in the product teardown as work evaporating on every re-open (cost,
latency, and non-determinism for no reason). GetInterviewPrep now
persists one note per job application and reuses it on subsequent
reads, only regenerating when the selected attachment context changes
or a refresh is explicitly requested.

- InterviewPrepNote: one row per (owner, job), keyed additionally by
  an attachment-selection fingerprint so picking different attachments
  correctly triggers a fresh brief without needing an explicit flag.
- GetInterviewPrep gained a `refresh` query param; the frontend adds a
  small "Regenerate" button as the explicit escape hatch for when the
  underlying job/notes have changed since the note was written.
- Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects.
- 3 new tests: reuse across calls, refresh regenerates, attachment
  context change regenerates. Verified against the real dev DB.
This commit is contained in:
cesnimda
2026-07-12 15:34:07 +02:00
parent 102938c28b
commit 5916f09852
6 changed files with 287 additions and 2 deletions
@@ -2398,13 +2398,31 @@ Candidate master CV:
}
[HttpGet("{id:int}/interview-prep")]
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
if (!refresh && userId is not null)
{
var existing = await _db.InterviewPrepNotes.FirstOrDefaultAsync(
x => x.OwnerUserId == userId && x.JobApplicationId == id && x.AttachmentContextSignature == attachmentSignature,
cancellationToken);
if (existing is not null)
{
return Ok(new InterviewPrepDto(
existing.Summary,
JsonSerializer.Deserialize<List<string>>(existing.TalkingPointsJson) ?? new List<string>(),
JsonSerializer.Deserialize<List<string>>(existing.LikelyQuestionsJson) ?? new List<string>(),
JsonSerializer.Deserialize<List<string>>(existing.WeakSpotsJson) ?? new List<string>()));
}
}
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, attachmentContext?.Context }
.Where(x => !string.IsNullOrWhiteSpace(x)));
@@ -2423,9 +2441,34 @@ Candidate master CV:
180,
70) ?? "Prepare concise, outcome-focused stories that match the core role requirements.";
if (userId is not null)
{
var note = await _db.InterviewPrepNotes.FirstOrDefaultAsync(x => x.OwnerUserId == userId && x.JobApplicationId == id, cancellationToken);
if (note is null)
{
note = new InterviewPrepNote { OwnerUserId = userId, JobApplicationId = id };
_db.InterviewPrepNotes.Add(note);
}
note.AttachmentContextSignature = attachmentSignature;
note.Summary = summary;
note.TalkingPointsJson = JsonSerializer.Serialize(talkingPoints);
note.LikelyQuestionsJson = JsonSerializer.Serialize(likelyQuestions);
note.WeakSpotsJson = JsonSerializer.Serialize(weakSpots);
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
}
return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots));
}
private static string NormalizeAttachmentIdsSignature(string? attachmentIds)
{
if (string.IsNullOrWhiteSpace(attachmentIds)) return string.Empty;
var ids = attachmentIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.OrderBy(x => x, StringComparer.Ordinal);
return string.Join(",", ids);
}
[HttpGet("{id:int}/readiness")]
public async Task<ActionResult<ReadinessDto>> GetReadiness([FromRoute] int id, CancellationToken cancellationToken)
{