Public Access
Improve toast duration and speaker override samples
PR and Push Build/Test / build-and-test (push) Failing after 9m48s
PR and Push Build/Test / build-and-test (push) Failing after 9m48s
This commit is contained in:
@@ -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.
|
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.
|
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.
|
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.
|
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:
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Preserve behavior during this pass and run the relevant tests again afterward.
|
Preserve behavior during this pass and run the relevant tests again afterward.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -350,6 +350,48 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 8, 8, 8 }));
|
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]
|
[Fact]
|
||||||
public async Task SpeakerOverrideDoesNotCreateNamedIdentityWithoutSourceSample()
|
public async Task SpeakerOverrideDoesNotCreateNamedIdentityWithoutSourceSample()
|
||||||
{
|
{
|
||||||
@@ -800,6 +842,8 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
{
|
{
|
||||||
public int ExtractCalls { get; private set; }
|
public int ExtractCalls { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<TranscriptionSegment> LastSpeakerSegments { get; private set; } = [];
|
||||||
|
|
||||||
public Task<byte[]> ExtractSnippetAsync(
|
public Task<byte[]> ExtractSnippetAsync(
|
||||||
string audioPath,
|
string audioPath,
|
||||||
IReadOnlyList<TranscriptionSegment> speakerSegments,
|
IReadOnlyList<TranscriptionSegment> speakerSegments,
|
||||||
@@ -811,6 +855,7 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
ExtractCalls++;
|
ExtractCalls++;
|
||||||
|
LastSpeakerSegments = speakerSegments.ToList();
|
||||||
return Task.FromResult<byte[]>([7, 8, 9]);
|
return Task.FromResult<byte[]>([7, 8, 9]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -845,10 +890,13 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
{
|
{
|
||||||
public bool SampleIsValid { get; set; } = true;
|
public bool SampleIsValid { get; set; } = true;
|
||||||
|
|
||||||
|
public int ValidateSampleCalls { get; private set; }
|
||||||
|
|
||||||
public Task<bool> ValidateSampleAsync(
|
public Task<bool> ValidateSampleAsync(
|
||||||
byte[] wavBytes,
|
byte[] wavBytes,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
ValidateSampleCalls++;
|
||||||
return Task.FromResult(SampleIsValid);
|
return Task.FromResult(SampleIsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using CommunityToolkit.WinUI.Notifications;
|
using CommunityToolkit.WinUI.Notifications;
|
||||||
|
using MeetingAssistant.Notifications;
|
||||||
using MeetingAssistant.Recording;
|
using MeetingAssistant.Recording;
|
||||||
|
|
||||||
namespace MeetingAssistant.Calendar;
|
namespace MeetingAssistant.Calendar;
|
||||||
@@ -44,7 +45,7 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
|
|||||||
{
|
{
|
||||||
toast.Group = NotificationGroup;
|
toast.Group = NotificationGroup;
|
||||||
toast.Tag = promptId;
|
toast.Tag = promptId;
|
||||||
toast.ExpirationTime = request.Meeting.End;
|
toast.ExpirationTime = MeetingToastExpirationPolicy.StartRecordingPromptExpiration(DateTimeOffset.Now);
|
||||||
});
|
});
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Displayed native Windows meeting-start notification {PromptId} for {Subject}",
|
"Displayed native Windows meeting-start notification {PromptId} for {Subject}",
|
||||||
@@ -134,6 +135,8 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
|
|||||||
return new ToastContentBuilder()
|
return new ToastContentBuilder()
|
||||||
.AddArgument("source", NotificationSource)
|
.AddArgument("source", NotificationSource)
|
||||||
.AddArgument("promptId", promptId)
|
.AddArgument("promptId", promptId)
|
||||||
|
.SetToastScenario(ToastScenario.Reminder)
|
||||||
|
.SetToastDuration(ToastDuration.Long)
|
||||||
.AddText("Record meeting?")
|
.AddText("Record meeting?")
|
||||||
.AddText($"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.")
|
.AddText($"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.")
|
||||||
.AddButton(yesButton)
|
.AddButton(yesButton)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public sealed class AutomationOptions
|
|||||||
|
|
||||||
public sealed class CalendarRecordingPromptOptions
|
public sealed class CalendarRecordingPromptOptions
|
||||||
{
|
{
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(30);
|
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using CommunityToolkit.WinUI.Notifications;
|
using CommunityToolkit.WinUI.Notifications;
|
||||||
|
using MeetingAssistant.Notifications;
|
||||||
|
|
||||||
namespace MeetingAssistant.Recording;
|
namespace MeetingAssistant.Recording;
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
|||||||
{
|
{
|
||||||
toast.Group = NotificationGroup;
|
toast.Group = NotificationGroup;
|
||||||
toast.Tag = promptId;
|
toast.Tag = promptId;
|
||||||
toast.ExpirationTime = DateTimeOffset.Now.AddMinutes(15);
|
toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now);
|
||||||
});
|
});
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
|
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
|
||||||
@@ -138,6 +139,8 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
|||||||
return new ToastContentBuilder()
|
return new ToastContentBuilder()
|
||||||
.AddArgument("source", NotificationSource)
|
.AddArgument("source", NotificationSource)
|
||||||
.AddArgument("promptId", promptId)
|
.AddArgument("promptId", promptId)
|
||||||
|
.SetToastScenario(ToastScenario.Reminder)
|
||||||
|
.SetToastDuration(ToastDuration.Long)
|
||||||
.AddText("Stop meeting?")
|
.AddText("Stop meeting?")
|
||||||
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.")
|
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.")
|
||||||
.AddButton(yesButton)
|
.AddButton(yesButton)
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
var meetingReference = CreateReference(request.MeetingNote, now);
|
var meetingReference = CreateReference(request.MeetingNote, now);
|
||||||
var snippetResolution = await ResolveOverrideSnippetAsync(request, sourceLabel, cancellationToken);
|
var snippetResolution = await ResolveOverrideSnippetAsync(request, sourceLabel, cancellationToken);
|
||||||
var snippet = snippetResolution.WavBytes;
|
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 target = await FindIdentityByAcceptedNameAsync(context, targetName, cancellationToken);
|
||||||
var sourceCandidate = await FindCurrentRunCandidateAsync(
|
var sourceCandidate = await FindCurrentRunCandidateAsync(
|
||||||
context,
|
context,
|
||||||
@@ -277,20 +288,10 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
if (snippet.Length == 0)
|
if (snippet.Length == 0)
|
||||||
{
|
{
|
||||||
snippetSource = "extracted";
|
snippetSource = "extracted";
|
||||||
var span = SpeakerSampleSpanSelector.SelectBestContinuousSpan(
|
(snippet, _) = await ExtractBestContinuousSampleAsync(
|
||||||
request.Segments,
|
request,
|
||||||
speaker,
|
speaker,
|
||||||
options.MaximumSampleSegmentGap,
|
"Speaker identity processing",
|
||||||
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,
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,13 +522,39 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
return new SpeakerSnippetResolution([], "missing-segments", SegmentCount: 0, Score: null, Duration: TimeSpan.Zero);
|
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(
|
return new SpeakerSnippetResolution(
|
||||||
snippet,
|
snippet,
|
||||||
"extracted-from-recording",
|
"extracted-from-recording",
|
||||||
segments.Count,
|
span.Count,
|
||||||
Score: null,
|
Score: null,
|
||||||
segments.Max(segment => segment.End) - segments.Min(segment => segment.Start));
|
SpeakerSampleSpanSelector.SpanDuration(span));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(byte[] Snippet, IReadOnlyList<TranscriptionSegment> 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<SpeakerIdentity?> FindIdentityByAcceptedNameAsync(
|
private static async Task<SpeakerIdentity?> FindIdentityByAcceptedNameAsync(
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
"RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml"
|
"RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml"
|
||||||
},
|
},
|
||||||
"CalendarRecordingPrompts": {
|
"CalendarRecordingPrompts": {
|
||||||
"Enabled": false,
|
"Enabled": true,
|
||||||
"SyncInterval": "00:30:00",
|
"SyncInterval": "00:30:00",
|
||||||
"PromptWindow": "00:05:00"
|
"PromptWindow": "00:05:00"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ This example is abbreviated so the most common shape is readable. The checked-in
|
|||||||
"RulesPath": "meeting-rules.local.yaml"
|
"RulesPath": "meeting-rules.local.yaml"
|
||||||
},
|
},
|
||||||
"CalendarRecordingPrompts": {
|
"CalendarRecordingPrompts": {
|
||||||
"Enabled": false,
|
"Enabled": true,
|
||||||
"SyncInterval": "00:30:00",
|
"SyncInterval": "00:30:00",
|
||||||
"PromptWindow": "00:05: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: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 |
|
| Setting | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -295,9 +295,9 @@ See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported va
|
|||||||
|
|
||||||
## Calendar Recording Prompts
|
## 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 |
|
| Setting | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -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 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.
|
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.
|
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
|
- **AND** no transcript text has arrived for the first configured inactivity prompt threshold
|
||||||
- **WHEN** the inactivity safeguard checks the active recording
|
- **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
|
- **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
|
- **AND** does not abort or discard meeting artifacts
|
||||||
|
|
||||||
#### Scenario: Ignored inactivity prompt does not block auto-stop
|
#### Scenario: Ignored inactivity prompt does not block auto-stop
|
||||||
|
|||||||
@@ -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
|
- **AND** omits `scheduled_end` from assistant-context frontmatter
|
||||||
|
|
||||||
### Requirement: Outlook Teams meetings can prompt recording start
|
### 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.
|
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.
|
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.
|
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.
|
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
|
- **AND** Meeting Assistant has synced Outlook Classic Teams appointments for today
|
||||||
- **WHEN** the appointment reaches its scheduled start window
|
- **WHEN** the appointment reaches its scheduled start window
|
||||||
- **THEN** Meeting Assistant shows a native Windows app notification asking whether to record the meeting
|
- **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
|
- **AND** marks that appointment as prompted for the day
|
||||||
|
|
||||||
#### Scenario: Back-to-back cached Teams meetings prompt without another Outlook sync
|
#### 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
|
- **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
|
- **WHEN** a new user or assistant message is appended
|
||||||
- **THEN** the conversation scrolls to the bottom of the newly rendered message content
|
- **THEN** the conversation scrolls to the bottom of the newly rendered message content
|
||||||
|
|
||||||
|
|||||||
@@ -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 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.
|
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.
|
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
|
- **WHEN** pyannote reports multiple speakers in a candidate sample
|
||||||
- **THEN** Meeting Assistant does not retain that sample for identity matching
|
- **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
|
#### Scenario: Pyannote rejects primary match
|
||||||
- **GIVEN** pyannote secondary validation is enabled
|
- **GIVEN** pyannote secondary validation is enabled
|
||||||
- **AND** the primary identity matcher confirms `Guest03` as `Chris`
|
- **AND** the primary identity matcher confirms `Guest03` as `Chris`
|
||||||
|
|||||||
Reference in New Issue
Block a user