Make meeting lifecycle stateful

This commit is contained in:
2026-05-27 12:55:17 +02:00
parent d607b957bb
commit e85274829a
91 changed files with 4076 additions and 479 deletions
@@ -1,8 +1,10 @@
using MeetingAssistant.Recording;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Threading.Channels;
@@ -105,6 +107,8 @@ public sealed class RecordingCoordinatorTests
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", artifactStore.CreatedArtifacts?.AssistantContextPath, StringComparison.Ordinal);
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", artifactStore.CreatedArtifacts?.SummaryPath, StringComparison.Ordinal);
Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title);
Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime);
await coordinator.StopAsync(CancellationToken.None);
}
@@ -137,12 +141,47 @@ public sealed class RecordingCoordinatorTests
Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
Assert.Equal(["Ada <ada@example.com>", "Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal("Architecture Sync", artifactStore.ContextMeetingNote?.Frontmatter.Title);
Assert.Equal("Review API shape", artifactStore.Agenda);
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartCanonicalizesOutlookMeetingAttendeesBeforeWritingNote()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: new FixedMeetingMetadataProvider(new MeetingMetadata(
"Architecture Sync",
["Karl Berger <karl.work@example.com>", "Berger, Karl <karl.private@example.com>", "Ada"],
"",
null)),
attendeeCanonicalizer: new FixedAttendeeCanonicalizer(["Karl Berger", "Ada"]));
await coordinator.StartAsync(CancellationToken.None);
await WaitUntilAsync(() =>
noteStore.SavedNote?.Frontmatter.Title == "Architecture Sync" &&
noteStore.SavedNote.Frontmatter.Attendees.Count > 0);
Assert.Equal(["Karl Berger", "Ada"], noteStore.SavedNote?.Frontmatter.Attendees);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater()
{
@@ -182,6 +221,61 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartCanCreateNewRunWhilePreviousRunIsStillFinalizing()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new SequencedTranscriptStore();
var noteStore = new SequencedMeetingNoteStore();
var artifactStore = new CapturingArtifactStore();
var summaryPipeline = new RecordingSummaryPipeline();
var finalizer = new BlockingFirstFinalizer();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(
new EchoStreamingTranscriptionProvider(),
finalizer.FinalizeAsync),
transcriptStore,
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
var firstStarted = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await transcriptStore.WaitForAppendCountAsync(1);
var stopFirst = coordinator.StopAsync(CancellationToken.None);
await finalizer.WaitUntilFirstFinalizerIsBlockedAsync();
var secondStarted = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([2, 0, 3, 0], 16000, 1), CancellationToken.None);
await transcriptStore.WaitForAppendCountAsync(2);
finalizer.ReleaseFirstFinalizer();
await stopFirst.WaitAsync(TimeSpan.FromSeconds(5));
await coordinator.StopAsync(CancellationToken.None);
Assert.True(secondStarted.IsRecording);
Assert.NotEqual(firstStarted.TranscriptPath, secondStarted.TranscriptPath);
Assert.NotEqual(firstStarted.MeetingNotePath, secondStarted.MeetingNotePath);
Assert.Contains(summaryPipeline.ArtifactHistory, artifacts => artifacts.MeetingNotePath == firstStarted.MeetingNotePath);
Assert.Contains(summaryPipeline.ArtifactHistory, artifacts => artifacts.MeetingNotePath == secondStarted.MeetingNotePath);
Assert.Contains(transcriptStore.ReplacementHistory, entry =>
entry.Session.TranscriptPath == firstStarted.TranscriptPath &&
entry.Segments.Single().Text == "final run 1");
Assert.Contains(transcriptStore.ReplacementHistory, entry =>
entry.Session.TranscriptPath == secondStarted.TranscriptPath &&
entry.Segments.Single().Text == "final run 2");
Assert.All(
transcriptStore.MetadataHistory.Where(entry => entry.Session.TranscriptPath == firstStarted.TranscriptPath),
entry => Assert.Equal(firstStarted.MeetingNotePath, entry.MeetingNote.Path));
Assert.All(
transcriptStore.MetadataHistory.Where(entry => entry.Session.TranscriptPath == secondStarted.TranscriptPath),
entry => Assert.Equal(secondStarted.MeetingNotePath, entry.MeetingNote.Path));
}
[Fact]
public async Task StartCreatesMeetingNoteWhenMetadataProviderFails()
{
@@ -300,6 +394,63 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartPassesLaunchProfileToSpeechPipelineFactory()
{
var audioSource = new ControlledAudioSource();
var pipelineFactory = new CapturingProfileSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider());
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,
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
NullLogger<MeetingRecordingCoordinator>.Instance,
launchProfiles: launchProfiles);
await coordinator.StartAsync("english", CancellationToken.None);
Assert.Equal("english", pipelineFactory.LastProfileName);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartUsesLaunchProfileStorageAndSummaryOptions()
{
var defaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default");
var englishRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english");
var launchProfiles = CreateLaunchProfiles(defaultRoot, englishRoot);
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0], 16000, 1)),
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
new ProfileAwareTranscriptStore(),
new ProfileAwareMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new ProfileAwareRecordedAudioStore(),
summaryPipeline,
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
NullLogger<MeetingRecordingCoordinator>.Instance,
launchProfiles: launchProfiles);
var started = await coordinator.StartAsync("english", CancellationToken.None);
await coordinator.StopAsync(CancellationToken.None);
Assert.StartsWith(Path.Combine(englishRoot, "Transcripts"), started.TranscriptPath, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(Path.Combine(englishRoot, "Notes"), started.MeetingNotePath, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(Path.Combine(englishRoot, "Context"), started.AssistantContextPath, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(Path.Combine(englishRoot, "Summaries"), started.SummaryPath, StringComparison.OrdinalIgnoreCase);
Assert.Equal("english-summary-model", summaryPipeline.Options?.Agent.Model);
}
[Fact]
public async Task StartPassesDictationWordsToSpeechPipeline()
{
@@ -512,9 +663,9 @@ public sealed class RecordingCoordinatorTests
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None);
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal)));
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => segment.Text?.Contains("first", StringComparison.Ordinal) == true));
await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal)));
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Text?.Contains("first", StringComparison.Ordinal) == true));
Assert.Equal("Chris", transcriptStore.ReplacedSegments.Single().Speaker);
await Task.Delay(50);
await audioSource.WriteAsync(new AudioChunk(Samples(8, 9, 10, 11, 12, 13, 14, 15), 4, 1), CancellationToken.None);
@@ -528,7 +679,7 @@ public sealed class RecordingCoordinatorTests
second => Assert.Equal("Chris", second.Speaker));
Assert.Contains(
transcriptStore.ReplacedSegments,
segment => segment.Text.Contains("first", StringComparison.Ordinal) && segment.Speaker == "Chris");
segment => segment.Text?.Contains("first", StringComparison.Ordinal) == true && segment.Speaker == "Chris");
}
[Fact]
@@ -714,13 +865,14 @@ public sealed class RecordingCoordinatorTests
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var transcriptStore = new InMemoryTranscriptStore();
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
@@ -732,6 +884,7 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
Assert.NotNull(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime);
Assert.Equal(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime, artifactStore.ContextMeetingNote?.Frontmatter.EndTime);
}
[Fact]
@@ -757,7 +910,7 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(
[AssistantContextState.Summarizing, AssistantContextState.Finished],
[AssistantContextState.Transcribing, AssistantContextState.Summarizing, AssistantContextState.Finished],
artifactStore.States);
}
@@ -784,7 +937,7 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(
[AssistantContextState.Summarizing, AssistantContextState.Error],
[AssistantContextState.Transcribing, AssistantContextState.Summarizing, AssistantContextState.Error],
artifactStore.States);
}
@@ -818,7 +971,7 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(
[AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished],
[AssistantContextState.Transcribing, AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished],
artifactStore.States);
}
@@ -842,6 +995,23 @@ public sealed class RecordingCoordinatorTests
Assert.True(Directory.Exists(vaultFolder));
Assert.EndsWith(".md", session.TranscriptPath, StringComparison.Ordinal);
Assert.Contains("hello vault", await File.ReadAllTextAsync(session.TranscriptPath));
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(vaultFolder, "meeting.md"),
TranscriptPath: session.TranscriptPath,
AssistantContextPath: Path.Combine(vaultFolder, "context.md"),
SummaryPath: Path.Combine(vaultFolder, "summary.md"));
await store.UpdateMetadataAsync(
session,
artifacts,
MeetingNoteTemplate.Create("Transcript Metadata", transcriptPath: session.TranscriptPath),
CancellationToken.None);
var content = await File.ReadAllTextAsync(session.TranscriptPath);
Assert.Contains("meeting: \"[[meeting|Meeting Note]]\"", content);
Assert.Contains("assistant_context: \"[[context|Assistant Context]]\"", content);
Assert.Contains("summary: \"[[summary|Summary]]\"", content);
Assert.DoesNotContain("transcript:", content);
}
[Fact]
@@ -903,6 +1073,29 @@ public sealed class RecordingCoordinatorTests
};
}
private static ILaunchProfileOptionsProvider CreateLaunchProfiles(
string defaultRoot,
string englishRoot)
{
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"
})
.Build();
return new ConfigurationLaunchProfileOptionsProvider(configuration);
}
private sealed class InMemoryTranscriptStore : ITranscriptStore
{
private readonly List<TranscriptionSegment> segments = [];
@@ -931,7 +1124,7 @@ public sealed class RecordingCoordinatorTests
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (segments.Any(segment => segment.Text.Contains(text, StringComparison.Ordinal)))
if (segments.Any(segment => segment.Text?.Contains(text, StringComparison.Ordinal) == true))
{
return;
}
@@ -968,6 +1161,82 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class SequencedTranscriptStore : ITranscriptStore
{
private int created;
private int appendCount;
private TaskCompletionSource appendObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<TranscriptWrite> AppendHistory { get; } = [];
public List<TranscriptReplacement> ReplacementHistory { get; } = [];
public List<TranscriptMetadataUpdate> MetadataHistory { get; } = [];
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
var index = Interlocked.Increment(ref created);
return Task.FromResult(new TranscriptSession($"memory-transcript-{index}.md"));
}
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
{
AppendHistory.Add(new TranscriptWrite(session, segment));
Interlocked.Increment(ref appendCount);
appendObserved.TrySetResult();
return Task.CompletedTask;
}
public async Task WaitForAppendCountAsync(int expectedCount)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (Volatile.Read(ref appendCount) >= expectedCount)
{
return;
}
var observed = appendObserved;
await observed.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
if (ReferenceEquals(observed, appendObserved))
{
appendObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
throw new TimeoutException($"Expected {expectedCount} transcript append(s).");
}
public Task ReplaceAsync(
TranscriptSession session,
IReadOnlyList<TranscriptionSegment> replacementSegments,
CancellationToken cancellationToken)
{
ReplacementHistory.Add(new TranscriptReplacement(session, replacementSegments));
return Task.CompletedTask;
}
public Task UpdateMetadataAsync(
TranscriptSession session,
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
CancellationToken cancellationToken)
{
MetadataHistory.Add(new TranscriptMetadataUpdate(session, artifacts, meetingNote));
return Task.CompletedTask;
}
public sealed record TranscriptWrite(TranscriptSession Session, TranscriptionSegment Segment);
public sealed record TranscriptReplacement(TranscriptSession Session, IReadOnlyList<TranscriptionSegment> Segments);
public sealed record TranscriptMetadataUpdate(
TranscriptSession Session,
MeetingSessionArtifacts Artifacts,
MeetingNote MeetingNote);
}
private sealed class InMemoryMeetingNoteStore : IMeetingNoteStore
{
private readonly string notePath;
@@ -1006,6 +1275,29 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class SequencedMeetingNoteStore : IMeetingNoteStore
{
private int saved;
private readonly Dictionary<string, MeetingNote> notes = new(StringComparer.OrdinalIgnoreCase);
public Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
{
var path = string.IsNullOrWhiteSpace(note.Path)
? $"memory-meeting-{Interlocked.Increment(ref saved)}.md"
: note.Path;
var savedNote = note with { Path = path };
notes[path] = savedNote;
return Task.FromResult(savedNote);
}
public Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
{
return Task.FromResult(notes.TryGetValue(path, out var note)
? note
: throw new FileNotFoundException(path));
}
}
private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener
{
public string? OpenedPath { get; private set; }
@@ -1027,13 +1319,17 @@ public sealed class RecordingCoordinatorTests
public DateTimeOffset? ScheduledEnd { get; private set; }
public MeetingNote? ContextMeetingNote { get; private set; }
public Task CreateAssistantContextAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
CreatedArtifacts = artifacts;
ContextMeetingNote = meetingNote;
Agenda = agenda;
ScheduledEnd = scheduledEnd;
return Task.CompletedTask;
@@ -1050,14 +1346,70 @@ public sealed class RecordingCoordinatorTests
public Task UpdateAssistantContextMetadataAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
ContextMeetingNote = meetingNote;
Agenda = agenda;
ScheduledEnd = scheduledEnd;
return Task.CompletedTask;
}
public Task UpdateAssistantContextMeetingAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
CancellationToken cancellationToken)
{
ContextMeetingNote = meetingNote;
return Task.CompletedTask;
}
}
private sealed class CapturingArtifactStore : IMeetingArtifactStore
{
public List<MeetingSessionArtifacts> CreatedArtifacts { get; } = [];
public List<(MeetingSessionArtifacts Artifacts, AssistantContextState State)> States { get; } = [];
public Task CreateAssistantContextAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
CreatedArtifacts.Add(artifacts);
return Task.CompletedTask;
}
public Task UpdateAssistantContextMetadataAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task UpdateAssistantContextMeetingAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task UpdateAssistantContextStateAsync(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
CancellationToken cancellationToken)
{
States.Add((artifacts, state));
return Task.CompletedTask;
}
}
private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider
@@ -1130,6 +1482,8 @@ public sealed class RecordingCoordinatorTests
public MeetingSessionArtifacts? Artifacts { get; private set; }
public MeetingAssistantOptions? Options { get; private set; }
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
@@ -1141,6 +1495,192 @@ public sealed class RecordingCoordinatorTests
succeeded,
succeeded ? null : "error"));
}
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
Options = options;
return RunAsync(artifacts, cancellationToken);
}
}
private sealed class RecordingSummaryPipeline : IMeetingSummaryPipeline
{
public List<MeetingSessionArtifacts> ArtifactHistory { get; } = [];
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
ArtifactHistory.Add(artifacts);
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 BlockingFirstFinalizer
{
private readonly TaskCompletionSource firstFinalizerBlocked =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource releaseFirstFinalizer =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int calls;
public Task WaitUntilFirstFinalizerIsBlockedAsync()
{
return firstFinalizerBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void ReleaseFirstFinalizer()
{
releaseFirstFinalizer.TrySetResult();
}
public async Task<IReadOnlyList<TranscriptionSegment>> FinalizeAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> liveSegments,
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
var call = Interlocked.Increment(ref calls);
if (call == 1)
{
firstFinalizerBlocked.TrySetResult();
await releaseFirstFinalizer.Task.WaitAsync(cancellationToken);
}
return
[
new TranscriptionSegment(
TimeSpan.Zero,
TimeSpan.FromSeconds(1),
"Unknown",
$"final run {call}")
];
}
}
private sealed class ProfileAwareTranscriptStore : ITranscriptStore
{
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
throw new InvalidOperationException("Profile options were not supplied.");
}
public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
return Task.FromResult(new TranscriptSession(Path.Combine(folder, "transcript.md")));
}
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task ReplaceAsync(
TranscriptSession session,
IReadOnlyList<TranscriptionSegment> replacementSegments,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task UpdateMetadataAsync(
TranscriptSession session,
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class ProfileAwareMeetingNoteStore : IMeetingNoteStore
{
private MeetingNote? savedNote;
public Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
{
throw new InvalidOperationException("Profile options were not supplied.");
}
public Task<MeetingNote> SaveAsync(
MeetingNote note,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
savedNote = note with { Path = string.IsNullOrWhiteSpace(note.Path) ? Path.Combine(folder, "meeting.md") : note.Path };
return Task.FromResult(savedNote);
}
public Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
{
return Task.FromResult(savedNote ?? throw new FileNotFoundException(path));
}
}
private sealed class ProfileAwareRecordedAudioStore : IRecordedAudioStore
{
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
{
throw new InvalidOperationException("Profile options were not supplied.");
}
public Task<IRecordedAudioSink> CreateSessionAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder);
return Task.FromResult<IRecordedAudioSink>(new Sink(Path.Combine(folder, "recording.wav")));
}
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private sealed class Sink : IRecordedAudioSink
{
public Sink(string audioPath)
{
AudioPath = audioPath;
}
public string AudioPath { get; }
public Task AppendAsync(AudioChunk chunk, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task CompleteAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
public Task DeleteAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}
private sealed class InMemoryRecordedAudioStore : IRecordedAudioStore
@@ -1233,6 +1773,31 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class CapturingProfileSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly IStreamingTranscriptionProvider provider;
public CapturingProfileSpeechRecognitionPipelineFactory(IStreamingTranscriptionProvider provider)
{
this.provider = provider;
}
public string? LastProfileName { get; private set; }
public ISpeechRecognitionPipeline Create()
{
return Create(null);
}
public ISpeechRecognitionPipeline Create(string? launchProfileName)
{
LastProfileName = launchProfileName;
return new TestSpeechRecognitionPipeline(
provider,
(_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
}
}
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
{
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
@@ -1397,6 +1962,23 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class FixedAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
{
private readonly IReadOnlyList<string> attendees;
public FixedAttendeeCanonicalizer(IReadOnlyList<string> attendees)
{
this.attendees = attendees;
}
public Task<IReadOnlyList<string>> CanonicalizeAsync(
IReadOnlyList<string> attendees,
CancellationToken cancellationToken)
{
return Task.FromResult(this.attendees);
}
}
private sealed class CapturingPipelineOptionsProvider : IStreamingTranscriptionProvider
{
public TaskCompletionSource OptionsObserved { get; } =