Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions src/main/api/mock-interview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,8 +56,17 @@ export class MockInterviewApi extends ApiClient {
return this.post<MockTurnDecision>('/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<ApiResponse<MockReport>> {
return this.post<MockReport>('/api/mock-interview/report', data, MOCK_REPORT_TIMEOUT_MS);
return this.post<MockReport>(
'/api/mock-interview/report',
data,
mockReportTimeoutMs(data.questions.length)
);
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/main/ipc/mock-interview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
1 change: 1 addition & 0 deletions src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
},
Expand Down
75 changes: 70 additions & 5 deletions src/main/services/mock-interview.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function initialSession(): MockInterviewSessionState {
liveHints: [],
report: null,
reportError: null,
rescoring: false,
exported: false,
error: null,
};
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
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 })),
Expand All @@ -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<void> {
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.
Expand Down
8 changes: 8 additions & 0 deletions src/main/types/mock-interview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/hooks/use-mock-interview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export function useMockInterview() {
}
};

const retryScoring = async (): Promise<void> => {
await window.electronAPI?.mockInterview.retryScoring();
};

const endSession = async (): Promise<void> => {
await window.electronAPI?.mockInterview.endSession();
};
Expand All @@ -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 };
}
3 changes: 2 additions & 1 deletion src/renderer/pages/mock-interview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -187,6 +187,7 @@ export default function MockInterviewPage() {
<ReportScreen
session={session!}
onExport={(format) => exportMockReport(format)}
onRetryScoring={retryScoring}
onPracticeAgain={async () => {
if (!(await confirmDiscard('mock-again'))) return;
await clear();
Expand Down
66 changes: 57 additions & 9 deletions src/renderer/pages/mock-interview/report.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,6 +20,7 @@ import type { MockInterviewSessionState } from '@/types/mock-interview';
interface ReportScreenProps {
session: MockInterviewSessionState;
onExport: (format: 'docx' | 'md') => Promise<string | null>;
onRetryScoring: () => Promise<void>;
onPracticeAgain: () => Promise<void>;
onDone: () => Promise<void>;
}
Expand All @@ -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<HTMLHeadingElement>(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);
Expand Down Expand Up @@ -81,9 +100,31 @@ export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: Rep
</h1>
{reportError && (
<Alert variant="destructive">
<AlertDescription>
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. */}
<AlertDescription className="gap-3">
<span>
The overall score could not be produced ({reportError}). Your answers are still
shown below and can still be exported.
</span>
{/* 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. */}
<Button
variant="outline"
size="sm"
disabled={rescoring}
onClick={() => void onRetryScoring()}
>
{rescoring ? (
<>
<Loader className="animate-spin" />
Scoring…
</>
) : (
'Score again'
)}
</Button>
</AlertDescription>
</Alert>
)}
Expand Down Expand Up @@ -179,7 +220,9 @@ export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: Rep
</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Stronger answer</p>
<p className="text-xs font-medium text-muted-foreground">
Stronger answer
</p>
<SafeMarkdown content={scored.stronger_answer} />
</div>
</>
Expand Down Expand Up @@ -224,7 +267,12 @@ export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: Rep
</Button>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={busy !== null} onClick={() => void practiceAgain()}>
<Button
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() => void practiceAgain()}
>
Practise again
</Button>
<Button size="sm" disabled={busy !== null} onClick={() => void done()}>
Expand Down
1 change: 1 addition & 0 deletions src/renderer/types/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ declare global {
ingestAnswer: (payload: { type: 'partial' | 'final'; text: string }) => Promise<void>;
answerFinished: () => Promise<void>;
answerReady: () => Promise<void>;
retryScoring: () => Promise<void>;
endSession: () => Promise<void>;
clear: () => Promise<void>;
};
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/types/mock-interview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export interface MockInterviewSessionState {
liveHints: LiveSuggestion[];
report: MockReport | null;
reportError: string | null;
/** A retry of a failed report is in flight - see the main-process mirror. */
rescoring: boolean;
/** True once this session has been written to a file - see the main-process mirror. */
exported: boolean;
error: string | null;
Expand Down
Loading