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.
This commit is contained in:
cesnimda
2026-07-12 15:41:58 +02:00
parent 5916f09852
commit 00a035ea20
6 changed files with 312 additions and 6 deletions
+17
View File
@@ -31,6 +31,7 @@ namespace JobTrackerApi.Data
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>(); public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -179,6 +180,22 @@ namespace JobTrackerApi.Data
.WithMany() .WithMany()
.HasForeignKey(x => x.JobApplicationId) .HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade); .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<AiWorkspaceNote>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<AiWorkspaceNote>()
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.NoteType })
.IsUnique();
modelBuilder.Entity<AiWorkspaceNote>()
.HasOne(x => x.JobApplication)
.WithMany()
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
} }
} }
} }
@@ -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<ISummarizerService>();
var callCount = 0;
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.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<ISummarizerService>();
var callCount = 0;
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.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<ISummarizerService>();
var callCount = 0;
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.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<JobApplicationsController.CandidateFitDto> result)
=> (JobApplicationsController.CandidateFitDto)Assert.IsType<OkObjectResult>(result.Result).Value!;
private static async Task<JobApplication> 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<IAppEmailSender>(),
TestHostFactory.CreateUserManager(user).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;
}
}
@@ -2192,7 +2192,7 @@ Canonical profile:
} }
[HttpGet("{id:int}/candidate-fit")] [HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([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)
@@ -2202,6 +2202,13 @@ Canonical profile:
var userId = CurrentUserId; var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
if (!refresh)
{
var cached = await TryGetCachedAiNoteAsync<CandidateFitDto>(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 user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText; var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText)) if (string.IsNullOrWhiteSpace(cvText))
@@ -2298,7 +2305,7 @@ Candidate CV/profile:
"Close with a clear expression of interest and availability." "Close with a clear expression of interest and availability."
}); });
return Ok(new CandidateFitDto( var dto = new CandidateFitDto(
MatchSummary: matchSummary, MatchSummary: matchSummary,
FitLevel: fitLevel, FitLevel: fitLevel,
MatchScore: matchScore, MatchScore: matchScore,
@@ -2312,11 +2319,14 @@ Candidate CV/profile:
TailoredPitch: tailoredPitch, TailoredPitch: tailoredPitch,
Guidance: guidance, Guidance: guidance,
CoverLetterDraft: coverLetterDraft, CoverLetterDraft: coverLetterDraft,
RecruiterMessageDraft: recruiterMessageDraft)); RecruiterMessageDraft: recruiterMessageDraft);
await SaveAiNoteAsync(userId, id, "candidate-fit", attachmentSignature, dto, cancellationToken);
return Ok(dto);
} }
[HttpGet("{id:int}/focus-plan")] [HttpGet("{id:int}/focus-plan")]
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([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)
@@ -2326,6 +2336,13 @@ Candidate CV/profile:
var userId = CurrentUserId; var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
if (!refresh)
{
var cached = await TryGetCachedAiNoteAsync<FocusPlanDto>(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 user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText; var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText)) if (string.IsNullOrWhiteSpace(cvText))
@@ -2388,13 +2405,41 @@ Candidate master CV:
var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags); var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags);
return Ok(new FocusPlanDto( var dto = new FocusPlanDto(
ImmediatePriorities: immediatePriorities, ImmediatePriorities: immediatePriorities,
CvBulletIdeas: cvBulletIdeas, CvBulletIdeas: cvBulletIdeas,
ProofPointsToLeadWith: proofPointsToLeadWith, ProofPointsToLeadWith: proofPointsToLeadWith,
CoverLetterAngles: coverLetterAngles, CoverLetterAngles: coverLetterAngles,
FollowUpApproach: followUpApproach, FollowUpApproach: followUpApproach,
StrategicSummary: strategicSummary)); StrategicSummary: strategicSummary);
await SaveAiNoteAsync(userId, id, "focus-plan", attachmentSignature, dto, cancellationToken);
return Ok(dto);
}
private async Task<T?> TryGetCachedAiNoteAsync<T>(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<T>(existing.ResultJson);
}
private async Task SaveAiNoteAsync<T>(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")] [HttpGet("{id:int}/interview-prep")]
@@ -680,12 +680,34 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");"""); 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); EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn); EnsureImapConnectionsTable(conn);
EnsureCvTables(conn); EnsureCvTables(conn);
EnsureCareerProfileTables(conn); EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn); EnsureInterviewPrepNotesTable(conn);
EnsureAiWorkspaceNotesTable(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.
@@ -837,6 +859,15 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery(); 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/ // 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.
@@ -1116,6 +1147,24 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery(); 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")) if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{ {
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
+19
View File
@@ -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;
}
@@ -289,6 +289,18 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); }).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]); }, [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<CandidateFit>(`/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 // 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. // independently of the slow AI narrative so users see the number instantly.
useEffect(() => { useEffect(() => {
@@ -348,6 +360,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false)); }).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]); }, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
const regenerateFocusPlan = useCallback(() => {
if (!jobId) return;
setLoadingFocusPlan(true);
api.get<FocusPlanResponse>(`/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(() => { useEffect(() => {
if (!open || !jobId || tab !== 7 || interviewPrep) return; if (!open || !jobId || tab !== 7 || interviewPrep) return;
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`; const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
@@ -1143,6 +1165,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{tab === 5 && ( {tab === 5 && (
<Box> <Box>
<MatchScoreCard score={matchScore} loading={loadingMatchScore} /> <MatchScoreCard score={matchScore} loading={loadingMatchScore} />
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? ( {loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography> <Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
@@ -1171,6 +1198,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{tab === 6 && ( {tab === 6 && (
<Box> <Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? ( {loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} /> <DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />