[Backend] check_transcript_covers_session destroys a valid transcript when a session has a long silent tail #431
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
check_transcript_covers_sessioncompares the last segment's end time against the recording's wall clock. Because every track is tail-padded with silence to the full wall clock, that ratio is speech-end ÷ wall-clock — which is not a coverage measure of anything. Any session where play ends before 60% of the elapsed recording time fails, deterministically, and the already-transcribed session is discarded.Why this is not hypothetical
Nothing stops a recording when the voice channel empties — there is no
voice_state_updatelistener in the recording cog. The only backstop ismax_recording_hours, which defaults to 6 (bot/questboard_bot/config.py:39, scheduled atbot/questboard_bot/cogs/recording.py:589). A GM who forgets to/record stopis the expected case, not an exotic one.Concrete triggers:
Verified mechanism
rec.sink.close(pad_to=float(duration_s))atbot/questboard_bot/cogs/recording.py:843, via_write_silence(recording.py:197,:316-341). Track length ≡ wall clock, independent of when speech stopped.duration_s = int(time.monotonic() - rec.started_at)— pure wall clock (recording.py:768).derive_session_durationreturns the longest WAV (webapp/backend/app/services/audio_service.py:1421-1448), which is the padded full length. Called atapp/routers/admin.py:214andapp/routers/sessions.py:948.check_transcript_covers_session(app/services/audio_service.py:1477-1496) computeslast_end = max(s["end"] for s in all_segments)and raises whenlast_end < 0.6 * duration_seconds. Trailing padded silence contributes nothing tolast_end.Why the cost is high
It runs at
app/tasks/reminder_tasks.py:2284— aftertranscribe_with_optional_vad(:2267), and before the merge (:2286) and theTranscriptSegmentinserts (:2305).So at the moment it raises, the GPU work is already paid for and the segments are correct and correctly attributed. The exception path sets
audio_processing_status = failedand stores the message; the complete transcript is thrown away. Retrying reproduces it exactly, because the duration is re-derived from the same padded tracks. There is no force-skip flag — the function short-circuits only onduration_seconds <= 600or empty segments, and all threeprocess_audiocall sites pass a real duration.The error message also asserts a specific wrong cause — "The per-speaker tracks are likely not on a shared session clock" — sending whoever reads it into the capture code, which is not where the problem is.
The counter-argument, and why it fails
The docstring calls this "belt and braces behind
check_tracks_cover_session" (audio_service.py:1483). The case it uniquely catches is "tracks are full length but each on its own clock". Since #320, capture derives every track's position from a single_t0(recording.py:151-156), so the capture code cannot produce that shape.That is an argument that the guard has low value, not that it is safe. A low-value guard with a high-cost, unrecoverable false positive is the trade worth changing.
Suggested fix
Preferred: compare
last_endagainst the audible extent rather than wall clock. The VAD path already computes per-track speech spans (audio_service.py:1004), somax(span_end)across tracks is free — and it is the number the guard's own error message is pretending to use.Alternatives:
0.6 × duration".SummarisationRun. The transcript is complete; the GM can judge. Current behaviour trades a complete transcript for a scary and misleading message.Acceptance criteria
Fixed in
54116c2onfeat/v4-deterministic-attribution.What changed
check_transcript_covers_sessionnow measures the transcript against the audible extent of the tracks instead of the wall clock, via a newlast_audible_second— the mirror of the existinghas_audible_speech, scanning backward for the same reason that one scans forward: the answer is usually in the first chunk, and only a long silent tail gets walked back through, which is exactly the measurement being asked for.Two deliberate choices worth recording:
tracksis required, not optional. Defaulting it would leave a caller silently measuring against the wall clock again — the bug this exists to remove.t0.The error message no longer asserts a cause the code has not established. It now reports the audible extent alongside the timeline end, so the reader is pointed at transcription rather than at capture.
One thing worth noting for the release
v4.0.0 made this worse rather than introducing it. The guard predates the release, but admin retry used to pass
duration=0(admin.py:215onmain), which short-circuited both coverage checks — so a long-tailed session that failed could at least be recovered by retrying.derive_session_durationdoes not exist onmainat all; it arrives in this release via7b91626and arms the guard on precisely that recovery path. Without this fix, v4.0.0 would have turned "recoverable" into "fails identically every time".Verification
The regression fixture was run against the pre-fix guard body, taken verbatim from git, before the fix was trusted:
That third row was the one worth checking. Widening a guard's denominator is an easy way to disable it by accident, and a suite full of green tests would not have noticed.
Seven new tests in
test_duration_guards.py, covering both directions (a silent tail must not destroy a good transcript; a silent tail must not blind the guard to a transcript that genuinely stops short of the speech that is there), plus unit coverage onlast_audible_second— including that speech below_SILENT_TRACK_PEAKdoes not count as audible, using the same threshold asdrop_silent_tracksso a track nothing would transcribe cannot extend the window a transcript is expected to cover.1,369 backend tests pass (up from 1,362), ruff format and check clean,
check_version_sync.pyOK. CHANGELOG entry added under 4.0.0 and confirmed extractable by re-running the release workflow's own regex against it.Acceptance criteria