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
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,24 @@ Handler registration lives in [src/main/ipc/](src/main/ipc/) - one file per doma

### Transcription and Suggestion Flow

[src/main/services/transcript.service.ts](src/main/services/transcript.service.ts) is the central orchestrator. `transcriptService.ingest(channel, type, text)` merges both audio channels, deduplicates overlapping segments, and triggers `liveSuggestionService.startGenerateSuggestion()` when a final `Other` transcript arrives - with a `LIVE_SUGGESTION_GAP_MS` guard that suppresses the call if Self spoke recently.
[src/main/services/transcript.service.ts](src/main/services/transcript.service.ts) is the central orchestrator. `transcriptService.ingest(channel, type, text)` merges both audio channels, deduplicates overlapping segments, and decides whether a final `Other` transcript is worth answering - with a `LIVE_SUGGESTION_GAP_MS` guard that suppresses the call if Self spoke recently.

- `ch_0` = `Speaker.Other` (interviewer, captured via loopback audio)
- `ch_1` = `Speaker.Self` (candidate, captured via microphone)

**Not every interviewer turn needs an answer**, and deciding that is a cascade, cheapest stage first. `classifyInterviewerTurn()` ([src/main/utils/interviewer-turn.ts](src/main/utils/interviewer-turn.ts)) runs in-process on the *merged* turn and returns one of three verdicts: `Skip` drops the turn outright with no request and no card, `Answer` generates immediately, and `Uncertain` parks on an `INTERVIEWER_TURN_SETTLE_MS` timer that any further `ch_0` final re-arms. The `NO_SUGGESTION_NEEDED` sentinel is the *last* stage of the same cascade, not the only one.

Three things this ordering buys, all of which the sentinel alone could not. A turn caught at `Skip` costs no upload of the profile and context, no model call, and never reaches the panel, so nothing flashes on screen and is retracted. A question the ASR split across two finals - an ASR final is an acoustic endpoint, not the end of a thought - is classified whole instead of firing a request on the fragment that the continuation immediately aborts. And a completed question skips the settle wait entirely, so the latency is paid only by turns that are genuinely ambiguous.

The classifier is deliberately asymmetric, and `test/interviewer-turn.test.mjs` pins both halves. A filler that slips through costs one request and a card that flashes; a question misread as filler produces *nothing at all*, mid-interview, with no error anywhere. So `Skip` is returned only when the backchannel lexicon consumes the whole turn from the front, and everything it cannot fully consume falls through rather than being guessed at.

`Answer` and `Uncertain` both reach the backend, as `turn_verdict` on `GenerateLiveSuggestionRequest` ([types/llm.ts](src/main/types/llm.ts) mirrors the wire values `answer` / `uncertain`; `Skip` never becomes a request and has no wire value). `Answer` tells the backend to trust the client and skip its own classifier; `Uncertain` asks it to run one. The backend's decision is speculative - it runs *beside* the generation it might cancel, not in front of it - and the client cooperates by holding the card back: `generateSuggestion()` in [suggestion-live.service.ts](src/main/services/suggestion-live.service.ts) does not append a `Pending` card on request start. It arms a `LIVE_SUGGESTION_RENDER_DELAY_MS` timer instead, so a turn the backend suppresses within that window produces no card at all rather than one that flashes and is retracted - the exact failure this whole cascade exists to remove. Any real write (loading state once headers arrive, a streamed chunk, an error) cancels the timer and renders immediately through `publish()`; a `Stopped` state from being superseded before ever rendering goes through `refresh()` instead, which is a no-op unless a card already exists, so a card the candidate never saw pending does not appear only to say it was cancelled.

Action suggestions are independent of transcripts - triggered by screenshot captures (up to `ACTION_SUGGESTION_MAX_CAPTURES` = 4 images per request).

**Professional mode** (`professionalMode` in ConfigStore, off by default) asks the backend for hints - a headline plus keyword bullets - instead of full sentences. Both suggestion services read the flag once at the top of `generateSuggestion` and send it as `mode` on the request; the backend defaults it to `normal`, so the field is safe to omit against an older deployment.

