Skip to content
Closed
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
2 changes: 1 addition & 1 deletion backend/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const API_VERSION = '2026-06-12';
export const API_VERSION = '2026-07-01';
44 changes: 44 additions & 0 deletions frontend/src/api/async-job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export type AsyncJobStatus = "ONGOING" | "SUCCEEDED" | "FAILED";

export interface AsyncJob<TPayload = unknown> {
id: string;
status: AsyncJobStatus;
payload?: TPayload;
}

const DEFAULT_POLL_INTERVAL_MS = 10 * 1_000;
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1_000;

export async function pollUntilSucceeded<TPayload>(
fetchJob: () => Promise<AsyncJob<TPayload>>,
options: { intervalMs?: number; timeoutMs?: number } = {},
): Promise<TPayload> {
const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;

while (true) {
await sleep(intervalMs);

if (Date.now() >= deadline) {
throw new Error(`Did not get results within ${timeoutMs}ms. Aborting polling.`);
}

const job = await fetchJob();

if (job.status === "SUCCEEDED") {
if (job.payload === undefined) {
throw new Error("Async job succeeded but returned no payload.");
}
return job.payload;
}

if (job.status === "FAILED") {
throw new Error(`Async job ${job.id} failed.`);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeout check precedes fetch, causing premature timeout

Medium Severity

In the polling loop, the deadline check on line 23 runs before fetchJob() on line 27. This means on the final iteration, the function throws a timeout error without making one last fetch. The effective timeout is timeoutMs - intervalMs (e.g. ~4 min 50 s instead of 5 min). A job that succeeds during the last sleep interval is never seen, producing a false timeout. Moving the deadline check after the fetch-and-status-check block fixes this.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b4b53c5. Configure here.

}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
9 changes: 9 additions & 0 deletions frontend/src/api/dictate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ export interface AudioChunkAck {
ack_id: number;
}

export interface AsyncCorrection {
type: "ASYNC_CORRECTION";
suffix: string;
replacement: string;
}

export type DictateServerMessage =
| DictatedText
| AudioChunkAck
| AsyncCorrection
| { type: string };

export const DICTATE_ENCODING = "PCM_S16LE" as const;
Expand Down Expand Up @@ -51,6 +58,8 @@ export function buildDictateConfig(locale: DictationLocale, noteText: string) {
selection_start: noteText.length,
selection_length: 0,
},
// Opts into server-side refinements of already-streamed text via ASYNC_CORRECTION.
enable_async_corrections: true,
};
}

Expand Down
22 changes: 15 additions & 7 deletions frontend/src/api/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type AsyncJob, pollUntilSucceeded } from "./async-job.js";
import { nablaFetch } from "../transport/client.js";
import type { ClinicalNote } from "./note.js";

Expand All @@ -18,11 +19,18 @@ export interface NormalizedData {
export async function generateNormalizedData(
note: ClinicalNote,
): Promise<NormalizedData> {
const response = await nablaFetch("/v1/core/user/generate-normalized-data", {
method: "POST",
body: JSON.stringify({
note,
}),
});
return response.json() as Promise<NormalizedData>;
const submitResponse = await nablaFetch(
"/v1/core/user/generate-normalized-data-async",
{
method: "POST",
body: JSON.stringify({ note }),
},
);
const { id } = (await submitResponse.json()) as AsyncJob;

return pollUntilSucceeded<NormalizedData>(() =>
nablaFetch(`/v1/core/user/generate-normalized-data-async/${id}`).then(
(response) => response.json() as Promise<AsyncJob<NormalizedData>>,
),
);
}
2 changes: 1 addition & 1 deletion frontend/src/api/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const API_VERSION = "2026-06-12";
export const API_VERSION = "2026-07-01";
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function markup(): string {
<div class="bg-white rounded-xl border border-grey-200 p-5">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold text-grey-400">Normalize Data</h3>
<a href="${DOCUMENTATION_LINKS.generateNormalizedData}" target="_blank" rel="noopener" class="text-xs font-mono text-grey-250 hover:text-primary-600 bg-grey-100 hover:bg-primary-50 px-2 py-0.5 rounded transition-colors">POST /generate-normalized-data ↗</a>
<a href="${DOCUMENTATION_LINKS.generateNormalizedData}" target="_blank" rel="noopener" class="text-xs font-mono text-grey-250 hover:text-primary-600 bg-grey-100 hover:bg-primary-50 px-2 py-0.5 rounded transition-colors">POST /generate-normalized-data-async ↗</a>
</div>
<p class="text-xs text-grey-300 mb-4">Extract ICD-10 / LOINC codes in FHIR format from the note.</p>
<button id="generate-normalized-btn" disabled class="bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/pages/in-depth/dictate/dictate.render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,25 @@ export function getNoteText(): string {
}

// dictate-ws emits DICTATED_TEXT segments that must be appended verbatim — no extra
// spaces, punctuation, or formatting (the server already handles those).
// spaces, punctuation, or formatting (the server already handles those). The server
// may later refine trailing text via ASYNC_CORRECTION (see applyDictationCorrection).
export function appendDictatedText(text: string): void {
const note = document.getElementById("dictation-note") as HTMLTextAreaElement;
note.value += text;
note.scrollTop = note.scrollHeight;
}

export function applyDictationCorrection(
suffix: string,
replacement: string,
): void {
const note = document.getElementById("dictation-note") as HTMLTextAreaElement;
if (note.value.endsWith(suffix)) {
note.value = note.value.slice(0, -suffix.length) + replacement;
note.scrollTop = note.scrollHeight;
}
}

export function clearNote(): void {
(document.getElementById("dictation-note") as HTMLTextAreaElement).value = "";
}
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/pages/in-depth/dictate/dictate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type AsyncCorrection,
type AudioChunkAck,
buildDictateAudioChunk,
buildDictateConfig,
Expand All @@ -17,6 +18,7 @@ import clientSource from "../../../transport/client.ts?raw";
import {
addWsMessage,
appendDictatedText,
applyDictationCorrection,
clearNote,
getNoteText,
readLocaleSelection,
Expand Down Expand Up @@ -58,7 +60,7 @@ const CODE_SNIPPETS = [
region: "dictate-messages",
},
{
title: "5. Receive DICTATED_TEXT & append verbatim",
title: "5. Receive DICTATED_TEXT & apply async corrections",
file: "pages/in-depth/dictate/dictate.ts",
source: dictatePageSource,
region: "dictate-receive",
Expand Down Expand Up @@ -224,6 +226,9 @@ function attachSocketHandlers(): void {
addWsMessage("recv", event.data);
if (message.type === "DICTATED_TEXT") {
appendDictatedText((message as DictatedText).text);
} else if (message.type === "ASYNC_CORRECTION") {
const { suffix, replacement } = message as AsyncCorrection;
applyDictationCorrection(suffix, replacement);
} else if (message.type === "AUDIO_CHUNK_ACK") {
handleAck((message as AudioChunkAck).ack_id);
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/shared/documentationLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
export const DOCUMENTATION_LINKS = {
transcribeWs: "https://docs.nabla.com/user/transcribe-ws",
generateNote: "https://docs.nabla.com/user/generate-note",
generateNormalizedData: "https://docs.nabla.com/user/generate-normalized-data",
generateNormalizedData:
"https://docs.nabla.com/user/generate-normalized-data-async",
generatePatientInstructions: "https://docs.nabla.com/user/generate-patient-instructions",
} as const;