[Backend] Define the ASR provider contract and normalise on word timestamps #350

Closed
opened 2026-08-25 20:39:13 +00:00 by claude-bot · 3 comments
Contributor

Found in the August 2026 session lifecycle review (#319). Core of the provider abstraction; the shape everything else in this milestone depends on.

Why

The backend currently speaks one bespoke dialect to one out-of-repo WhisperX server, whose behaviour it cannot verify — file/speaker zip order, internal VAD, per-file error handling and whether it ever re-labels a speaker are all unknown from inside this repo. That is an unacceptable dependency for a product that must run on self-hosted CPU, self-hosted GPU, or any of several managed APIs without a rewrite.

Proposed contract

The adapter surface, kept deliberately narrow:

  • Submit one job per track, with a caller-supplied track_id passed through and returned. Association is by id, never by ordering.
  • Complete by webhook or poll.
  • Return a normalised result: words: [{text, start_ms, end_ms, confidence}], plus segments derived from words rather than trusted separately.
  • Accept a vocabulary: list[str] input, mapped per provider to keyterms, keyword boosting, or an initial prompt.
  • Declare its constraints — max duration, max bytes, whether it needs chunking, whether it offers word timestamps at all — so the caller owns chunk-and-remap logic rather than each adapter reimplementing it.

Word-level timestamps are the normalisation target because they are the only representation that supports every downstream need — segment rendering, beat evidence citations, and any future interjection-level interleaving — without a second round trip.

Note that speaker identity never comes from the ASR. Quest Board knows it from Discord track ownership, which is a genuine structural advantage: overlapping speech is already separated onto clean per-speaker tracks, so no provider diarization is needed or wanted.

Acceptance criteria

  • An adapter interface exists with submit / poll / normalise, and track_id passthrough
  • Results normalise to word-level timestamps regardless of provider
  • Providers declare max duration, max size, chunking need, and word-timestamp support
  • Chunk-and-remap lives in shared caller code, not per adapter
  • vocabulary maps correctly for at least the local adapter and one managed adapter
  • No adapter can influence speaker attribution
Found in the August 2026 session lifecycle review (#319). Core of the provider abstraction; the shape everything else in this milestone depends on. ## Why The backend currently speaks one bespoke dialect to one out-of-repo WhisperX server, whose behaviour it cannot verify — file/speaker zip order, internal VAD, per-file error handling and whether it ever re-labels a speaker are all unknown from inside this repo. That is an unacceptable dependency for a product that must run on self-hosted CPU, self-hosted GPU, or any of several managed APIs without a rewrite. ## Proposed contract The adapter surface, kept deliberately narrow: - **Submit** one job **per track**, with a caller-supplied `track_id` passed through and returned. Association is by id, never by ordering. - **Complete** by webhook or poll. - **Return** a normalised result: `words: [{text, start_ms, end_ms, confidence}]`, plus segments derived from words rather than trusted separately. - **Accept** a `vocabulary: list[str]` input, mapped per provider to keyterms, keyword boosting, or an initial prompt. - **Declare** its constraints — max duration, max bytes, whether it needs chunking, whether it offers word timestamps at all — so the **caller** owns chunk-and-remap logic rather than each adapter reimplementing it. Word-level timestamps are the normalisation target because they are the only representation that supports every downstream need — segment rendering, beat evidence citations, and any future interjection-level interleaving — without a second round trip. Note that speaker identity never comes from the ASR. Quest Board knows it from Discord track ownership, which is a genuine structural advantage: overlapping speech is already separated onto clean per-speaker tracks, so no provider diarization is needed or wanted. ## Acceptance criteria - [ ] An adapter interface exists with submit / poll / normalise, and `track_id` passthrough - [ ] Results normalise to word-level timestamps regardless of provider - [ ] Providers declare max duration, max size, chunking need, and word-timestamp support - [ ] Chunk-and-remap lives in shared caller code, not per adapter - [ ] `vocabulary` maps correctly for at least the local adapter and one managed adapter - [ ] No adapter can influence speaker attribution
Author
Contributor

Picking this up alongside #351 on feat/350-351-provider-contracts — the two contracts land together, since chunk sizing on the LLM side reads a declared window and neither shape is safe to fix in isolation.

Grounding: what the ASR path actually is today

One bespoke dialect, in transcribe_track (audio_service.py:502):

  • Synchronous POST {url}/transcribe, multipart form audio + speaker + optional language. No submit/poll, no webhook.
  • Returns {"segments": [{start, end, text, speaker}], "language"}segment-level only. There are no word timestamps anywhere in the pipeline.
  • No declared constraints (max duration, max bytes, chunking need).
  • No vocabulary input.

Two things this issue asks for are already true, and should not be rebuilt:

  • Association is already by id, not ordering. transcribe_track sends one file per request and stamps speaker / track_owner_id from the Track object it was handed. The server's speaker echo is read and deliberately discarded — #342 already fixed that, and the comment at :556 says so explicitly. The adapter contract needs to preserve this property, not introduce it.
  • Chunk-and-remap already lives in caller code. compute_speech_spanstranscribe_track_vad_transcribe_span_resiliently does span cutting and offset remapping above the transport, which is exactly where this issue says it belongs.

The word-timestamp entanglement

The acceptance criterion "results normalise to word-level timestamps regardless of provider" cannot be satisfied while the only implementation is the bundled server, which is segment-only and out of repo. That is #352's work.

Rather than block this issue behind a server change, I am building to the declare-and-degrade design this issue already describes: a provider declares word_timestamps: bool, and the local adapter declares False and synthesises word spans by interpolating across the segment, flagged as degraded. Downstream code reads words either way and does not branch on provider; #358 surfaces the gap to the user. When #352 lands, the local adapter flips the flag and the interpolation stops being reached.

This keeps the criterion honest — normalisation is real, the precision is declared — instead of asserting a word-level guarantee the pipeline cannot currently make.

Async shape

Contract is submit/poll per this issue's spec. The bundled server is synchronous, so its adapter resolves immediately on submit and poll returns the completed result. That keeps a managed async provider (AssemblyAI-shaped) from needing a second code path, without making the local path pay for polling it does not need.

Picking this up alongside #351 on `feat/350-351-provider-contracts` — the two contracts land together, since chunk sizing on the LLM side reads a declared window and neither shape is safe to fix in isolation. ## Grounding: what the ASR path actually is today One bespoke dialect, in `transcribe_track` ([audio_service.py:502](webapp/backend/app/services/audio_service.py#L502)): - **Synchronous** `POST {url}/transcribe`, multipart form `audio` + `speaker` + optional `language`. No submit/poll, no webhook. - Returns `{"segments": [{start, end, text, speaker}], "language"}` — **segment-level only. There are no word timestamps anywhere in the pipeline.** - No declared constraints (max duration, max bytes, chunking need). - No `vocabulary` input. Two things this issue asks for are **already true**, and should not be rebuilt: - **Association is already by id, not ordering.** `transcribe_track` sends one file per request and stamps `speaker` / `track_owner_id` from the `Track` object it was handed. The server's speaker echo is read and deliberately discarded — #342 already fixed that, and the comment at `:556` says so explicitly. The adapter contract needs to *preserve* this property, not introduce it. - **Chunk-and-remap already lives in caller code.** `compute_speech_spans` → `transcribe_track_vad` → `_transcribe_span_resiliently` does span cutting and offset remapping above the transport, which is exactly where this issue says it belongs. ## The word-timestamp entanglement The acceptance criterion *"results normalise to word-level timestamps regardless of provider"* **cannot be satisfied while the only implementation is the bundled server**, which is segment-only and out of repo. That is #352's work. Rather than block this issue behind a server change, I am building to the declare-and-degrade design this issue already describes: a provider **declares** `word_timestamps: bool`, and the local adapter declares `False` and synthesises word spans by interpolating across the segment, flagged as degraded. Downstream code reads words either way and does not branch on provider; #358 surfaces the gap to the user. When #352 lands, the local adapter flips the flag and the interpolation stops being reached. This keeps the criterion honest — normalisation is real, the *precision* is declared — instead of asserting a word-level guarantee the pipeline cannot currently make. ## Async shape Contract is submit/poll per this issue's spec. The bundled server is synchronous, so its adapter resolves immediately on submit and `poll` returns the completed result. That keeps a managed async provider (AssemblyAI-shaped) from needing a second code path, without making the local path pay for polling it does not need.
Author
Contributor

Landed in PR #483 (merged, CI green on all 7 jobs). Leaving this open — five of six criteria are met and the sixth genuinely needs a second adapter.

Criteria

  • Adapter interface with submit / poll / normalise, and track_id passthroughAsrProvider in app/providers/asr.py. Synchronous providers resolve on submit and poll returns what it holds, so an async managed provider needs no second code path.
  • Results normalise to word-level timestamps regardless of provider — with the declare-and-degrade caveat below.
  • Providers declare max duration, max size, chunking need, and word-timestamp supportAsrCapabilities, with needs_chunking derived from the declared limits.
  • Chunk-and-remap lives in shared caller code, not per adapter — was already true; the contract preserves it rather than introducing it.
  • vocabulary maps correctly for at least the local adapter and one managed adapterlocal only. LocalWhisperProvider maps it to Whisper's initial_prompt, truncated to a declared 100-term limit. A managed adapter needs #360 to pick one first. transcribe_track carries the parameter through so #355 is a call-site change, not a plumbing one.
  • No adapter can influence speaker attributionTranscriptionResult has no speaker field. There is a test asserting its absence, so adding one to make an adapter's life easier fails and sends the author to #342.

The word-timestamp caveat, restated now it is real

LocalWhisperProvider declares word_timestamps=False and interpolates spans across each segment by character length — proportional, so "a" and "extraordinarily" do not get equal time — marking every word interpolated=True and the result word_timestamps_interpolated.

Nothing downstream consumes words yet. On this provider they would add no precision over the segments they came from, and pushing a new shape through merge_attributed_transcript and the transcript_segments rows is a change worth making when #352 makes the words real. So the normalisation is in place and unexercised, which is the honest state.

Wiring

transcribe_track now resolves an adapter and submits through it. Wire format is byte-for-byte unchanged and the returned dict shape is unchanged, so transcribe_session, transcribe_track_vad, _transcribe_span_resiliently and the merge are untouched; the VAD path comes along because it delegates here.

Mutation-checked rather than assumed: dropping the segment conversion fails 7 of the 54 transcription tests, and pointing the adapter at a wrong path fails 1. Worth recording that only one of those 54 asserts the request URL — the contract tests cover the adapter directly, but that is a thin spot in the pipeline suite if someone changes the endpoint later.

Landed in PR #483 (merged, CI green on all 7 jobs). **Leaving this open** — five of six criteria are met and the sixth genuinely needs a second adapter. ## Criteria - [x] **Adapter interface with submit / poll / normalise, and `track_id` passthrough** — `AsrProvider` in `app/providers/asr.py`. Synchronous providers resolve on submit and `poll` returns what it holds, so an async managed provider needs no second code path. - [x] **Results normalise to word-level timestamps regardless of provider** — with the declare-and-degrade caveat below. - [x] **Providers declare max duration, max size, chunking need, and word-timestamp support** — `AsrCapabilities`, with `needs_chunking` derived from the declared limits. - [x] **Chunk-and-remap lives in shared caller code, not per adapter** — was already true; the contract preserves it rather than introducing it. - [ ] **`vocabulary` maps correctly for at least the local adapter and one managed adapter** — **local only.** `LocalWhisperProvider` maps it to Whisper's `initial_prompt`, truncated to a declared 100-term limit. A managed adapter needs #360 to pick one first. `transcribe_track` carries the parameter through so #355 is a call-site change, not a plumbing one. - [x] **No adapter can influence speaker attribution** — `TranscriptionResult` has no speaker field. There is a test asserting its *absence*, so adding one to make an adapter's life easier fails and sends the author to #342. ## The word-timestamp caveat, restated now it is real `LocalWhisperProvider` declares `word_timestamps=False` and interpolates spans across each segment by character length — proportional, so "a" and "extraordinarily" do not get equal time — marking every word `interpolated=True` and the result `word_timestamps_interpolated`. **Nothing downstream consumes words yet.** On this provider they would add no precision over the segments they came from, and pushing a new shape through `merge_attributed_transcript` and the `transcript_segments` rows is a change worth making when #352 makes the words real. So the normalisation is in place and unexercised, which is the honest state. ## Wiring `transcribe_track` now resolves an adapter and submits through it. Wire format is byte-for-byte unchanged and the returned dict shape is unchanged, so `transcribe_session`, `transcribe_track_vad`, `_transcribe_span_resiliently` and the merge are untouched; the VAD path comes along because it delegates here. Mutation-checked rather than assumed: dropping the segment conversion fails 7 of the 54 transcription tests, and pointing the adapter at a wrong path fails 1. Worth recording that **only one of those 54 asserts the request URL** — the contract tests cover the adapter directly, but that is a thin spot in the pipeline suite if someone changes the endpoint later.
rbrooks referenced this issue from a commit 2026-09-05 00:11:41 +00:00
Author
Contributor

The last open criterion — vocabulary maps correctly for a managed adapter — is met by PR #496 (merged 2026-09-05): OpenAiCompatibleAsrProvider (app/providers/asr_openai.py, registered as openai, serving OpenAI, Groq and any /v1/audio/transcriptions server) maps vocabulary to the prompt field, 40 terms, asserted on the wire by the conformance suite. It also returns real word spans, declares the 25 MiB cap (enforced client-side, with chunk-and-remap staying in the caller), and is selectable explicitly in Admin → Bot Settings.

Recap of the whole issue now that it closes: AsrProvider contract with submit/poll/normalise and track_id passthrough (PR #483); declared limits actually enforced and typed errors (PR #493); a second real adapter so the contract is no longer a description of one implementation (PR #496); and the bundled WhisperX server's API v2 (Rhoving/iac-repo#396) makes the local adapter's word timestamps real rather than interpolated — the client side of that is in flight on #352.

The last open criterion — **`vocabulary` maps correctly for a managed adapter** — is met by **PR #496** (merged 2026-09-05): `OpenAiCompatibleAsrProvider` (`app/providers/asr_openai.py`, registered as `openai`, serving OpenAI, Groq and any `/v1/audio/transcriptions` server) maps vocabulary to the `prompt` field, 40 terms, asserted on the wire by the conformance suite. It also returns real word spans, declares the 25 MiB cap (enforced client-side, with chunk-and-remap staying in the caller), and is selectable explicitly in Admin → Bot Settings. Recap of the whole issue now that it closes: `AsrProvider` contract with submit/poll/normalise and `track_id` passthrough (PR #483); declared limits actually enforced and typed errors (PR #493); a second real adapter so the contract is no longer a description of one implementation (PR #496); and the bundled WhisperX server's API v2 (Rhoving/iac-repo#396) makes the local adapter's word timestamps real rather than interpolated — the client side of that is in flight on #352.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#350
No description provided.