The `NO_SUGGESTION_NEEDED` sentinel goes through `isNoSuggestionSentinel()` ([src/main/utils/suggestion-sentinel.ts](src/main/utils/suggestion-sentinel.ts)) rather than a direct comparison. It is prefix-matched because it runs on every streamed chunk, and it strips leading markdown first: the professional prompt asks for a bold headline on line 1, so a model that carries that format over emits `**NO_SUGGESTION_NEEDED**` and a bare match would leave the sentinel on screen as a card. `test/suggestion-sentinel.test.mjs` pins both halves - the wrapped forms are suppressed, real answers are not.
The `NO_SUGGESTION_NEEDED` sentinel goes through `isNoSuggestionSentinel()` ([src/main/utils/suggestion-sentinel.ts](src/main/utils/suggestion-sentinel.ts)) rather than a direct comparison. It backs up the deterministic gate above for turns the lexicon cannot settle, and it is in-band by nature - a control decision travelling in the answer stream - which is why it is the fallback rather than the mechanism. It is prefix-matched because it runs on every streamed chunk, and it strips leading markdown first: the professional prompt asks for a bold headline on line 1, so a model that carries that format over emits `**NO_SUGGESTION_NEEDED**` and a bare match would leave the sentinel on screen as a card. `test/suggestion-sentinel.test.mjs` pins both halves - the wrapped forms are suppressed, real answers are not.

Both live modes render through `SafeMarkdown`, the same component the action panel uses. The normal-mode prompt asks for plain text *with light formatting*, so any bold or bullet the model reached for used to land on screen as literal asterisks. Prose is passed through `withHardBreaks()` ([src/renderer/lib/suggestions.ts](src/renderer/lib/suggestions.ts)) first: Markdown folds a single newline into a space, and the `whitespace-pre-wrap` rendering it replaced showed every newline the model emitted.

Expand Down
21 changes: 21 additions & 0 deletions src/main/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,30 @@ export const SELF_PARTIAL_STALE_MS = 15_000;
// export read the full transcript from app state.
export const TRANSCRIPT_UPLOAD_LIMIT = 60;

// How long an interviewer turn that is neither a clear question nor clear filler is allowed to
// settle before it is answered. An ASR final is an acoustic endpoint, not the end of a thought, so
// a question broken by a thinking pause ("So tell me about" ... "your Kafka work") arrives as two
// finals: without this the first fires a request that the second immediately aborts, and the card
// rewrites itself under the candidate mid-read.
//
// Paid for only by turns that reach TurnVerdict.Uncertain. A turn ending in a question mark, or a
// punctuated directive, is answered with no added latency at all - see interviewer-turn.ts.
export const INTERVIEWER_TURN_SETTLE_MS = 700;

// Suggestion constants
export const LIVE_SUGGESTION_GAP_MS = 2000;
export const LIVE_SUGGESTION_NO_SUGGESTION = 'NO_SUGGESTION_NEEDED';

// How long a live-suggestion card waits before it is allowed to render at all. The backend's
// speculative gate (LLMService.turn_needs_answer) runs beside the answer generation and can
// suppress the whole response after it has already started - within roughly this long. Rendering
// a Pending card immediately would make every suppressed turn flash a spinner and then vanish,
// which is worse than the delay: a card that disappears reads as broken, one that never appears
// reads as nothing having happened, which is correct for a "yeah, got it".
//
// Not a cap on generation - a genuinely slow real answer still renders once this fires, it just
// starts one step behind instead of at the first byte.
export const LIVE_SUGGESTION_RENDER_DELAY_MS = 900;
export const ACTION_SUGGESTION_MAX_CAPTURES = 4;
export const ACTION_TIMEOUT_MS = 30_000; // 30 seconds

