Public Access
Archive summary refinements and profile switching
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
This commit is contained in:
@@ -582,6 +582,139 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleToDifferentLaunchProfileWhileRecordingSwitchesPipelineWithoutNewArtifactsOrSummary()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var pipelineFactory = new ProfileSwitchSpeechRecognitionPipelineFactory();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var metadataProvider = new CountingMeetingMetadataProvider();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var launchProfiles = CreateLaunchProfiles(
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"));
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
pipelineFactory,
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: metadataProvider,
|
||||
launchProfiles: launchProfiles);
|
||||
|
||||
var started = await coordinator.ToggleAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("default chunk:2");
|
||||
|
||||
var switched = await coordinator.ToggleAsync("english", CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("english chunk:4");
|
||||
|
||||
Assert.True(switched.IsRecording);
|
||||
Assert.Equal(started.TranscriptPath, switched.TranscriptPath);
|
||||
Assert.Equal(started.MeetingNotePath, switched.MeetingNotePath);
|
||||
Assert.Equal(started.AssistantContextPath, switched.AssistantContextPath);
|
||||
Assert.Equal(started.SummaryPath, switched.SummaryPath);
|
||||
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
|
||||
Assert.Contains(transcriptStore.Segments, segment =>
|
||||
segment.Speaker == "System" &&
|
||||
segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal));
|
||||
Assert.Null(summaryPipeline.Artifacts);
|
||||
await WaitUntilAsync(() => metadataProvider.CallCount == 1);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleToDifferentLaunchProfileBuffersAudioCapturedWhilePreviousPipelineDrains()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var pipelineFactory = new BlockingProfileSwitchSpeechRecognitionPipelineFactory();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var launchProfiles = CreateLaunchProfiles(
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"));
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
pipelineFactory,
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
launchProfiles: launchProfiles);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("default chunk:2");
|
||||
|
||||
var switchTask = coordinator.ToggleAsync("english", CancellationToken.None);
|
||||
await pipelineFactory.WaitUntilFirstPipelineDrainIsBlockedAsync();
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0, 3, 0], 16000, 1), CancellationToken.None);
|
||||
|
||||
Assert.False(switchTask.IsCompleted);
|
||||
|
||||
pipelineFactory.ReleaseFirstPipelineDrain();
|
||||
await switchTask.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await transcriptStore.WaitForTextAsync("english chunk:6");
|
||||
|
||||
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleToDifferentLaunchProfileClearsFutureSpeakerMappings()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var speakerIdentification = new SingleMappingSpeakerIdentificationService("Guest03", "Chris");
|
||||
var launchProfiles = CreateLaunchProfiles(
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"),
|
||||
enableSpeakerIdentification: true);
|
||||
var options = launchProfiles.GetRequiredProfile(null).Options;
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new ProfileSwitchSpeechRecognitionPipelineFactory("Guest03"),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification,
|
||||
launchProfiles: launchProfiles);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(CreateThreeSecondChunk(), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("default chunk:");
|
||||
await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Speaker == "Chris"));
|
||||
|
||||
await coordinator.ToggleAsync("english", CancellationToken.None);
|
||||
await audioSource.WriteAsync(CreateThreeSecondChunk(), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("english chunk:");
|
||||
|
||||
var afterMarker = transcriptStore.Segments
|
||||
.SkipWhile(segment => !segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal))
|
||||
.Skip(1)
|
||||
.ToList();
|
||||
Assert.Contains(afterMarker, segment => segment.Speaker == "Guest03");
|
||||
Assert.DoesNotContain(afterMarker, segment => segment.Speaker == "Chris");
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartUsesLaunchProfileStorageAndSummaryOptions()
|
||||
{
|
||||
@@ -1263,6 +1396,118 @@ public sealed class RecordingCoordinatorTests
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalSpeakerIdentityProcessingUsesAttendeesUpdatedBySummary()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var speakerIdentification = new CapturingFinalSpeakerIdentificationService();
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")]);
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(
|
||||
new FinalSegmentOnAudioCompletionProvider(),
|
||||
finalizer.FinalizeAsync),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new AttendeeUpdatingSummaryPipeline(noteStore, ["Summary Attendee"]),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(["Summary Attendee"], speakerIdentification.FinalAttendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalSpeakerIdentityProcessingAppliesSummarySpeakerOverrides()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "Transcripts", "transcript.md"));
|
||||
var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "Notes", "meeting.md"));
|
||||
var speakerIdentification = new CapturingOverrideSpeakerIdentificationService();
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-01", "hello")]);
|
||||
var options = CreateOptionsWithoutFinalizer();
|
||||
options.Vault.MeetingNotesFolder = Path.Combine(root, "Notes");
|
||||
options.Vault.TranscriptsFolder = Path.Combine(root, "Transcripts");
|
||||
options.Vault.AssistantContextFolder = Path.Combine(root, "Assistant Context");
|
||||
options.Vault.SummariesFolder = Path.Combine(root, "Summaries");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(
|
||||
new FinalSegmentOnAudioCompletionProvider(),
|
||||
finalizer.FinalizeAsync),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(createAssistantContextFile: true),
|
||||
new InMemoryRecordedAudioStore(Path.Combine(root, "recording.wav")),
|
||||
new SummarySpeakerOverridePipeline("Guest-01", "Sabrina"),
|
||||
Options.Create(options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(("Guest-01", "Sabrina"), speakerIdentification.Overrides.Single());
|
||||
Assert.Equal("Sabrina", speakerIdentification.FinalSegments.Single().Speaker);
|
||||
Assert.Equal("Sabrina", transcriptStore.ReplacedSegments.Single().Speaker);
|
||||
Assert.Equal("Sabrina", speakerIdentification.FinalKnownSpeakerMappings["Guest-01"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalSpeakerIdentityProcessingAppliesSummaryIdentityDeletions()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "Transcripts", "transcript.md"));
|
||||
var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "Notes", "meeting.md"));
|
||||
var speakerIdentification = new CapturingOverrideSpeakerIdentificationService("Guest-01", "Sabrina");
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-01", "hello")]);
|
||||
var options = CreateOptionsWithoutFinalizer();
|
||||
options.Vault.MeetingNotesFolder = Path.Combine(root, "Notes");
|
||||
options.Vault.TranscriptsFolder = Path.Combine(root, "Transcripts");
|
||||
options.Vault.AssistantContextFolder = Path.Combine(root, "Assistant Context");
|
||||
options.Vault.SummariesFolder = Path.Combine(root, "Summaries");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(
|
||||
new FinalSegmentOnAudioCompletionProvider(),
|
||||
finalizer.FinalizeAsync),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(createAssistantContextFile: true),
|
||||
new InMemoryRecordedAudioStore(Path.Combine(root, "recording.wav")),
|
||||
new SummaryIdentityDeletionPipeline("Sabrina"),
|
||||
Options.Create(options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("Sabrina", speakerIdentification.DeletedIdentities.Single());
|
||||
Assert.Equal("Removed-1", speakerIdentification.FinalSegments.Single().Speaker);
|
||||
Assert.Equal("Removed-1", transcriptStore.ReplacedSegments.Single().Speaker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordingLifecycleRunsMeetingWorkflowEvents()
|
||||
{
|
||||
@@ -1398,25 +1643,39 @@ public sealed class RecordingCoordinatorTests
|
||||
};
|
||||
}
|
||||
|
||||
private static AudioChunk CreateThreeSecondChunk()
|
||||
{
|
||||
return new AudioChunk(new byte[16000 * 2 * 3], 16000, 1);
|
||||
}
|
||||
|
||||
private static ILaunchProfileOptionsProvider CreateLaunchProfiles(
|
||||
string defaultRoot,
|
||||
string englishRoot)
|
||||
string englishRoot,
|
||||
bool enableSpeakerIdentification = false)
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Vault:BaseFolder"] = defaultRoot,
|
||||
["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts",
|
||||
["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes",
|
||||
["MeetingAssistant:Vault:AssistantContextFolder"] = "Context",
|
||||
["MeetingAssistant:Vault:SummariesFolder"] = "Summaries",
|
||||
["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(defaultRoot, "Recordings"),
|
||||
["MeetingAssistant:Agent:Model"] = "default-summary-model",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:Vault:BaseFolder"] = englishRoot,
|
||||
["MeetingAssistant:LaunchProfiles:english:Recording:TemporaryRecordingsFolder"] = Path.Combine(englishRoot, "Recordings"),
|
||||
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-summary-model"
|
||||
};
|
||||
if (enableSpeakerIdentification)
|
||||
{
|
||||
values["MeetingAssistant:SpeakerIdentification:Enabled"] = "true";
|
||||
values["MeetingAssistant:SpeakerIdentification:InitialDelay"] = "00:00:00";
|
||||
values["MeetingAssistant:SpeakerIdentification:Interval"] = "00:00:00.050";
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Vault:BaseFolder"] = defaultRoot,
|
||||
["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts",
|
||||
["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes",
|
||||
["MeetingAssistant:Vault:AssistantContextFolder"] = "Context",
|
||||
["MeetingAssistant:Vault:SummariesFolder"] = "Summaries",
|
||||
["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(defaultRoot, "Recordings"),
|
||||
["MeetingAssistant:Agent:Model"] = "default-summary-model",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:Vault:BaseFolder"] = englishRoot,
|
||||
["MeetingAssistant:LaunchProfiles:english:Recording:TemporaryRecordingsFolder"] = Path.Combine(englishRoot, "Recordings"),
|
||||
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-summary-model"
|
||||
})
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
return new ConfigurationLaunchProfileOptionsProvider(configuration);
|
||||
}
|
||||
@@ -1737,6 +1996,13 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
private readonly bool createAssistantContextFile;
|
||||
|
||||
public InMemoryMeetingArtifactStore(bool createAssistantContextFile = false)
|
||||
{
|
||||
this.createAssistantContextFile = createAssistantContextFile;
|
||||
}
|
||||
|
||||
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
|
||||
|
||||
public List<AssistantContextState> States { get; } = [];
|
||||
@@ -1758,6 +2024,21 @@ public sealed class RecordingCoordinatorTests
|
||||
ContextMeetingNote = meetingNote;
|
||||
Agenda = agenda;
|
||||
ScheduledEnd = scheduledEnd;
|
||||
if (createAssistantContextFile)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
return File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
"""
|
||||
---
|
||||
state: summarizing
|
||||
---
|
||||
|
||||
# Assistant Context
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1855,6 +2136,19 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult<MeetingMetadata?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
@@ -1953,6 +2247,102 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AttendeeUpdatingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly InMemoryMeetingNoteStore noteStore;
|
||||
private readonly IReadOnlyList<string> attendees;
|
||||
|
||||
public AttendeeUpdatingSummaryPipeline(
|
||||
InMemoryMeetingNoteStore noteStore,
|
||||
IReadOnlyList<string> attendees)
|
||||
{
|
||||
this.noteStore = noteStore;
|
||||
this.attendees = attendees;
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
noteStore.UpdateAttendees(attendees);
|
||||
return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok"));
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return RunAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SummarySpeakerOverridePipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly string sourceSpeaker;
|
||||
private readonly string targetSpeaker;
|
||||
|
||||
public SummarySpeakerOverridePipeline(
|
||||
string sourceSpeaker,
|
||||
string targetSpeaker)
|
||||
{
|
||||
this.sourceSpeaker = sourceSpeaker;
|
||||
this.targetSpeaker = targetSpeaker;
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await SpeakerOverrideArtifacts.AppendToAssistantContextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
new SpeakerOverride(sourceSpeaker, targetSpeaker),
|
||||
cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok");
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return RunAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SummaryIdentityDeletionPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly string identity;
|
||||
|
||||
public SummaryIdentityDeletionPipeline(string identity)
|
||||
{
|
||||
this.identity = identity;
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deletion = await SpeakerOverrideArtifacts.ApplyDeletionToTranscriptAsync(
|
||||
artifacts.TranscriptPath,
|
||||
identity,
|
||||
cancellationToken);
|
||||
await SpeakerOverrideArtifacts.AppendIdentityDeletionToAssistantContextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
deletion,
|
||||
cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok");
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return RunAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingFirstFinalizer
|
||||
{
|
||||
private readonly TaskCompletionSource firstFinalizerBlocked =
|
||||
@@ -2224,6 +2614,129 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly string speaker;
|
||||
|
||||
public ProfileSwitchSpeechRecognitionPipelineFactory(string speaker = "Unknown")
|
||||
{
|
||||
this.speaker = speaker;
|
||||
}
|
||||
|
||||
public List<string?> ProfileNames { get; } = [];
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return Create(null);
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||
{
|
||||
ProfileNames.Add(launchProfileName);
|
||||
return new TestSpeechRecognitionPipeline(
|
||||
new ProfileSwitchTranscriptionProvider(launchProfileName ?? "default", speaker),
|
||||
(_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly TaskCompletionSource firstPipelineDrainBlocked =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource releaseFirstPipelineDrain =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int created;
|
||||
|
||||
public List<string?> ProfileNames { get; } = [];
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return Create(null);
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||
{
|
||||
ProfileNames.Add(launchProfileName);
|
||||
var createIndex = Interlocked.Increment(ref created);
|
||||
var provider = createIndex == 1
|
||||
? new BlockingOnCompletionProfileSwitchTranscriptionProvider(
|
||||
launchProfileName ?? "default",
|
||||
firstPipelineDrainBlocked,
|
||||
releaseFirstPipelineDrain)
|
||||
: new ProfileSwitchTranscriptionProvider(launchProfileName ?? "default", "Unknown");
|
||||
return new TestSpeechRecognitionPipeline(
|
||||
provider,
|
||||
(_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
|
||||
}
|
||||
|
||||
public Task WaitUntilFirstPipelineDrainIsBlockedAsync()
|
||||
{
|
||||
return firstPipelineDrainBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void ReleaseFirstPipelineDrain()
|
||||
{
|
||||
releaseFirstPipelineDrain.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private class ProfileSwitchTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
private readonly string profileName;
|
||||
private readonly string speaker;
|
||||
|
||||
public ProfileSwitchTranscriptionProvider(string profileName, string speaker)
|
||||
{
|
||||
this.profileName = profileName;
|
||||
this.speaker = speaker;
|
||||
}
|
||||
|
||||
public virtual async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
yield return new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(3),
|
||||
speaker,
|
||||
$"{profileName} chunk:{chunk.Pcm.Length} has enough words for identification.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingOnCompletionProfileSwitchTranscriptionProvider : ProfileSwitchTranscriptionProvider
|
||||
{
|
||||
private readonly TaskCompletionSource drainBlocked;
|
||||
private readonly TaskCompletionSource releaseDrain;
|
||||
|
||||
public BlockingOnCompletionProfileSwitchTranscriptionProvider(
|
||||
string profileName,
|
||||
TaskCompletionSource drainBlocked,
|
||||
TaskCompletionSource releaseDrain)
|
||||
: base(profileName, "Unknown")
|
||||
{
|
||||
this.drainBlocked = drainBlocked;
|
||||
this.releaseDrain = releaseDrain;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var segment in base.TranscribeAsync(audio, options, cancellationToken))
|
||||
{
|
||||
yield return segment;
|
||||
}
|
||||
|
||||
drainBlocked.TrySetResult();
|
||||
await releaseDrain.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||
{
|
||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||
@@ -2348,6 +2861,143 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleMappingSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
private readonly string sourceSpeaker;
|
||||
private readonly string targetSpeaker;
|
||||
private int mapped;
|
||||
|
||||
public SingleMappingSpeakerIdentificationService(string sourceSpeaker, string targetSpeaker)
|
||||
{
|
||||
this.sourceSpeaker = sourceSpeaker;
|
||||
this.targetSpeaker = targetSpeaker;
|
||||
}
|
||||
|
||||
public TaskCompletionSource IdentificationObserved { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IdentificationObserved.TrySetResult();
|
||||
if (Interlocked.Exchange(ref mapped, 1) == 0)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(
|
||||
request.Segments,
|
||||
new Dictionary<string, string> { [sourceSpeaker] = targetSpeaker },
|
||||
[new SpeakerIdentityAttendeeMatch(targetSpeaker, [targetSpeaker])]));
|
||||
}
|
||||
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingFinalSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
public IReadOnlyList<string> FinalAttendees { get; private set; } = [];
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FinalAttendees = request.MeetingNote.Frontmatter.Attendees.ToList();
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingOverrideSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
private readonly string? readOnlySourceSpeaker;
|
||||
private readonly string? readOnlyTargetSpeaker;
|
||||
|
||||
public CapturingOverrideSpeakerIdentificationService(
|
||||
string? readOnlySourceSpeaker = null,
|
||||
string? readOnlyTargetSpeaker = null)
|
||||
{
|
||||
this.readOnlySourceSpeaker = readOnlySourceSpeaker;
|
||||
this.readOnlyTargetSpeaker = readOnlyTargetSpeaker;
|
||||
}
|
||||
|
||||
public List<(string SourceSpeaker, string TargetSpeaker)> Overrides { get; } = [];
|
||||
|
||||
public List<string> DeletedIdentities { get; } = [];
|
||||
|
||||
public IReadOnlyList<TranscriptionSegment> FinalSegments { get; private set; } = [];
|
||||
|
||||
public IReadOnlyDictionary<string, string> FinalKnownSpeakerMappings { get; private set; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(readOnlySourceSpeaker) ||
|
||||
string.IsNullOrWhiteSpace(readOnlyTargetSpeaker))
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
return Task.FromResult(new SpeakerIdentificationResult(
|
||||
request.Segments.Select(segment => segment.Speaker == readOnlySourceSpeaker
|
||||
? segment with { Speaker = readOnlyTargetSpeaker }
|
||||
: segment).ToList(),
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[readOnlySourceSpeaker] = readOnlyTargetSpeaker
|
||||
}));
|
||||
}
|
||||
|
||||
public Task ApplySpeakerOverrideAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
string sourceSpeaker,
|
||||
string targetSpeaker,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Overrides.Add((sourceSpeaker, targetSpeaker));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteSpeakerIdentityAsync(
|
||||
string identity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DeletedIdentities.Add(identity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FinalSegments = request.Segments;
|
||||
FinalKnownSpeakerMappings = request.KnownSpeakerMappings ??
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
public List<SpeakerIdentificationRequest> Requests { get; } = [];
|
||||
|
||||
Reference in New Issue
Block a user