feat(ai): queue strategy snapshots

This commit is contained in:
cesnimda
2026-08-09 12:51:46 +02:00
parent 5eb9b3cb96
commit a62122640c
12 changed files with 790 additions and 158 deletions
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
@@ -21,7 +21,7 @@ import {
import { alpha } from "@mui/material/styles";
import { api, getApiErrorMessage } from "../api";
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types";
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, StrategySnapshotOperationResponse, TailoredCvDraft, UserOperation } from "../types";
import { statusLabel } from "../pipeline";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
@@ -188,6 +188,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
const [focusPlanOperation, setFocusPlanOperation] = useState<UserOperation | null>(null);
const announcedFocusPlanOperation = useRef<string | null>(null);
const focusPlanLookupVersion = useRef(0);
const [loadingStrategySnapshot, setLoadingStrategySnapshot] = useState(false);
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepResponse | null>(null);
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
@@ -231,6 +234,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setDraftReloadToken(0);
setDraftSubject("");
setDraftBody("");
setFocusPlanOperation(null);
announcedFocusPlanOperation.current = null;
focusPlanLookupVersion.current += 1;
followUpCache.clearCached();
candidateFitCache.clearCached();
focusPlanCache.clearCached();
@@ -372,31 +378,97 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}
};
useEffect(() => {
if (!canUseAi || !open || !jobId || tab !== 6 || focusPlan) return;
const loadCachedFocusPlan = useCallback(async () => {
if (!jobId) return null;
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
const cached = focusPlanCache.getCached(cacheKey);
if (cached) {
setFocusPlan(cached);
return;
return cached;
}
setLoadingFocusPlan(true);
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
try {
const r = await api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } });
focusPlanCache.setCached(cacheKey, r.data);
setFocusPlan(r.data);
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
}, [canUseAi, open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
return r.data;
} catch {
setFocusPlan(null);
return null;
}
}, [jobId, selectedAttachmentCsv, focusPlanCache]);
const regenerateFocusPlan = useCallback(() => {
useEffect(() => {
if (!canUseAi || !open || !jobId || tab !== 6 || focusPlan) return;
setLoadingFocusPlan(true);
void loadCachedFocusPlan().finally(() => setLoadingFocusPlan(false));
}, [canUseAi, open, jobId, tab, focusPlan, loadCachedFocusPlan]);
useEffect(() => {
if (!canUseAi || !open || !jobId) return;
const version = ++focusPlanLookupVersion.current;
api.get<UserOperation>(`/jobapplications/${jobId}/focus-plan/operation`, { params: { attachmentIds: selectedAttachmentCsv || undefined } })
.then(response => { if (focusPlanLookupVersion.current === version) setFocusPlanOperation(response.data); })
.catch(() => { if (focusPlanLookupVersion.current === version) setFocusPlanOperation(null); });
}, [canUseAi, open, jobId, selectedAttachmentCsv]);
useEffect(() => {
if (!open || !focusPlanOperation || ["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status)) return;
const timer = window.setTimeout(() => {
api.get<UserOperation>(`/operations/${focusPlanOperation.id}`)
.then(response => setFocusPlanOperation(response.data))
.catch(() => undefined);
}, 1000);
return () => window.clearTimeout(timer);
}, [open, focusPlanOperation]);
useEffect(() => {
if (!focusPlanOperation || !["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status) || announcedFocusPlanOperation.current === `${focusPlanOperation.id}:${focusPlanOperation.status}`) return;
announcedFocusPlanOperation.current = `${focusPlanOperation.id}:${focusPlanOperation.status}`;
if (focusPlanOperation.status === "succeeded") {
void loadCachedFocusPlan().then(() => toast("Strategy snapshot completed.", "success"));
} else if (focusPlanOperation.status === "failed") {
toast("Strategy snapshot failed. You can retry safely.", "error");
} else {
toast("Strategy snapshot cancelled.", "info");
}
}, [focusPlanOperation, loadCachedFocusPlan, toast]);
const regenerateFocusPlan = useCallback(async () => {
if (!canUseAi || !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));
}, [canUseAi, jobId, selectedAttachmentCsv, focusPlanCache, toast]);
try {
const response = await api.post<StrategySnapshotOperationResponse>(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: selectedAttachmentCsv || null });
focusPlanLookupVersion.current += 1;
announcedFocusPlanOperation.current = null;
setFocusPlanOperation(response.data.operation);
toast(response.data.created ? "Strategy snapshot queued." : "Strategy snapshot is already queued.", "info");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to queue strategy snapshot."), "error");
} finally {
setLoadingFocusPlan(false);
}
}, [canUseAi, jobId, selectedAttachmentCsv, toast]);
const cancelFocusPlan = useCallback(async () => {
if (!focusPlanOperation?.canCancel) return;
try {
const response = await api.post<UserOperation>(`/operations/${focusPlanOperation.id}/cancel`);
setFocusPlanOperation(response.data);
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to cancel strategy snapshot."), "error");
}
}, [focusPlanOperation, toast]);
const retryFocusPlan = useCallback(async () => {
if (!focusPlanOperation?.canRetry) return;
try {
announcedFocusPlanOperation.current = null;
const response = await api.post<UserOperation>(`/operations/${focusPlanOperation.id}/retry`);
setFocusPlanOperation(response.data);
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to retry strategy snapshot."), "error");
}
}, [focusPlanOperation, toast]);
useEffect(() => {
if (!canUseAi || !open || !jobId || tab !== 7 || interviewPrep) return;
@@ -758,18 +830,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="overline" sx={{ fontWeight: 700 }}>{t("jobDetailsStrategySnapshot")}</Typography>
<GradientButton size="small" disabled={loadingStrategySnapshot || !canUseAi} onClick={async () => {
<GradientButton size="small" disabled={loadingStrategySnapshot || !canUseAi || !!focusPlanOperation && !["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status)} onClick={async () => {
if (!jobId) return;
setLoadingStrategySnapshot(true);
try {
const [fitRes, focusRes] = await Promise.all([
const [fitRes, operationRes] = await Promise.all([
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
api.post<StrategySnapshotOperationResponse>(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: selectedAttachmentCsv || null }),
]);
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, fitRes.data);
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, focusRes.data);
setCandidateFit(fitRes.data);
setFocusPlan(focusRes.data);
focusPlanLookupVersion.current += 1;
announcedFocusPlanOperation.current = null;
setFocusPlanOperation(operationRes.data.operation);
} catch {
toast(t("jobDetailsStrategySnapshotFailed"), "error");
} finally {
@@ -777,6 +850,15 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : canUseAi ? t("jobDetailsGenerateStrategySnapshot") : "Pro required"}</GradientButton>
</Box>
{focusPlanOperation && focusPlanOperation.status !== "succeeded" ? (
<Alert severity={focusPlanOperation.status === "failed" ? "error" : focusPlanOperation.status === "cancelled" ? "warning" : "info"} sx={{ gridColumn: "1 / -1" }}
action={<>
{focusPlanOperation.canCancel ? <Button size="small" color="inherit" onClick={() => void cancelFocusPlan()}>Cancel</Button> : null}
{focusPlanOperation.canRetry ? <Button size="small" color="inherit" onClick={() => void retryFocusPlan()}>Retry</Button> : null}
</>}>
Strategy snapshot: {strategyOperationLabel(focusPlanOperation)}
</Alert>
) : null}
{candidateFit || focusPlan ? (
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 4, backgroundColor: "background.paper", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
@@ -1223,8 +1305,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
regenerateCandidateFit={regenerateCandidateFit}
fitLevel={fitLevel}
focusPlan={focusPlan}
loadingFocusPlan={loadingFocusPlan}
loadingFocusPlan={loadingFocusPlan || !!focusPlanOperation && !["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status)}
regenerateFocusPlan={regenerateFocusPlan}
focusPlanOperation={focusPlanOperation}
cancelFocusPlan={() => void cancelFocusPlan()}
retryFocusPlan={() => void retryFocusPlan()}
interviewPrep={interviewPrep}
loadingInterviewPrep={loadingInterviewPrep}
regenerateInterviewPrep={regenerateInterviewPrep}
@@ -1243,3 +1328,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Dialog>
);
}
function strategyOperationLabel(operation: UserOperation) {
if (operation.cancellationRequestedAtUtc) return "cancellation requested";
switch (operation.status) {
case "queued": return "queued";
case "running": return "processing locally";
case "waiting_for_retry": return "waiting to retry";
case "waiting_for_external_fallback": return "waiting for approved fallback";
case "failed": return `failed${operation.failureCategory ? ` (${operation.failureCategory.replaceAll("_", " ")})` : ""}`;
case "cancelled": return "cancelled";
default: return "completed";
}
}
@@ -1,8 +1,8 @@
import React from "react";
import { Box, Button, Chip, CircularProgress, Typography } from "@mui/material";
import { Alert, Box, Button, Chip, CircularProgress, Typography } from "@mui/material";
import { useI18n } from "../i18n/I18nProvider";
import { CandidateFit, FocusPlanResponse, InterviewPrepResponse, MatchScore, ReadinessResponse } from "../types";
import { CandidateFit, FocusPlanResponse, InterviewPrepResponse, MatchScore, ReadinessResponse, UserOperation } from "../types";
import { DraftCard, ListCard, MatchScoreCard, SectionChips, TwoColumnSection } from "./JobDetailsPanels";
type Props = {
@@ -17,6 +17,9 @@ type Props = {
focusPlan: FocusPlanResponse | null;
loadingFocusPlan: boolean;
regenerateFocusPlan: () => void;
focusPlanOperation: UserOperation | null;
cancelFocusPlan: () => void;
retryFocusPlan: () => void;
interviewPrep: InterviewPrepResponse | null;
loadingInterviewPrep: boolean;
regenerateInterviewPrep: () => void;
@@ -26,7 +29,7 @@ type Props = {
export default function JobInsightTabs(props: Props) {
const { t } = useI18n();
const { tab, matchScore, loadingMatchScore, updateLearningRecommendation, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
const { tab, matchScore, loadingMatchScore, updateLearningRecommendation, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, focusPlanOperation, cancelFocusPlan, retryFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
return <>
{tab === 5 && (
<Box>
@@ -66,10 +69,19 @@ export default function JobInsightTabs(props: Props) {
<Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
{loadingFocusPlan ? "Starting..." : focusPlan ? "Regenerate" : "Generate"}
</Button>
</Box>
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
{focusPlanOperation && focusPlanOperation.status !== "succeeded" ? (
<Alert severity={focusPlanOperation.status === "failed" ? "error" : focusPlanOperation.status === "cancelled" ? "warning" : "info"} sx={{ mb: 2 }}
action={<>
{focusPlanOperation.canCancel ? <Button size="small" color="inherit" onClick={cancelFocusPlan}>Cancel</Button> : null}
{focusPlanOperation.canRetry ? <Button size="small" color="inherit" onClick={retryFocusPlan}>Retry</Button> : null}
</>}>
Strategy snapshot: {operationLabel(focusPlanOperation)}
</Alert>
) : null}
{loadingFocusPlan && !focusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
@@ -116,3 +128,16 @@ export default function JobInsightTabs(props: Props) {
)}
</>;
}
function operationLabel(operation: UserOperation) {
if (operation.cancellationRequestedAtUtc) return "cancellation requested";
switch (operation.status) {
case "queued": return "queued";
case "running": return "processing locally";
case "waiting_for_retry": return "waiting to retry";
case "waiting_for_external_fallback": return "waiting for approved fallback";
case "failed": return `failed${operation.failureCategory ? ` (${operation.failureCategory.replaceAll("_", " ")})` : ""}`;
case "cancelled": return "cancelled";
default: return "completed";
}
}
@@ -99,6 +99,9 @@ beforeEach(() => {
if (url === '/jobapplications/42/focus-plan') {
return Promise.resolve({ data: { strategicSummary: 'Lead with backend delivery and measurable outcomes.', immediatePriorities: ['Highlight .NET ownership'], cvBulletIdeas: [], proofPointsToLeadWith: [], coverLetterAngles: [], followUpApproach: [] } } as any);
}
if (url === '/jobapplications/42/focus-plan/operation') {
return Promise.reject({ response: { status: 404 } });
}
return Promise.resolve({ data: [] } as any);
});
@@ -136,6 +139,11 @@ beforeEach(() => {
if (url === '/jobapplications/42/generate-application-package') {
return Promise.resolve({ data: { tailoredCvText: 'Generated package CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
}
if (url === '/jobapplications/42/focus-plan/operations') {
return Promise.resolve({ data: { created: true, statusUrl: '/api/operations/strategy-1', operation: {
id: 'strategy-1', taskType: 'strategy.snapshot', status: 'succeeded', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: false, canRetry: false,
} } } as any);
}
return Promise.resolve({ data: {} } as any);
});
@@ -252,3 +260,47 @@ test('strategy snapshot can be generated from overview', async () => {
expect(await screen.findByText(/lead with backend delivery and measurable outcomes/i)).toBeInTheDocument();
expect(await screen.findByText(/highlight \.net ownership/i)).toBeInTheDocument();
});
test('strategy snapshot exposes queued and cancelled durable states', async () => {
mockedApi.post.mockImplementation((url: string) => {
if (url === '/jobapplications/42/focus-plan/operations') {
return Promise.resolve({ data: { created: true, statusUrl: '/api/operations/strategy-queued', operation: {
id: 'strategy-queued', taskType: 'strategy.snapshot', status: 'queued', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: true, canRetry: false,
} } } as any);
}
if (url === '/operations/strategy-queued/cancel') {
return Promise.resolve({ data: {
id: 'strategy-queued', taskType: 'strategy.snapshot', status: 'cancelled', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: false, canRetry: true,
} } as any);
}
return Promise.resolve({ data: {} } as any);
});
renderDialog();
fireEvent.click(await screen.findByRole('button', { name: /generate strategy snapshot/i }));
expect(await screen.findByText(/strategy snapshot: queued/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(await screen.findByText(/strategy snapshot: cancelled/i)).toBeInTheDocument();
});
test('failed strategy snapshot offers a safe retry', async () => {
mockedApi.post.mockImplementation((url: string) => {
if (url === '/jobapplications/42/focus-plan/operations') {
return Promise.resolve({ data: { created: false, statusUrl: '/api/operations/strategy-failed', operation: {
id: 'strategy-failed', taskType: 'strategy.snapshot', status: 'failed', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), failureCategory: 'provider_unavailable', canCancel: false, canRetry: true,
} } } as any);
}
if (url === '/operations/strategy-failed/retry') {
return Promise.resolve({ data: {
id: 'strategy-failed', taskType: 'strategy.snapshot', status: 'queued', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: true, canRetry: false,
} } as any);
}
return Promise.resolve({ data: {} } as any);
});
renderDialog();
fireEvent.click(await screen.findByRole('button', { name: /generate strategy snapshot/i }));
expect(await screen.findByText(/strategy snapshot: failed.*provider unavailable/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
expect(await screen.findByText(/strategy snapshot: queued/i)).toBeInTheDocument();
});
+23
View File
@@ -198,6 +198,29 @@ export interface FocusPlanResponse {
strategicSummary: string;
}
export interface UserOperation {
id: string;
taskType: string;
status: "queued" | "running" | "waiting_for_retry" | "waiting_for_external_fallback" | "succeeded" | "failed" | "cancelled";
subjectType?: string | null;
createdAtUtc: string;
startedAtUtc?: string | null;
completedAtUtc?: string | null;
deadlineAtUtc?: string | null;
cancellationRequestedAtUtc?: string | null;
progressStage?: string | null;
progressPercent?: number | null;
failureCategory?: string | null;
canCancel: boolean;
canRetry: boolean;
}
export interface StrategySnapshotOperationResponse {
operation: UserOperation;
statusUrl: string;
created: boolean;
}
export interface InterviewPrepResponse {
summary: string;
talkingPoints: string[];