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

-

Stronger answer

+

+ Stronger answer +

@@ -224,7 +267,12 @@ export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: Rep
-