From a3977682fa2afe645653e163add75d98122498d3 Mon Sep 17 00:00:00 2001 From: Robin Nicollet Date: Wed, 15 Jul 2026 11:57:16 +0200 Subject: [PATCH 1/3] Add ASYNC_CORRECTION support for dictate-ws (API-431). Opt into async corrections on dictate CONFIG, handle server refinements in the note UI, and bump API version to 2026-07-01. Co-authored-by: Cursor --- backend/src/version.ts | 2 +- frontend/src/api/dictate.ts | 9 +++++++++ frontend/src/api/version.ts | 2 +- .../src/pages/in-depth/dictate/dictate.render.ts | 14 +++++++++++++- frontend/src/pages/in-depth/dictate/dictate.ts | 7 ++++++- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/backend/src/version.ts b/backend/src/version.ts index 4beee82..9411128 100644 --- a/backend/src/version.ts +++ b/backend/src/version.ts @@ -1 +1 @@ -export const API_VERSION = '2026-06-12'; +export const API_VERSION = '2026-07-01'; diff --git a/frontend/src/api/dictate.ts b/frontend/src/api/dictate.ts index c186348..e7da19a 100644 --- a/frontend/src/api/dictate.ts +++ b/frontend/src/api/dictate.ts @@ -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; @@ -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, }; } diff --git a/frontend/src/api/version.ts b/frontend/src/api/version.ts index 91561a8..28331d1 100644 --- a/frontend/src/api/version.ts +++ b/frontend/src/api/version.ts @@ -1 +1 @@ -export const API_VERSION = "2026-06-12"; +export const API_VERSION = "2026-07-01"; diff --git a/frontend/src/pages/in-depth/dictate/dictate.render.ts b/frontend/src/pages/in-depth/dictate/dictate.render.ts index b8926c7..6d56640 100644 --- a/frontend/src/pages/in-depth/dictate/dictate.render.ts +++ b/frontend/src/pages/in-depth/dictate/dictate.render.ts @@ -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 = ""; } diff --git a/frontend/src/pages/in-depth/dictate/dictate.ts b/frontend/src/pages/in-depth/dictate/dictate.ts index 1327e14..625a34d 100644 --- a/frontend/src/pages/in-depth/dictate/dictate.ts +++ b/frontend/src/pages/in-depth/dictate/dictate.ts @@ -1,4 +1,5 @@ import { + type AsyncCorrection, type AudioChunkAck, buildDictateAudioChunk, buildDictateConfig, @@ -17,6 +18,7 @@ import clientSource from "../../../transport/client.ts?raw"; import { addWsMessage, appendDictatedText, + applyDictationCorrection, clearNote, getNoteText, readLocaleSelection, @@ -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", @@ -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); } From 6f6296db43e1a00eb2663f083e9dddc628c766f1 Mon Sep 17 00:00:00 2001 From: Robin Nicollet Date: Wed, 15 Jul 2026 13:35:11 +0200 Subject: [PATCH 2/3] Replace sync normalized-data with async submit + poll flow (API-432). Showcase the recommended integration pattern for generate-normalized-data-async with a shared pollUntilSucceeded helper. Co-authored-by: Cursor --- frontend/src/api/async-job.ts | 33 +++++++++++++++++++ frontend/src/api/normalize.ts | 22 +++++++++---- .../work-on-note.render.ts | 2 +- frontend/src/shared/documentationLinks.ts | 3 +- 4 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 frontend/src/api/async-job.ts diff --git a/frontend/src/api/async-job.ts b/frontend/src/api/async-job.ts new file mode 100644 index 0000000..ecd2e42 --- /dev/null +++ b/frontend/src/api/async-job.ts @@ -0,0 +1,33 @@ +export type AsyncJobStatus = "ONGOING" | "SUCCEEDED" | "FAILED"; + +export interface AsyncJob { + id: string; + status: AsyncJobStatus; + payload?: TPayload; +} + +const DEFAULT_POLL_INTERVAL_MS = 5_000; + +export async function pollUntilSucceeded( + fetchJob: () => Promise>, + options: { intervalMs?: number } = {}, +): Promise { + const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; + + while (true) { + 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.`); + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} diff --git a/frontend/src/api/normalize.ts b/frontend/src/api/normalize.ts index a3862b8..7c3e99a 100644 --- a/frontend/src/api/normalize.ts +++ b/frontend/src/api/normalize.ts @@ -1,3 +1,4 @@ +import { type AsyncJob, pollUntilSucceeded } from "./async-job.js"; import { nablaFetch } from "../transport/client.js"; import type { ClinicalNote } from "./note.js"; @@ -18,11 +19,18 @@ export interface NormalizedData { export async function generateNormalizedData( note: ClinicalNote, ): Promise { - const response = await nablaFetch("/v1/core/user/generate-normalized-data", { - method: "POST", - body: JSON.stringify({ - note, - }), - }); - return response.json() as Promise; + 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(() => + nablaFetch(`/v1/core/user/generate-normalized-data-async/${id}`).then( + (response) => response.json() as Promise>, + ), + ); } diff --git a/frontend/src/pages/full-encounter-demo/work-on-note.render.ts b/frontend/src/pages/full-encounter-demo/work-on-note.render.ts index cf6450e..4e27cc6 100644 --- a/frontend/src/pages/full-encounter-demo/work-on-note.render.ts +++ b/frontend/src/pages/full-encounter-demo/work-on-note.render.ts @@ -66,7 +66,7 @@ export function markup(): string {

Extract ICD-10 / LOINC codes in FHIR format from the note.