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
+15
View File
@@ -30,6 +30,7 @@ namespace JobTrackerApi.Data
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -164,6 +165,20 @@ namespace JobTrackerApi.Data
.WithMany()
.HasForeignKey(x => x.CareerProfileId)
.OnDelete(DeleteBehavior.Cascade);
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
modelBuilder.Entity<InterviewPrepNote>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<InterviewPrepNote>()
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
.IsUnique();
modelBuilder.Entity<InterviewPrepNote>()
.HasOne(x => x.JobApplication)
.WithMany()
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -0,0 +1,134 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5): the AI call
// used to re-run on every tab open. These tests lock in that a note is generated once, reused on
// subsequent reads, and only regenerated when the attachment context changes or a refresh is
// explicitly requested.
public sealed class InterviewPrepPersistenceTests
{
[Fact]
public async Task GetInterviewPrep_persists_and_reuses_the_generated_note()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var job = await SeedJobAsync(db);
var summarizer = new Mock<ISummarizerService>();
summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync("First generated brief.")
.ReturnsAsync("Second generated brief.");
var controller = CreateController(db, summarizer.Object, "user-1");
var first = await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
Assert.Equal("First generated brief.", GetDto(first).Summary);
var second = await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
Assert.Equal("First generated brief.", GetDto(second).Summary);
summarizer.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Once);
var stored = Assert.Single(db.InterviewPrepNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id));
Assert.Equal("First generated brief.", stored.Summary);
}
[Fact]
public async Task GetInterviewPrep_regenerates_when_refresh_is_requested()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var job = await SeedJobAsync(db);
var summarizer = new Mock<ISummarizerService>();
summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync("First generated brief.")
.ReturnsAsync("Refreshed brief.");
var controller = CreateController(db, summarizer.Object, "user-1");
await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
var refreshed = await controller.GetInterviewPrep(job.Id, null, true, CancellationToken.None);
Assert.Equal("Refreshed brief.", GetDto(refreshed).Summary);
summarizer.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(2));
var stored = Assert.Single(db.InterviewPrepNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id));
Assert.Equal("Refreshed brief.", stored.Summary);
}
[Fact]
public async Task GetInterviewPrep_regenerates_when_attachment_selection_changes()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var job = await SeedJobAsync(db);
var summarizer = new Mock<ISummarizerService>();
summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync("No-attachment brief.")
.ReturnsAsync("With-attachment brief.");
var controller = CreateController(db, summarizer.Object, "user-1");
await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
var withAttachment = await controller.GetInterviewPrep(job.Id, "7", false, CancellationToken.None);
Assert.Equal("With-attachment brief.", GetDto(withAttachment).Summary);
summarizer.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(2));
}
private static JobApplicationsController.InterviewPrepDto GetDto(ActionResult<JobApplicationsController.InterviewPrepDto> result)
=> (JobApplicationsController.InterviewPrepDto)Assert.IsType<OkObjectResult>(result.Result).Value!;
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db)
{
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1",
Description = "Needs .NET and SQL experience.",
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
return job;
}
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
{
var controller = new JobApplicationsController(
db,
summarizer,
Mock.Of<IAppEmailSender>(),
TestHostFactory.CreateUserManager(null).Object,
NullLogger<JobApplicationsController>.Instance,
Mock.Of<ICvTemplateRenderer>(),
Mock.Of<ICvPdfExporter>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId)
}, "test"))
}
};
return controller;
}
}
@@ -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)
{
@@ -657,11 +657,35 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
}
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5):
// stop re-running the AI call on every tab open by persisting the last generated
// note per job, keyed by the attachment selection it was generated from.
static void EnsureInterviewPrepNotesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "InterviewPrepNotes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepNotes" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"AttachmentContextSignature" TEXT NOT NULL,
"Summary" TEXT NOT NULL,
"TalkingPointsJson" TEXT NOT NULL,
"LikelyQuestionsJson" TEXT NOT NULL,
"WeakSpotsJson" TEXT NOT NULL,
"GeneratedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_InterviewPrepNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");""");
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn);
EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -804,6 +828,15 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");
if (!MySqlIndexExists(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_InterviewPrepNotes_OwnerUserId_JobApplicationId` ON `InterviewPrepNotes` (`OwnerUserId`, `JobApplicationId`);";
cmd.ExecuteNonQuery();
}
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
// Correspondences/Attachments) -- re-run once more after Migrate() below via
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
@@ -1063,6 +1096,26 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
if (!HasMySqlTable(conn, "InterviewPrepNotes"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepNotes` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`AttachmentContextSignature` longtext NOT NULL,
`Summary` longtext NOT NULL,
`TalkingPointsJson` longtext NOT NULL,
`LikelyQuestionsJson` longtext NOT NULL,
`WeakSpotsJson` longtext NOT NULL,
`GeneratedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_InterviewPrepNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
+22
View File
@@ -0,0 +1,22 @@
namespace JobTrackerApi.Models;
// Persists interview prep so it survives tab switches and page reloads instead of being an AI
// call re-run every time the tab opens (career-workspace-implementation-roadmap.md Phase F5 --
// "persist interview prep / fit outputs" -- flagged in the product teardown as work evaporating
// on every re-open). One row per job application; regenerated when the selected attachment
// context changes or the user explicitly requests a refresh.
public sealed class InterviewPrepNote
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int JobApplicationId { get; set; }
public JobApplication? JobApplication { get; set; }
// Fingerprint of the attachment selection this note was generated from, so a different
// attachment selection triggers regeneration without needing an explicit refresh.
public string AttachmentContextSignature { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string TalkingPointsJson { get; set; } = "[]";
public string LikelyQuestionsJson { get; set; } = "[]";
public string WeakSpotsJson { get; set; } = "[]";
public DateTimeOffset GeneratedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
@@ -364,6 +364,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
// Interview prep is now persisted server-side (career-workspace-implementation-roadmap.md
// Phase F5) so it survives tab switches without re-running the AI call. Regenerate is the
// explicit escape hatch for when the underlying job/notes have changed since it was written.
const regenerateInterviewPrep = useCallback(() => {
if (!jobId) return;
setLoadingInterviewPrep(true);
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
interviewPrepCache.setCached(`${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`, r.data);
setInterviewPrep(r.data);
toast("Interview prep regenerated.", "success");
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate interview prep."), "error")).finally(() => setLoadingInterviewPrep(false));
}, [jobId, selectedAttachmentCsv, interviewPrepCache, toast]);
useEffect(() => {
setFollowUpDraft(null);
setCandidateFit(null);
@@ -1171,6 +1184,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{tab === 7 && (
<Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />