From 5916f09852ab1897691207c3f90bbfbc568c3098 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 15:34:07 +0200 Subject: [PATCH] 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. --- Data/JobTrackerContext.cs | 15 ++ .../InterviewPrepPersistenceTests.cs | 134 ++++++++++++++++++ .../Controllers/JobApplicationsController.cs | 45 +++++- .../StartupInitializationExtensions.cs | 53 +++++++ Models/InterviewPrepNote.cs | 22 +++ .../src/components/JobDetailsDialog.tsx | 20 ++- 6 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs create mode 100644 Models/InterviewPrepNote.cs diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 178091a..b50fc4a 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -30,6 +30,7 @@ namespace JobTrackerApi.Data public DbSet TailoredCvDrafts => Set(); public DbSet CareerProfiles => Set(); public DbSet CareerProfileVersions => Set(); + public DbSet InterviewPrepNotes => Set(); 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() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(x => x.JobApplication) + .WithMany() + .HasForeignKey(x => x.JobApplicationId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs b/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs new file mode 100644 index 0000000..ae164c5 --- /dev/null +++ b/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs @@ -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(); + summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny(), It.IsAny()), 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(); + summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny(), It.IsAny()), 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(); + summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + private static JobApplicationsController.InterviewPrepDto GetDto(ActionResult result) + => (JobApplicationsController.InterviewPrepDto)Assert.IsType(result.Result).Value!; + + private static async Task 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(), + TestHostFactory.CreateUserManager(null).Object, + NullLogger.Instance, + Mock.Of(), + Mock.Of()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + }; + return controller; + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 94f5dac..c21ddac 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -2398,13 +2398,31 @@ Candidate master CV: } [HttpGet("{id:int}/interview-prep")] - public async Task> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) + public async Task> 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>(existing.TalkingPointsJson) ?? new List(), + JsonSerializer.Deserialize>(existing.LikelyQuestionsJson) ?? new List(), + JsonSerializer.Deserialize>(existing.WeakSpotsJson) ?? new List())); + } + } + 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> GetReadiness([FromRoute] int id, CancellationToken cancellationToken) { diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 458faec..784b3ba 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -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(); diff --git a/Models/InterviewPrepNote.cs b/Models/InterviewPrepNote.cs new file mode 100644 index 0000000..af16663 --- /dev/null +++ b/Models/InterviewPrepNote.cs @@ -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; +} diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index c3d5877..306bf4c 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -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(`/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 && ( + + + {loadingInterviewPrep ? : interviewPrep ? (