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:
@@ -30,6 +30,7 @@ namespace JobTrackerApi.Data
|
|||||||
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
||||||
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
|
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
|
||||||
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
|
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
|
||||||
|
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -164,6 +165,20 @@ namespace JobTrackerApi.Data
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.CareerProfileId)
|
.HasForeignKey(x => x.CareerProfileId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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")]
|
[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
|
var job = await _db.JobApplications
|
||||||
.Include(j => j.Company)
|
.Include(j => j.Company)
|
||||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
if (job is null) return NotFound();
|
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 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 }
|
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)));
|
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
@@ -2423,9 +2441,34 @@ Candidate master CV:
|
|||||||
180,
|
180,
|
||||||
70) ?? "Prepare concise, outcome-focused stories that match the core role requirements.";
|
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));
|
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")]
|
[HttpGet("{id:int}/readiness")]
|
||||||
public async Task<ActionResult<ReadinessDto>> GetReadiness([FromRoute] int id, CancellationToken cancellationToken)
|
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");""");
|
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);
|
EnsureGmailConnectionsTable(conn);
|
||||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||||
EnsureImapConnectionsTable(conn);
|
EnsureImapConnectionsTable(conn);
|
||||||
EnsureCvTables(conn);
|
EnsureCvTables(conn);
|
||||||
EnsureCareerProfileTables(conn);
|
EnsureCareerProfileTables(conn);
|
||||||
|
EnsureInterviewPrepNotesTable(conn);
|
||||||
|
|
||||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||||
// and at least one of the new columns already exists.
|
// and at least one of the new columns already exists.
|
||||||
@@ -804,6 +828,15 @@ public static class StartupInitializationExtensions
|
|||||||
cmd.ExecuteNonQuery();
|
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/
|
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
||||||
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
||||||
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
|
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
|
||||||
@@ -1063,6 +1096,26 @@ public static class StartupInitializationExtensions
|
|||||||
cmd.ExecuteNonQuery();
|
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"))
|
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -364,6 +364,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
||||||
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setFollowUpDraft(null);
|
setFollowUpDraft(null);
|
||||||
setCandidateFit(null);
|
setCandidateFit(null);
|
||||||
@@ -1171,6 +1184,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
|
|
||||||
{tab === 7 && (
|
{tab === 7 && (
|
||||||
<Box>
|
<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 ? (
|
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
|
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
|
||||||
|
|||||||
Reference in New Issue
Block a user