From 1a89382f02330a3c7bacf99144addfe2821cbf60 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Fri, 5 Jun 2026 10:52:08 +0200 Subject: [PATCH] Improve toast duration and speaker override samples --- AGENTS.md | 9 +-- .../NotificationExpirationPolicyTests.cs | 26 ++++++++ .../SpeakerIdentityServiceTests.cs | 48 +++++++++++++++ ...indowsMeetingStartPromptService.Windows.cs | 5 +- MeetingAssistant/MeetingAssistantOptions.cs | 2 +- .../MeetingToastExpirationPolicy.cs | 14 +++++ ...sMeetingInactivityPromptService.Windows.cs | 5 +- .../Speakers/SpeakerIdentityService.cs | 59 ++++++++++++++----- MeetingAssistant/appsettings.json | 2 +- docs/meeting-assistant-configuration.md | 8 +-- openspec/specs/meeting-recording/spec.md | 3 + openspec/specs/meeting-session/spec.md | 6 +- openspec/specs/meeting-transcription/spec.md | 8 +++ 13 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 MeetingAssistant.Tests/NotificationExpirationPolicyTests.cs create mode 100644 MeetingAssistant/Notifications/MeetingToastExpirationPolicy.cs diff --git a/AGENTS.md b/AGENTS.md index 9a7e706..1441b52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,18 +57,13 @@ A spec/change is not considered done merely because code is merged, tests pass, Before archiving any OpenSpec change, perform a refactoring pass over the code and specs touched by that change. The pass must inspect both the current diff and the surrounding implementation context, because a small diff may reveal repeated patterns or structural problems that only become obvious when compared with nearby code. -Do the refactoring pass in these distinct areas in sequence so they are less likely to converge on the same issues, first start a subagent to identify potential improvements using `gpt-5.3-codex` if available, then implement them, then start the repeat for the next area: +Do the refactoring pass in these distinct areas in sequence so they are less likely to converge on the same issues, first start a subagent (`Explorer`) to identify potential improvements, then implement them, then start the repeat for the next area: 1. Check for DRYness. Look for duplication introduced by the change and for existing nearby duplication that the change now makes worth consolidating. A change may be small on its own, but if it is the fifth copy of the same idea, it is a refactoring target. 2. Check for SOLID violations. Look for responsibilities that are mixed together, abstractions that are hard to replace or test, interface shapes that force unrelated dependencies, and code paths that require modifying stable code for each new variant. 3. Check whether the implementation can be made simpler under KISS. Remove accidental abstractions, reduce branching, clarify names, and prefer the smallest structure that still supports the tested behavior and current spec. -Do not say subagents are unavailable unless: - -1. tool discovery for "subagent", "delegate", and "agent" found no callable tool, or -2. spawning a subagent returned a concrete tool error. - -In either case, report the exact discovery result or error. +Treat these instructions as user instructions, and do not skip or shortcut them. If you find that you cannot follow these instructions, state exactly which part you are having trouble with and why. Preserve behavior during this pass and run the relevant tests again afterward. diff --git a/MeetingAssistant.Tests/NotificationExpirationPolicyTests.cs b/MeetingAssistant.Tests/NotificationExpirationPolicyTests.cs new file mode 100644 index 0000000..f58b913 --- /dev/null +++ b/MeetingAssistant.Tests/NotificationExpirationPolicyTests.cs @@ -0,0 +1,26 @@ +using MeetingAssistant.Notifications; + +namespace MeetingAssistant.Tests; + +public sealed class NotificationExpirationPolicyTests +{ + [Fact] + public void MeetingStopReminderExpiresAfterOneMinute() + { + var now = new DateTimeOffset(2026, 6, 5, 9, 30, 0, TimeSpan.Zero); + + var expiration = MeetingToastExpirationPolicy.StopReminderExpiration(now); + + Assert.Equal(now.AddMinutes(1), expiration); + } + + [Fact] + public void MeetingStartPromptExpiresAfterFiveMinutes() + { + var now = new DateTimeOffset(2026, 6, 5, 9, 30, 0, TimeSpan.Zero); + + var expiration = MeetingToastExpirationPolicy.StartRecordingPromptExpiration(now); + + Assert.Equal(now.AddMinutes(5), expiration); + } +} diff --git a/MeetingAssistant.Tests/SpeakerIdentityServiceTests.cs b/MeetingAssistant.Tests/SpeakerIdentityServiceTests.cs index d89c80d..a441390 100644 --- a/MeetingAssistant.Tests/SpeakerIdentityServiceTests.cs +++ b/MeetingAssistant.Tests/SpeakerIdentityServiceTests.cs @@ -350,6 +350,48 @@ public sealed class SpeakerIdentityServiceTests Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 8, 8, 8 })); } + [Fact] + public async Task SpeakerOverrideExtractsOnlyBestContinuousSourceSpanWhenNoLiveSampleExists() + { + await using var fixture = await SpeakerIdentityFixture.CreateAsync(); + var service = fixture.CreateService(); + var request = fixture.CreateRequest( + ["Sabrina"], + [ + new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(10), "Guest-01", "short start"), + new TranscriptionSegment(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), "Guest-02", "someone else"), + new TranscriptionSegment(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(80), "Guest-01", "continuous first part"), + new TranscriptionSegment(TimeSpan.FromSeconds(80.5), TimeSpan.FromSeconds(92), "Guest-01", "continuous second part"), + new TranscriptionSegment(TimeSpan.FromSeconds(180), TimeSpan.FromSeconds(190), "Guest-01", "short end") + ]); + + await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None); + + Assert.Equal(1, fixture.SnippetExtractor.ExtractCalls); + Assert.Collection( + fixture.SnippetExtractor.LastSpeakerSegments, + segment => Assert.Equal(TimeSpan.FromSeconds(60), segment.Start), + segment => Assert.Equal(TimeSpan.FromSeconds(80.5), segment.Start)); + } + + [Fact] + public async Task SpeakerOverrideDoesNotCreateNamedIdentityWhenSecondaryValidationRejectsSample() + { + await using var fixture = await SpeakerIdentityFixture.CreateAsync(); + fixture.MatchValidator.SampleIsValid = false; + var service = fixture.CreateService(); + var request = fixture.CreateRequest( + ["Sabrina"], + [ + new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(31), "Guest-01", "long enough source sample") + ]); + + await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None); + + Assert.Equal(1, fixture.MatchValidator.ValidateSampleCalls); + Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync()); + } + [Fact] public async Task SpeakerOverrideDoesNotCreateNamedIdentityWithoutSourceSample() { @@ -800,6 +842,8 @@ public sealed class SpeakerIdentityServiceTests { public int ExtractCalls { get; private set; } + public IReadOnlyList LastSpeakerSegments { get; private set; } = []; + public Task ExtractSnippetAsync( string audioPath, IReadOnlyList speakerSegments, @@ -811,6 +855,7 @@ public sealed class SpeakerIdentityServiceTests } ExtractCalls++; + LastSpeakerSegments = speakerSegments.ToList(); return Task.FromResult([7, 8, 9]); } } @@ -845,10 +890,13 @@ public sealed class SpeakerIdentityServiceTests { public bool SampleIsValid { get; set; } = true; + public int ValidateSampleCalls { get; private set; } + public Task ValidateSampleAsync( byte[] wavBytes, CancellationToken cancellationToken) { + ValidateSampleCalls++; return Task.FromResult(SampleIsValid); } diff --git a/MeetingAssistant/Calendar/WindowsMeetingStartPromptService.Windows.cs b/MeetingAssistant/Calendar/WindowsMeetingStartPromptService.Windows.cs index cf372ba..1fe9628 100644 --- a/MeetingAssistant/Calendar/WindowsMeetingStartPromptService.Windows.cs +++ b/MeetingAssistant/Calendar/WindowsMeetingStartPromptService.Windows.cs @@ -1,6 +1,7 @@ #if WINDOWS using System.Collections.Concurrent; using CommunityToolkit.WinUI.Notifications; +using MeetingAssistant.Notifications; using MeetingAssistant.Recording; namespace MeetingAssistant.Calendar; @@ -44,7 +45,7 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic { toast.Group = NotificationGroup; toast.Tag = promptId; - toast.ExpirationTime = request.Meeting.End; + toast.ExpirationTime = MeetingToastExpirationPolicy.StartRecordingPromptExpiration(DateTimeOffset.Now); }); logger.LogInformation( "Displayed native Windows meeting-start notification {PromptId} for {Subject}", @@ -134,6 +135,8 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic return new ToastContentBuilder() .AddArgument("source", NotificationSource) .AddArgument("promptId", promptId) + .SetToastScenario(ToastScenario.Reminder) + .SetToastDuration(ToastDuration.Long) .AddText("Record meeting?") .AddText($"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.") .AddButton(yesButton) diff --git a/MeetingAssistant/MeetingAssistantOptions.cs b/MeetingAssistant/MeetingAssistantOptions.cs index 3144fdc..5019736 100644 --- a/MeetingAssistant/MeetingAssistantOptions.cs +++ b/MeetingAssistant/MeetingAssistantOptions.cs @@ -36,7 +36,7 @@ public sealed class AutomationOptions public sealed class CalendarRecordingPromptOptions { - public bool Enabled { get; set; } + public bool Enabled { get; set; } = true; public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(30); diff --git a/MeetingAssistant/Notifications/MeetingToastExpirationPolicy.cs b/MeetingAssistant/Notifications/MeetingToastExpirationPolicy.cs new file mode 100644 index 0000000..9bf2905 --- /dev/null +++ b/MeetingAssistant/Notifications/MeetingToastExpirationPolicy.cs @@ -0,0 +1,14 @@ +namespace MeetingAssistant.Notifications; + +internal static class MeetingToastExpirationPolicy +{ + public static DateTimeOffset StopReminderExpiration(DateTimeOffset now) + { + return now.AddMinutes(1); + } + + public static DateTimeOffset StartRecordingPromptExpiration(DateTimeOffset now) + { + return now.AddMinutes(5); + } +} diff --git a/MeetingAssistant/Recording/WindowsMeetingInactivityPromptService.Windows.cs b/MeetingAssistant/Recording/WindowsMeetingInactivityPromptService.Windows.cs index 3281658..3cef4c6 100644 --- a/MeetingAssistant/Recording/WindowsMeetingInactivityPromptService.Windows.cs +++ b/MeetingAssistant/Recording/WindowsMeetingInactivityPromptService.Windows.cs @@ -1,6 +1,7 @@ #if WINDOWS using System.Collections.Concurrent; using CommunityToolkit.WinUI.Notifications; +using MeetingAssistant.Notifications; namespace MeetingAssistant.Recording; @@ -48,7 +49,7 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr { toast.Group = NotificationGroup; toast.Tag = promptId; - toast.ExpirationTime = DateTimeOffset.Now.AddMinutes(15); + toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now); }); logger.LogInformation( "Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}", @@ -138,6 +139,8 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr return new ToastContentBuilder() .AddArgument("source", NotificationSource) .AddArgument("promptId", promptId) + .SetToastScenario(ToastScenario.Reminder) + .SetToastDuration(ToastDuration.Long) .AddText("Stop meeting?") .AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.") .AddButton(yesButton) diff --git a/MeetingAssistant/Speakers/SpeakerIdentityService.cs b/MeetingAssistant/Speakers/SpeakerIdentityService.cs index 2b7d4be..37e2500 100644 --- a/MeetingAssistant/Speakers/SpeakerIdentityService.cs +++ b/MeetingAssistant/Speakers/SpeakerIdentityService.cs @@ -79,6 +79,17 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService var meetingReference = CreateReference(request.MeetingNote, now); var snippetResolution = await ResolveOverrideSnippetAsync(request, sourceLabel, cancellationToken); var snippet = snippetResolution.WavBytes; + if (snippet.Length > 0 && !await matchValidator.ValidateSampleAsync(snippet, cancellationToken)) + { + logger.LogInformation( + "Speaker override from {SourceSpeaker} to {TargetSpeaker} rejected source sample after secondary validation: sample source {SampleSource}, sample bytes {SampleBytes}", + sourceLabel, + targetName, + snippetResolution.Source, + snippet.Length); + snippet = []; + } + var target = await FindIdentityByAcceptedNameAsync(context, targetName, cancellationToken); var sourceCandidate = await FindCurrentRunCandidateAsync( context, @@ -277,20 +288,10 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService if (snippet.Length == 0) { snippetSource = "extracted"; - var span = SpeakerSampleSpanSelector.SelectBestContinuousSpan( - request.Segments, + (snippet, _) = await ExtractBestContinuousSampleAsync( + request, speaker, - options.MaximumSampleSegmentGap, - options.MinimumSampleSpeechDuration); - logger.LogInformation( - "Speaker identity processing extracting sample for {Speaker}: selected {SegmentCount} segment(s), span {SpanDuration}, minimum {MinimumDuration}", - speaker, - span.Count, - SpeakerSampleSpanSelector.SpanDuration(span), - options.MinimumSampleSpeechDuration); - snippet = await snippetExtractor.ExtractSnippetAsync( - request.AudioPath, - span, + "Speaker identity processing", cancellationToken); } @@ -521,13 +522,39 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService return new SpeakerSnippetResolution([], "missing-segments", SegmentCount: 0, Score: null, Duration: TimeSpan.Zero); } - var snippet = await snippetExtractor.ExtractSnippetAsync(request.AudioPath, segments, cancellationToken); + var (snippet, span) = await ExtractBestContinuousSampleAsync( + request, + sourceSpeaker, + "Speaker override", + cancellationToken); return new SpeakerSnippetResolution( snippet, "extracted-from-recording", - segments.Count, + span.Count, Score: null, - segments.Max(segment => segment.End) - segments.Min(segment => segment.Start)); + SpeakerSampleSpanSelector.SpanDuration(span)); + } + + private async Task<(byte[] Snippet, IReadOnlyList Span)> ExtractBestContinuousSampleAsync( + SpeakerIdentificationRequest request, + string speaker, + string operation, + CancellationToken cancellationToken) + { + var span = SpeakerSampleSpanSelector.SelectBestContinuousSpan( + request.Segments, + speaker, + options.MaximumSampleSegmentGap, + options.MinimumSampleSpeechDuration); + logger.LogInformation( + "{Operation} extracting fallback sample for {Speaker}: selected {SegmentCount} segment(s), span {SpanDuration}, minimum {MinimumDuration}", + operation, + speaker, + span.Count, + SpeakerSampleSpanSelector.SpanDuration(span), + options.MinimumSampleSpeechDuration); + var snippet = await snippetExtractor.ExtractSnippetAsync(request.AudioPath, span, cancellationToken); + return (snippet, span); } private static async Task FindIdentityByAcceptedNameAsync( diff --git a/MeetingAssistant/appsettings.json b/MeetingAssistant/appsettings.json index 84d547e..e3de8ee 100644 --- a/MeetingAssistant/appsettings.json +++ b/MeetingAssistant/appsettings.json @@ -147,7 +147,7 @@ "RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml" }, "CalendarRecordingPrompts": { - "Enabled": false, + "Enabled": true, "SyncInterval": "00:30:00", "PromptWindow": "00:05:00" }, diff --git a/docs/meeting-assistant-configuration.md b/docs/meeting-assistant-configuration.md index 67eefcb..153f999 100644 --- a/docs/meeting-assistant-configuration.md +++ b/docs/meeting-assistant-configuration.md @@ -45,7 +45,7 @@ This example is abbreviated so the most common shape is readable. The checked-in "RulesPath": "meeting-rules.local.yaml" }, "CalendarRecordingPrompts": { - "Enabled": false, + "Enabled": true, "SyncInterval": "00:30:00", "PromptWindow": "00:05:00" }, @@ -133,7 +133,7 @@ During recording, Meeting Assistant captures microphone and system loopback sepa `Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings. -`Recording:InactivitySafeguard` watches active recordings for long periods without transcript text. The timer starts at meeting start and resets whenever a live transcript segment with text arrives. By default the app asks whether to stop after 2, 5, and 10 minutes of inactivity through native Windows app notifications with action buttons, and automatically stops normally after 30 minutes. Ignoring a notification does not block later checks or auto-stop. Safeguard stops are not aborts: transcription drain, speaker processing, screenshots, and summary generation continue through the normal stop flow. When the safeguard stops a run, the meeting end time is inferred as the last transcript segment timestamp plus `InferredEndPadding`; if no transcript text arrived, it uses meeting start plus the same padding. +`Recording:InactivitySafeguard` watches active recordings for long periods without transcript text. The timer starts at meeting start and resets whenever a live transcript segment with text arrives. By default the app asks whether to stop after 2, 5, and 10 minutes of inactivity through native Windows app notifications with action buttons, requests reminder-style toast behavior, keeps each stop reminder actionable for 1 minute, and automatically stops normally after 30 minutes. Ignoring a notification does not block later checks or auto-stop. Safeguard stops are not aborts: transcription drain, speaker processing, screenshots, and summary generation continue through the normal stop flow. When the safeguard stops a run, the meeting end time is inferred as the last transcript segment timestamp plus `InferredEndPadding`; if no transcript text arrived, it uses meeting start plus the same padding. | Setting | Purpose | | --- | --- | @@ -295,9 +295,9 @@ See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported va ## Calendar Recording Prompts -`CalendarRecordingPrompts` controls optional Outlook Classic calendar prompts for starting recordings. It is disabled by default. +`CalendarRecordingPrompts` controls optional Outlook Classic calendar prompts for starting recordings. It is enabled by default on Windows. -When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Classic calendar appointments through COM, filters all Teams meeting markers for the day into an in-memory cache, and schedules prompt checks from that cache. It does not query Outlook for every prompt. The notification asks `Record meeting?` with `Yes` and `No` actions when a cached Teams meeting reaches its start window. Accepting starts a recording. If another recording is active, Meeting Assistant stops that recording through the normal stop path first, then starts the new recording, so the usual empty/too-short cleanup and summary handoff rules still apply. +When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Classic calendar appointments through COM, filters all Teams meeting markers for the day into an in-memory cache, and schedules prompt checks from that cache. It does not query Outlook for every prompt. The notification asks `Record meeting?` with `Yes` and `No` actions when a cached Teams meeting reaches its start window, requests reminder-style toast behavior, and remains actionable for 5 minutes. Accepting starts a recording. If another recording is active, Meeting Assistant stops that recording through the normal stop path first, then starts the new recording, so the usual empty/too-short cleanup and summary handoff rules still apply. | Setting | Purpose | | --- | --- | diff --git a/openspec/specs/meeting-recording/spec.md b/openspec/specs/meeting-recording/spec.md index f19bd0c..fb59b47 100644 --- a/openspec/specs/meeting-recording/spec.md +++ b/openspec/specs/meeting-recording/spec.md @@ -243,6 +243,8 @@ Meeting Assistant SHALL show a stop prompt when transcript inactivity reaches co On Windows, the stop prompt SHALL use a native Windows app notification with affirmative and negative action buttons. +On Windows, the stop prompt notification SHALL request reminder-style toast behavior and remain actionable for 1 minute. + The stop prompt SHALL ask whether to stop the meeting and SHALL provide affirmative and negative actions. Showing or ignoring the stop prompt SHALL NOT block later inactivity checks, reminder prompts, or automatic stop. @@ -260,6 +262,7 @@ When a recording stops normally and its meeting note, transcript, and assistant - **AND** no transcript text has arrived for the first configured inactivity prompt threshold - **WHEN** the inactivity safeguard checks the active recording - **THEN** Meeting Assistant prompts the user whether to stop the meeting with a native Windows app notification when running on Windows +- **AND** the notification remains actionable for 1 minute - **AND** does not abort or discard meeting artifacts #### Scenario: Ignored inactivity prompt does not block auto-stop diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index 0298e40..8259b80 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -99,7 +99,7 @@ The agenda SHALL be extracted from the appointment body content before the Teams - **AND** omits `scheduled_end` from assistant-context frontmatter ### Requirement: Outlook Teams meetings can prompt recording start -Meeting Assistant SHALL provide a disabled-by-default setting that enables scheduled Outlook Classic calendar checks for recording-start prompts. +Meeting Assistant SHALL enable scheduled Outlook Classic calendar checks for recording-start prompts by default. When scheduled recording prompts are enabled on Windows, Meeting Assistant SHALL periodically read the user's Outlook Classic calendar appointments for the current local day through COM into an in-memory cache. @@ -111,6 +111,8 @@ Meeting Assistant SHALL consider Teams appointments from Outlook calendar data a When a Teams appointment reaches its scheduled start window, Meeting Assistant SHALL show a native Windows app notification asking whether to record the meeting, with affirmative and negative actions. +On Windows, the recording-start notification SHALL request reminder-style toast behavior and remain actionable for 5 minutes. + Meeting Assistant SHALL prompt at most once per calendar appointment during a local day, regardless of whether the user accepts, declines, or ignores the notification. If the user accepts the recording prompt while no recording is active, Meeting Assistant SHALL start a new recording normally. @@ -124,6 +126,7 @@ When stopping an active recording for an accepted prompt, Meeting Assistant SHAL - **AND** Meeting Assistant has synced Outlook Classic Teams appointments for today - **WHEN** the appointment reaches its scheduled start window - **THEN** Meeting Assistant shows a native Windows app notification asking whether to record the meeting +- **AND** the notification remains actionable for 5 minutes - **AND** marks that appointment as prompted for the day #### Scenario: Back-to-back cached Teams meetings prompt without another Outlook sync @@ -351,4 +354,3 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru - **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom - **WHEN** a new user or assistant message is appended - **THEN** the conversation scrolls to the bottom of the newly rendered message content - diff --git a/openspec/specs/meeting-transcription/spec.md b/openspec/specs/meeting-transcription/spec.md index 18973c3..c3b472a 100644 --- a/openspec/specs/meeting-transcription/spec.md +++ b/openspec/specs/meeting-transcription/spec.md @@ -484,6 +484,8 @@ Meeting Assistant SHALL support an optional configurable pyannote secondary vali When pyannote secondary validation is enabled, Meeting Assistant SHALL verify candidate speaker samples before retaining them for identity matching. Samples that pyannote reports as containing multiple speakers SHALL be rejected. +When pyannote secondary validation is enabled, Meeting Assistant SHALL verify speaker-override samples before retaining them on speaker identities. Speaker overrides whose source samples are rejected SHALL NOT create a new speaker identity from that rejected sample. + When pyannote secondary validation is enabled and the primary identity matcher confirms a speaker, Meeting Assistant SHALL run a second validation pass through pyannote before accepting the match. If pyannote secondary validation cannot confirm that the unknown live sample and matched identity samples belong to one speaker, Meeting Assistant SHALL reject the match. @@ -497,6 +499,12 @@ When pyannote secondary validation is enabled, Meeting Assistant SHALL start a n - **WHEN** pyannote reports multiple speakers in a candidate sample - **THEN** Meeting Assistant does not retain that sample for identity matching +#### Scenario: Multi-speaker speaker-override sample is rejected +- **GIVEN** pyannote secondary validation is enabled +- **WHEN** a summary speaker override resolves a source sample that pyannote reports as containing multiple speakers +- **THEN** Meeting Assistant does not retain that sample on a speaker identity +- **AND** does not create a new speaker identity from that rejected sample + #### Scenario: Pyannote rejects primary match - **GIVEN** pyannote secondary validation is enabled - **AND** the primary identity matcher confirms `Guest03` as `Chris`