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
@@ -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} />