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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user