feat(interview): integrate contextual AI prep

This commit is contained in:
cesnimda
2026-08-28 12:53:35 +02:00
parent 3b4adb8281
commit 8e344379b4
3 changed files with 272 additions and 30 deletions
+155 -29
View File
@@ -5,16 +5,22 @@ import {
Skeleton, Stack, TextField, Tooltip, Typography,
} from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import { getApiErrorMessage } from "../api";
import {
FollowUp, INTERVIEW_PREP_CATEGORIES, InterviewPrepBoard, InterviewPrepItem, interviewPrepApi,
ApplicationCv, applicationAssetsApi,
} from "../applicationWorkspace";
import { aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
import { useConfirm } from "../confirm";
// Phase 5.5 — Interview preparation and follow-up.
//
// The prep content is the user's: this component never generates anything. AI suggestions live in the
// AI panel below and only become prep items when the user adds them.
// The prep content is the user's. AI suggestions are generated in this dedicated workspace and only
// become prep items after an explicit accept action.
// docs/architecture/application-workspace.md.
function Shell({ title, subtitle, loading, error, children }: {
@@ -41,6 +47,7 @@ function Shell({ title, subtitle, loading, error, children }: {
}
export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
const { t } = useI18n();
const [board, setBoard] = useState<InterviewPrepBoard | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -87,15 +94,15 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
return (
<Stack spacing={2}>
<Shell
title="Interview preparation"
subtitle="Your own research, answers and questions. Nothing here is generated or overwritten."
title={t("interviewPrepTitle")}
subtitle={t("interviewPrepSubtitle")}
loading={loading}
error={error}
>
<Stack spacing={2}>
{board && !board.isInterviewStage && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
This application has not reached an interview stage yet. Preparing early is fine.
{t("interviewPrepEarly")}
</Alert>
)}
@@ -103,16 +110,16 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 0.75 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
Preparation progress
{t("interviewPrepProgress")}
</Typography>
<Typography variant="body2" color="text.secondary">
{board.prepared} of {board.total} ready
{t("interviewPrepReadyCount", { prepared: board.prepared, total: board.total })}
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={board.percent}
aria-label="Interview preparation progress"
aria-label={t("interviewPrepProgress")}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
@@ -120,8 +127,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
{board && board.total === 0 ? (
<Typography variant="body2" color="text.secondary">
Nothing prepared yet. Add a question you expect, a company fact worth knowing, or a STAR
example you want ready.
{t("interviewPrepEmpty")}
</Typography>
) : (
(board?.groups ?? []).map((group) => (
@@ -150,7 +156,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
<TextField
select
size="small"
label="Category"
label={t("interviewPrepCategory")}
value={category}
disabled={busy}
onChange={(e) => setCategory(e.target.value)}
@@ -163,22 +169,132 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
<TextField
fullWidth
size="small"
label="Add a question, topic or note"
label={t("interviewPrepAddLabel")}
value={title}
disabled={busy}
onChange={(e) => setTitle(e.target.value)}
/>
<Button type="submit" variant="contained" disabled={busy || !title.trim()}>Add</Button>
<Button type="submit" variant="contained" disabled={busy || !title.trim()}>{t("interviewPrepAdd")}</Button>
</Stack>
</Box>
</Stack>
</Shell>
<InterviewAiAssistant jobId={jobId} onAccepted={load} />
<ApplicationFollowUp jobId={jobId} />
</Stack>
);
}
const INTERVIEW_AI_FOCUS = [
{ key: "full", labelKey: "interviewAiFull", category: "note" },
{ key: "technical", labelKey: "interviewAiTechnical", category: "technical" },
{ key: "behavioural", labelKey: "interviewAiBehavioural", category: "behavioural" },
{ key: "star", labelKey: "interviewAiStar", category: "star" },
{ key: "gaps", labelKey: "interviewAiGaps", category: "note" },
] as const;
function InterviewAiAssistant({ jobId, onAccepted }: { jobId: number; onAccepted: () => void }) {
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
const [cv, setCv] = useState<ApplicationCv | null>(null);
const [focus, setFocus] = useState<(typeof INTERVIEW_AI_FOCUS)[number]["key"]>("full");
const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en");
const [instructions, setInstructions] = useState("");
const [suggestion, setSuggestion] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
applicationAssetsApi.cv(jobId)
.then((result) => { if (active) setCv(result); })
.catch(() => { if (active) setCv(null); });
return () => { active = false; };
}, [jobId]);
const selected = INTERVIEW_AI_FOCUS.find((item) => item.key === focus) ?? INTERVIEW_AI_FOCUS[0];
const generate = async () => {
setBusy(true);
setError(null);
try {
const focusLabel = t(selected.labelKey);
const extraContext = [
`Focus this preparation on: ${focusLabel}.`,
instructions.trim(),
].filter(Boolean).join("\n");
const result = await aiWorkspaceApi.generate(jobId, {
module: "interview",
targetLanguage: language,
extraContext,
});
setSuggestion(result.result.text?.trim() ?? "");
} catch (err) {
setError(getApiErrorMessage(err, t("interviewAiFailed")));
} finally {
setBusy(false);
}
};
const accept = async () => {
if (!suggestion) return;
setBusy(true);
setError(null);
try {
await interviewPrepApi.add(jobId, {
category: selected.category,
title: t(selected.labelKey),
content: suggestion,
source: "ai",
});
setSuggestion("");
onAccepted();
} catch (err) {
setError(getApiErrorMessage(err, t("interviewAiAcceptFailed")));
} finally {
setBusy(false);
}
};
const hasCv = !!cv?.attachedVariantId;
return (
<Shell title={t("interviewAiTitle")} subtitle={t("interviewAiSubtitle")} loading={false} error={null}>
<Stack spacing={2}>
{hasCv ? (
<Alert severity="success" variant="outlined">{t("interviewAiUsing")} <strong>{cv?.attachedVariantName}</strong></Alert>
) : (
<Alert severity="info">{t("interviewAiSelectCv")}</Alert>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<TextField select size="small" label={t("interviewAiFocus")} value={focus} onChange={(event) => setFocus(event.target.value as typeof focus)} sx={{ minWidth: 220 }}>
{INTERVIEW_AI_FOCUS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
</TextField>
<TextField select size="small" label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")} sx={{ minWidth: 190 }}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
</TextField>
</Stack>
<TextField multiline minRows={2} fullWidth label={t("coverAiAdditionalInstructions")} value={instructions} onChange={(event) => setInstructions(event.target.value)} />
{error && <Alert severity="error">{error}</Alert>}
<Button variant="contained" startIcon={<AutoFixHighIcon />} disabled={!canUseAi || !hasCv || busy} onClick={() => void generate()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("interviewAiGenerating") : canUseAi ? t("interviewAiGenerate") : t("interviewAiProRequired")}
</Button>
{suggestion && (
<Paper variant="outlined" sx={{ p: 2, borderColor: "primary.main" }}>
<Typography variant="overline" color="primary">{t("coverAiSuggestion")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<Button size="small" variant="contained" disabled={busy} onClick={() => void accept()}>{t("interviewAiAddToPrep")}</Button>
<Button size="small" disabled={busy} onClick={() => setSuggestion("")}>{t("coverAiReject")}</Button>
</Stack>
</Paper>
)}
</Stack>
</Shell>
);
}
// One prep entry. The answer is a local draft until saved, so a background reload never eats typing.
function PrepRow({ jobId, item, busy, onChanged, onError }: {
jobId: number;
@@ -187,6 +303,8 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
onChanged: () => void;
onError: (message: string) => void;
}) {
const { t } = useI18n();
const { confirm } = useConfirm();
const [draft, setDraft] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const content = draft ?? item.content ?? "";
@@ -212,7 +330,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
size="small"
checked={item.isPrepared}
disabled={busy || saving}
inputProps={{ "aria-label": `Ready: ${item.title}` }}
inputProps={{ "aria-label": t("interviewPrepReadyLabel", { title: item.title }) }}
onChange={() => run(() => interviewPrepApi.update(jobId, item.id, { isPrepared: !item.isPrepared }))}
sx={{ mt: -0.5 }}
/>
@@ -220,7 +338,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: 600 }}>{item.title}</Typography>
{item.source === "ai" && (
<Chip size="small" label="From AI" variant="outlined" />
<Chip size="small" label={t("interviewPrepFromAi")} variant="outlined" />
)}
</Stack>
<TextField
@@ -228,7 +346,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
minRows={2}
fullWidth
size="small"
placeholder="Your answer, in your own words."
placeholder={t("interviewPrepAnswerPlaceholder")}
value={content}
disabled={busy || saving}
onChange={(e) => setDraft(e.target.value)}
@@ -242,19 +360,27 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
disabled={saving}
onClick={() => run(() => interviewPrepApi.update(jobId, item.id, { content }))}
>
Save answer
{t("interviewPrepSaveAnswer")}
</Button>
<Button size="small" disabled={saving} onClick={() => setDraft(null)}>Discard</Button>
<Button size="small" disabled={saving} onClick={() => setDraft(null)}>{t("interviewPrepDiscard")}</Button>
</Stack>
)}
</Box>
<Tooltip title="Delete">
<Tooltip title={t("interviewPrepDelete")}>
<span>
<IconButton
size="small"
disabled={busy || saving}
aria-label={`Delete: ${item.title}`}
onClick={() => run(() => interviewPrepApi.remove(jobId, item.id))}
aria-label={t("interviewPrepDeleteLabel", { title: item.title })}
onClick={async () => {
const accepted = await confirm({
title: t("interviewPrepDeleteTitle"),
message: t("interviewPrepDeleteMessage", { title: item.title }),
confirmLabel: t("interviewPrepDelete"),
destructive: true,
});
if (accepted) await run(() => interviewPrepApi.remove(jobId, item.id));
}}
>
<DeleteOutlineIcon fontSize="inherit" />
</IconButton>
@@ -266,6 +392,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
}
export function ApplicationFollowUp({ jobId }: { jobId: number }) {
const { t } = useI18n();
const [data, setData] = useState<FollowUp | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -307,16 +434,15 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) {
return (
<Shell
title="Follow-up"
subtitle="The same date the reminder service already uses. Follow-up tasks live in the checklist."
title={t("interviewFollowUpTitle")}
subtitle={t("interviewFollowUpSubtitle")}
loading={loading}
error={error}
>
<Stack spacing={2}>
{data && data.openFollowUpTasks > 0 && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
{data.openFollowUpTasks} open follow-up {data.openFollowUpTasks === 1 ? "task" : "tasks"} on
the checklist.
{t(data.openFollowUpTasks === 1 ? "interviewFollowUpOpenTask" : "interviewFollowUpOpenTasks", { count: data.openFollowUpTasks })}
</Alert>
)}
@@ -324,7 +450,7 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) {
<TextField
type="date"
size="small"
label="Follow up on"
label={t("interviewFollowUpDate")}
value={date}
disabled={busy}
onChange={(e) => setDate(e.target.value)}
@@ -333,17 +459,17 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) {
<TextField
fullWidth
size="small"
label="Next action"
label={t("workspaceNextAction")}
value={action}
disabled={busy}
onChange={(e) => setAction(e.target.value)}
/>
<Button variant="contained" disabled={busy} onClick={save}>Save</Button>
<Button variant="contained" disabled={busy} onClick={save}>{t("save")}</Button>
</Stack>
{data && !data.followUpAt && (
<Typography variant="body2" color="text.secondary">
No follow-up scheduled. Applications without one go quiet.
{t("interviewFollowUpEmpty")}
</Typography>
)}
</Stack>