Expand Down
63 changes: 53 additions & 10 deletions src/main/services/suggestion-live.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { LLMApi } from '../api/llm.js';
import {
LIVE_SUGGESTION_RENDER_DELAY_MS,
LIVE_SUGGESTION_TTFB_MS,
SUGGESTION_STALL_MS,
TRANSCRIPT_UPLOAD_LIMIT,
} from '../consts.js';
import { configStore } from '../store/config.store.js';
import { LiveSuggestion, Speaker, SuggestionState, Transcript } from '../types/app-state.js';
import { GenerateLiveSuggestionRequest, SuggestionMode } from '../types/llm.js';
import {
GenerateLiveSuggestionRequest,
RequestTurnVerdict,
SuggestionMode,
} from '../types/llm.js';
import { DateTimeUtil } from '../utils/datetime.js';
import { getSuggestionErrorMessage } from '../utils/suggestion-error.js';
import { isNoSuggestionSentinel } from '../utils/suggestion-sentinel.js';
Expand Down Expand Up @@ -49,7 +54,8 @@ class LiveSuggestionService {
private async generateSuggestion(
taskId: string,
controller: AbortController,
transcripts: Transcript[]
transcripts: Transcript[],
turnVerdict: RequestTurnVerdict
): Promise<void> {
// No empty-transcript guard here on purpose. startGenerateSuggestion already returns
// before registering a task, and a second check would return ahead of the finally that
Expand All @@ -71,8 +77,33 @@ class LiveSuggestionService {
mode,
};

// Append initial suggestion
this.appendSuggestion(timestamp, suggestion, epoch);
// The card is *not* appended yet. A turn the backend suppresses resolves in a few hundred
// milliseconds, and appending here would put a spinner on screen only to delete it again -
// exactly the flicker the gate exists to remove. The card appears on whichever comes first:
// real content, an error, or LIVE_SUGGESTION_RENDER_DELAY_MS of waiting, which is what keeps
// a genuinely slow answer from looking like a dropped one.
let renderTimer: NodeJS.Timeout | null = setTimeout(() => {
renderTimer = null;
this.appendSuggestion(timestamp, suggestion, epoch);
}, LIVE_SUGGESTION_RENDER_DELAY_MS);

// Every write after the first goes through here, so the pending card can never be scheduled
// into existence after the sentinel has already decided there is nothing to show.
const publish = (): void => {
if (renderTimer) {
clearTimeout(renderTimer);
renderTimer = null;
}
this.appendSuggestion(timestamp, suggestion, epoch);
};

// Refresh a card that is already on screen, without bringing one into existence. Used for
// state changes that carry nothing for the candidate to read.
const refresh = (): void => {
if (this.suggestions.has(timestamp)) {
this.appendSuggestion(timestamp, suggestion, epoch);
}
};

// A plain resettable timer, not a race against reader.read(): a losing read promise stays
// pending and has already consumed a read request, so looping would leave two outstanding
Expand All @@ -93,6 +124,7 @@ class LiveSuggestionService {
context: interviewConfig.context,
transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT),
mode,
turn_verdict: turnVerdict,
};

armStallTimer(LIVE_SUGGESTION_TTFB_MS);
Expand All @@ -109,8 +141,11 @@ class LiveSuggestionService {
// check below only fires for Loading, and no timeout rescues it because the stream
// ended rather than stalled. An upstream that emits only a <think> block reaches here
// with nothing to yield, since _strip_think_stream swallows the whole buffer.
// Refresh, not publish. The response headers land before the backend's gate has decided
// anything - it holds the body back, not the response - so publishing here would render
// the exact card the delay above exists to withhold.
suggestion.state = SuggestionState.Loading;
this.appendSuggestion(timestamp, suggestion, epoch);
refresh();

try {
while (true) {
Expand All @@ -122,7 +157,7 @@ class LiveSuggestionService {
suggestion.answer += chunk;

// Update the suggestion
this.appendSuggestion(timestamp, suggestion, epoch);
publish();
}
}

Expand All @@ -135,7 +170,7 @@ class LiveSuggestionService {
} else {
suggestion.state = SuggestionState.Success;
}
this.appendSuggestion(timestamp, suggestion, epoch);
publish();
}
} finally {
// releaseLock alone does not cancel the body. Undici documents that an unconsumed,
Expand All @@ -157,6 +192,9 @@ class LiveSuggestionService {
if (aborted && !stalled) {
// Superseded by a newer question. Expected, not a failure.
suggestion.state = SuggestionState.Stopped;
// Refresh: a card superseded before it was ever shown has no partial answer on it, and
// a Stopped ghost appearing for a question the candidate never saw asked reads as a bug.
refresh();
} else {
if (!aborted) {
console.error('[LiveSuggestionService] Failed to generate suggestion:', error);
Expand All @@ -165,15 +203,20 @@ class LiveSuggestionService {
suggestion.error = stalled
? 'The response timed out. Please try again.'
: getSuggestionErrorMessage(error);
// Publish: a failure is always worth surfacing, even one that failed fast.
publish();
}
this.appendSuggestion(timestamp, suggestion, epoch);
} finally {
if (stallTimer) clearTimeout(stallTimer);
if (renderTimer) clearTimeout(renderTimer);
this.abortMap.delete(taskId);
}
}

async startGenerateSuggestion(transcripts: Transcript[]): Promise<void> {
async startGenerateSuggestion(
transcripts: Transcript[],
turnVerdict: RequestTurnVerdict = RequestTurnVerdict.Uncertain
): Promise<void> {
// Remove trailing SELF transcripts (same logic as Python)
const filteredTranscripts = [...transcripts];
while (
Expand All @@ -198,7 +241,7 @@ class LiveSuggestionService {
// generateSuggestion owns the abort-map cleanup in its own finally, but it can throw before
// reaching the try that guards it - the config and state reads sit above it. Deleting the
// entry here on a synchronous rejection keeps a dead controller from being aborted forever.
this.generateSuggestion(taskId, controller, filteredTranscripts).catch((error) => {
this.generateSuggestion(taskId, controller, filteredTranscripts, turnVerdict).catch((error) => {
console.error('[LiveSuggestionService] generateSuggestion rejected:', error);
this.abortMap.delete(taskId);
});
Expand Down
Loading