diff --git a/src/main/api/mock-interview.ts b/src/main/api/mock-interview.ts
index 8429647a..c92fa78c 100644
--- a/src/main/api/mock-interview.ts
+++ b/src/main/api/mock-interview.ts
@@ -18,9 +18,31 @@ import { ApiClient, ApiResponse } from './client.js';
// non-streaming JSON reply rather than the first chunk of a stream.
const MOCK_QUESTION_TIMEOUT_MS = 30_000;
const MOCK_TURN_TIMEOUT_MS = 15_000;
-const MOCK_REPORT_TIMEOUT_MS = 60_000;
const MOCK_SPEAK_TIMEOUT_MS = 20_000;
+// The report is the one call whose duration scales with the session, so it is the one call a
+// single number cannot cover. `MockReport` carries a score, a justification *and* a full rewritten
+// answer for every question, so a twelve-turn session asks for several times the generation a
+// three-turn one does - and the flat 60s this replaced was calibrated to neither. Long sessions
+// hit it routinely, which is what "scoring sometimes times out" was: an abort on the client while
+// the backend went on to finish the report and charge for it.
+//
+// Wall clock rather than a stall timer, unlike the streaming paths. The reply is one JSON body, so
+// nothing arrives until the whole thing is generated and there is no progress to detect - the
+// ceiling is what keeps the deadline honest, and End stays live for the whole of `Scoring` as the
+// manual way out of a request that really has hung.
+const MOCK_REPORT_BASE_MS = 45_000;
+const MOCK_REPORT_PER_QUESTION_MS = 20_000;
+const MOCK_REPORT_MAX_MS = 240_000;
+
+export function mockReportTimeoutMs(questionCount: number): number {
+ const questions = Number.isFinite(questionCount) ? Math.max(0, questionCount) : 0;
+ return Math.min(
+ MOCK_REPORT_MAX_MS,
+ MOCK_REPORT_BASE_MS + questions * MOCK_REPORT_PER_QUESTION_MS
+ );
+}
+
// The probe below is one unauthenticated round-trip against a route that is never going to do
// any work, so it can be far tighter than the calls that generate something.
const MOCK_SUPPORT_PROBE_TIMEOUT_MS = 8_000;
@@ -34,8 +56,17 @@ export class MockInterviewApi extends ApiClient {
return this.post('/api/mock-interview/turn', data, MOCK_TURN_TIMEOUT_MS);
}
+ /**
+ * Scored off the turns actually being sent rather than the session's configured length, so a
+ * session that ended early is not held to a deadline for questions it never asked and one that
+ * ran long on follow-ups gets the time they cost.
+ */
async generateReport(data: GenerateMockReportRequest): Promise> {
- return this.post('/api/mock-interview/report', data, MOCK_REPORT_TIMEOUT_MS);
+ return this.post(
+ '/api/mock-interview/report',
+ data,
+ mockReportTimeoutMs(data.questions.length)
+ );
}
/**
diff --git a/src/main/ipc/mock-interview.ts b/src/main/ipc/mock-interview.ts
index 4c47bf58..e1e2ba3d 100644
--- a/src/main/ipc/mock-interview.ts
+++ b/src/main/ipc/mock-interview.ts
@@ -35,6 +35,10 @@ export function registerMockInterviewHandlers(): void {
mockInterviewService.answerReady();
});
+ ipcMain.handle('mock-interview:retry-scoring', async () => {
+ await mockInterviewService.retryScoring();
+ });
+
ipcMain.handle('mock-interview:end-session', async () => {
await mockInterviewService.endSession();
});
diff --git a/src/main/preload.cts b/src/main/preload.cts
index e1238006..e5011579 100644
--- a/src/main/preload.cts
+++ b/src/main/preload.cts
@@ -141,6 +141,7 @@ const electronApi = {
ipcRenderer.invoke('mock-interview:ingest-answer', payload),
answerFinished: () => ipcRenderer.invoke('mock-interview:answer-finished'),
answerReady: () => ipcRenderer.invoke('mock-interview:answer-ready'),
+ retryScoring: () => ipcRenderer.invoke('mock-interview:retry-scoring'),
endSession: () => ipcRenderer.invoke('mock-interview:end-session'),
clear: () => ipcRenderer.invoke('mock-interview:clear'),
},
diff --git a/src/main/services/mock-interview.service.ts b/src/main/services/mock-interview.service.ts
index 0f1a44e7..b2d2d002 100644
--- a/src/main/services/mock-interview.service.ts
+++ b/src/main/services/mock-interview.service.ts
@@ -76,6 +76,7 @@ function initialSession(): MockInterviewSessionState {
liveHints: [],
report: null,
reportError: null,
+ rescoring: false,
exported: false,
error: null,
};
@@ -315,7 +316,9 @@ class MockInterviewService {
if (response.status === HTTP_PAYMENT_REQUIRED) {
this.lastQuestionError = response.error?.message || 'Not enough credits';
this.lastQuestionUnaffordable = true;
- console.warn(`[MockInterviewService] question refused for credits: ${this.lastQuestionError}`);
+ console.warn(
+ `[MockInterviewService] question refused for credits: ${this.lastQuestionError}`
+ );
await this.finishToScoring(seq);
return;
}
@@ -615,7 +618,8 @@ class MockInterviewService {
// tests the follow-up against the report alone, on a session that is already inconsistent.
const remainingQuestions = Math.max(
0,
- (this.session.setup?.question_count ?? this.session.questionNumber) - this.session.questionNumber
+ (this.session.setup?.question_count ?? this.session.questionNumber) -
+ this.session.questionNumber
);
let action: MockTurnAction = MockTurnAction.Next;
@@ -760,13 +764,30 @@ class MockInterviewService {
this.setState(MockInterviewState.Scoring);
this.broadcast();
+ await this.requestReport(seq);
+ }
- if (!this.session.setup) return;
+ /**
+ * Ask the backend to score the session, landing on `Finished` whatever happens.
+ *
+ * Split out of `finishToScoring` because `retryScoring()` re-runs exactly this and nothing
+ * around it - the guards, the state change and the reset-with-a-reason above all belong to
+ * reaching the end of an interview, not to the request itself.
+ *
+ * A missing setup is a failure rather than an early return, which is what it used to be. By
+ * here the state is already `Scoring`, and `Scoring` has no control that ends it except End -
+ * so returning stranded the session on a spinner instead of holding the terminal-state
+ * invariant this file's tests pin.
+ */
+ private async requestReport(seq: number): Promise {
try {
+ const setup = this.session.setup;
+ if (!setup) throw new Error('The interview setup is no longer available.');
+
const interviewConfig = appStateService.getState().interviewConfig;
const request: GenerateMockReportRequest = {
language: this.language,
- setup: this.session.setup,
+ setup,
profile_data: interviewConfig.profileData,
context: interviewConfig.context,
questions: this.session.answers.map((a) => ({ question: a.question, answer: a.answer })),
@@ -781,20 +802,64 @@ class MockInterviewService {
...this.session,
report: response.data,
reportError: null,
+ rescoring: false,
+ // The same rule `appendAnswer` follows: a score that arrives after an export is content
+ // that file does not contain. Unreachable before `retryScoring` existed - a report only
+ // ever arrived before there was anything to export it from - but the report screen offers
+ // Export beside the failure, so a candidate can save the answers, retry, and otherwise be
+ // waved past by Done and Practise again for a score that was never written anywhere.
+ exported: false,
state: MockInterviewState.Finished,
};
} catch (error) {
if (seq !== this.sessionSeq) return;
+ // Logged for the same reason the question path logs its own failures: this is the end of a
+ // session the candidate has paid for, and the reason it produced nothing was the one thing
+ // neither the screen nor the log recorded.
+ const message = describeApiError(error);
+ console.error(`[MockInterviewService] scoring failed: ${message}`, error);
this.session = {
...this.session,
report: null,
- reportError: error instanceof Error ? error.message : 'Failed to score the interview',
+ reportError: message,
+ rescoring: false,
state: MockInterviewState.Finished,
};
}
this.broadcast();
}
+ /**
+ * Score the session again after a failed attempt, because the candidate asked.
+ *
+ * The report is the only charged call in a session whose failure was terminal: the answers stay
+ * on screen and stay exportable, but the score they were billed for could not be obtained by any
+ * route short of paying for a whole second interview.
+ *
+ * Deliberately a button rather than the automatic retry `generateNextQuestion` has. A timeout on
+ * this call says nothing about whether the backend finished and billed the attempt that timed
+ * out, so a silent second attempt can spend twice on the candidate's behalf - and at this call's
+ * deadline it would also double the wait before they are told anything at all. Asked for on a
+ * screen that already has their answers on it, the spend is their decision.
+ *
+ * Stays on `Finished` rather than returning to `Scoring`: `Scoring` is an active session, so it
+ * would re-arm the navigation lock and swap the report screen the candidate is looking at for
+ * the session screen, which has no question left to show. `rescoring` carries the in-flight
+ * state instead.
+ */
+ async retryScoring(): Promise {
+ if (this.session.state !== MockInterviewState.Finished) return;
+ if (this.session.rescoring || !this.session.reportError) return;
+ // The same guard `finishToScoring` applies before the first attempt - a billed report over a
+ // session with no real answers scores an interview that did not happen.
+ if (!this.hasRealAnswers()) return;
+
+ const seq = this.sessionSeq;
+ this.session = { ...this.session, rescoring: true };
+ this.broadcast();
+ await this.requestReport(seq);
+ }
+
/**
* End the session from any point. Scores whatever was answered so far, the same as reaching
* the last question naturally - the answers already given are worth a report, not a discard.
diff --git a/src/main/types/mock-interview.ts b/src/main/types/mock-interview.ts
index 04baafd5..e97f204e 100644
--- a/src/main/types/mock-interview.ts
+++ b/src/main/types/mock-interview.ts
@@ -223,6 +223,14 @@ export interface MockInterviewSessionState {
report: MockReport | null;
/** Set when report generation failed - the transcript is still shown and still exportable. */
reportError: string | null;
+ /**
+ * A retry of a failed report is in flight (`retryScoring`).
+ *
+ * Separate from the `Scoring` state on purpose: this runs while the session is already
+ * `Finished`, so that the report screen keeps the candidate's answers on screen and the
+ * navigation lock stays off. See `retryScoring`.
+ */
+ rescoring: boolean;
/**
* True once everything this session holds has been written to a file.
*
diff --git a/src/renderer/hooks/use-mock-interview.ts b/src/renderer/hooks/use-mock-interview.ts
index fbe3eec6..94917e20 100644
--- a/src/renderer/hooks/use-mock-interview.ts
+++ b/src/renderer/hooks/use-mock-interview.ts
@@ -113,6 +113,10 @@ export function useMockInterview() {
}
};
+ const retryScoring = async (): Promise => {
+ await window.electronAPI?.mockInterview.retryScoring();
+ };
+
const endSession = async (): Promise => {
await window.electronAPI?.mockInterview.endSession();
};
@@ -129,5 +133,5 @@ export function useMockInterview() {
await window.electronAPI?.mockInterview.clear();
};
- return { session, startSession, endSession, answerFinished, answerReady, clear };
+ return { session, startSession, endSession, retryScoring, answerFinished, answerReady, clear };
}
diff --git a/src/renderer/pages/mock-interview/index.tsx b/src/renderer/pages/mock-interview/index.tsx
index cd01ff8a..07e3b790 100644
--- a/src/renderer/pages/mock-interview/index.tsx
+++ b/src/renderer/pages/mock-interview/index.tsx
@@ -41,7 +41,7 @@ export default function MockInterviewPage() {
const navigate = useNavigate();
const location = useLocation();
const { appState } = useAppState();
- const { session, startSession, endSession, answerFinished, answerReady, clear } =
+ const { session, startSession, endSession, retryScoring, answerFinished, answerReady, clear } =
useMockInterview();
const { exportMockReport } = useTools();
const { confirmDiscard } = useSaveHistoryGuard();
@@ -187,6 +187,7 @@ export default function MockInterviewPage() {
exportMockReport(format)}
+ onRetryScoring={retryScoring}
onPracticeAgain={async () => {
if (!(await confirmDiscard('mock-again'))) return;
await clear();
diff --git a/src/renderer/pages/mock-interview/report.tsx b/src/renderer/pages/mock-interview/report.tsx
index ddb4d05d..443de1cc 100644
--- a/src/renderer/pages/mock-interview/report.tsx
+++ b/src/renderer/pages/mock-interview/report.tsx
@@ -4,7 +4,12 @@ import { toast } from 'sonner';
import { showExportSuccessToast } from '@/components/custom/export-success-toast';
import { SafeMarkdown } from '@/components/custom/safe-markdown';
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from '@/components/ui/accordion';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -15,6 +20,7 @@ import type { MockInterviewSessionState } from '@/types/mock-interview';
interface ReportScreenProps {
session: MockInterviewSessionState;
onExport: (format: 'docx' | 'md') => Promise;
+ onRetryScoring: () => Promise;
onPracticeAgain: () => Promise;
onDone: () => Promise;
}
@@ -26,17 +32,30 @@ function scoreVerdict(score: number): string {
return 'Needs work';
}
-export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: ReportScreenProps) {
- const { report, reportError, answers } = session;
+export function ReportScreen({
+ session,
+ onExport,
+ onRetryScoring,
+ onPracticeAgain,
+ onDone,
+}: ReportScreenProps) {
+ const { report, reportError, rescoring, answers } = session;
const [saving, setSaving] = useState<'docx' | 'md' | null>(null);
const [busy, setBusy] = useState<'again' | 'done' | null>(null);
const headingRef = useRef(null);
// This screen replaces SessionScreen the moment the session reaches Finished, not through a
// real navigation, so nothing else moves focus here on its own.
+ //
+ // Keyed on the report arriving rather than on mount alone, because a successful retry is the
+ // same kind of replacement one step further in: it unmounts the failure alert along with the
+ // Score again button the candidate just pressed, which drops focus to the body and loses their
+ // place in a screen that has just filled up with the score they were waiting for. The flag
+ // only ever flips once per session, so nothing steals focus while they are reading.
+ const hasReport = report !== null;
useEffect(() => {
headingRef.current?.focus();
- }, []);
+ }, [hasReport]);
const save = async (format: 'docx' | 'md') => {
setSaving(format);
@@ -81,9 +100,31 @@ export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: Rep
{reportError && (
-
- The overall score could not be produced ({reportError}). Your answers are still shown
- below and can still be exported.
+ {/* `gap-3` overrides AlertDescription's own `gap-1`: it is a grid, and the default
+ gap is sized for two lines of copy rather than copy followed by a control. */}
+
+
+ The overall score could not be produced ({reportError}). Your answers are still
+ shown below and can still be exported.
+
+ {/* The answers are kept, so scoring can be asked for again without re-running the
+ interview. Not automatic - see `retryScoring` in the service for why the spend
+ is the candidate's to make. */}
+
)}
@@ -179,7 +220,9 @@ export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: Rep