STT language + prompt hinting and real segment timestamps (LC-591) #576

Merged
longjacksonle merged 1 commit from feat/lc591-stt-accuracy into main 2026-07-21 04:24:54 +02:00

Closes LC-591, the transcription "Accuracy" child of LC-589. This is also the prerequisite you asked me to land before LC-593 (provider flexibility), since LC-593 normalizes every provider onto the SttResult struct this introduces.

What was missing

The STT request sent only model + file, and the parser read only {"text"}. So the engine autodetected language with no hint, an operator couldn't bias spelling of names/jargon, and WebVTT cue times were synthetic monotonic second-resolution guesses.

The changes

SttClient::transcribe now returns a normalized SttResult { text, segments } and takes a language hint. ReqwestSttClient:

  • requests response_format=verbose_json, parses segments[]{start,end,text}, and falls back to the plain {text} shape (empty segments) for engines that ignore it. parse_openai_result handles both and is unit-tested.
  • sends language from the speaker's/uploader's preferred locale when set, else omits it (autodetect). Rooms carry no locale, so the user's is the signal - and it's the accurate one, since the clip is that user's own mic.
  • sends an optional glossary via LETS_CHAT_STT_PROMPT.

Real timestamps

One additive column, transcript_segments.duration_ms (migration 0088): the clip's spoken span from the engine's segment timings. build_vtt uses it for the cue length when > 0, and falls back to the pre-LC-591 synthetic length when 0 - which is the browser Web Speech path and any non-verbose_json engine - so both paths stay valid and non-overlapping.

Scope note I want to be explicit about: live-caption granularity is unchanged (one caption per clip), and genuine sub-clip absolute placement is bounded by the capture model - the browser POSTs independent ~5s clips that the server stamps at second resolution. Real per-clip durations are a clear improvement over synthetic 2-3s guesses; finer sub-clip timing would require restructuring capture and is a deliberate non-goal here.

Tests

  • stt.rs unit tests: verbose_json parse (with trimming + duration), plain-json fallback, malformed-body-to-empty (matching the old behaviour), and LETS_CHAT_STT_PROMPT from env.
  • Integration: a verbose_json mock -> stored duration_ms = 2500 -> a real 00:00:00.000 --> 00:00:02.500 VTT cue. Proven load-bearing by forcing the synthetic branch (the cue assertion then fails).
  • MockSttClient gained canned_segments + a ::text constructor; the four existing call sites migrated to ::text.

Full suite green (184 test binaries), just check clean. Provider-seam docs (LETS_CHAT_STT_PROMPT, verbose_json, language hint) added to .env.standalone, .env.saas, and README.md.

Next

LC-593 lands on top: LETS_CHAT_STT_PROVIDER selecting a request builder / response parser behind SttClient, with Deepgram normalized onto this SttResult.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5

