diff --git a/CLAUDE.md b/CLAUDE.md index 034962b9..fa512b07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,13 +71,17 @@ Three things this ordering buys, all of which the sentinel alone could not. A tu 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. +**The lexicon is English, and that used to leak into a test it had no business deciding.** `normalize()` reduces a turn to ASCII, which is correct for matching English backchannel and useless as a test for whether anything was said - a Japanese, Chinese, Thai, Russian, Korean, Arabic, Hindi, Greek or Hebrew turn reduces to nothing at all. Empty then hit the "entirely non-speech" branch meant for `[laugh]` and `(inaudible)`, so **every interviewer question in a non-Latin script was dropped outright**: no request, no card, no error, in roughly a third of the languages the picker offers. The two cases are now told apart by whether any letter in any script survived the non-speech markers (`\p{L}`); if one did, the verdict is `Uncertain`, which defers to the backend gate - the one stage that can actually read the language. + +**A turn is not required to be in one script, and that is the same bug one branch further in.** "OK、では次の質問です。" does not normalize to nothing - it normalizes to exactly `ok`, because the loanword the interviewer opened on is Latin and Deepgram transcribes it that way. The lexicon eats it, the core comes back empty, and the question is dropped by the `core.length === 0` branch without the empty-normalized branch ever running. An interviewer opening on "OK" or "Yes" is ordinary in Japanese, Korean, Chinese, Russian, Greek, Arabic, Hebrew, Thai and Hindi alike. So `Skip` there additionally requires that no letter from a script the lexicon cannot read survived. The test is on **script**, not on the codepoint being non-ASCII: an accented Latin letter belongs to a word the lexicon does read, so blanking the umlaut in "Ähm" and matching `hm` stays a correct consumption rather than becoming a gate call on every filler in seventeen Latin-script languages. Non-ASCII question marks (`?`, `؟`) are folded to `?` in `normalize`, so a finished question in those scripts still answers immediately instead of waiting out the settle timer. Greek's `;` is deliberately left alone: it is an ordinary semicolon everywhere else, and reading it as terminal would answer English fragments. + `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 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. +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. Unicode format characters are stripped with the emphasis, and that is what makes the fallback hold in Arabic and Hebrew: a model writing right-to-left routinely opens on a directional mark, and U+200F is not whitespace, so it survives `\s` and leaves the comparison starting on a character the sentinel does not - putting `NO_SUGGESTION_NEEDED` on screen as the answer to a question the backend had just decided needed none. `test/suggestion-sentinel.test.mjs` pins both halves - the wrapped forms are suppressed, real answers are not, including a real Hebrew one opening on the same mark. 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. @@ -85,6 +89,156 @@ The backend prompts now ask for inline emphasis on the words an answer turns on, Each `LiveSuggestion` still carries the `mode` it was *generated* under, and the panel keys off that rather than the current setting, so toggling mid-interview leaves cards already on screen alone. What the mode selects is the presentation around the Markdown: professional promotes the headline line, normal keeps the 🪄 marker in a column of its own - prepending it to the content instead would swallow whatever structure the answer opens with. +### Assistant lifecycle + +`RunningState` is what every control on the bar is gated on, and `Starting` and `Stopping` disable +all of them - Stop included. So the one invariant `useAssistantService` has to hold is that the +state always lands back on a terminal value, whatever went wrong on the way. `stopAssistant` +returns to `Idle` in a `finally`, and tears the four services down through `Promise.allSettled` +rather than `Promise.all`: `all` rejects on the first one that throws and abandons the other three, +so a single failing teardown used to leave the rest running *and* strand the app in `Stopping` +with no reachable control - unrecoverable without restarting the app, mid-interview. A partial +failure is now a toast rather than a throw, because there is nothing left for a caller to do about +it and the session is over either way. + +The failed-start path is the mirror of that, and it belongs in exactly one place. `startAssistant` +already tears both services down and returns to `Idle` in its own `catch`, so `doStart` in +[control-panel/index.tsx](src/renderer/components/custom/control-panel/index.tsx) reports the error +and stops there. Calling `stopAssistant()` after it, as it used to, walked the button through a +three-second `Stopping` for a session that never started, and that call's own failure landed +outside the `try` as an unhandled rejection. + +`useMediaDevices` reports `ready` alongside the device list because an empty list means two +different things - `enumerateDevices()` has not answered yet, and this machine has none - and the +control panel renders a destructive badge and refuses Start on the second. Reading them as one +put a red `!` on a working microphone for the first frames after every launch, and refused a Start +pressed quickly with a message naming a device that was there all along. An unset +`audioInputDeviceName` is a third state again, and also not "missing": `AudioGroup` is choosing +the default at that moment, in an effect - never in the render body, where the store write +re-enters React mid-commit and a failed IPC call rolls the value back into the same condition that +triggered it, one write per frame. + +### Interview language + +One setting decides three things: which speech model transcribes the call, what language suggestions come back in, and the language of the exported report. `Language` is mirrored across the processes the way `SuggestionMode` is - [src/main/types/language.ts](src/main/types/language.ts) for the request bodies, [src/renderer/types/language.ts](src/renderer/types/language.ts) for the same enum plus the display metadata the picker needs. 28 languages, which is exactly what the backend's Deepgram Nova-3 ASR streams: offering one the ASR cannot hear would not degrade, it would answer a question that was never asked. + +A code this build knows but an older backend does not is resolved back to English there rather than faked, so a client ahead of its backend degrades one session instead of breaking it. `test/language.test.mjs` pins the two mirrors staying in step, which is the failure the widening made likely: an enum member with no picker entry renders a blank trigger, and a picker entry with no enum member resolves straight back to English when picked. The menu is capped and scrolls, because it opens upward from the bottom-most control into an overflow-hidden `main` - an uncapped 28-item list runs off the top of the window rather than flipping. + +**English is the absence of the feature.** `buildStreamingUrl` sends no `language` parameter at all for English rather than `language=en`, and the backend defaults the request field, so a session that never touches the picker produces exactly the traffic it produced before this existed. + +`configStore.getConfig()` resolves the language on the way *out*, not on the way in. The disk holds whatever some build wrote - a code a later release dropped, or one an older release never knew - and every consumer reads through `getConfig`, so that is the single place an unknown code can be stopped before it reaches the ASR URL and three request bodies. `test/language.test.mjs` pins it. + +**The picker stays live mid-interview**, unlike Model, because an interview that switches language is the case it exists for and not one the candidate can prepare for by restarting. The two halves of the setting move at different speeds and `useInterviewLanguage` is where that is reconciled. Suggestions need nothing: every request reads the config store as it is built, so the next one already follows. The ASR carries its language as a *connection* parameter, so `liveTranscriptionService.setLanguage()` tears both sockets down and re-opens them - a second or two of gap, and whatever utterance was mid-flight is orphaned, which is why the button shows a spinner rather than pretending the change was instant and why the menu says so before the user commits. + +Three guards in `AudioWsStream` make that safe, and all three protect against the same failure - two sockets on one channel, one of them orphaned and still relaying audio into a dead session. `ws.onclose` ignores a close from a socket that is no longer `this.ws`, since that is the tail of a replacement rather than a disconnect; and the `switching` flag suppresses the ordinary backoff reconnect for the close `setLanguage` causes itself, which it then handles immediately instead of after `WS_RETRY_BASE_DELAY_MS`. `connectWebSocket` rebuilds the URL per attempt rather than capturing it, which is what lets a reconnect pick up the new language at all. + +The third is `switchSeq`, the generation token the microphone path already had, and it exists because the other two are per-socket while the failure is per-channel. Two switches overlap - the picker disables its trigger on `switching`, but the hook only sets that *after* awaiting the config write, so a second pick lands in the gap - and both run a `connectWithRetry` loop that assigns `this.ws` synchronously per attempt. The older loop wakes from its backoff after the newer one has opened its socket and overwrites the field with its own; the newer socket is then unreferenced, so `stop()` never closes it and the Deepgram session behind it stays open for the life of the app, billing and transcribing a language nobody selected. The loop captures the generation at entry and stops before building another socket, `connectWebSocket` re-checks on open (the window is a whole `WS_OPEN_TIMEOUT_MS`), the reconnect timer carries its own since `setLanguage` can only cancel a timer that has not fired yet, and a superseded switch leaves the backoff and the toast to whichever switch replaced it. `test/language-switch.test.mjs` pins it, source-level, for the same reason the device tests are. + +`useInterviewLanguage` needs the same guard one layer up, and so does `useAudioInputDevice`. A superseded switch is *abandoned* by the service, which resolves it in the hook as a success - so without a generation ref there, a switch the user has already moved off clears the warning raised by the one that replaced it and drops the spinner while that one is still reconnecting. + +Two consequences of that first guard. `setLanguage` has to report `channelDisconnected` itself rather than leaving it to `onclose`: `new WebSocket` assigns `this.ws` synchronously, so the old socket's close event always arrives after the replacement exists and is correctly ignored. And `setLanguage` keys its own no-op check on `this.ws` rather than on `active`, which `start()` only sets *after* its first connect returns - in that window a socket exists on the old language and an `active` check would skip it. + +The setting is persisted *before* the reconnect and never rolled back on failure: a failed reconnect that reverted the setting would leave the user with no route to the language they picked, whereas leaving it set means stopping and starting the assistant recovers. + +**That choice leaves the two halves disagreeing, and the UI has to say so for longer than a toast does.** Suggestions have moved and transcription has not, so the menu shows the new language with a check beside it while the transcript is still arriving in the old one - and a candidate reading answers in one language and a transcript in another has no other way to tell which half moved. `reconnectFailed` keeps it visible: the trigger icon goes destructive, the tooltip says "Suggestions only", and the menu replaces its reconnect notice with what actually happened. It is cleared on a switch that succeeds and on leaving `Running`, because the next start opens both sockets on the stored language and the disagreement is gone with the session that produced it. + +The trigger shows the code (`EN`, `ES`) next to the icon for the same reason the tooltip names the language - the one question this control has to answer at a glance is what it is currently set to. + +Menu items carry an explicit `textValue` of the **English** name. Radix runs its own typeahead on an open menu and matches a prefix of that; left unset it uses the item's rendered text, which is the two names run together (`PolskiPolish`), so only the endonym was ever reachable by typing. At six entries that hardly mattered. At 28 the endonym column is what the eye scans and the English name is what a user types, and they should be two access paths rather than one. + +**Whether two transcript blocks are joined with a space is a property of the words, not of the setting.** `Transcript` therefore carries the language it was transcribed in, stamped at ingest, and `mergeAdjacentTranscripts` reads it per block. `cleaned` is rebuilt from every stored transcript on each ingest, so a single reading of the current setting did not apply to new text - it applied to the whole session, retroactively: switching an English interview to Japanese an hour in stripped the spaces out of every block merged so far, on screen and in the transcript the next request carries. `test/transcript-merge.test.mjs` pins both directions. + +**Arabic and Hebrew need a text direction, and the panels are laid out left-to-right.** Every block `SafeMarkdown` emits carries `dir="auto"`, as do the transcript lines and both panels' question lines. The defect without it is not that RTL text renders left-to-right - it does not - it is that the neutrals go the wrong way: sentence-final punctuation takes the *paragraph's* direction, so the question mark lands at the wrong end, and a technical answer reorders at every switch of script, which is every answer since the prompts keep product names and code in Latin. Per block rather than once on a wrapper, because `auto` resolves from the first strong character it contains. Block code is pinned to `dir="ltr"` instead: code is left-to-right in every language and one RTL comment in a fence flips the whole block. `test/rtl-rendering.test.mjs` pins it, and it is a no-op in every language that shipped before the picker. + +The app's own chrome is **not** localised, deliberately: an English button on a Spanish interview is an inconvenience, an English transcript of Spanish speech is a wrong answer read out loud. + +**The exported report is the one exception**, because it is the one artifact that leaves the machine and is handed to someone who was not there. The summarize prompt translates the headings *it* writes; the five words the client wraps around them - Transcripts, Suggestions, Suggestion, Interviewer, Date/Time - live in [export-labels.ts](src/main/utils/export-labels.ts) and follow the same setting, or the export is the half-translated document that prompt exists to avoid. The candidate is named rather than labelled, and timestamps stay on the machine's locale. `test/tools-export.test.mjs` pins that every enum member has a full set and that an unknown code falls back to English rather than throwing. + +### Audio input device + +**The microphone can be changed mid-interview**, and for the same reason the language can: the case +it exists for only shows up once the session is running. A headset that dies, is unplugged, or was +the wrong device to begin with is noticed when the interviewer says they cannot hear you, and the +control used to be locked at exactly that moment - the only fix was stopping the assistant, which +drops the transcript and the suggestion history with it. + +It is cheaper than the language switch, and the difference is worth keeping straight. The device is +only what feeds the worklet; it is **not** a connection parameter. So `AudioWsStream.setStream()` +replaces the `MediaStreamAudioSourceNode` while the socket, the provider session and any utterance +in flight all survive. Nothing reconnects, there is no gap in the transcript, and the dialog +therefore promises the opposite of what the language menu warns about. Reaching for `setLanguage`'s +machinery here would reintroduce the gap this avoids. + +Two things `liveTranscriptionService.setAudioInputDevice()` has to hold, both pinned by +`test/audio-device-switch.test.mjs`. **The replacement stream is acquired before anything is torn +down**, and the previous one stopped only after the swap succeeds, so a device that is unplugged, +held by another app, or refused by permissions leaves the interview on the microphone it already +had. Releasing first reads as the obvious cleanup order and works every time the new device is +present; on the one path that matters it leaves the session with no microphone at all, mid-answer. +And a stream that finishes opening *after* the session stopped is released rather than left holding +the device with its indicator light on, since nothing else keeps a reference to it. + +`setStream` reuses the existing `AudioContext` rather than building one. Its `sampleRate` is fixed +at construction and `convertTo16kPcm` reads it, so a fresh context would resample every frame +against the wrong rate - quietly, and only for users whose second device runs at a different rate +than their first. + +Its bail-out tests the context **alone**, never also the worklet node, and that is not tidiness. +`start()` creates `source` from `this.stream` and only assigns `workletNode` after +`await addModule()`, so there is a real window where a context and a source exist and the node does +not. An early return covering that window leaves `source` bound to the stream the caller is about +to stop, and `start()` then wires that dead source into the graph: socket up, channel relaying +silence for the rest of the session, nothing reporting it. The worklet connect is guarded instead, +because `start()` has its own `source.connect(workletNode)` and reads `this.source` - which is the +replacement by then. + +Only `ch_1` moves. `ch_0` is loopback audio captured from the call and has no device to change. + +The setting is persisted before the swap and never rolled back on failure, the same as the language +picker: a failed swap leaves the audio running, so reverting would only remove the user's route to +the device they picked. + +**And the same consequence: the picker then names a microphone the session is not using.** That is +the state where the interviewer has just said they cannot hear you, so it is carried on the control +bar rather than only in the dialog - `failedDeviceName` raises the same badge a missing device +raises, and the dialog names the device that failed. Nobody opens a dialog spontaneously +mid-interview. It clears on a swap that succeeds and on leaving `Running`. + +The dialog's running-state line is one conditional chain rather than sibling `&&` blocks, and that +is not style: written as siblings, retrying after a failure was both `switching` and +`failedDeviceName` and satisfied neither, so the line vanished at exactly the moment the user was +waiting to hear whether it had worked. + +The tests are source-level, unusually for this directory - every other one loads a built +main-process module, and this is renderer code with no runtime harness. They are worth the +awkwardness because the ordering above is what a later tidy-up breaks, with no symptom a type +checker or a linter can see. + +### Navigation and external links + +The panels render Markdown that came from a language model, and `remark-gfm` autolinks bare URLs, +so an anchor in this app is not necessarily one a person wrote. `installNavigationGuard()` +([src/main/navigation-guard.ts](src/main/navigation-guard.ts)) is installed before the window's +first load and closes the two routes that follow from that, neither of which announced itself. + +`setWindowOpenHandler` denies **every** new window. A `target="_blank"` anchor - which is what +`SafeMarkdown` renders - asks Electron for one, and with no handler installed the default is to +make it: a chromeless BrowserWindow with no address bar showing a page the user did not choose. +A web URL is handed to the real browser instead, through `setImmediate` as Electron's own +guidance requires. + +`will-navigate` pins the window to the app's own document. An anchor without a target navigates +the frame it is in, and that frame is the app - preload runs on whatever document loads next, so +a remote page would inherit `window.electronAPI`, and with it the session token through +`config.get()` and the candidate's CV through `account.get()`. `file:` origins serialize to +`"null"`, so the packaged build is matched on its exact document URL rather than on an origin +comparison that could never hold. + +Both routes and the `external:open` IPC handler go through the same `openExternally()`, which +allows `http:`, `https:` and `mailto:` only. `shell.openExternal` delegates to the OS protocol +handler, so `file:` launches whatever the path points at and a registered custom scheme runs +whatever claimed it. `test/navigation-guard.test.mjs` pins all three. + ### Routing Hash-based router (required for Electron `file://` protocol). Routes: `/` (index, redirects based on login state) -> `/auth/login`, `/auth/signup`, or `/auth/forgot-password` -> `/main` (interview UI) -> `/payment`. diff --git a/README.md b/README.md index b5194d4e..b4b2963b 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ pnpm test:main # main-process checks ### Configuration - Set profile (CV, job description) -- Select microphone +- Select microphone (changeable mid-interview, without interrupting transcription) - Start assistant ## Use Cases diff --git a/src/main/index.ts b/src/main/index.ts index 8dad9914..7775ee4b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, Menu } from 'electron'; import path from 'path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -22,6 +22,7 @@ import { registerLiveSuggestionHandlers } from './ipc/suggestion-live.js'; import { registerToolsHandlers } from './ipc/tools.js'; import { initializeAudioLoopback, registerTranscriptHandlers } from './ipc/transcript.js'; import { registerWindowHandlers } from './ipc/window.js'; +import { installNavigationGuard } from './navigation-guard.js'; import { autoUpdaterService } from './services/auto-updater.service.js'; import { healthCheckService } from './services/health-check.service.js'; import { transcriptService } from './services/transcript.service.js'; @@ -172,14 +173,20 @@ async function createWindow() { // Clear cache before loading await win.webContents.session.clearCache(); + // Installed before the load, so the guard is in place for the app's very first document. + // Idempotent, because this function runs again when the single-instance lock recovers a + // destroyed window and `web-contents-created` is an app-level event. if (EnvUtil.isDev()) { - win.loadURL('http://localhost:15173'); + const devUrl = 'http://localhost:15173'; + installNavigationGuard(devUrl); + win.loadURL(devUrl); win.webContents.openDevTools(); } else { // Use app.getAppPath() for conventional path resolution // This works correctly whether the app is packaged or not const distPath = path.join(app.getAppPath(), 'dist', 'index.html'); console.log('Loading from:', distPath); + installNavigationGuard(pathToFileURL(distPath).href); win.loadFile(distPath); } } diff --git a/src/main/ipc/external.ts b/src/main/ipc/external.ts index 93c1783b..ce7f97bb 100644 --- a/src/main/ipc/external.ts +++ b/src/main/ipc/external.ts @@ -1,16 +1,12 @@ import { ipcMain, shell } from 'electron'; +import { openExternally } from '../navigation-guard.js'; + export function registerExternalHandlers(): void { - ipcMain.handle('external:open', async (_event, url: string) => { - try { - if (!url || typeof url !== 'string') return { success: false, error: 'invalid-url' }; - await shell.openExternal(url); - return { success: true }; - } catch (err: unknown) { - console.warn('[ExternalHandlers] external:open error:', err); - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }); + // Shared with the window-open handler rather than calling shell.openExternal directly, so a + // link takes the same route and the same scheme check whichever way it arrives. openExternal + // hands the URL to the OS protocol handler, so `file:` launches what the path points at. + ipcMain.handle('external:open', async (_event, url: string) => openExternally(url)); ipcMain.handle('external:open-file', async (_event, filePath: string) => { const err = await shell.openPath(filePath); diff --git a/src/main/navigation-guard.ts b/src/main/navigation-guard.ts new file mode 100644 index 00000000..64c9d395 --- /dev/null +++ b/src/main/navigation-guard.ts @@ -0,0 +1,109 @@ +import { app, shell } from 'electron'; + +/** + * Schemes `shell.openExternal` is allowed to hand to the operating system. + * + * openExternal delegates to the OS protocol handler, so `file:` launches whatever the path points + * at and a registered custom scheme runs whatever claimed it. Only the three that mean "show this + * to the user in their own application" are permitted. + */ +const OPENABLE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']); + +export function isOpenableExternally(url: string): boolean { + try { + return OPENABLE_PROTOCOLS.has(new URL(url).protocol); + } catch { + return false; + } +} + +/** + * Open a URL in the user's own browser, or refuse it. + * + * Shared by the `external:open` IPC handler and the window-open handler below, so a link takes + * the same route whether the renderer asked for it explicitly or a `target="_blank"` anchor did. + */ +export async function openExternally(url: string): Promise<{ success: boolean; error?: string }> { + if (!url || typeof url !== 'string') return { success: false, error: 'invalid-url' }; + if (!isOpenableExternally(url)) { + console.warn('[NavigationGuard] Refused to open a non-web URL:', url); + return { success: false, error: 'unsupported-scheme' }; + } + + try { + await shell.openExternal(url); + return { success: true }; + } catch (err: unknown) { + console.warn('[NavigationGuard] openExternal error:', err); + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Keep the app's own web contents on the app. + * + * The panels render Markdown that came from a language model, and `remark-gfm` autolinks bare + * URLs, so an anchor in this app is not necessarily one anybody wrote. Two things follow from + * that, and neither was covered before. + * + * A `target="_blank"` anchor asks Electron for a new window, and with no handler installed the + * default is to make one: a chromeless BrowserWindow, no address bar, showing a page the user did + * not choose. Every one of those is denied and handed to the real browser instead, which is both + * safer and what the user expected from a link. + * + * An anchor without a target navigates the frame it is in, and that frame is the app - carrying + * the preload bridge with it, since preload runs on whatever document loads next. A remote page + * inheriting `window.electronAPI` would have the session token through `config.get()` and the + * candidate's CV through `account.get()`. `will-navigate` pins the window to the app's own + * document; the dev server and the packaged `file://` bundle are the only origins it may hold. + */ +let installed = false; + +export function installNavigationGuard(appUrl: string): void { + // `createWindow()` runs again when the single-instance lock recovers a destroyed window, and + // `web-contents-created` is an app-level event: without this the second call would stack a + // duplicate will-navigate listener on every web contents for the rest of the process. + if (installed) return; + installed = true; + + let appOrigin: string; + try { + appOrigin = new URL(appUrl).origin; + } catch { + appOrigin = ''; + } + + app.on('web-contents-created', (_event, contents) => { + contents.setWindowOpenHandler(({ url }) => { + // setImmediate, per Electron's own guidance: openExternal must not run inside the handler. + if (isOpenableExternally(url)) { + setImmediate(() => void openExternally(url)); + } else { + console.warn('[NavigationGuard] Blocked a window for:', url); + } + return { action: 'deny' }; + }); + + contents.on('will-navigate', (event, navigationUrl) => { + let target: URL; + try { + target = new URL(navigationUrl); + } catch { + event.preventDefault(); + return; + } + + // `file:` origins serialize to "null", so the packaged build is matched on the document it + // is already showing rather than on an origin comparison that can never hold. + const sameDocument = + target.href === appUrl || (appOrigin !== '' && target.origin === appOrigin); + if (sameDocument) return; + + event.preventDefault(); + console.warn('[NavigationGuard] Blocked navigation to:', navigationUrl); + if (isOpenableExternally(navigationUrl)) { + setImmediate(() => void openExternally(navigationUrl)); + } + }); + }); +} diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index af84fd0e..9f86f012 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -12,6 +12,7 @@ import { Speaker, SuggestionState, } from '../types/app-state.js'; +import { DEFAULT_LANGUAGE } from '../types/language.js'; import { SuggestionMode } from '../types/llm.js'; import { getWindowReference, refreshWindowSurfaces } from './window-control.service.js'; @@ -57,6 +58,7 @@ export class AppStateService { speaker: Speaker.Other, isFinal: false, endTimestamp: tstampNow + 5000, + language: DEFAULT_LANGUAGE, }, ], liveSuggestions: [ diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index 51196770..e12d700c 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -13,6 +13,19 @@ import { pushNotificationService } from './push-notification.service.js'; const SUCCESS_INTERVAL = 5 * 1000; // 5 seconds const FAILURE_INTERVAL = 1 * 1000; // 1 second +// A backend that is down is usually down for longer than a second, and the first retry is the +// only one that benefits from being immediate. Without a ceiling the loop below polls at 1 Hz +// for as long as the app is open - a laptop left overnight on a dropped connection makes tens of +// thousands of failing requests, and every installed client comes back at the same rate the +// moment a real outage ends. Backoff is capped rather than unbounded so recovery is still +// noticed within half a minute, which is what the reconnect notice in the UI is waiting on. +const MAX_FAILURE_INTERVAL = 30 * 1000; +const FAILURE_BACKOFF_FACTOR = 2; + +function nextFailureInterval(current: number): number { + return Math.min(current * FAILURE_BACKOFF_FACTOR, MAX_FAILURE_INTERVAL); +} + export class HealthCheckService { private running = false; private client = new HealthCheckApi(); @@ -64,6 +77,8 @@ export class HealthCheckService { /** Backend ping loop */ private startBackendLoop(): void { (async () => { + let failureInterval = FAILURE_INTERVAL; + while (this.running) { let backendLive = false; try { @@ -74,13 +89,17 @@ export class HealthCheckService { } if (!backendLive) { - console.log('[HealthCheckService] Backend not live'); + console.log(`[HealthCheckService] Backend not live, next check in ${failureInterval}ms`); } // Update app state appStateService.updateState({ isBackendLive: backendLive }); - const next = backendLive ? SUCCESS_INTERVAL : FAILURE_INTERVAL; + // Reset on the way back up, so one blip does not leave the app checking slowly for the + // rest of the session. + const next = backendLive ? SUCCESS_INTERVAL : failureInterval; + failureInterval = backendLive ? FAILURE_INTERVAL : nextFailureInterval(failureInterval); + await safeSleep(next); } })(); @@ -89,12 +108,17 @@ export class HealthCheckService { /** Client ping loop */ private startClientLoop(): void { (async () => { + let failureInterval = FAILURE_INTERVAL; + while (this.running) { const state = appStateService.getState(); - // skip if not logged in + // skip if not logged in. Kept at FAILURE_INTERVAL: it makes no request, so it costs a + // timer wake-up rather than traffic, and it is what decides how soon after a sign-in the + // credits and role reach the UI. if (!state.isLoggedIn) { await safeSleep(FAILURE_INTERVAL); + failureInterval = FAILURE_INTERVAL; continue; } @@ -117,9 +141,11 @@ export class HealthCheckService { userRole: res.data?.user_role, }); } + failureInterval = FAILURE_INTERVAL; } catch (error) { console.error('[HealthCheckService] Client ping error:', error); - nextInterval = FAILURE_INTERVAL; + nextInterval = failureInterval; + failureInterval = nextFailureInterval(failureInterval); } await safeSleep(nextInterval); diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index a6ef0977..182bdce5 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -218,6 +218,7 @@ export class ActionSuggestionService { transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT), image_names: [...this.uploadedImageNames], mode: conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal, + language: conf.language, }; const lastQuestion = this.getLastInterviewerQuestion(transcripts); diff --git a/src/main/services/suggestion-live.service.ts b/src/main/services/suggestion-live.service.ts index d7a0b6da..aa23f553 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -125,6 +125,7 @@ class LiveSuggestionService { transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT), mode, turn_verdict: turnVerdict, + language: conf.language, }; armStallTimer(LIVE_SUGGESTION_TTFB_MS); diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index bf37643b..9fde508e 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -21,11 +21,23 @@ class ToolsService { const transcripts = appStateService.getState().transcripts; const suggestions = appStateService.getState().liveSuggestions; + // Checked before the request, not after. Summarizing an empty interview is a billed model + // call whose only possible output is invented, and it lands in a document the candidate is + // told is a record of their interview. The export button is live whenever the assistant is + // idle, which includes every launch before the first session. + if (transcripts.length === 0 && suggestions.length === 0) { + throw new Error('There is nothing to export yet. Run an interview first.'); + } + // Call the API to generate the summary text + const conf = configStore.getConfig(); const response = await this.llmApi.generateSummary({ - config: configStore.getConfig().llmConf, + config: conf.llmConf, username, transcripts, + // The exported report is written in the interview's language too. A Spanish interview + // summarised in English is a document the candidate cannot hand to anyone involved in it. + language: conf.language, } as GenerateSummarizeRequest); if (response.error) { throw new Error(response.error.message); @@ -36,6 +48,9 @@ class ToolsService { summary: response.data ?? '', transcripts, suggestions, + // Same setting the summary was requested in, so the words this file adds around it are in + // the language the rest of the document is written in. + language: conf.language, }); const isMarkdown = format === 'md'; diff --git a/src/main/services/transcript.service.ts b/src/main/services/transcript.service.ts index 5de7505f..bf2c5cfa 100644 --- a/src/main/services/transcript.service.ts +++ b/src/main/services/transcript.service.ts @@ -4,9 +4,11 @@ import { SELF_PARTIAL_STALE_MS, TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS, } from '../consts.js'; +import { configStore } from '../store/config.store.js'; import { Speaker, Transcript } from '../types/app-state.js'; import { RequestTurnVerdict } from '../types/llm.js'; import { classifyInterviewerTurn, TurnVerdict } from '../utils/interviewer-turn.js'; +import { transcriptSeparator } from '../utils/transcript-join.js'; import { appStateService } from './app-state.service.js'; import { liveSuggestionService } from './suggestion-live.service.js'; @@ -30,6 +32,46 @@ export function selectTrailingOtherTurn(cleaned: Transcript[]): Transcript[] { return trailing; } +/** + * Collapse runs of same-speaker transcripts that arrived close together into one block each. + * + * The ASR delivers a sentence as several finals when the speaker pauses inside it, so without + * this the panel reads as a column of fragments and the interviewer-turn gate classifies half a + * question. Anything further apart than TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS stays its own block. + * + * Each transcript is joined under *its own* language, which is why `Transcript` carries one. The + * caller rebuilds this from every stored transcript on each ingest, so reading the current + * setting once here would re-join the whole session under it: switch to Japanese an hour in and + * every English block merged so far loses its spaces, on screen and in the transcript the next + * suggestion request carries. The separator sits in front of the text being appended, so it is + * that text's language that decides it. + * + * Exported standalone for the same reason `selectTrailingOtherTurn` is: the merge is testable on + * a plain array, without driving real wall-clock gaps through `ingest()`. + */ +export function mergeAdjacentTranscripts(allTranscripts: Transcript[]): Transcript[] { + const cleaned: Transcript[] = []; + for (const t of allTranscripts) { + const lastIndex = cleaned.length - 1; + if (lastIndex < 0) { + cleaned.push({ ...t }); + continue; + } + + const lastCleaned = cleaned[lastIndex]; + if ( + lastCleaned.speaker === t.speaker && + t.timestamp - lastCleaned.endTimestamp <= TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS + ) { + lastCleaned.text += transcriptSeparator(t.language) + t.text; + lastCleaned.endTimestamp = t.endTimestamp; + } else { + cleaned.push({ ...t }); + } + } + return cleaned; +} + class TranscriptService { private isActive = false; @@ -51,12 +93,17 @@ class TranscriptService { const isFinal = String(typeRaw).toLowerCase() === 'final'; const now = Date.now(); + // Stamped as the text arrives, not read later where it is used. A mid-session switch moves + // the setting and leaves every word already spoken in the language it was spoken in. + const language = configStore.getConfig().language; + const transcript: Transcript = { timestamp: now, text, isFinal, speaker, endTimestamp: now, + language, }; if (transcript.speaker === Speaker.Self) { @@ -67,6 +114,7 @@ class TranscriptService { } else if (this.selfPartialTranscript) { this.selfPartialTranscript.text = transcript.text; this.selfPartialTranscript.endTimestamp = transcript.endTimestamp; + this.selfPartialTranscript.language = transcript.language; } else { this.selfPartialTranscript = transcript; } @@ -77,6 +125,7 @@ class TranscriptService { } else if (this.otherPartialTranscript) { this.otherPartialTranscript.text = transcript.text; this.otherPartialTranscript.endTimestamp = transcript.endTimestamp; + this.otherPartialTranscript.language = transcript.language; } else { this.otherPartialTranscript = transcript; } @@ -86,25 +135,7 @@ class TranscriptService { if (this.otherPartialTranscript) allTranscripts.push(this.otherPartialTranscript); allTranscripts = allTranscripts.filter(Boolean).sort((a, b) => a.timestamp - b.timestamp); - const cleaned: Transcript[] = []; - for (const t of allTranscripts) { - const lastIndex = cleaned.length - 1; - if (lastIndex < 0) { - cleaned.push({ ...t }); - continue; - } - - const lastCleaned = cleaned[lastIndex]; - if ( - lastCleaned.speaker === t.speaker && - t.timestamp - lastCleaned.endTimestamp <= TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS - ) { - lastCleaned.text += ' ' + t.text; - lastCleaned.endTimestamp = t.endTimestamp; - } else { - cleaned.push({ ...t }); - } - } + const cleaned = mergeAdjacentTranscripts(allTranscripts); // Read by the settle timer, which fires after this call has returned and must see the turn as // it stands then, not as it stood when the timer was armed. diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 64930300..92816bac 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -6,11 +6,13 @@ import ElectronStore from 'electron-store'; import { OPACITY_DEFAULT } from '../consts.js'; +import { DEFAULT_LANGUAGE, Language, resolveLanguage } from '../types/language.js'; import { LLMConfig } from '../types/llm.js'; // Runtime configuration (matches Config type in frontend) export interface RuntimeConfig { - language: string; + /** Interview language: what the ASR transcribes and what suggestions come back in. */ + language: Language; sessionToken: string; rememberMe: boolean; email: string; @@ -36,7 +38,7 @@ export interface RuntimeConfig { // Default runtime configuration const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { - language: 'en', + language: DEFAULT_LANGUAGE, sessionToken: '', rememberMe: true, email: '', @@ -102,7 +104,15 @@ class ConfigStore { getConfig(): RuntimeConfig { const stored: StoredRuntime = { ...this.store.get('runtime', DEFAULT_RUNTIME_CONFIG) }; delete stored.interviewConf; - return { ...DEFAULT_RUNTIME_CONFIG, ...stored } as RuntimeConfig; + const config = { ...DEFAULT_RUNTIME_CONFIG, ...stored } as RuntimeConfig; + + // Resolved on the way out, not on the way in. The disk holds whatever some build wrote - + // a language a later release dropped, or one an older one never knew - and every consumer + // reads through here, so this is the one place that can stop an unknown code reaching the + // ASR URL and the request bodies. + config.language = resolveLanguage(config.language); + + return config; } /** diff --git a/src/main/types/app-state.ts b/src/main/types/app-state.ts index 7e17ecd2..cfceaacf 100644 --- a/src/main/types/app-state.ts +++ b/src/main/types/app-state.ts @@ -3,6 +3,7 @@ */ import { UserRole } from './health-check.js'; +import { Language } from './language.js'; import { SuggestionMode } from './llm.js'; export enum Speaker { @@ -33,6 +34,14 @@ export interface Transcript { speaker: Speaker; isFinal: boolean; endTimestamp: number; + /** + * The interview language this text was transcribed in. + * + * Recorded per transcript rather than read from the config when it is needed, because the + * setting moves mid-session and the text does not: whether two blocks are joined with a space + * is a property of the words, not of what the picker says now. See `transcriptSeparator`. + */ + language: Language; } export interface LiveSuggestion { diff --git a/src/main/types/language.ts b/src/main/types/language.ts new file mode 100644 index 00000000..b6e404dc --- /dev/null +++ b/src/main/types/language.ts @@ -0,0 +1,63 @@ +/** + * The language an interview runs in: what the ASR transcribes and what suggestions come back in. + * + * ISO 639-1 codes, mirroring `Language` in the backend's `app/schemas/language.py`, which is + * exactly what its Deepgram Nova-3 ASR streams. Deliberately no wider than that: offering a + * language the transcription cannot deliver produces confident answers to a question that was + * never asked, which is worse than not offering it. + * + * A code this build knows but an older backend does not is resolved back to English there rather + * than faked, so a client ahead of its backend degrades one session instead of breaking it. + * + * `src/renderer/types/language.ts` carries the same enum plus the display metadata the picker + * needs, the way `SuggestionMode` is mirrored across the two processes. + */ +export enum Language { + English = 'en', + Spanish = 'es', + German = 'de', + French = 'fr', + Portuguese = 'pt', + Italian = 'it', + Dutch = 'nl', + Polish = 'pl', + Russian = 'ru', + Ukrainian = 'uk', + Czech = 'cs', + Romanian = 'ro', + Greek = 'el', + Hungarian = 'hu', + Swedish = 'sv', + Danish = 'da', + Norwegian = 'no', + Finnish = 'fi', + Turkish = 'tr', + Hindi = 'hi', + Japanese = 'ja', + Korean = 'ko', + Chinese = 'zh', + Vietnamese = 'vi', + Thai = 'th', + Indonesian = 'id', + Arabic = 'ar', + Hebrew = 'he', +} + +export const DEFAULT_LANGUAGE = Language.English; + +const LANGUAGE_CODES = new Set(Object.values(Language)); + +/** + * Map a stored or incoming value onto the enum, falling back to English. + * + * The config store holds whatever was written to disk, which may be a language a later build + * removed or an older build never knew. Sending that through unchecked puts an unknown code on + * the ASR URL and in every request body, where the backend can only fall back anyway - so it + * resolves here, once, at the point the value leaves the store. + */ +export function resolveLanguage(raw: string | null | undefined): Language { + if (!raw) return DEFAULT_LANGUAGE; + + const normalized = raw.trim().toLowerCase(); + return LANGUAGE_CODES.has(normalized) ? (normalized as Language) : DEFAULT_LANGUAGE; +} diff --git a/src/main/types/llm.ts b/src/main/types/llm.ts index 0ec83eb8..8513e254 100644 --- a/src/main/types/llm.ts +++ b/src/main/types/llm.ts @@ -1,4 +1,5 @@ import { Transcript } from './app-state.js'; +import { Language } from './language.js'; export enum LLMProvider { OPENAI = 'openai', @@ -48,6 +49,12 @@ export interface LLMConfigValidationResult { export interface LLMRequest { config: LLMConfig | null; + + /** + * Interview language. Carried on the shared base so the three request kinds cannot drift, and + * defaulted server-side, so omitting it against an older deployment still means English. + */ + language?: Language; } /** diff --git a/src/main/utils/export-labels.ts b/src/main/utils/export-labels.ts new file mode 100644 index 00000000..e97b27d4 --- /dev/null +++ b/src/main/utils/export-labels.ts @@ -0,0 +1,242 @@ +import { DEFAULT_LANGUAGE, Language } from '../types/language.js'; + +/** + * The words the exported report needs that no model writes for it. + * + * The report is the one part of this app that is deliberately not in English. The summary comes + * back in the interview language - the summarize prompt goes further than the other two and asks + * for the section headings to be translated as well, on the grounds that a report is a document a + * person reads rather than a format anything parses, so a half-translated one just looks + * unfinished. Everything the client wraps around that summary was still English, which produced + * exactly that document: a Spanish report under an English `# Transcripts`, every turn attributed + * to an `Interviewer`. + * + * This is not app chrome, and the distinction is the whole reason the table exists. An English + * button on a Spanish interview is an inconvenience to one person for one session; the report is + * handed to someone who was not there, and may not read English at all. + * + * Five nouns, and no attempt at a general localisation layer. The rest of the document is the + * candidate's name, timestamps and text that already arrived in the right language. + */ +export interface ExportLabels { + /** Heading over the full transcript section. */ + transcripts: string; + /** Heading over the suggestion section. */ + suggestions: string; + /** Sub-heading over a single suggested answer. */ + suggestion: string; + /** Attribution for every turn that is not the candidate's. */ + interviewer: string; + /** Label on the export timestamp under the report title. */ + dateTime: string; +} + +const LABELS: Record = { + [Language.English]: { + transcripts: 'Transcripts', + suggestions: 'Suggestions', + suggestion: 'Suggestion', + interviewer: 'Interviewer', + dateTime: 'Date/Time', + }, + [Language.Spanish]: { + transcripts: 'Transcripciones', + suggestions: 'Sugerencias', + suggestion: 'Sugerencia', + interviewer: 'Entrevistador', + dateTime: 'Fecha y hora', + }, + [Language.German]: { + transcripts: 'Transkripte', + suggestions: 'Vorschläge', + suggestion: 'Vorschlag', + interviewer: 'Interviewer', + dateTime: 'Datum/Uhrzeit', + }, + [Language.French]: { + transcripts: 'Transcriptions', + suggestions: 'Suggestions', + suggestion: 'Suggestion', + interviewer: 'Intervieweur', + dateTime: 'Date/heure', + }, + [Language.Portuguese]: { + transcripts: 'Transcrições', + suggestions: 'Sugestões', + suggestion: 'Sugestão', + interviewer: 'Entrevistador', + dateTime: 'Data/hora', + }, + [Language.Italian]: { + transcripts: 'Trascrizioni', + suggestions: 'Suggerimenti', + suggestion: 'Suggerimento', + interviewer: 'Intervistatore', + dateTime: 'Data/ora', + }, + [Language.Dutch]: { + transcripts: 'Transcripties', + suggestions: 'Suggesties', + suggestion: 'Suggestie', + interviewer: 'Interviewer', + dateTime: 'Datum/tijd', + }, + [Language.Polish]: { + transcripts: 'Transkrypcje', + suggestions: 'Sugestie', + suggestion: 'Sugestia', + interviewer: 'Prowadzący rozmowę', + dateTime: 'Data/godzina', + }, + [Language.Russian]: { + transcripts: 'Расшифровки', + suggestions: 'Подсказки', + suggestion: 'Подсказка', + interviewer: 'Интервьюер', + dateTime: 'Дата и время', + }, + [Language.Ukrainian]: { + transcripts: 'Розшифровки', + suggestions: 'Підказки', + suggestion: 'Підказка', + interviewer: 'Інтерв’юер', + dateTime: 'Дата й час', + }, + [Language.Czech]: { + transcripts: 'Přepisy', + suggestions: 'Návrhy', + suggestion: 'Návrh', + interviewer: 'Tazatel', + dateTime: 'Datum a čas', + }, + [Language.Romanian]: { + transcripts: 'Transcrieri', + suggestions: 'Sugestii', + suggestion: 'Sugestie', + interviewer: 'Intervievator', + dateTime: 'Data și ora', + }, + [Language.Greek]: { + transcripts: 'Μεταγραφές', + suggestions: 'Προτάσεις', + suggestion: 'Πρόταση', + interviewer: 'Συνεντευκτής', + dateTime: 'Ημερομηνία/ώρα', + }, + [Language.Hungarian]: { + transcripts: 'Átiratok', + suggestions: 'Javaslatok', + suggestion: 'Javaslat', + interviewer: 'Kérdező', + dateTime: 'Dátum/idő', + }, + [Language.Swedish]: { + transcripts: 'Transkriptioner', + suggestions: 'Förslag', + suggestion: 'Förslag', + interviewer: 'Intervjuare', + dateTime: 'Datum/tid', + }, + [Language.Danish]: { + transcripts: 'Transskriptioner', + suggestions: 'Forslag', + suggestion: 'Forslag', + interviewer: 'Interviewer', + dateTime: 'Dato/klokkeslæt', + }, + [Language.Norwegian]: { + transcripts: 'Transkripsjoner', + suggestions: 'Forslag', + suggestion: 'Forslag', + interviewer: 'Intervjuer', + dateTime: 'Dato/tid', + }, + [Language.Finnish]: { + transcripts: 'Litteroinnit', + suggestions: 'Ehdotukset', + suggestion: 'Ehdotus', + interviewer: 'Haastattelija', + dateTime: 'Päivämäärä/aika', + }, + [Language.Turkish]: { + transcripts: 'Transkriptler', + suggestions: 'Öneriler', + suggestion: 'Öneri', + interviewer: 'Görüşmeci', + dateTime: 'Tarih/saat', + }, + [Language.Hindi]: { + transcripts: 'प्रतिलेख', + suggestions: 'सुझाव', + suggestion: 'सुझाव', + interviewer: 'साक्षात्कारकर्ता', + dateTime: 'दिनांक/समय', + }, + [Language.Japanese]: { + transcripts: '文字起こし', + suggestions: '提案', + suggestion: '提案', + interviewer: '面接官', + dateTime: '日時', + }, + [Language.Korean]: { + transcripts: '대화록', + suggestions: '제안', + suggestion: '제안', + interviewer: '면접관', + dateTime: '날짜/시간', + }, + [Language.Chinese]: { + transcripts: '转录文本', + suggestions: '建议', + suggestion: '建议', + interviewer: '面试官', + dateTime: '日期/时间', + }, + [Language.Vietnamese]: { + transcripts: 'Bản ghi', + suggestions: 'Gợi ý', + suggestion: 'Gợi ý', + interviewer: 'Người phỏng vấn', + dateTime: 'Ngày/giờ', + }, + [Language.Thai]: { + transcripts: 'บทถอดเสียง', + suggestions: 'ข้อเสนอแนะ', + suggestion: 'ข้อเสนอแนะ', + interviewer: 'ผู้สัมภาษณ์', + dateTime: 'วันที่/เวลา', + }, + [Language.Indonesian]: { + transcripts: 'Transkrip', + suggestions: 'Saran', + suggestion: 'Saran', + interviewer: 'Pewawancara', + dateTime: 'Tanggal/waktu', + }, + [Language.Arabic]: { + transcripts: 'النصوص', + suggestions: 'الاقتراحات', + suggestion: 'اقتراح', + interviewer: 'المحاور', + dateTime: 'التاريخ/الوقت', + }, + [Language.Hebrew]: { + transcripts: 'תמלולים', + suggestions: 'הצעות', + suggestion: 'הצעה', + interviewer: 'המראיין', + dateTime: 'תאריך/שעה', + }, +}; + +/** + * The labels for a language, falling back to English for one this build does not know. + * + * `configStore.getConfig()` already resolves the stored code, so the fallback is a belt on top of + * a brace - and the cheaper of the two failures either way: an English heading over a Spanish + * report is worse than no report only if the export throws instead. + */ +export function getExportLabels(language: Language): ExportLabels { + return LABELS[language] ?? LABELS[DEFAULT_LANGUAGE]; +} diff --git a/src/main/utils/export-markdown.ts b/src/main/utils/export-markdown.ts index 8bbc8a79..8e2704da 100644 --- a/src/main/utils/export-markdown.ts +++ b/src/main/utils/export-markdown.ts @@ -1,11 +1,15 @@ import { LiveSuggestion, Speaker, Transcript } from '../types/app-state.js'; import { ExportFormat } from '../types/export.js'; +import { Language } from '../types/language.js'; +import { getExportLabels } from './export-labels.js'; interface ExportMarkdownInput { username: string; summary: string; transcripts: Transcript[]; suggestions: LiveSuggestion[]; + /** Interview language. Decides the words this file adds around the model's summary. */ + language: Language; } /** @@ -17,14 +21,20 @@ export function buildExportMarkdown({ summary, transcripts, suggestions, + language, }: ExportMarkdownInput): string { + // The summary already arrives in the interview language, headings included - the summarize + // prompt asks for that explicitly. These are the words this file adds on top of it, and + // leaving them English is what made the export a half-translated document. + const labels = getExportLabels(language); + // Add Date/Time to summary (insert after first line) let summaryPart = summary; if (summaryPart) { const lines = summaryPart.split('\n'); if (lines.length > 0) { const datetimeNow = new Date().toLocaleString(); - lines.splice(1, 0, `\n##### Date/Time: ${datetimeNow}`); + lines.splice(1, 0, `\n##### ${labels.dateTime}: ${datetimeNow}`); summaryPart = lines.join('\n'); } } @@ -33,20 +43,20 @@ export function buildExportMarkdown({ const transcriptLines: string[] = []; for (const t of transcripts) { const timeStr = new Date(t.timestamp).toLocaleString(); - const speakerName = t.speaker === Speaker.Self ? username : 'Interviewer'; + const speakerName = t.speaker === Speaker.Self ? username : labels.interviewer; transcriptLines.push(`#### ***${timeStr} | ${speakerName}***\n${t.text}\n`); } - const transcriptsPart = `# **Transcripts**\n\n${transcriptLines.join('\n')}`; + const transcriptsPart = `# **${labels.transcripts}**\n\n${transcriptLines.join('\n')}`; // Build Suggestions section const suggestionLines: string[] = []; for (const s of suggestions) { const timeStr = new Date(s.timestamp).toLocaleString(); suggestionLines.push( - `#### ***${timeStr} | Interviewer***\n${s.last_question}\n\n#### ***Suggestion***\n${s.answer}\n` + `#### ***${timeStr} | ${labels.interviewer}***\n${s.last_question}\n\n#### ***${labels.suggestion}***\n${s.answer}\n` ); } - const suggestionsPart = `# **Suggestions**\n\n${suggestionLines.join('\n')}`; + const suggestionsPart = `# **${labels.suggestions}**\n\n${suggestionLines.join('\n')}`; return `${summaryPart}\n\n${transcripts.length > 0 ? transcriptsPart : ''}\n\n${suggestions.length > 0 ? suggestionsPart : ''}`.trim(); } diff --git a/src/main/utils/interviewer-turn.ts b/src/main/utils/interviewer-turn.ts index ef4b1c1f..f2c3599e 100644 --- a/src/main/utils/interviewer-turn.ts +++ b/src/main/utils/interviewer-turn.ts @@ -27,6 +27,36 @@ const NON_LEXICAL = /[[(<][^\])>]*[\])>]/g; const TERMINAL_PUNCTUATION = /[.!?]["')\]]*\s*$/; +/** + * Question marks that are not `?`. + * + * Japanese and Chinese use the fullwidth form, Arabic and Persian the mirrored one, Greek the + * semicolon. They are folded to `?` in `normalize` so the completeness check below reads them the + * same way it reads an English one - which is worth doing because it is the difference between + * answering a finished question immediately and making it wait out the settle timer first. + */ +const FOREIGN_QUESTION_MARKS = /[?؟;]/g; + +/** + * Any letter, in any script. + * + * `normalize` reduces a turn to ASCII, which is right for an English lexicon and wrong as a test + * for whether anything was said: a Japanese question reduces to nothing at all. This tells the + * two apart. + */ +const ANY_LETTER = /\p{L}/u; + +/** + * A letter the backchannel lexicon cannot have read. + * + * `normalize` blanks every non-ASCII character, so the lexicon only ever matches against Latin + * residue. Script rather than codepoint is the right test: an accented Latin letter is part of a + * word the lexicon *does* read - blanking the umlaut in "Ähm" and matching "hm" is a correct + * consumption - while a Han, Cyrillic, Greek, Arabic, Hebrew, Thai or Devanagari letter is + * content it never saw. + */ +const NON_LATIN_LETTER = /(?!\p{Script=Latin})\p{L}/u; + /** * Question and directive openers. Only consulted together with terminal punctuation, so this does * not have to distinguish "how" mid-sentence from "how" as an opener. @@ -129,7 +159,9 @@ const BACKCHANNEL_WORDS: string[][] = BACKCHANNEL_PHRASES.map((phrase) => phrase * Lowercase, drop non-speech events and every punctuation mark except `?`. * * The question mark is kept because it is the single strongest completeness signal available: the - * ASR session runs with `format_turns`, so a finished question reliably arrives punctuated. + * backend opens its Deepgram session with `punctuate` and `smart_format`, so a finished question + * reliably arrives punctuated. (It said `format_turns` until the ASR moved off AssemblyAI - the + * behaviour this relies on survived the migration, the parameter that produces it did not.) * Apostrophes are kept so "let's" and "i'd" still match the cue list. */ function normalize(text: string): string { @@ -137,6 +169,7 @@ function normalize(text: string): string { .toLowerCase() .replace(NON_LEXICAL, ' ') .replace(/[‘’]/g, "'") + .replace(FOREIGN_QUESTION_MARKS, '?') .replace(/[^a-z0-9'?\s]/g, ' ') .replace(/\s+/g, ' ') .trim(); @@ -167,12 +200,41 @@ export function classifyInterviewerTurn(rawText: string): TurnVerdict { const raw = String(rawText ?? '').trim(); if (!raw) return TurnVerdict.Skip; + // Computed once for both script tests below. Non-speech markers go first in each: `[laugh]` + // and `(inaudible)` are not speech in any language, and their letters must not read as content. + const withoutNonSpeech = raw.replace(NON_LEXICAL, ' '); + const normalized = normalize(raw); - // Empty only when the turn was entirely non-speech, e.g. "[laugh]" or "(inaudible)". - if (!normalized) return TurnVerdict.Skip; + if (!normalized) { + // Empty means one of two very different things, and the lexicon cannot tell them apart on + // its own. `[laugh]` and `(inaudible)` really were non-speech and are correctly dropped. A + // Japanese, Chinese, Thai, Russian, Arabic, Korean, Greek, Hebrew or Hindi question also + // reduces to nothing here, because `normalize` keeps only ASCII - and dropping *that* is a + // question silently answered with nothing, mid-interview, which is the one failure this + // classifier is built to never produce. + // + // So the test is whether any letters survived the non-speech markers. If they did, this is a + // language the lexicon cannot read rather than an absence of speech, and it goes to + // `Uncertain` - which defers to the backend gate, the one stage that can actually read it. + return ANY_LETTER.test(withoutNonSpeech) ? TurnVerdict.Uncertain : TurnVerdict.Skip; + } const core = stripLeadingBackchannel(normalized.split(' ')); - if (core.length === 0) return TurnVerdict.Skip; + if (core.length === 0) { + // Reaching here means the lexicon consumed every word it could see - but it can only see + // Latin residue, and a turn is not required to be entirely in one script. + // + // "OK、では次の質問です。" normalizes to exactly "ok", because `normalize` blanks the + // Japanese and leaves the loanword the interviewer opened with. The lexicon eats "ok", the + // core comes back empty, and a real question is dropped outright: no request, no card, no + // error. That is the same failure the empty-normalized branch above exists to prevent, and + // it is not rare - a Japanese, Korean, Chinese, Russian or Greek interviewer opening on + // "OK" or "Yes" is ordinary, and Deepgram transcribes those loanwords in Latin script. + // + // So Skip requires that there was nothing else there. Any letter from a script the lexicon + // never read means it did not consume the turn, whatever it did to the Latin part of it. + return NON_LATIN_LETTER.test(withoutNonSpeech) ? TurnVerdict.Uncertain : TurnVerdict.Skip; + } const coreText = core.join(' '); diff --git a/src/main/utils/suggestion-sentinel.ts b/src/main/utils/suggestion-sentinel.ts index 2d626432..1ab1c84d 100644 --- a/src/main/utils/suggestion-sentinel.ts +++ b/src/main/utils/suggestion-sentinel.ts @@ -12,9 +12,17 @@ import { LIVE_SUGGESTION_NO_SUGGESTION } from '../consts.js'; * `**NO_SUGGESTION_NEEDED**`; a bare-string match would leave that sitting in the panel as a card. * The prompt asks for it bare, but the fallback costs one regex and the failure is visible * mid-interview. + * + * Format characters (`\p{Cf}`) go with it, and that is what makes the fallback hold in Arabic and + * Hebrew. Models writing right-to-left routinely open a response with a directional mark, and + * U+200F is not whitespace - it survives `\s`, leaves `bare` starting with a character the + * sentinel does not, and puts `NO_SUGGESTION_NEEDED` on screen as the answer to a question the + * backend had decided needed none. The class also covers the zero-width joiners and isolates, and + * cannot make a real answer match: an answer only ever collides by being a genuine prefix of the + * sentinel, which is the streaming case this function is built around. */ export function isNoSuggestionSentinel(answer: string): boolean { - const bare = answer.replace(/^[\s*`#>-]+/, '').replace(/[\s*`]+$/, ''); + const bare = answer.replace(/^[\s*`#>\p{Cf}-]+/u, '').replace(/[\s*`\p{Cf}]+$/u, ''); return bare.length > 0 && LIVE_SUGGESTION_NO_SUGGESTION.startsWith(bare); } diff --git a/src/main/utils/transcript-join.ts b/src/main/utils/transcript-join.ts new file mode 100644 index 00000000..b8ffaef7 --- /dev/null +++ b/src/main/utils/transcript-join.ts @@ -0,0 +1,27 @@ +import { Language } from '../types/language.js'; + +/** + * Languages written without spaces between words. + * + * Kept in step with the backend's `_UNSPACED_LANGUAGES` in `app/services/asr_service.py`, which + * applies the same rule when it rejoins the segments of a single utterance. This one covers the + * other half: transcripts that arrived as separate finals and are merged here because they fell + * inside `TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS` of each other. + */ +const UNSPACED_LANGUAGES: ReadonlySet = new Set([ + Language.Japanese, + Language.Chinese, + Language.Thai, +]); + +/** + * What to put between two transcript blocks being merged into one. + * + * A space is a word boundary in English and a visible defect in Japanese, and it does not stop at + * the panel: `cleaned` is what the suggestion request carries, so the model is asked to answer a + * question with breaks nobody spoke. The backend already avoids inserting them inside an + * utterance; joining with a space here would put them back at every merge. + */ +export function transcriptSeparator(language: Language): string { + return UNSPACED_LANGUAGES.has(language) ? '' : ' '; +} diff --git a/src/renderer/components/custom/change-password-dialog.tsx b/src/renderer/components/custom/change-password-dialog.tsx index 740026c5..854fa57b 100644 --- a/src/renderer/components/custom/change-password-dialog.tsx +++ b/src/renderer/components/custom/change-password-dialog.tsx @@ -54,6 +54,8 @@ export function ChangePasswordDialog({ onOpenChange(newOpen); }; + const passwordsMismatch = confirmPassword !== '' && newPassword !== confirmPassword; + return ( @@ -69,6 +71,8 @@ export function ChangePasswordDialog({
setCurrentPassword(e.target.value)} placeholder="Enter current password" @@ -83,6 +87,8 @@ export function ChangePasswordDialog({
setNewPassword(e.target.value)} placeholder="Enter new password" @@ -97,6 +103,8 @@ export function ChangePasswordDialog({
setConfirmPassword(e.target.value)} placeholder="Confirm new password" @@ -128,7 +136,20 @@ export function ChangePasswordDialog({ {loading ? 'Changing...' : 'Change Password'} - {error &&
{error}
} + {/* Says why the button is dead. A mismatch is the one condition above that the user + cannot see from the fields themselves - both are masked - so without this the + dialog silently refuses to submit and gives no reason. Held back until the confirm + field has something in it, so it is not an error for a half-typed entry. */} + {passwordsMismatch && ( +
+ The new passwords do not match. +
+ )} + {error && ( +
+ {error} +
+ )}
); diff --git a/src/renderer/components/custom/configuration-dialog.tsx b/src/renderer/components/custom/configuration-dialog.tsx index 1b706913..4fb376fd 100644 --- a/src/renderer/components/custom/configuration-dialog.tsx +++ b/src/renderer/components/custom/configuration-dialog.tsx @@ -20,6 +20,34 @@ const MAX_FIELD_LENGTH = 128_000; // Kept in sync with the backend's MAX_USERNAME_LENGTH (app/cfg/llm.py) const MAX_NAME_LENGTH = 1_000; +/** + * How much of a long field's budget is left, once it is close enough to matter. + * + * `maxLength` on a textarea truncates a paste silently, which for these two fields means a CV or + * a job description arriving 2,000 characters shorter than the one the user copied, with nothing + * on screen having said so. Hidden below the threshold: a counter over an empty box is noise, + * and the limit is generous enough that most sessions never approach it. + */ +const LIMIT_NOTICE_RATIO = 0.9; + +function FieldLimitNotice({ value, max }: { value: string; max: number }) { + if (value.length < max * LIMIT_NOTICE_RATIO) return null; + + const atLimit = value.length >= max; + return ( +

+ {atLimit + ? `Character limit reached (${max.toLocaleString()}). Extra text was not added.` + : `${(max - value.length).toLocaleString()} characters left`} +

+ ); +} + interface ConfigurationDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; @@ -77,7 +105,11 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat throw new Error('Electron API not available'); } - const result = await electron.account.update(name, profileData, context); + // Trimmed on the way out, not just validated. The Save button is already gated on the + // trimmed name being non-empty, so a name of pure whitespace could never be saved - but a + // name with a trailing space could, and it is the string the prompts address the candidate + // by. The same goes for a CV pasted with a leading blank line. + const result = await electron.account.update(name.trim(), profileData.trim(), context.trim()); if (!result.success) { throw new Error(result.error || 'Failed to save configuration'); } @@ -106,10 +138,18 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat
-
- +
+ + +