fix(stt): count words in the text, not entries in the list, when verifying a speaker sample - #12169
Conversation
…fying a speaker sample
`verify_and_transcribe_sample` decides a sample's fate with `len(words)`, where `words` is whatever
the pre-recorded transcriber returned. Deepgram emits one entry per word, so entry count and word
count are the same number there and everything works. Parakeet emits one entry per SEGMENT with the
whole utterance inside — a 24.9s sample comes back from its `/v2/transcribe` as `segments: 1`, with
no `words` field at all.
So with `STT_PRERECORDED_MODEL=parakeet` the count reads 1 and every sample is rejected as
`insufficient_words: 1/5` regardless of what it contains. Nothing is ever stored, no person reaches
`speech_samples_version >= 3`, and `routers/listen/speakers.py` therefore loads nobody: speaker
identification across conversations cannot work at all on that provider.
The same miscount runs the other way in the multi-speaker guard. `speaker_counts` adds 1 per entry,
so a single-segment sample has a dominant ratio of exactly 1.0 — the check meant to reject a sample
carrying two voices stops rejecting anything. That one is a false pass, which is the worse of the two.
Both now weigh each entry by the words in its text. For a word-granular provider this is arithmetic
that cannot change: each entry contributes 1, so every count is what it was. The sixteen existing
tests in test_speaker_sample.py pass untouched, which is the evidence that Deepgram behaviour is
unaffected.
## Source for the expected values
Measured against a live parakeet built from this repo's own `backend/parakeet/Dockerfile.oss`, not
inferred: `POST /v2/transcribe` with a 24.9s 16 kHz mono WAV returns
`{"text": ..., "segments": [{...}], "detected_language": ...}` — one segment, no `words` key. The
client at `utils/stt/pre_recorded.py:883-902` then builds one list entry per segment, which is
correct for what the API provides.
## Verification
- `tests/unit/test_speaker_sample.py` — 20 passed. The four new cases fail on this branch's
merge-base and the sixteen existing ones pass on both sides.
- End to end on a live stack (parakeet + a pyannote diarizer), tagging a person in one conversation
and asking for recognition in a second one built from a different half of the same 24.9s recording:
before, `insufficient_words: 1/5` and nothing stored; after, the sample stores, the person reaches
version 3, an embedding is written, the next session loads the person, and the segment matches.
Failure-Class: none
|
Thanks @abunet — sharp catch, and the diagnosis is even more right than the PR description claims. Verified on the head:
Also checked that the changed counts inside Leaving the formal approval to a human maintainer; code-wise this looks ready to merge. by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
kodjima33
left a comment
There was a problem hiding this comment.
Confidence 5/5: clear root cause (word vs segment granularity miscount for parakeet), scoped diff, 4 new regression tests, CI green. Verified not already fixed on main.
…nd it was ours to drop `fix/replay-harness-storage-fake-kwargs` (BasedHardware#12188) and the speaker-sample word count (BasedHardware#12169) were both merged upstream, so both files return to byte-identical with theirs — verified, `git diff upstream/main` is empty for each. The single conflict was our own marked workaround in replay_harness_phase0a/apps.py against their now-merged fix: took theirs, and the comment that said "once that is merged this file goes back to being byte-identical to theirs, and this comment with it" went with it. ADR-0030 audit, now including the Dart surface — the gap the +12 merge left, where `getTimeZone` came through unchanged while upstream updated its FCM sibling. The nine `check_oss_*` guards are 0, and our port markers are intact (118 `_object_store()`, 261 `_store()`, 152 `_signed_url`, 58 `get_auth_provider`, 53 `_vector_store()`, 48 `get_document_store`).
verify_and_transcribe_sampledecides a sample's fate withlen(words), wherewordsis whateverthe pre-recorded transcriber returned. Deepgram emits one entry per word, so entry count and word
count are the same number there and everything works. Parakeet emits one entry per SEGMENT with the
whole utterance inside — a 24.9s sample comes back from its
/v2/transcribeassegments: 1, withno
wordsfield at all.So with
STT_PRERECORDED_MODEL=parakeetthe count reads 1 and every sample is rejected asinsufficient_words: 1/5regardless of what it contains. Nothing is ever stored, no person reachesspeech_samples_version >= 3, androuters/listen/speakers.pytherefore loads nobody: speakeridentification across conversations cannot work at all on that provider.
The same miscount runs the other way in the multi-speaker guard.
speaker_countsadds 1 per entry,so a single-segment sample has a dominant ratio of exactly 1.0 — the check meant to reject a sample
carrying two voices stops rejecting anything. That one is a false pass, which is the worse of the two.
Both now weigh each entry by the words in its text. For a word-granular provider this is arithmetic
that cannot change: each entry contributes 1, so every count is what it was. The sixteen existing
tests in test_speaker_sample.py pass untouched, which is the evidence that Deepgram behaviour is
unaffected.
Source for the expected values
Measured against a live parakeet built from this repo's own
backend/parakeet/Dockerfile.oss, notinferred:
POST /v2/transcribewith a 24.9s 16 kHz mono WAV returns{"text": ..., "segments": [{...}], "detected_language": ...}— one segment, nowordskey. Theclient at
utils/stt/pre_recorded.py:883-902then builds one list entry per segment, which iscorrect for what the API provides.
Verification
tests/unit/test_speaker_sample.py— 20 passed. The four new cases fail on this branch'smerge-base and the sixteen existing ones pass on both sides.
and asking for recognition in a second one built from a different half of the same 24.9s recording:
before,
insufficient_words: 1/5and nothing stored; after, the sample stores, the person reachesversion 3, an embedding is written, the next session loads the person, and the segment matches.
Failure-Class: none
Context
Found while reproducing #4455 ("Diarization doesn't work through different conversations throughout
the day") on a parakeet-backed deployment. This is not that issue — #4455 describes the user-visible
symptom and has other causes worth their own discussion (the one-sample-per-person cap, and nine
silent exits inside a background task that has already returned 200). This is one mechanism that
produces it, and it is provider-specific, so it is sent on its own.
Product invariants affected
none