Closes LC-591, the transcription "Accuracy" child of LC-589. This is also the prerequisite you asked me to land before LC-593 (provider flexibility), since LC-593 normalizes every provider onto the `SttResult` struct this introduces. ## What was missing The STT request sent only `model` + `file`, and the parser read only `{"text"}`. So the engine autodetected language with no hint, an operator couldn't bias spelling of names/jargon, and WebVTT cue times were synthetic monotonic second-resolution guesses. ## The changes `SttClient::transcribe` now returns a normalized `SttResult { text, segments }` and takes a `language` hint. `ReqwestSttClient`: - requests `response_format=verbose_json`, parses `segments[]{start,end,text}`, and **falls back to the plain `{text}` shape** (empty segments) for engines that ignore it. `parse_openai_result` handles both and is unit-tested. - sends `language` from the speaker's/uploader's preferred locale when set, else omits it (autodetect). Rooms carry no locale, so the user's is the signal - and it's the accurate one, since the clip is that user's own mic. - sends an optional glossary via `LETS_CHAT_STT_PROMPT`. ## Real timestamps One additive column, `transcript_segments.duration_ms` (migration 0088): the clip's spoken span from the engine's segment timings. `build_vtt` uses it for the cue length when > 0, and falls back to the pre-LC-591 synthetic length when 0 - which is the browser Web Speech path and any non-verbose_json engine - so both paths stay valid and non-overlapping. **Scope note I want to be explicit about:** live-caption granularity is unchanged (one caption per clip), and genuine sub-clip *absolute* placement is bounded by the capture model - the browser POSTs independent ~5s clips that the server stamps at second resolution. Real per-clip durations are a clear improvement over synthetic 2-3s guesses; finer sub-clip timing would require restructuring capture and is a deliberate non-goal here. ## Tests - `stt.rs` unit tests: verbose_json parse (with trimming + duration), plain-json fallback, malformed-body-to-empty (matching the old behaviour), and `LETS_CHAT_STT_PROMPT` from env. - Integration: a verbose_json mock -> stored `duration_ms` = 2500 -> a real `00:00:00.000 --> 00:00:02.500` VTT cue. Proven load-bearing by forcing the synthetic branch (the cue assertion then fails). - `MockSttClient` gained `canned_segments` + a `::text` constructor; the four existing call sites migrated to `::text`. Full suite green (184 test binaries), `just check` clean. Provider-seam docs (`LETS_CHAT_STT_PROMPT`, verbose_json, language hint) added to `.env.standalone`, `.env.saas`, and `README.md`. ## Next LC-593 lands on top: `LETS_CHAT_STT_PROVIDER` selecting a request builder / response parser behind `SttClient`, with Deepgram normalized onto this `SttResult`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
feat(stt): language + prompt hinting and real segment timestamps (LC-591)
All checks were successful
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / Nosey parker (push) Successful in 8s
check-secrets / TruffleHog (push) Successful in 10s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 9s
check-secrets / TruffleHog (pull_request) Successful in 9s
Check / clippy + fmt + tests (pull_request) Successful in 13m43s
Create release / Create release from merged PR (pull_request) Has been skipped
c9d41fb046
The transcription "Accuracy" child of LC-589, and the foundation LC-593 (provider flexibility) builds on. The STT request sent only `model` + `file` and the parser read only `{"text"}`, so language was left to autodetect and WebVTT cue times were synthetic monotonic second-resolution guesses.

`SttClient::transcribe` now returns a normalized `SttResult { text, segments }` and takes a `language` hint. `ReqwestSttClient`:
- requests `response_format=verbose_json` and parses `segments[]{start,end,text}` into the result, falling back to the plain `{text}` shape (empty segments) for engines that ignore it - `parse_openai_result` handles both and is unit-tested;
- sends `language` when the speaker's/uploader's preferred locale is set (else omits it, letting the engine autodetect). Rooms carry no locale, so the user's locale is the signal, which is also the most accurate one - the clip is that user's own mic;
- sends an optional operator glossary via `LETS_CHAT_STT_PROMPT` to bias spelling of names and jargon.

Real timestamps reach the export via one additive column: `transcript_segments.duration_ms` (migration 0088), the clip's spoken span from the engine's segment timings. `build_vtt` uses it for the cue length when > 0, and falls back to the pre-LC-591 synthetic "until the next cue" length when 0 (the browser Web Speech path, or a non-verbose_json engine), so both paths stay valid and non-overlapping. Live-caption granularity is unchanged (one caption per clip); genuine sub-clip absolute placement is bounded by the clip-based capture model (independent ~5s clips the server stamps at second resolution) and is a deliberate non-goal here.

`MockSttClient` gains `canned_segments` (and a `::text` constructor for the common no-segments case) so the real-timestamp path is testable.

Tests: verbose_json parse, plain-json fallback, malformed-body-to-empty, and prompt-from-env as stt.rs unit tests; a `verbose_json engine -> stored duration_ms -> real VTT cue` integration test, proven load-bearing by forcing the synthetic branch. Full suite green (184 test binaries). Provider seam docs (`LETS_CHAT_STT_PROMPT`) in `.env.standalone`, `.env.saas`, and `README.md`.

Unblocks LC-593: providers now normalize onto `SttResult`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-21 04:13:18 +02:00
longjacksonle deleted branch feat/lc591-stt-accuracy 2026-07-21 04:24:54 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
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
psa-systems/lets-chat!576
No description provided.