From 00a035ea20e757ed5749c13c882cb6239932a1b5 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 15:41:58 +0200 Subject: [PATCH] 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. --- Data/JobTrackerContext.cs | 17 +++ .../AiWorkspaceNotePersistenceTests.cs | 144 ++++++++++++++++++ .../Controllers/JobApplicationsController.cs | 57 ++++++- .../StartupInitializationExtensions.cs | 49 ++++++ Models/AiWorkspaceNote.cs | 19 +++ .../src/components/JobDetailsDialog.tsx | 32 ++++ 6 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs create mode 100644 Models/AiWorkspaceNote.cs diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index b50fc4a..8358d09 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -31,6 +31,7 @@ namespace JobTrackerApi.Data public DbSet CareerProfiles => Set(); public DbSet CareerProfileVersions => Set(); public DbSet InterviewPrepNotes => Set(); + public DbSet AiWorkspaceNotes => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -179,6 +180,22 @@ namespace JobTrackerApi.Data .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + + // Generic AI workspace note persistence (candidate fit, focus plan) -- same rationale as + // InterviewPrepNote above, generalized because these DTOs are irregular/nested enough that + // per-field columns would be unreasonable. + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.NoteType }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(x => x.JobApplication) + .WithMany() + .HasForeignKey(x => x.JobApplicationId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs b/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs new file mode 100644 index 0000000..6d6ae1b --- /dev/null +++ b/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs @@ -0,0 +1,144 @@ +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.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Candidate fit and focus plan share AiWorkspaceNote persistence with the same rules as +// InterviewPrepNote: reuse across calls, regenerate on refresh, regenerate when the attachment +// selection changes (career-workspace-implementation-roadmap.md Phase F5). +public sealed class AiWorkspaceNotePersistenceTests +{ + [Fact] + public async Task GetCandidateFit_persists_and_reuses_the_generated_note() + { + await using var db = TestHostFactory.CreateInMemoryDb(); + var job = await SeedJobWithCvAsync(db); + + var summarizer = new Mock(); + var callCount = 0; + summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; }); + + var controller = CreateController(db, summarizer.Object, "user-1"); + + var first = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None); + var callsAfterFirst = callCount; + + var second = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None); + + Assert.Equal(GetDto(first).MatchSummary, GetDto(second).MatchSummary); + Assert.Equal(callsAfterFirst, callCount); + + var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "candidate-fit")); + Assert.NotEmpty(stored.ResultJson); + } + + [Fact] + public async Task GetCandidateFit_regenerates_when_refresh_is_requested() + { + await using var db = TestHostFactory.CreateInMemoryDb(); + var job = await SeedJobWithCvAsync(db); + + var summarizer = new Mock(); + var callCount = 0; + summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; }); + + var controller = CreateController(db, summarizer.Object, "user-1"); + + await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None); + var callsAfterFirst = callCount; + + var refreshed = await controller.GetCandidateFit(job.Id, null, true, CancellationToken.None); + + Assert.True(callCount > callsAfterFirst); + Assert.NotNull(GetDto(refreshed).MatchSummary); + } + + [Fact] + public async Task GetFocusPlan_persists_and_reuses_the_generated_note() + { + await using var db = TestHostFactory.CreateInMemoryDb(); + var job = await SeedJobWithCvAsync(db); + + var summarizer = new Mock(); + var callCount = 0; + summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => { callCount++; return $"Text {callCount}"; }); + + var controller = CreateController(db, summarizer.Object, "user-1"); + + await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None); + var callsAfterFirst = callCount; + + await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None); + + Assert.Equal(callsAfterFirst, callCount); + + var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "focus-plan")); + Assert.NotEmpty(stored.ResultJson); + } + + private static JobApplicationsController.CandidateFitDto GetDto(ActionResult result) + => (JobApplicationsController.CandidateFitDto)Assert.IsType(result.Result).Value!; + + private static async Task SeedJobWithCvAsync(JobTrackerContext db) + { + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + db.Users.Add(new ApplicationUser + { + Id = "user-1", + UserName = "user@example.test", + Email = "user@example.test", + ProfileCvText = "Built .NET APIs and led backend delivery with SQL and Docker.", + ProfileCvStructureJson = "[]", + }); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Description = "Needs .NET, SQL, and Docker experience.", + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId) + { + var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId); + var controller = new JobApplicationsController( + db, + summarizer, + Mock.Of(), + TestHostFactory.CreateUserManager(user).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 c21ddac..c36fe19 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -2192,7 +2192,7 @@ Canonical profile: } [HttpGet("{id:int}/candidate-fit")] - public async Task> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) + public async Task> 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(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> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) + public async Task> 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(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 TryGetCachedAiNoteAsync(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(existing.ResultJson); + } + + private async Task SaveAiNoteAsync(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")] diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 784b3ba..4b6a936 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -680,12 +680,34 @@ public static class StartupInitializationExtensions Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");"""); } + // Generic AI workspace note persistence (candidate fit, focus plan) -- same + // rationale as EnsureInterviewPrepNotesTable, generalized for DTOs too irregular + // for per-field columns. + static void EnsureAiWorkspaceNotesTable(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "AiWorkspaceNotes" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_AiWorkspaceNotes" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "JobApplicationId" INTEGER NOT NULL, + "NoteType" TEXT NOT NULL, + "AttachmentContextSignature" TEXT NOT NULL, + "ResultJson" TEXT NOT NULL, + "GeneratedAtUtc" TEXT NOT NULL, + CONSTRAINT "FK_AiWorkspaceNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE + ); + """); + + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType" ON "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType");"""); + } + EnsureGmailConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); EnsureCvTables(conn); EnsureCareerProfileTables(conn); EnsureInterviewPrepNotesTable(conn); + EnsureAiWorkspaceNotesTable(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // and at least one of the new columns already exists. @@ -837,6 +859,15 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id"); + + if (!MySqlIndexExists(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE UNIQUE INDEX `IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType` ON `AiWorkspaceNotes` (`OwnerUserId`, `JobApplicationId`, `NoteType`);"; + 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. @@ -1116,6 +1147,24 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + // Generic AI workspace note persistence (candidate fit, focus plan). + if (!HasMySqlTable(conn, "AiWorkspaceNotes")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiWorkspaceNotes` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `JobApplicationId` int NOT NULL, + `NoteType` varchar(50) NOT NULL, + `AttachmentContextSignature` longtext NOT NULL, + `ResultJson` longtext NOT NULL, + `GeneratedAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_AiWorkspaceNotes_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/AiWorkspaceNote.cs b/Models/AiWorkspaceNote.cs new file mode 100644 index 0000000..084324e --- /dev/null +++ b/Models/AiWorkspaceNote.cs @@ -0,0 +1,19 @@ +namespace JobTrackerApi.Models; + +// Generic persistence for per-job AI workspace outputs whose shape is irregular/nested enough +// that per-field columns (as used by InterviewPrepNote) would be unreasonable -- candidate fit +// and focus plan each make several AI calls and return DTOs with 6-13 fields including nested +// objects. One row per (owner, job, note type); ResultJson is the serialized DTO. See +// career-workspace-implementation-roadmap.md Phase F5 -- "persist interview prep / fit outputs". +public sealed class AiWorkspaceNote +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int JobApplicationId { get; set; } + public JobApplication? JobApplication { get; set; } + // "candidate-fit" | "focus-plan" + public string NoteType { get; set; } = string.Empty; + public string AttachmentContextSignature { get; set; } = string.Empty; + public string ResultJson { get; set; } = string.Empty; + 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 306bf4c..04f0a80 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -289,6 +289,18 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, }).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); }, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]); + // Persisted server-side like interview prep (career-workspace-implementation-roadmap.md Phase + // F5); Regenerate is the explicit escape hatch when the job has changed since it was written. + const regenerateCandidateFit = useCallback(() => { + if (!jobId) return; + setLoadingCandidateFit(true); + api.get(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => { + candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, r.data); + setCandidateFit(r.data); + toast("Candidate fit regenerated.", "success"); + }).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate candidate fit."), "error")).finally(() => setLoadingCandidateFit(false)); + }, [jobId, selectedAttachmentCsv, candidateFitCache, toast]); + // Match score is deterministic and cheap: load it on the Candidate Fit tab // independently of the slow AI narrative so users see the number instantly. useEffect(() => { @@ -348,6 +360,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, }).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false)); }, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]); + const regenerateFocusPlan = useCallback(() => { + if (!jobId) return; + setLoadingFocusPlan(true); + api.get(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => { + focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, r.data); + setFocusPlan(r.data); + toast("Focus plan regenerated.", "success"); + }).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate focus plan."), "error")).finally(() => setLoadingFocusPlan(false)); + }, [jobId, selectedAttachmentCsv, focusPlanCache, toast]); + useEffect(() => { if (!open || !jobId || tab !== 7 || interviewPrep) return; const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`; @@ -1143,6 +1165,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {tab === 5 && ( + + + {loadingCandidateFit ? : candidateFit ? ( {t("jobDetailsAiFitHint")} @@ -1171,6 +1198,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {tab === 6 && ( + + + {loadingFocusPlan ? : focusPlan ? (