[Backend] #425's uncaptured-member fix is overridden — a backend-dropped silent speaker is still proposed as "spoke" #432
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
merge_uncaptured_memberscorrectly adds the owner of a backend-dropped silent track touncaptured_member_ids— and the value is then discarded. Because thespeakersdict is built from all discovered WAVs and is never narrowed afterdrop_silent_tracks, that same owner is still counted as a captured speaker, getsAttendanceProposalSource.spokeassigned first, and thein_channel_silentassignment is skipped.The GM sees someone who never made a sound proposed as having spoken — the opposite of what #425 set out to fix.
Verified mechanism
speakersis built from every discovered WAV, before silent tracks are dropped:webapp/backend/app/tasks/reminder_tasks.py:2150-2153—speakers = {wav.stem: resolved_speaker_name(...) for wav in wav_files}.Grepped every assignment: the only other one is
speakers = {}at:2135. It is never narrowed afterdrop_silent_tracks.merge_uncaptured_members(uncaptured_member_ids, silent_tracks)at:2490-2492does its job correctly — the dropped owner is added to the list.generate_attendance_proposalsat:2496-2502.webapp/backend/app/services/attendance_service.py:212-229: The dropped owner is inspeaker_ids, soproposed[uid]is alreadyspokeby the time the second loop runs.This directly contradicts the stated intent in the comment three lines above the call site (
reminder_tasks.py:2485-2489) and inmerge_uncaptured_members' own docstring (app/services/audio_service.py:1345-1350).Blast radius
Only the backend-drop route is affected — an older recording, a hand-placed directory, or a bot running a version behind the backend. The bot's own pre-upload drops (#348) never appear in
speakersat all, so those still resolve correctly.That is precisely the route
drop_silent_tracksexists for, and precisely the case where the bot's own report is absent — which is the reason #425 was opened.How this got through
The #425 work added
merge_uncaptured_membersand tested that function in isolation, where it behaves correctly. Nothing exercised the composition ofmerge_uncaptured_memberswithgenerate_attendance_proposalsagainst an un-narrowedspeakersdict, so the override was invisible. A guard that was never watched fire, and it doesn't.Suggested fix
Narrow
speakersto the surviving tracks afterdrop_silent_tracks— the dict exists to describe captured speakers, and after the drop the dropped owners are no longer that. Alternatively, letuncaptured_member_idswin overspokeingenerate_attendance_proposals, since a member explicitly reported as uncaptured is better evidence than their mere presence in the file list.Narrowing the dict is preferable: it fixes the cause rather than the symptom, and
speakersis also used for theunmatcheddisplay-name pass atattendance_service.py:231+, which has the same wrong input today.Acceptance criteria
in_channel_silent, notspoke.in_channel_silentas before.spoke.unmatcheddisplay-name pass is checked against the same narrowing.Fixed in
d4881b9onfix/v4.0.1-retention-safety, alongside #427 (same call path).The fix
Narrowing extracted as
narrow_speakers_to_captured, placed directly besidemerge_uncaptured_membersinaudio_service, and called fromprocess_audioright afterdrop_silent_tracks.Extracting it rather than inlining the dict comprehension was deliberate. The reason this defect survived a release is that the missing step would have lived only inside
process_audio— a task no test drives end to end. A step that exists only inline in an untested task is a step nothing can catch. As a named function with its own tests, it is reachable; as three lines in the middle of a 400-line task, it was not.The two functions are now documented as a pair, with each docstring pointing at the other and explaining that changing one without the other is exactly what happened here.
Tests
Three unit tests on the pair, including the invariant that actually matters:
A dropped owner must leave one collection and enter the other, and appear in neither both nor neither. That overlap is the defect — stated as an invariant rather than as two separate assertions that could both pass while the composition stayed broken.
Plus two service-level tests in
test_attendance_proposals.py. The second one is unusual and worth flagging: it asserts the unfixed behaviour ofgenerate_attendance_proposals— that passing a dropped owner in both arguments still resolves tospoke. That pins why the narrowing lives in the caller. If someone later "fixes" it a second time by makinguncapturedwin inside that function, this test fails and points them at the caller-side narrowing rather than leaving two competing fixes in the codebase.Acceptance criteria
in_channel_silent, notspokein_channel_silent— unchanged, since those members never had a WAV and so were never inspeakersspokeunmatcheddisplay-name pass is checked against the same narrowing — it iterates the same dict, so it now sees the narrowed set, and the test asserts a dropped owner does not surface as an unmatched name either1,378 backend tests pass.