Public Access
Add resilient Azure offline transcription backlog
PR and Push Build/Test / build-and-test (push) Successful in 18m41s
PR and Push Build/Test / build-and-test (push) Successful in 18m41s
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -203,6 +204,71 @@ public sealed class MeetingWorkflowEngineTests
|
||||
Assert.Equal("[00:00:04] Guest-1: Azure returned [redacted] here.", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TranscriptLineRulePreservesUtf8CharactersWhenRenderingRazor()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml);
|
||||
|
||||
var line = await fixture.Engine.TransformTranscriptLineAsync(
|
||||
MeetingWorkflowEvent.TranscriptLine(
|
||||
fixture.Artifacts,
|
||||
"[00:00:57] Guest-1: Das heißt, Schimpfwörter wie ***** müssen als nächstes richtig bleiben.",
|
||||
"Guest-1"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
"[00:00:57] Guest-1: Das heißt, Schimpfwörter wie [redacted] müssen als nächstes richtig bleiben.",
|
||||
line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggeredWorkflowRuleLogsStartAndCompletion()
|
||||
{
|
||||
var logger = new ListLogger<MeetingWorkflowEngine>();
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: logged-rule
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: Diagnostics
|
||||
""",
|
||||
logger: logger);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
Assert.Contains(logger.Messages, message =>
|
||||
message.Contains("Triggered meeting workflow rule logged-rule for event Created", StringComparison.Ordinal));
|
||||
Assert.Contains(logger.Messages, message =>
|
||||
message.Contains("Completed meeting workflow rule logged-rule for event Created", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowRuleFailureLogsRuleNameAndEventType()
|
||||
{
|
||||
var logger = new ListLogger<MeetingWorkflowEngine>();
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: broken-rule
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: set_property
|
||||
property: unsupported
|
||||
value: Diagnostics
|
||||
""",
|
||||
logger: logger);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => RunCreatedAsync(fixture));
|
||||
|
||||
Assert.Contains(logger.Messages, message =>
|
||||
message.Contains("Meeting workflow rule broken-rule failed for event Created", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuleCanRemoveAttendeeWhenNestedConditionMatches()
|
||||
{
|
||||
@@ -775,7 +841,8 @@ public sealed class MeetingWorkflowEngineTests
|
||||
string title = "Planning Sync",
|
||||
IReadOnlyList<string>? attendees = null,
|
||||
IReadOnlyList<string>? projects = null,
|
||||
string? rulesPath = null)
|
||||
string? rulesPath = null,
|
||||
ILogger<MeetingWorkflowEngine>? logger = null)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
@@ -837,7 +904,7 @@ public sealed class MeetingWorkflowEngineTests
|
||||
NullLogger<FileMeetingWorkflowRulesProvider>.Instance),
|
||||
noteStore,
|
||||
artifactStore,
|
||||
NullLogger<MeetingWorkflowEngine>.Instance);
|
||||
logger ?? NullLogger<MeetingWorkflowEngine>.Instance);
|
||||
return new WorkflowFixture(options, noteStore, artifacts, engine);
|
||||
}
|
||||
|
||||
@@ -851,4 +918,39 @@ public sealed class MeetingWorkflowEngineTests
|
||||
return File.ReadAllTextAsync(Artifacts.AssistantContextPath);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ListLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state)
|
||||
where TState : notnull
|
||||
{
|
||||
return NullScope.Instance;
|
||||
}
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Messages.Add(formatter(state, exception));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static NullScope Instance { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
var started = await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() => FileContainsText(started.TranscriptPath!, "[redacted]"));
|
||||
await WaitUntilAsync(() => FileContainsText(started.TranscriptPath!, "Azure returned"));
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(started.TranscriptPath!);
|
||||
@@ -180,6 +180,224 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.DoesNotContain("*****", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TranscriptLineWorkflowFailureKeepsWritingLiveTranscriptSegments()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingWorkflowEngine: new FailingFirstTranscriptLineWorkflowEngine());
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([2, 0], 16000, 1), CancellationToken.None);
|
||||
|
||||
await transcriptStore.WaitForTextAsync("first has enough words for identification.");
|
||||
await transcriptStore.WaitForTextAsync("second has enough words for identification.");
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Contains(transcriptStore.Segments, segment =>
|
||||
segment.Text.Contains("first has enough words for identification.", StringComparison.Ordinal));
|
||||
Assert.Contains(transcriptStore.Segments, segment =>
|
||||
segment.Text.Contains("second has enough words for identification.", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TranscriptLineWorkflowRewriteRunsAfterOriginalLineIsWritten()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(
|
||||
new FixedSegmentStreamingTranscriptionProvider(
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(4),
|
||||
TimeSpan.FromSeconds(5),
|
||||
"Guest-1",
|
||||
"Azure returned ***** here."))),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingWorkflowEngine: new ObservingTranscriptRewriteWorkflowEngine(transcriptStore));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
|
||||
await transcriptStore.WaitForTextAsync("Azure returned [redacted] here.");
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain(transcriptStore.Segments, segment =>
|
||||
segment.Text.Contains("*****", StringComparison.Ordinal));
|
||||
Assert.Contains(transcriptStore.Segments, segment =>
|
||||
segment.Text.Contains("Azure returned [redacted] here.", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PendingTranscriptLineWorkflowRewriteDoesNotRemoveFollowingLine()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var workflowEngine = new DelayedFirstTranscriptLineWorkflowEngine();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingWorkflowEngine: workflowEngine);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("first has enough words for identification.");
|
||||
await workflowEngine.WaitUntilFirstLineWorkflowStartedAsync();
|
||||
|
||||
await audioSource.WriteAsync(new AudioChunk([2, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("second has enough words for identification.");
|
||||
|
||||
workflowEngine.CompleteFirstLineWorkflow();
|
||||
await transcriptStore.WaitForTextAsync("rewritten first has enough words for identification.");
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
var segments = transcriptStore.Segments;
|
||||
Assert.Equal(
|
||||
["rewritten first has enough words for identification.", "second has enough words for identification."],
|
||||
segments.Select(segment => segment.Text).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TranscriptReconnectMarkerIsReplacedByNextAzureTranscriptLine()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new StaticOnAudioCompletionProvider(
|
||||
[
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
"System",
|
||||
"<Reconnecting... 1/5>",
|
||||
TranscriptionSegmentKind.Marker,
|
||||
MarkerId: "azure-reconnect"),
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2),
|
||||
"Guest-1",
|
||||
"speech after reconnect",
|
||||
ReplacesMarkerId: "azure-reconnect"),
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(3),
|
||||
TimeSpan.FromSeconds(4),
|
||||
"Guest-2",
|
||||
"following speech")
|
||||
])),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
var segments = transcriptStore.Segments;
|
||||
Assert.Equal(
|
||||
["speech after reconnect", "following speech"],
|
||||
segments.Select(segment => segment.Text).ToArray());
|
||||
Assert.DoesNotContain(segments, segment =>
|
||||
segment.Text.Contains("Reconnecting", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TranscriptReconnectMarkerUpdatesReuseSameLineUntilAzureTranscriptResumes()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new StaticOnAudioCompletionProvider(
|
||||
[
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
"System",
|
||||
"<Reconnecting... 1/5>",
|
||||
TranscriptionSegmentKind.Marker,
|
||||
MarkerId: "azure-reconnect"),
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
"System",
|
||||
"<Reconnecting... 2/5>",
|
||||
TranscriptionSegmentKind.Marker,
|
||||
MarkerId: "azure-reconnect"),
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
"System",
|
||||
"<Azure Speech disconnected. The meeting can continue; transcription and summarization will continue once Azure Speech reconnects.>",
|
||||
TranscriptionSegmentKind.Marker,
|
||||
MarkerId: "azure-reconnect"),
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2),
|
||||
"Guest-1",
|
||||
"speech after reconnect",
|
||||
ReplacesMarkerId: "azure-reconnect"),
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(3),
|
||||
TimeSpan.FromSeconds(4),
|
||||
"Guest-2",
|
||||
"following speech")
|
||||
])),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
var segments = transcriptStore.Segments;
|
||||
Assert.Equal(
|
||||
["speech after reconnect", "following speech"],
|
||||
segments.Select(segment => segment.Text).ToArray());
|
||||
Assert.DoesNotContain(segments, segment =>
|
||||
segment.Text.Contains("Reconnecting", StringComparison.Ordinal)
|
||||
|| segment.Text.Contains("disconnected", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
|
||||
{
|
||||
@@ -822,6 +1040,124 @@ public sealed class RecordingCoordinatorTests
|
||||
await store.WaitForTextAsync("final:2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopQueuesAzureBacklogAndAllowsNextMeetingWhenTranscriptionCannotDrain()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var provider = new BlockingBeforeTranscriptionProvider();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var audioArchive = new InMemoryRecordedAudioStore();
|
||||
var backlog = new InMemoryOfflineTranscriptionBacklog();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
audioArchive,
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions
|
||||
{
|
||||
TranscriptionProvider = "azure-speech",
|
||||
StopProcessingTimeout = TimeSpan.FromMilliseconds(20)
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
offlineTranscriptionBacklog: backlog);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await provider.WaitUntilWaitingForBackendAsync();
|
||||
|
||||
var stopped = await coordinator.StopAsync(CancellationToken.None);
|
||||
var restarted = await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(stopped.IsRecording);
|
||||
Assert.True(restarted.IsRecording);
|
||||
var item = Assert.Single(backlog.Items);
|
||||
Assert.Equal("memory-recording.wav", item.AudioPath);
|
||||
Assert.Equal(transcriptStore.CreatedSessions[0].TranscriptPath, item.TranscriptPath);
|
||||
Assert.True(audioArchive.Completed);
|
||||
Assert.False(audioArchive.Deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfflineBacklogReplaysQueuedRecordingAndCompletesMeetingArtifacts()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var audioPath = Path.Combine(root, "queued.wav");
|
||||
Directory.CreateDirectory(root);
|
||||
using (var writer = new NAudio.Wave.WaveFileWriter(audioPath, new NAudio.Wave.WaveFormat(16000, 16, 1)))
|
||||
{
|
||||
writer.Write([1, 0, 2, 0], 0, 4);
|
||||
}
|
||||
|
||||
var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "transcript.md"));
|
||||
var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "meeting.md"));
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var workflowEngine = new CapturingMeetingWorkflowEngine();
|
||||
var backlog = new InMemoryOfflineTranscriptionBacklog();
|
||||
var pipelineFactory = new ProfileSwitchSpeechRecognitionPipelineFactory();
|
||||
var startedAt = DateTimeOffset.Now.AddMinutes(-5);
|
||||
var stoppedAt = DateTimeOffset.Now.AddMinutes(-1);
|
||||
var note = new MeetingNote(
|
||||
Path.Combine(root, "meeting.md"),
|
||||
new MeetingNoteFrontmatter
|
||||
{
|
||||
Title = "Queued meeting",
|
||||
StartTime = startedAt,
|
||||
Attendees = ["Ada", "Grace"],
|
||||
Transcript = Path.Combine(root, "transcript.md"),
|
||||
AssistantContext = Path.Combine(root, "context.md"),
|
||||
Summary = Path.Combine(root, "summary.md")
|
||||
},
|
||||
"");
|
||||
noteStore.UpdateSavedNote(note);
|
||||
await backlog.EnqueueAsync(new OfflineTranscriptionBacklogItem(
|
||||
"queued",
|
||||
audioPath,
|
||||
Path.Combine(root, "transcript.md"),
|
||||
Path.Combine(root, "meeting.md"),
|
||||
Path.Combine(root, "context.md"),
|
||||
Path.Combine(root, "summary.md"),
|
||||
startedAt,
|
||||
stoppedAt,
|
||||
"english"), CancellationToken.None);
|
||||
var launchProfiles = CreateLaunchProfiles(root, root);
|
||||
var processor = new OfflineTranscriptionBacklogProcessor(
|
||||
backlog,
|
||||
pipelineFactory,
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
artifactStore,
|
||||
summaryPipeline,
|
||||
workflowEngine,
|
||||
CreateRecordingDictationWordProvider(new FixedDictationWordStore(["codex"])),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<OfflineTranscriptionBacklogProcessor>.Instance,
|
||||
launchProfiles);
|
||||
|
||||
var processed = await processor.ProcessPendingAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, processed);
|
||||
Assert.Empty(backlog.Items);
|
||||
Assert.Equal(["english"], pipelineFactory.ProfileNames);
|
||||
var segment = Assert.Single(transcriptStore.ReplacedSegments);
|
||||
Assert.Contains("english chunk:4", segment.Text, StringComparison.Ordinal);
|
||||
Assert.Equal(stoppedAt, noteStore.SavedNote?.Frontmatter.EndTime);
|
||||
Assert.True(summaryPipeline.WasRun);
|
||||
Assert.Equal(
|
||||
new[] { AssistantContextState.Summarizing, AssistantContextState.Finished },
|
||||
artifactStore.States);
|
||||
Assert.Contains(workflowEngine.Events, workflowEvent =>
|
||||
workflowEvent.Type == MeetingWorkflowEventType.TranscriptLine &&
|
||||
workflowEvent.TranscriptLineText?.Contains("english chunk:4", StringComparison.Ordinal) == true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbortStopsRecordingDeletesArtifactsAndSkipsSummary()
|
||||
{
|
||||
@@ -2031,6 +2367,46 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.DoesNotContain("transcript:", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VaultTranscriptStoreRewritesOneLineWithoutRemovingFollowingLine()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var vaultFolder = Path.Combine(root, "Transcripts");
|
||||
var store = new VaultTranscriptStore(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions { TranscriptsFolder = vaultFolder }
|
||||
}),
|
||||
NullLogger<VaultTranscriptStore>.Instance);
|
||||
var session = await store.CreateSessionAsync(CancellationToken.None);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Notes", "meeting.md"),
|
||||
TranscriptPath: session.TranscriptPath,
|
||||
AssistantContextPath: Path.Combine(root, "Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Summaries", "summary.md"));
|
||||
await store.UpdateMetadataAsync(
|
||||
session,
|
||||
artifacts,
|
||||
MeetingNoteTemplate.Create("Transcript Rewrite", transcriptPath: session.TranscriptPath),
|
||||
CancellationToken.None);
|
||||
var original = "[00:00:01] Guest-1: original workflow text";
|
||||
var rewritten = "[00:00:01] Guest-1: rewritten workflow text";
|
||||
var following = "[00:00:02] Guest-2: following transcript text";
|
||||
|
||||
var originalReference = await store.AppendLineAsync(session, original, CancellationToken.None);
|
||||
await store.AppendLineAsync(session, following, CancellationToken.None);
|
||||
await store.ReplaceLineAsync(session, originalReference, rewritten, CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(session.TranscriptPath);
|
||||
Assert.Contains(rewritten, content);
|
||||
Assert.Contains(following, content);
|
||||
Assert.DoesNotContain(original, content);
|
||||
Assert.True(
|
||||
content.IndexOf(rewritten, StringComparison.Ordinal) <
|
||||
content.IndexOf(following, StringComparison.Ordinal));
|
||||
Assert.Contains("meeting:", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VaultTranscriptStoreNamesGeneratedTranscriptWithMinutePrecision()
|
||||
{
|
||||
@@ -2129,6 +2505,40 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.True(File.Exists(unrelatedFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemporaryRecordedAudioStoreKeepsQueuedOfflineRecordingsOnStartup()
|
||||
{
|
||||
var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(recordingFolder);
|
||||
var queuedRecording = Path.Combine(recordingFolder, "queued.wav");
|
||||
var staleRecording = Path.Combine(recordingFolder, "stale.wav");
|
||||
await File.WriteAllTextAsync(queuedRecording, "queued");
|
||||
await File.WriteAllTextAsync(staleRecording, "stale");
|
||||
var backlog = new InMemoryOfflineTranscriptionBacklog();
|
||||
await backlog.EnqueueAsync(new OfflineTranscriptionBacklogItem(
|
||||
Id: "queued",
|
||||
AudioPath: queuedRecording,
|
||||
TranscriptPath: "transcript.md",
|
||||
MeetingNotePath: "meeting.md",
|
||||
AssistantContextPath: "context.md",
|
||||
SummaryPath: "summary.md",
|
||||
StartedAt: DateTimeOffset.Parse("2026-06-15T10:00:00+02:00"),
|
||||
InferredEndTime: null,
|
||||
LaunchProfileName: "default"), CancellationToken.None);
|
||||
var store = new TemporaryRecordedAudioStore(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions { TemporaryRecordingsFolder = recordingFolder }
|
||||
}),
|
||||
NullLogger<TemporaryRecordedAudioStore>.Instance,
|
||||
backlog);
|
||||
|
||||
await store.DeleteStaleRecordingsAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(File.Exists(queuedRecording));
|
||||
Assert.False(File.Exists(staleRecording));
|
||||
}
|
||||
|
||||
private static MeetingAssistantOptions CreateOptionsWithoutFinalizer()
|
||||
{
|
||||
return new MeetingAssistantOptions
|
||||
@@ -2190,9 +2600,13 @@ public sealed class RecordingCoordinatorTests
|
||||
this.transcriptPath = transcriptPath;
|
||||
}
|
||||
|
||||
public List<TranscriptSession> CreatedSessions { get; } = [];
|
||||
|
||||
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new TranscriptSession(transcriptPath));
|
||||
var session = new TranscriptSession(transcriptPath);
|
||||
CreatedSessions.Add(session);
|
||||
return Task.FromResult(session);
|
||||
}
|
||||
|
||||
public Task<TranscriptSession> CreateSessionAsync(
|
||||
@@ -2203,13 +2617,34 @@ public sealed class RecordingCoordinatorTests
|
||||
return CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
|
||||
public Task<TranscriptLineReference> AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
|
||||
{
|
||||
int index;
|
||||
lock (gate)
|
||||
{
|
||||
index = segments.Count;
|
||||
segments.Add(ParseTranscriptLine(line));
|
||||
}
|
||||
|
||||
return Task.FromResult(new TranscriptLineReference(session.TranscriptPath, index, line));
|
||||
}
|
||||
|
||||
public Task ReplaceLineAsync(
|
||||
TranscriptSession session,
|
||||
TranscriptLineReference lineReference,
|
||||
string replacementLine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var replacement = ParseTranscriptLine(replacementLine);
|
||||
lock (gate)
|
||||
{
|
||||
if (lineReference.BodyLineIndex >= 0 &&
|
||||
lineReference.BodyLineIndex < segments.Count)
|
||||
{
|
||||
segments[lineReference.BodyLineIndex] = replacement;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -2280,11 +2715,12 @@ public sealed class RecordingCoordinatorTests
|
||||
return CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
|
||||
public Task<TranscriptLineReference> AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
|
||||
{
|
||||
var index = AppendHistory.Count(entry => entry.Session.TranscriptPath == session.TranscriptPath);
|
||||
AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line)));
|
||||
Interlocked.Increment(ref appendCount);
|
||||
return Task.CompletedTask;
|
||||
return Task.FromResult(new TranscriptLineReference(session.TranscriptPath, index, line));
|
||||
}
|
||||
|
||||
public Task WaitForAppendCountAsync(int expectedCount)
|
||||
@@ -2294,6 +2730,16 @@ public sealed class RecordingCoordinatorTests
|
||||
$"Expected {expectedCount} transcript append(s).");
|
||||
}
|
||||
|
||||
public Task ReplaceLineAsync(
|
||||
TranscriptSession session,
|
||||
TranscriptLineReference lineReference,
|
||||
string replacementLine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ReplacementHistory.Add(new TranscriptReplacement(session, [ParseTranscriptLine(replacementLine)]));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReplaceLinesAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<string> replacementLines,
|
||||
@@ -2420,6 +2866,105 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
public Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> TransformTranscriptLineAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (workflowEvent.TranscriptLineText?.Contains("first", StringComparison.Ordinal) == true)
|
||||
{
|
||||
throw new InvalidOperationException("Workflow rule failed.");
|
||||
}
|
||||
|
||||
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ObservingTranscriptRewriteWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
private readonly InMemoryTranscriptStore transcriptStore;
|
||||
|
||||
public ObservingTranscriptRewriteWorkflowEngine(InMemoryTranscriptStore transcriptStore)
|
||||
{
|
||||
this.transcriptStore = transcriptStore;
|
||||
}
|
||||
|
||||
public Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> TransformTranscriptLineAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!transcriptStore.Segments.Any(segment =>
|
||||
segment.Text.Contains("Azure returned ***** here.", StringComparison.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException("Original transcript line was not written before workflow processing.");
|
||||
}
|
||||
|
||||
return Task.FromResult(
|
||||
(workflowEvent.TranscriptLineText ?? "").Replace("*****", "[redacted]", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DelayedFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
private readonly TaskCompletionSource firstLineWorkflowStarted =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource completeFirstLineWorkflow =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<string> TransformTranscriptLineAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var line = workflowEvent.TranscriptLineText ?? "";
|
||||
if (!line.Contains("first has enough words for identification.", StringComparison.Ordinal))
|
||||
{
|
||||
return line;
|
||||
}
|
||||
|
||||
firstLineWorkflowStarted.TrySetResult();
|
||||
await completeFirstLineWorkflow.Task.WaitAsync(cancellationToken);
|
||||
return line.Replace("first", "rewritten first", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public Task WaitUntilFirstLineWorkflowStartedAsync()
|
||||
{
|
||||
return firstLineWorkflowStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void CompleteFirstLineWorkflow()
|
||||
{
|
||||
completeFirstLineWorkflow.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingScreenshotService : IMeetingScreenshotService
|
||||
{
|
||||
public MeetingSessionArtifacts? Artifacts { get; private set; }
|
||||
@@ -2935,7 +3480,16 @@ public sealed class RecordingCoordinatorTests
|
||||
MeetingArtifactFileNames.Create(startedAt, MeetingArtifactFileNames.Transcript))));
|
||||
}
|
||||
|
||||
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
|
||||
public Task<TranscriptLineReference> AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new TranscriptLineReference(session.TranscriptPath, 0, line));
|
||||
}
|
||||
|
||||
public Task ReplaceLineAsync(
|
||||
TranscriptSession session,
|
||||
TranscriptLineReference lineReference,
|
||||
string replacementLine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -3114,6 +3668,28 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryOfflineTranscriptionBacklog : IOfflineTranscriptionBacklog
|
||||
{
|
||||
public List<OfflineTranscriptionBacklogItem> Items { get; } = [];
|
||||
|
||||
public Task EnqueueAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
Items.Add(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<OfflineTranscriptionBacklogItem>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<OfflineTranscriptionBacklogItem>>(Items.ToList());
|
||||
}
|
||||
|
||||
public Task CompleteAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
Items.RemoveAll(item => string.Equals(item.Id, id, StringComparison.Ordinal));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly IStreamingTranscriptionProvider provider;
|
||||
@@ -3946,6 +4522,31 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticOnAudioCompletionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
private readonly IReadOnlyList<TranscriptionSegment> segments;
|
||||
|
||||
public StaticOnAudioCompletionProvider(IReadOnlyList<TranscriptionSegment> segments)
|
||||
{
|
||||
this.segments = segments;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var _ in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
}
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
yield return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingBeforeTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
private readonly TaskCompletionSource waitingForBackend =
|
||||
|
||||
@@ -83,6 +83,34 @@ public sealed class TaskbarIconTests
|
||||
Assert.Equal(RecordingProcessState.Recording, menu.State);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RecordingProcessState.Idle, false)]
|
||||
[InlineData(RecordingProcessState.Recording, true)]
|
||||
[InlineData(RecordingProcessState.Summarizing, false)]
|
||||
public void MenuAlwaysOffersExit(RecordingProcessState state, bool isRecording)
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(isRecording: isRecording, state: state, profile: "default"),
|
||||
[Profile("default"), Profile("english")]);
|
||||
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.Exit &&
|
||||
item.Text == "Exit");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RecordingProcessState.Idle, false)]
|
||||
[InlineData(RecordingProcessState.Recording, true)]
|
||||
[InlineData(RecordingProcessState.Summarizing, true)]
|
||||
public void ExitRequiresConfirmationWhileRecordingOrProcessing(
|
||||
RecordingProcessState state,
|
||||
bool requiresConfirmation)
|
||||
{
|
||||
Assert.Equal(
|
||||
requiresConfirmation,
|
||||
MeetingTaskbarExitPolicy.RequiresConfirmation(Status(state: state)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SupportedOSPlatform("windows")]
|
||||
public void TaskbarIconGlyphsAreVisuallyCentered()
|
||||
|
||||
Reference in New Issue
Block a user