From 467281fbec5ec812c7560c6e250cc31afd6ce014 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Tue, 4 Aug 2026 17:34:03 +0200 Subject: [PATCH 1/6] feat(speakers): make one person owning several clusters the easy, correct answer A diarizer splits one voice across several clusters routinely, and the wider the split the likelier it is. Assigning several clusters to the same person was already possible, but it was neither the obvious action nor fully correct. Two evidence bugs, each with a failing test first: - The mutual hard-negative loop matched only the FIRST prototype per other person (`next(...)`). When someone owned two clusters of a meeting, the second produced no negative evidence at all, so a later meeting could still match that speaker to it. - Reassigning a cluster away from someone removed ALL their negatives for that meeting and channel. If they still owned another cluster there, that cluster's evidence went with it, and the rebuild below only restores negatives for the person being confirmed now. The removal is now conditional on them no longer owning any cluster in the channel - their negatives rest on having been present at all, not on this one cluster, which is why scoping the removal to the cluster's own id would have been wrong (it would have kept a negative that only the removed confirmation ever justified). Two UI changes: - Rows are ordered by speaking time. Reviewing is voluntary and can be abandoned at any point, so the order decides how much of the transcript the first decisions cover. Channel and cluster id, the previous order, is an artifact of how the diarizer numbered its slots. - The Change picker lists people already assigned in this meeting first, marked "here". This is the answer that gets commoner the more a voice is split, and the alternative a hurried reviewer reaches for is "New person" - which records one voice as two people and makes them a hard negative against themselves, permanently suppressing their future suggestions. Verified: 918 Python tests (basis 916), 156 vitest, 17/17 speaker-review.t1 including two new specs, typecheck clean, lint 37/0 and ruff 41 as the basis. The one red test (test_bundle_mlx) is the documented pre-existing harness artifact, 10/10 green in isolation. --- .../src/components/SpeakerReviewPanel.tsx | 59 ++++++++++++- .../components/speakerReviewOrdering.test.ts | 34 ++++++++ e2e/specs/speaker-review.t1.spec.ts | 51 +++++++++++ simple_recorder.py | 85 ++++++++++++------- tests/test_confirm_speaker_cli.py | 79 +++++++++++++++++ 5 files changed, 272 insertions(+), 36 deletions(-) create mode 100644 app/renderer/src/components/speakerReviewOrdering.test.ts diff --git a/app/renderer/src/components/SpeakerReviewPanel.tsx b/app/renderer/src/components/SpeakerReviewPanel.tsx index 4deaada8..39b090a5 100644 --- a/app/renderer/src/components/SpeakerReviewPanel.tsx +++ b/app/renderer/src/components/SpeakerReviewPanel.tsx @@ -67,6 +67,23 @@ function namesCollide(a: string, b: string): boolean { return a.trim().toLowerCase() === b.trim().toLowerCase(); } +/** People already assigned somewhere in this meeting first, the rest after, + * each group alphabetical. The picker is the only route to "this cluster is + * someone I already named here", and that answer gets commoner the more the + * diarizer splits a voice -- burying it in a global list of everyone ever + * named is what pushes a hurried reviewer towards "New person" instead. */ +export function orderProfilesForRow( + profiles: T[], + alreadyInMeeting: Set, +): T[] { + return [...profiles].sort((a, b) => { + const aHere = alreadyInMeeting.has(a.display_name); + const bHere = alreadyInMeeting.has(b.display_name); + if (aHere !== bHere) return aHere ? -1 : 1; + return a.display_name.localeCompare(b.display_name); + }); +} + // "mic" is always the device owner's own recording side (in-person audio); // "system" is loopback capture of the other call participant(s) -- see // determine_recording_type (src/speaker_suggestions.py) for the same @@ -257,8 +274,23 @@ export function SpeakerReviewPanel({ summaryFile, isDiarised }: SpeakerReviewPan rows.push({ channel, diarizationSpeakerId, suggestion }); } } + // Most speaking time first. Reviewing is voluntary and can be abandoned at + // any point, so the order decides how much of the transcript the first + // couple of decisions actually cover -- and the more the diarizer splits a + // recording, the further that diverges from channel/cluster-id order, + // which is only an artifact of how the diarizer numbered its slots. rows.sort( - (a, b) => a.channel.localeCompare(b.channel) || a.diarizationSpeakerId.localeCompare(b.diarizationSpeakerId), + (a, b) => + (b.suggestion.speech_duration_seconds ?? 0) - (a.suggestion.speech_duration_seconds ?? 0) + || a.channel.localeCompare(b.channel) + || a.diarizationSpeakerId.localeCompare(b.diarizationSpeakerId), + ); + // People this meeting has already been given a cluster for. Under an + // over-segmenting diarizer one person owns several clusters, so this is + // the set the reviewer reaches for most, not the long tail of everyone + // they have ever named. + const alreadyInMeeting = new Set( + rows.map((row) => row.suggestion.confirmed_by_user).filter((name): name is string => !!name), ); const notDismissed = rows.filter((row) => !dismissed.has(rowKey(row))); // A row a human has explicitly marked stays in the main list even if its @@ -499,7 +531,7 @@ export function SpeakerReviewPanel({ summaryFile, isDiarised }: SpeakerReviewPan No known people yet ) : ( - (profilesQuery.data ?? []).map((profile) => ( + orderProfilesForRow(profilesQuery.data ?? [], alreadyInMeeting).map((profile) => (