Public Access
Implement meeting assistant v1
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class AsrDiagnosticEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EndpointTranscribesLocalWavThroughConfiguredProvider()
|
||||
{
|
||||
await using var factory = CreateFactory<EndpointFakeTranscriptionProvider>();
|
||||
using var client = factory.CreateClient();
|
||||
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
|
||||
|
||||
using var response = await client.PostAsJsonAsync("/asr/transcribe-file", new { path = wavPath });
|
||||
var body = await response.Content.ReadFromJsonAsync<AsrDiagnosticResponse>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(wavPath, body?.Path);
|
||||
var segment = Assert.Single(body?.Segments ?? []);
|
||||
Assert.Equal("Unknown", segment.Speaker);
|
||||
Assert.StartsWith("endpoint-bytes:", segment.Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndpointReportsProviderFailuresAsBadGateway()
|
||||
{
|
||||
await using var factory = CreateFactory<FailingTranscriptionProvider>();
|
||||
using var client = factory.CreateClient();
|
||||
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
|
||||
|
||||
using var response = await client.PostAsJsonAsync("/asr/transcribe-file", new { path = wavPath });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndpointRunsFinalDiarizationForLocalWav()
|
||||
{
|
||||
SpeechRecognitionPipelineOptions? capturedOptions = null;
|
||||
await using var factory = CreateFactory<EndpointFakeTranscriptionProvider>(
|
||||
(audioPath, liveSegments, options, cancellationToken) =>
|
||||
{
|
||||
capturedOptions = options;
|
||||
return Task.FromResult<IReadOnlyList<TranscriptionSegment>>(
|
||||
liveSegments
|
||||
.Select(segment => segment with { Speaker = "Speaker 0", Text = $"diarized:{segment.Text}" })
|
||||
.ToList());
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
|
||||
|
||||
using var response = await client.PostAsJsonAsync("/asr/diarize-file", new { path = wavPath, numSpeakers = 5 });
|
||||
var body = await response.Content.ReadFromJsonAsync<AsrDiagnosticResponse>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(5, capturedOptions?.NumSpeakers);
|
||||
Assert.Equal(wavPath, body?.Path);
|
||||
var segment = Assert.Single(body?.Segments ?? []);
|
||||
Assert.Equal("Speaker 0", segment.Speaker);
|
||||
Assert.StartsWith("diarized:endpoint-bytes:", segment.Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static WebApplicationFactory<Program> CreateFactory<TProvider>()
|
||||
where TProvider : class, IStreamingTranscriptionProvider
|
||||
{
|
||||
return CreateFactory<TProvider>((_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
|
||||
}
|
||||
|
||||
private static WebApplicationFactory<Program> CreateFactory<TProvider>(
|
||||
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
|
||||
where TProvider : class, IStreamingTranscriptionProvider
|
||||
{
|
||||
return new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
|
||||
});
|
||||
});
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<ISpeechRecognitionPipelineFactory>();
|
||||
services.AddSingleton<ISpeechRecognitionPipelineFactory>(
|
||||
new TestSpeechRecognitionPipelineFactory<TProvider>(finalize));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class EndpointFakeTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var bytes = 0;
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
bytes += chunk.Pcm.Length;
|
||||
}
|
||||
|
||||
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"endpoint-bytes:{bytes}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("backend unavailable");
|
||||
#pragma warning disable CS0162
|
||||
yield break;
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipelineFactory<TProvider> : ISpeechRecognitionPipelineFactory
|
||||
where TProvider : class, IStreamingTranscriptionProvider
|
||||
{
|
||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||
|
||||
public TestSpeechRecognitionPipelineFactory(
|
||||
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
|
||||
{
|
||||
this.finalize = finalize;
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return new TestSpeechRecognitionPipeline(
|
||||
Activator.CreateInstance<TProvider>(),
|
||||
finalize);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||
{
|
||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||
|
||||
public TestSpeechRecognitionPipeline(
|
||||
IStreamingTranscriptionProvider provider,
|
||||
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
|
||||
: base(provider)
|
||||
{
|
||||
this.finalize = finalize;
|
||||
}
|
||||
|
||||
protected override Task<IReadOnlyList<TranscriptionSegment>> BuildFinishedTranscriptAsync(
|
||||
string audioPath,
|
||||
IReadOnlyList<TranscriptionSegment> liveSegments,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return finalize(audioPath, liveSegments, options, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AsrDiagnosticResponse(string Path, IReadOnlyList<AsrDiagnosticSegment> Segments);
|
||||
|
||||
private sealed record AsrDiagnosticSegment(string Speaker, string Text);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class AsrDiagnosticTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DiagnosticStreamsPcmWavThroughConfiguredProvider()
|
||||
{
|
||||
var provider = new CapturingTranscriptionProvider();
|
||||
var diagnostics = new AsrDiagnosticService(
|
||||
new TestSpeechRecognitionPipelineFactory(provider));
|
||||
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
|
||||
|
||||
var result = await diagnostics.TranscribeWavAsync(wavPath, CancellationToken.None);
|
||||
|
||||
Assert.Equal(wavPath, result.Path);
|
||||
Assert.Equal(16000, provider.Chunks.Single().SampleRate);
|
||||
Assert.Equal(1, provider.Chunks.Single().Channels);
|
||||
Assert.NotEmpty(provider.Chunks.Single().Pcm);
|
||||
var segment = Assert.Single(result.Segments);
|
||||
Assert.Equal("Unknown", segment.Speaker);
|
||||
Assert.Equal("bytes:" + provider.Chunks.Single().Pcm.Length, segment.Text);
|
||||
}
|
||||
|
||||
private sealed class CapturingTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public List<AudioChunk> Chunks { get; } = [];
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
Chunks.Add(chunk);
|
||||
}
|
||||
|
||||
yield return new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
"Unknown",
|
||||
"bytes:" + Chunks.Sum(chunk => chunk.Pcm.Length));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly IStreamingTranscriptionProvider provider;
|
||||
|
||||
public TestSpeechRecognitionPipelineFactory(IStreamingTranscriptionProvider provider)
|
||||
{
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return new TestSpeechRecognitionPipeline(provider);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||
{
|
||||
public TestSpeechRecognitionPipeline(IStreamingTranscriptionProvider provider)
|
||||
: base(provider)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<IReadOnlyList<TranscriptionSegment>> BuildFinishedTranscriptAsync(
|
||||
string audioPath,
|
||||
IReadOnlyList<TranscriptionSegment> liveSegments,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class AudioMixingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompositeAudioSourceMixesMicrophoneAndSystemAudioIntoOnePcmStream()
|
||||
{
|
||||
var microphone = new FixedAudioSource(Pcm16(10_000));
|
||||
var system = new FixedAudioSource(Pcm16(20_000));
|
||||
var source = new CompositeMeetingAudioSource(
|
||||
microphone,
|
||||
system,
|
||||
NullLogger<CompositeMeetingAudioSource>.Instance);
|
||||
|
||||
var chunks = await ReadChunks(source);
|
||||
|
||||
Assert.Single(chunks);
|
||||
Assert.Equal(30_000, BitConverter.ToInt16(chunks[0].Pcm));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeAudioSourceClampsMixedSamples()
|
||||
{
|
||||
var microphone = new FixedAudioSource(Pcm16(30_000));
|
||||
var system = new FixedAudioSource(Pcm16(10_000));
|
||||
var source = new CompositeMeetingAudioSource(
|
||||
microphone,
|
||||
system,
|
||||
NullLogger<CompositeMeetingAudioSource>.Instance);
|
||||
|
||||
var chunks = await ReadChunks(source);
|
||||
|
||||
Assert.Equal(short.MaxValue, BitConverter.ToInt16(chunks[0].Pcm));
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<AudioChunk>> ReadChunks(IMeetingAudioSource source)
|
||||
{
|
||||
var chunks = new List<AudioChunk>();
|
||||
await foreach (var chunk in source.CaptureAsync(CancellationToken.None))
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static byte[] Pcm16(short sample)
|
||||
{
|
||||
return BitConverter.GetBytes(sample);
|
||||
}
|
||||
|
||||
private sealed class FixedAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly AudioChunk chunk;
|
||||
|
||||
public FixedAudioSource(byte[] pcm)
|
||||
{
|
||||
chunk = new AudioChunk(pcm, 16000, 1);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class AzureSpeechRecognitionPipelineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FinishedTranscriptUsesLiveDiarizedAzureSegments()
|
||||
{
|
||||
await using var pipeline = new AzureSpeechRecognitionPipeline(
|
||||
new StaticTranscriptionProvider(
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "hello"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), "Guest-2", "there")
|
||||
]));
|
||||
|
||||
await pipeline.InitializeAsync(CancellationToken.None);
|
||||
await pipeline.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await pipeline.CompleteAsync(CancellationToken.None);
|
||||
var liveSegments = new List<TranscriptionSegment>();
|
||||
await foreach (var segment in pipeline.ReadLiveTranscriptAsync(CancellationToken.None))
|
||||
{
|
||||
liveSegments.Add(segment);
|
||||
}
|
||||
|
||||
var finishedSegments = await pipeline.ReadFinishedTranscriptAsync(
|
||||
"ignored.wav",
|
||||
new SpeechRecognitionPipelineOptions(5),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(liveSegments, finishedSegments);
|
||||
Assert.Collection(
|
||||
finishedSegments,
|
||||
first => Assert.Equal("Guest-1", first.Speaker),
|
||||
second => Assert.Equal("Guest-2", second.Speaker));
|
||||
}
|
||||
|
||||
private sealed class StaticTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
private readonly IReadOnlyList<TranscriptionSegment> segments;
|
||||
|
||||
public StaticTranscriptionProvider(IReadOnlyList<TranscriptionSegment> segments)
|
||||
{
|
||||
this.segments = segments;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var _ in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
}
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
yield return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TranscribeFailsClearlyWhenKeyIsMissing()
|
||||
{
|
||||
var provider = new AzureSpeechStreamingTranscriptionProvider(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
AzureSpeech = new AzureSpeechOptions
|
||||
{
|
||||
Region = "germanywestcentral",
|
||||
KeyEnv = $"MEETING_ASSISTANT_MISSING_AZURE_KEY_{Guid.NewGuid():N}"
|
||||
}
|
||||
}),
|
||||
NullLogger<AzureSpeechStreamingTranscriptionProvider>.Instance);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in provider.TranscribeAsync(ReadSingleChunk(), CancellationToken.None))
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Contains("Azure Speech key is not configured", exception.Message);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AudioChunk> ReadSingleChunk()
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return new AudioChunk(new byte[320], 16000, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ConfiguredSpeechRecognitionPipelineFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FactoryCreatesAzureSpeechPipelineWhenConfigured()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions
|
||||
{
|
||||
TranscriptionProvider = "azure-speech"
|
||||
}
|
||||
}));
|
||||
services.AddSingleton<ILogger<AzureSpeechStreamingTranscriptionProvider>>(
|
||||
NullLogger<AzureSpeechStreamingTranscriptionProvider>.Instance);
|
||||
services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
var factory = new ConfiguredSpeechRecognitionPipelineFactory(
|
||||
provider,
|
||||
provider.GetRequiredService<IOptions<MeetingAssistantOptions>>());
|
||||
|
||||
await using var pipeline = factory.Create();
|
||||
|
||||
Assert.IsType<AzureSpeechRecognitionPipeline>(pipeline);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,203 @@
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class FunAsrBackendLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LifecycleInstallsStartsAndStopsManagedDockerContainer()
|
||||
{
|
||||
var runner = new CapturingCommandRunner();
|
||||
var readinessProbe = new CapturingReadinessProbe();
|
||||
var modelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var lifecycle = new FunAsrDockerBackendLifecycle(
|
||||
runner,
|
||||
readinessProbe,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
FunAsr = new FunAsrOptions
|
||||
{
|
||||
Endpoint = "ws://127.0.0.1:10095",
|
||||
Backend = new FunAsrBackendOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Image = "funasr:test",
|
||||
ContainerName = "meeting-assistant-funasr-test",
|
||||
ModelsFolder = modelsFolder,
|
||||
StartupTimeout = TimeSpan.Zero
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<FunAsrDockerBackendLifecycle>.Instance);
|
||||
|
||||
await lifecycle.EnsureStartedAsync(CancellationToken.None);
|
||||
await lifecycle.EnsureStartedAsync(CancellationToken.None);
|
||||
await lifecycle.DisposeAsync();
|
||||
|
||||
Assert.Equal(new Uri("ws://127.0.0.1:10095"), readinessProbe.Endpoint);
|
||||
Assert.Equal(TimeSpan.Zero, readinessProbe.Timeout);
|
||||
Assert.Collection(
|
||||
runner.Commands,
|
||||
command =>
|
||||
{
|
||||
Assert.Equal("docker", command.FileName);
|
||||
Assert.Equal(["pull", "funasr:test"], command.Arguments);
|
||||
},
|
||||
command =>
|
||||
{
|
||||
Assert.Equal(["rm", "-f", "meeting-assistant-funasr-test"], command.Arguments);
|
||||
},
|
||||
command =>
|
||||
{
|
||||
Assert.Equal("docker", command.FileName);
|
||||
Assert.Contains("run", command.Arguments);
|
||||
Assert.Contains("-d", command.Arguments);
|
||||
Assert.Contains("--privileged=true", command.Arguments);
|
||||
Assert.Contains("--name", command.Arguments);
|
||||
Assert.Contains("meeting-assistant-funasr-test", command.Arguments);
|
||||
Assert.Contains("-p", command.Arguments);
|
||||
Assert.Contains("10095:10095", command.Arguments);
|
||||
Assert.Contains($"{modelsFolder}:/workspace/models", command.Arguments);
|
||||
Assert.Contains("funasr:test", command.Arguments);
|
||||
},
|
||||
command =>
|
||||
{
|
||||
Assert.Equal(["stop", "meeting-assistant-funasr-test"], command.Arguments);
|
||||
});
|
||||
Assert.True(File.Exists(Path.Combine(modelsFolder, "hotwords.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledLifecycleDoesNotRunDockerCommands()
|
||||
{
|
||||
var runner = new CapturingCommandRunner();
|
||||
var lifecycle = new FunAsrDockerBackendLifecycle(
|
||||
runner,
|
||||
new CapturingReadinessProbe(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
FunAsr = new FunAsrOptions
|
||||
{
|
||||
Backend = new FunAsrBackendOptions { Enabled = false }
|
||||
}
|
||||
}),
|
||||
NullLogger<FunAsrDockerBackendLifecycle>.Instance);
|
||||
|
||||
await lifecycle.EnsureStartedAsync(CancellationToken.None);
|
||||
await lifecycle.DisposeAsync();
|
||||
|
||||
Assert.Empty(runner.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LifecycleFailsWhenDockerCommandTimesOut()
|
||||
{
|
||||
var runner = new HangingCommandRunner();
|
||||
var lifecycle = new FunAsrDockerBackendLifecycle(
|
||||
runner,
|
||||
new CapturingReadinessProbe(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
FunAsr = new FunAsrOptions
|
||||
{
|
||||
Backend = new FunAsrBackendOptions
|
||||
{
|
||||
Enabled = true,
|
||||
CommandTimeout = TimeSpan.FromMilliseconds(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<FunAsrDockerBackendLifecycle>.Instance);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<TimeoutException>(
|
||||
() => lifecycle.EnsureStartedAsync(CancellationToken.None));
|
||||
|
||||
Assert.Contains("timed out", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LifecycleWaitsForWebSocketReadinessBeforeMarkingStarted()
|
||||
{
|
||||
var runner = new CapturingCommandRunner();
|
||||
var readinessProbe = new CapturingReadinessProbe();
|
||||
var lifecycle = new FunAsrDockerBackendLifecycle(
|
||||
runner,
|
||||
readinessProbe,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
FunAsr = new FunAsrOptions
|
||||
{
|
||||
Endpoint = "ws://127.0.0.1:10095",
|
||||
Backend = new FunAsrBackendOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Image = "funasr:test",
|
||||
ContainerName = "meeting-assistant-funasr-test",
|
||||
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")),
|
||||
StartupTimeout = TimeSpan.FromMinutes(3)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<FunAsrDockerBackendLifecycle>.Instance);
|
||||
|
||||
await lifecycle.EnsureStartedAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, readinessProbe.CallCount);
|
||||
Assert.Equal(new Uri("ws://127.0.0.1:10095"), readinessProbe.Endpoint);
|
||||
Assert.Equal(TimeSpan.FromMinutes(3), readinessProbe.Timeout);
|
||||
Assert.Equal(3, runner.Commands.Count);
|
||||
await lifecycle.DisposeAsync();
|
||||
}
|
||||
|
||||
private sealed class CapturingCommandRunner : ICommandRunner
|
||||
{
|
||||
public List<CapturedCommand> Commands { get; } = [];
|
||||
|
||||
public Task<CommandResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> arguments,
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyDictionary<string, string>? environment = null)
|
||||
{
|
||||
Commands.Add(new CapturedCommand(fileName, arguments.ToArray()));
|
||||
return Task.FromResult(new CommandResult(0, "", ""));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CapturedCommand(string FileName, IReadOnlyList<string> Arguments);
|
||||
|
||||
private sealed class HangingCommandRunner : ICommandRunner
|
||||
{
|
||||
public async Task<CommandResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> arguments,
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyDictionary<string, string>? environment = null)
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return new CommandResult(0, "", "");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingReadinessProbe : IFunAsrBackendReadinessProbe
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Uri? Endpoint { get; private set; }
|
||||
|
||||
public TimeSpan Timeout { get; private set; }
|
||||
|
||||
public Task WaitUntilReadyAsync(
|
||||
Uri endpoint,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
Endpoint = endpoint;
|
||||
Timeout = timeout;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Text.Json;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class FunAsrStreamingTranscriptionProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProviderStreamsPcmChunksAndEmitsSpeakerAttributedSegments()
|
||||
{
|
||||
var connection = new FakeFunAsrWebSocketConnection(
|
||||
"""
|
||||
{"mode":"2pass-offline","wav_name":"meeting","text":"hello from mike","is_final":true,"spk_name":"Mike","spk_score":0.92,"timestamp":[[1000,1800]]}
|
||||
""");
|
||||
var provider = new FunAsrStreamingTranscriptionProvider(
|
||||
new FakeFunAsrWebSocketConnectionFactory(connection),
|
||||
new NoopFunAsrBackendLifecycle(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
FunAsr = new FunAsrOptions
|
||||
{
|
||||
Endpoint = "ws://localhost:10095",
|
||||
Mode = "2pass",
|
||||
ChunkSize = [5, 10, 5],
|
||||
ChunkInterval = 10,
|
||||
FinalResultTimeout = TimeSpan.FromSeconds(1)
|
||||
}
|
||||
}),
|
||||
NullLogger<FunAsrStreamingTranscriptionProvider>.Instance);
|
||||
var chunks = new[]
|
||||
{
|
||||
new AudioChunk([1, 0, 2, 0], 16000, 1),
|
||||
new AudioChunk([3, 0, 4, 0], 16000, 1)
|
||||
};
|
||||
|
||||
var segments = await CollectAsync(provider.TranscribeAsync(ToAsyncEnumerable(chunks), CancellationToken.None));
|
||||
|
||||
Assert.Equal(new Uri("ws://localhost:10095"), connection.ConnectedEndpoint);
|
||||
Assert.Equal(2, connection.BinaryMessages.Count);
|
||||
Assert.Equal(chunks[0].Pcm, connection.BinaryMessages[0]);
|
||||
Assert.Equal(chunks[1].Pcm, connection.BinaryMessages[1]);
|
||||
Assert.Equal("Mike", Assert.Single(segments).Speaker);
|
||||
Assert.Equal("hello from mike", segments[0].Text);
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), segments[0].Start);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(1800), segments[0].End);
|
||||
|
||||
using var firstMessage = JsonDocument.Parse(connection.TextMessages[0]);
|
||||
Assert.Equal("2pass", firstMessage.RootElement.GetProperty("mode").GetString());
|
||||
Assert.Equal("pcm", firstMessage.RootElement.GetProperty("wav_format").GetString());
|
||||
Assert.Equal(16000, firstMessage.RootElement.GetProperty("audio_fs").GetInt32());
|
||||
Assert.True(firstMessage.RootElement.GetProperty("is_speaking").GetBoolean());
|
||||
|
||||
using var finalMessage = JsonDocument.Parse(connection.TextMessages[^1]);
|
||||
Assert.False(finalMessage.RootElement.GetProperty("is_speaking").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProviderUsesSentenceSpeakerFieldsAndUnknownFallback()
|
||||
{
|
||||
var connection = new FakeFunAsrWebSocketConnection(
|
||||
"""
|
||||
{"mode":"2pass-offline","wav_name":"meeting","text":"ignored","is_final":true,"sentence_info":[{"text":"first sentence","spk":0,"start":100,"end":600},{"text":"second sentence","start":700,"end":1200}]}
|
||||
""");
|
||||
var provider = new FunAsrStreamingTranscriptionProvider(
|
||||
new FakeFunAsrWebSocketConnectionFactory(connection),
|
||||
new NoopFunAsrBackendLifecycle(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
FunAsr = new FunAsrOptions { FinalResultTimeout = TimeSpan.FromSeconds(1) }
|
||||
}),
|
||||
NullLogger<FunAsrStreamingTranscriptionProvider>.Instance);
|
||||
|
||||
var segments = await CollectAsync(provider.TranscribeAsync(
|
||||
ToAsyncEnumerable([new AudioChunk([1, 0], 16000, 1)]),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Collection(
|
||||
segments,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("Speaker 0", first.Speaker);
|
||||
Assert.Equal("first sentence", first.Text);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(100), first.Start);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(600), first.End);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("Unknown", second.Speaker);
|
||||
Assert.Equal("second sentence", second.Text);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(700), second.Start);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(1200), second.End);
|
||||
});
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AudioChunk> ToAsyncEnumerable(IEnumerable<AudioChunk> chunks)
|
||||
{
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
yield return chunk;
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<TranscriptionSegment>> CollectAsync(IAsyncEnumerable<TranscriptionSegment> segments)
|
||||
{
|
||||
var collected = new List<TranscriptionSegment>();
|
||||
await foreach (var segment in segments)
|
||||
{
|
||||
collected.Add(segment);
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
private sealed class FakeFunAsrWebSocketConnectionFactory : IFunAsrWebSocketConnectionFactory
|
||||
{
|
||||
private readonly FakeFunAsrWebSocketConnection connection;
|
||||
|
||||
public FakeFunAsrWebSocketConnectionFactory(FakeFunAsrWebSocketConnection connection)
|
||||
{
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
public Task<IFunAsrWebSocketConnection> ConnectAsync(Uri endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
connection.ConnectedEndpoint = endpoint;
|
||||
return Task.FromResult<IFunAsrWebSocketConnection>(connection);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopFunAsrBackendLifecycle : IFunAsrBackendLifecycle
|
||||
{
|
||||
public Task EnsureStartedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeFunAsrWebSocketConnection : IFunAsrWebSocketConnection
|
||||
{
|
||||
private readonly Queue<string> responses;
|
||||
|
||||
public FakeFunAsrWebSocketConnection(params string[] responses)
|
||||
{
|
||||
this.responses = new Queue<string>(responses);
|
||||
}
|
||||
|
||||
public Uri? ConnectedEndpoint { get; set; }
|
||||
|
||||
public List<string> TextMessages { get; } = [];
|
||||
|
||||
public List<byte[]> BinaryMessages { get; } = [];
|
||||
|
||||
public Task SendTextAsync(string message, CancellationToken cancellationToken)
|
||||
{
|
||||
TextMessages.Add(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendBinaryAsync(ReadOnlyMemory<byte> message, CancellationToken cancellationToken)
|
||||
{
|
||||
BinaryMessages.Add(message.ToArray());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string?> ReceiveTextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(responses.Count == 0 ? null : responses.Dequeue());
|
||||
}
|
||||
|
||||
public Task CloseOutputAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class FunAsrTranscriptFinalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FinalizerRunsFunAsrAutoModelAndReturnsSentenceSpeakerSegments()
|
||||
{
|
||||
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
|
||||
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
|
||||
var commandRunner = new CapturingCommandRunner(
|
||||
"""
|
||||
setup noise
|
||||
__MEETING_ASSISTANT_DIARIZATION_JSON_START__
|
||||
[{"key":"input","sentence_info":[{"text":" hello","start":410,"end":4810,"spk":0},{"text":" yes","start":22550,"end":22770,"spk":1}]}]
|
||||
__MEETING_ASSISTANT_DIARIZATION_JSON_END__
|
||||
""");
|
||||
var finalizer = new FunAsrTranscriptFinalizer(
|
||||
commandRunner,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
FunAsr = new FunAsrOptions
|
||||
{
|
||||
Backend = new FunAsrBackendOptions
|
||||
{
|
||||
DockerCommand = "docker",
|
||||
Image = "funasr:test",
|
||||
ModelsFolder = "C:\\Models"
|
||||
},
|
||||
Diarization = new FunAsrDiarizationOptions
|
||||
{
|
||||
Enabled = true,
|
||||
CommandTimeout = TimeSpan.FromMinutes(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<FunAsrTranscriptFinalizer>.Instance);
|
||||
|
||||
var segments = await finalizer.FinalizeAsync(audioPath, [], CancellationToken.None);
|
||||
|
||||
Assert.Contains("run", commandRunner.Arguments);
|
||||
Assert.Contains("funasr:test", commandRunner.Arguments);
|
||||
Assert.Contains($"{audioPath}:/workspace/input.wav:ro", commandRunner.Arguments);
|
||||
Assert.Collection(
|
||||
segments,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(410), first.Start);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(4810), first.End);
|
||||
Assert.Equal("Speaker 0", first.Speaker);
|
||||
Assert.Equal("hello", first.Text);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(22550), second.Start);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(22770), second.End);
|
||||
Assert.Equal("Speaker 1", second.Speaker);
|
||||
Assert.Equal("yes", second.Text);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalizerReturnsNoSegmentsWhenDiarizationIsDisabled()
|
||||
{
|
||||
var commandRunner = new CapturingCommandRunner("");
|
||||
var finalizer = new FunAsrTranscriptFinalizer(
|
||||
commandRunner,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
FunAsr = new FunAsrOptions
|
||||
{
|
||||
Diarization = new FunAsrDiarizationOptions { Enabled = false }
|
||||
}
|
||||
}),
|
||||
NullLogger<FunAsrTranscriptFinalizer>.Instance);
|
||||
|
||||
var segments = await finalizer.FinalizeAsync("C:\\Recordings\\meeting.wav", [], CancellationToken.None);
|
||||
|
||||
Assert.Empty(segments);
|
||||
Assert.Empty(commandRunner.Arguments);
|
||||
}
|
||||
|
||||
private sealed class CapturingCommandRunner : ICommandRunner
|
||||
{
|
||||
private readonly string output;
|
||||
|
||||
public CapturingCommandRunner(string output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Arguments { get; private set; } = [];
|
||||
|
||||
public Task<CommandResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> arguments,
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyDictionary<string, string>? environment = null)
|
||||
{
|
||||
Arguments = arguments;
|
||||
return Task.FromResult(new CommandResult(0, output, ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MeetingAssistant.Recording;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
@@ -10,7 +16,16 @@ public sealed class HealthEndpointTests : IClassFixture<WebApplicationFactory<Pr
|
||||
|
||||
public HealthEndpointTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
this.factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -26,5 +41,41 @@ public sealed class HealthEndpointTests : IClassFixture<WebApplicationFactory<Pr
|
||||
Assert.Equal("ok", body?.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplicationStartupDeletesStaleTemporaryRecordings()
|
||||
{
|
||||
await using var cleanupFactory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IRecordedAudioStore>();
|
||||
services.AddSingleton<IRecordedAudioStore, StartupCleanupRecordedAudioStore>();
|
||||
});
|
||||
});
|
||||
using var client = cleanupFactory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/health");
|
||||
var store = cleanupFactory.Services.GetRequiredService<IRecordedAudioStore>() as StartupCleanupRecordedAudioStore;
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.True(store?.StaleRecordingsDeleted);
|
||||
}
|
||||
|
||||
private sealed record HealthResponse(string Service, string Status);
|
||||
|
||||
private sealed class StartupCleanupRecordedAudioStore : IRecordedAudioStore
|
||||
{
|
||||
public bool StaleRecordingsDeleted { get; private set; }
|
||||
|
||||
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StaleRecordingsDeleted = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using MeetingAssistant.Hotkeys;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class HotkeyDefinitionTests
|
||||
{
|
||||
[Fact]
|
||||
public void HotkeyDefinitionParsesConfiguredToggleShortcut()
|
||||
{
|
||||
var definition = HotkeyDefinition.Parse("Ctrl+Alt+M");
|
||||
|
||||
Assert.Equal(HotkeyModifiers.Control | HotkeyModifiers.Alt, definition.Modifiers);
|
||||
Assert.Equal((uint)'M', definition.VirtualKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.AI;
|
||||
using System.Net;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class LiteLlmResponsesChatClientTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParserIgnoresReasoningItemsWithNullStatusAndReadsText()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"id": "resp_test",
|
||||
"created_at": 1779147100,
|
||||
"model": "gpt-5.5-2026-04-23",
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [],
|
||||
"status": null
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "OK"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
|
||||
|
||||
Assert.Equal("OK", response.Text);
|
||||
Assert.Equal("resp_test", response.ResponseId);
|
||||
Assert.Equal("gpt-5.5-2026-04-23", response.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParserReadsFunctionCalls()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "write_summary",
|
||||
"arguments": "{\"markdown\":\"# Summary\\nDone\"}",
|
||||
"status": "completed"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
|
||||
var call = Assert.IsType<FunctionCallContent>(Assert.Single(response.Messages[0].Contents));
|
||||
|
||||
Assert.Equal("call_1", call.CallId);
|
||||
Assert.Equal("write_summary", call.Name);
|
||||
Assert.NotNull(call.Arguments);
|
||||
Assert.Equal("# Summary\nDone", call.Arguments["markdown"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParserReadsUsage()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"usage": {
|
||||
"input_tokens": 123,
|
||||
"output_tokens": 45,
|
||||
"total_tokens": 168
|
||||
},
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "OK"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
|
||||
|
||||
Assert.NotNull(response.Usage);
|
||||
Assert.Equal(123, response.Usage.InputTokenCount);
|
||||
Assert.Equal(45, response.Usage.OutputTokenCount);
|
||||
Assert.Equal(168, response.Usage.TotalTokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientRetriesTransientServerFailure()
|
||||
{
|
||||
var handler = new SequencedHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("Internal Server Error")
|
||||
},
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
});
|
||||
using var client = CreateClient(handler, reconnectionAttempts: 1);
|
||||
|
||||
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
|
||||
|
||||
Assert.Equal("Done.", response.Text);
|
||||
Assert.Equal(2, handler.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientDoesNotRetryNonTransientClientFailure()
|
||||
{
|
||||
var handler = new SequencedHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent("bad request")
|
||||
});
|
||||
using var client = CreateClient(handler, reconnectionAttempts: 3);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]));
|
||||
|
||||
Assert.Contains("400", exception.Message);
|
||||
Assert.Equal(1, handler.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientUsesResponsesCompactionEndpointWhenContextThresholdIsReached()
|
||||
{
|
||||
var handler = new RecordingHttpMessageHandler(request =>
|
||||
{
|
||||
if (request.RequestUri?.PathAndQuery == "/v1/responses/compact")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "compacted"
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
};
|
||||
});
|
||||
using var client = CreateClient(
|
||||
handler,
|
||||
reconnectionAttempts: 0,
|
||||
new LiteLlmResponsesCompactionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ContextWindowTokens = 100,
|
||||
MaxOutputTokens = 10,
|
||||
RemainingRatio = 0.10,
|
||||
CompactPath = "responses/compact"
|
||||
});
|
||||
|
||||
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, new string('x', 800))]);
|
||||
|
||||
Assert.Equal("Done.", response.Text);
|
||||
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
|
||||
Assert.Contains("\"content\":\"compacted\"", handler.RequestBodies[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientFallsBackToAgentFrameworkCompactionWhenResponsesCompactionFails()
|
||||
{
|
||||
var handler = new RecordingHttpMessageHandler(request =>
|
||||
{
|
||||
if (request.RequestUri?.PathAndQuery == "/v1/responses/compact")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("missing")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
};
|
||||
});
|
||||
using var client = CreateClient(
|
||||
handler,
|
||||
reconnectionAttempts: 0,
|
||||
new LiteLlmResponsesCompactionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ContextWindowTokens = 100,
|
||||
MaxOutputTokens = 10,
|
||||
RemainingRatio = 0.10,
|
||||
CompactPath = "responses/compact",
|
||||
FallbackStrategy = OpenAiMeetingSummaryAgentPipeline.CreateFallbackCompactionStrategyForTests(
|
||||
contextWindowTokens: 100,
|
||||
maxOutputTokens: 10,
|
||||
remainingRatio: 0.10,
|
||||
summaryClient: null)
|
||||
});
|
||||
|
||||
var response = await client.GetResponseAsync(
|
||||
Enumerable.Range(0, 8)
|
||||
.Select(index => new ChatMessage(ChatRole.User, $"turn {index} {new string('x', 200)}")));
|
||||
|
||||
Assert.Equal("Done.", response.Text);
|
||||
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
|
||||
Assert.DoesNotContain("turn 0", handler.RequestBodies[1]);
|
||||
}
|
||||
|
||||
private static LiteLlmResponsesChatClient CreateClient(
|
||||
HttpMessageHandler handler,
|
||||
int reconnectionAttempts,
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions = null)
|
||||
{
|
||||
return new LiteLlmResponsesChatClient(
|
||||
new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://litellm.example/v1/")
|
||||
},
|
||||
"test-key",
|
||||
"test-model",
|
||||
enableThinking: false,
|
||||
reasoningEffort: "none",
|
||||
reconnectionAttempts,
|
||||
TimeSpan.Zero,
|
||||
compactionOptions);
|
||||
}
|
||||
|
||||
private sealed class SequencedHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> responses;
|
||||
|
||||
public SequencedHttpMessageHandler(params HttpResponseMessage[] responses)
|
||||
{
|
||||
this.responses = new Queue<HttpResponseMessage>(responses);
|
||||
}
|
||||
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestCount++;
|
||||
return Task.FromResult(responses.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> handler;
|
||||
|
||||
public RecordingHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||
{
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
public List<string> RequestPaths { get; } = [];
|
||||
|
||||
public List<string> RequestBodies { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestPaths.Add(request.RequestUri?.PathAndQuery ?? string.Empty);
|
||||
RequestBodies.Add(request.Content is null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken));
|
||||
return handler(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreCreatesAssistantContextNoteLinkedToMeetingArtifacts()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var contextPath = Path.Combine(root, "Meetings", "Assistant Context", "20260519-context.md");
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "20260519-meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
"Review previous decisions\nAgree next steps",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("# Assistant Context", content);
|
||||
Assert.StartsWith("---", content, StringComparison.Ordinal);
|
||||
Assert.Contains("state: transcribing", content);
|
||||
Assert.Contains("agenda: |-", content);
|
||||
Assert.Contains(" Review previous decisions", content);
|
||||
Assert.Contains(" Agree next steps", content);
|
||||
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("meeting: \"[[../Notes/20260519-meeting|Meeting Note]]\"", content);
|
||||
Assert.Contains("transcript: \"[[../Transcripts/20260519-transcript|Transcript]]\"", content);
|
||||
Assert.Contains("summary: \"[[../Summaries/20260519-summary|Summary]]\"", content);
|
||||
Assert.Contains("[[../Notes/20260519-meeting|Meeting Note]]", content);
|
||||
Assert.Contains("[[../Transcripts/20260519-transcript|Transcript]]", content);
|
||||
Assert.Contains("[[../Summaries/20260519-summary|Summary]]", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreUpdatesAssistantContextStateAndPreservesBody()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var contextPath = Path.Combine(root, "Meetings", "Assistant Context", "20260519-context.md");
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "20260519-meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
"Initial agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
await File.AppendAllTextAsync(contextPath, "Observed context line.");
|
||||
|
||||
await artifactStore.UpdateAssistantContextStateAsync(
|
||||
artifacts,
|
||||
AssistantContextState.Summarizing,
|
||||
CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("state: summarizing", content);
|
||||
Assert.Contains("agenda: |-", content);
|
||||
Assert.Contains(" Initial agenda", content);
|
||||
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("Observed context line.", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreOmitsScheduledEndWhenNoTeamsMeetingWasDetected()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var contextPath = Path.Combine(root, "Meetings", "Assistant Context", "20260519-context.md");
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "20260519-meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
|
||||
await artifactStore.CreateAssistantContextAsync(artifacts, "", null, CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.DoesNotContain("scheduled_end:", content);
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,8 @@
|
||||
<ProjectReference Include="..\MeetingAssistant\MeetingAssistant.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<Content Include="Fixtures\sample-16khz-mono.wav" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingNoteStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreWritesTemplateFrontmatterAndReadsUserNotesBody()
|
||||
{
|
||||
var (vaultRoot, store) = CreateStore();
|
||||
var note = MeetingNoteTemplate.Create(
|
||||
title: "Leadership Sync",
|
||||
startTime: DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"),
|
||||
endTime: DateTimeOffset.Parse("2026-05-19T10:30:00+02:00"),
|
||||
attendees:
|
||||
[
|
||||
"Mike <m.ike@ibm.com>",
|
||||
"Ada"
|
||||
],
|
||||
projects:
|
||||
[
|
||||
"Meeting Assistant",
|
||||
"Alaric"
|
||||
],
|
||||
transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"),
|
||||
summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"),
|
||||
userNotes: "Discuss rollout risks.");
|
||||
|
||||
var saved = await store.SaveAsync(note, CancellationToken.None);
|
||||
var content = await File.ReadAllTextAsync(saved.Path);
|
||||
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
|
||||
|
||||
Assert.EndsWith(".md", saved.Path, StringComparison.Ordinal);
|
||||
Assert.Contains("attendees:", content);
|
||||
Assert.Contains("- Mike <m.ike@ibm.com>", content);
|
||||
Assert.Contains("- Ada", content);
|
||||
Assert.Contains("projects:", content);
|
||||
Assert.Contains("- Meeting Assistant", content);
|
||||
Assert.DoesNotContain("purpose:", content);
|
||||
Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("end_time: \"2026-05-19T10:30:00.0000000+02:00\"", content);
|
||||
Assert.Contains("transcript: \"[[../Transcripts/20260519-transcript|Transcript]]\"", content);
|
||||
Assert.Contains("assistant_context: \"[[../Assistant Context/20260519-context|Assistant Context]]\"", content);
|
||||
Assert.Contains("summary: \"[[../Summaries/20260519-summary|Summary]]\"", content);
|
||||
Assert.DoesNotContain("decisions:", content);
|
||||
Assert.DoesNotContain("next_steps:", content);
|
||||
Assert.Equal("Discuss rollout risks.", loaded.UserNotes);
|
||||
Assert.Equal(["Mike <m.ike@ibm.com>", "Ada"], loaded.Frontmatter.Attendees);
|
||||
Assert.Equal(["Meeting Assistant", "Alaric"], loaded.Frontmatter.Projects);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"), loaded.Frontmatter.StartTime);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-19T10:30:00+02:00"), loaded.Frontmatter.EndTime);
|
||||
Assert.Equal("[[../Transcripts/20260519-transcript|Transcript]]", loaded.Frontmatter.Transcript);
|
||||
Assert.Equal("[[../Assistant Context/20260519-context|Assistant Context]]", loaded.Frontmatter.AssistantContext);
|
||||
Assert.Equal("[[../Summaries/20260519-summary|Summary]]", loaded.Frontmatter.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FrontmatterUpdatePreservesExistingUserNotesBody()
|
||||
{
|
||||
var (vaultRoot, store) = CreateStore();
|
||||
var userNotes = """
|
||||
## User notes
|
||||
|
||||
- First point
|
||||
- Second point
|
||||
|
||||
```text
|
||||
Keep this block exactly.
|
||||
```
|
||||
|
||||
""";
|
||||
var saved = await store.SaveAsync(
|
||||
MeetingNoteTemplate.Create(
|
||||
title: "Body Preservation",
|
||||
attendees: [],
|
||||
projects: [],
|
||||
transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"),
|
||||
summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"),
|
||||
userNotes: userNotes),
|
||||
CancellationToken.None);
|
||||
|
||||
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
|
||||
loaded.Frontmatter.Title = "Updated title";
|
||||
await store.SaveAsync(loaded, CancellationToken.None);
|
||||
var reloaded = await store.ReadAsync(saved.Path, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Updated title", reloaded.Frontmatter.Title);
|
||||
Assert.Equal(userNotes, reloaded.UserNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActionLinkEscapesSummaryFileName()
|
||||
{
|
||||
var link = MeetingNoteActionLinks.CreateSummaryRetryLink(
|
||||
"http://localhost:5090/",
|
||||
"C:\\Vault\\Meetings\\Summaries\\summary with spaces.md");
|
||||
|
||||
Assert.Equal(
|
||||
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary%20with%20spaces.md)",
|
||||
link);
|
||||
}
|
||||
|
||||
private static (string VaultRoot, MarkdownMeetingNoteStore Store) CreateStore()
|
||||
{
|
||||
var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var store = new MarkdownMeetingNoteStore(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
MeetingNotesFolder = Path.Combine(vaultRoot, "Meetings", "Notes"),
|
||||
TranscriptsFolder = Path.Combine(vaultRoot, "Meetings", "Transcripts"),
|
||||
AssistantContextFolder = Path.Combine(vaultRoot, "Meetings", "Assistant Context"),
|
||||
SummariesFolder = Path.Combine(vaultRoot, "Meetings", "Summaries"),
|
||||
ProjectsFolder = Path.Combine(vaultRoot, "Projects"),
|
||||
DictationWordsPath = Path.Combine(vaultRoot, "Meetings", "dictation-words.md")
|
||||
}
|
||||
}),
|
||||
NullLogger<MarkdownMeetingNoteStore>.Instance);
|
||||
|
||||
return (vaultRoot, store);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingSummaryArtifactResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolverFindsMeetingArtifactsForSummaryPath()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var notes = Path.Combine(root, "Meetings", "Notes");
|
||||
var summaries = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(notes);
|
||||
Directory.CreateDirectory(summaries);
|
||||
var notePath = Path.Combine(notes, "meeting.md");
|
||||
await File.WriteAllTextAsync(
|
||||
notePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
attendees:
|
||||
projects:
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
---
|
||||
|
||||
User notes.
|
||||
""");
|
||||
var options = Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
MeetingNotesFolder = notes,
|
||||
SummariesFolder = summaries
|
||||
}
|
||||
});
|
||||
var resolver = new MeetingSummaryArtifactResolver(
|
||||
options,
|
||||
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
|
||||
|
||||
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(artifacts);
|
||||
Assert.Equal(notePath, artifacts.MeetingNotePath);
|
||||
Assert.Equal(Path.Combine(root, "Meetings", "Transcripts", "transcript.md"), artifacts.TranscriptPath);
|
||||
Assert.Equal(Path.Combine(root, "Meetings", "Assistant Context", "context.md"), artifacts.AssistantContextPath);
|
||||
Assert.Equal(Path.Combine(summaries, "summary.md"), artifacts.SummaryPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolverRejectsSummaryPathOutsideConfiguredSummaryFolder()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var options = Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
MeetingNotesFolder = Path.Combine(root, "Notes"),
|
||||
SummariesFolder = Path.Combine(root, "Summaries")
|
||||
}
|
||||
});
|
||||
var resolver = new MeetingSummaryArtifactResolver(
|
||||
options,
|
||||
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
|
||||
|
||||
var artifacts = await resolver.ResolveBySummaryPathAsync(
|
||||
Path.Combine(root, "Other", "summary.md"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(artifacts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingSummaryFailureWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriterOverwritesSummaryWithFailureDetails()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
Path.Combine(root, "Notes", "meeting.md"),
|
||||
Path.Combine(root, "Transcripts", "transcript.md"),
|
||||
Path.Combine(root, "Assistant Context", "context.md"),
|
||||
Path.Combine(root, "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Failure Meeting
|
||||
start_time: "2026-05-20T10:00:00.0000000+02:00"
|
||||
end_time: "2026-05-20T10:30:00.0000000+02:00"
|
||||
attendees: []
|
||||
projects: []
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
---
|
||||
""");
|
||||
await File.WriteAllTextAsync(artifacts.SummaryPath, "# Old Summary");
|
||||
var writer = new MeetingSummaryFailureWriter(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Api = new ApiOptions { PublicBaseUrl = "http://localhost:5090" }
|
||||
}),
|
||||
new MarkdownMeetingNoteStore(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions { MeetingNotesFolder = Path.GetDirectoryName(artifacts.MeetingNotePath)! }
|
||||
}),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<MarkdownMeetingNoteStore>.Instance));
|
||||
|
||||
var result = await writer.WriteAsync(
|
||||
artifacts,
|
||||
new InvalidOperationException("LiteLLM Responses request failed with 500"),
|
||||
CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(artifacts.SummaryPath, result.SummaryPath);
|
||||
Assert.Contains("Summary Generation Failed", content);
|
||||
Assert.Contains("title: Failure Meeting", content);
|
||||
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", content);
|
||||
Assert.Contains(
|
||||
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary.md)",
|
||||
content);
|
||||
Assert.Contains("LiteLLM Responses request failed with 500", content);
|
||||
Assert.Contains(artifacts.TranscriptPath, content);
|
||||
Assert.DoesNotContain("# Old Summary", content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingSummaryToolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ToolsReadMeetingInputsAndWriteSummaryNote()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
start_time: "2026-05-20T10:00:00.0000000+02:00"
|
||||
end_time: "2026-05-20T10:30:00.0000000+02:00"
|
||||
attendees:
|
||||
- Ada
|
||||
projects:
|
||||
- Meeting Assistant
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
---
|
||||
|
||||
User note line.
|
||||
""");
|
||||
await File.WriteAllTextAsync(artifacts.TranscriptPath, "Ada: Transcript line.");
|
||||
await File.WriteAllTextAsync(artifacts.AssistantContextPath, "Context line.");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
Assert.Equal("Ada: Transcript line.", await tools.ReadTranscript());
|
||||
Assert.Contains("title: Meeting", await tools.ReadMeetingNote());
|
||||
Assert.Equal("Context line.", await tools.ReadContext());
|
||||
Assert.Equal("User note line.", await tools.ReadUserNotes());
|
||||
Assert.Equal("", await tools.ReadGlossary());
|
||||
|
||||
var result = await tools.WriteSummary("# Summary\n\n- Done.");
|
||||
|
||||
Assert.Equal(artifacts.SummaryPath, result);
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("title: Meeting", summary);
|
||||
Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary);
|
||||
Assert.Contains("end_time: \"2026-05-20T10:30:00.0000000+02:00\"", summary);
|
||||
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", summary);
|
||||
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", summary);
|
||||
Assert.Contains("assistant_context: \"[[../Assistant Context/context|Assistant Context]]\"", summary);
|
||||
Assert.Contains("# Summary\n\n- Done.", summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadToolsSupportClampedLineRanges()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
var glossaryPath = Path.Combine(root, "Meetings", "dictation-words.md");
|
||||
var projectRoot = Path.Combine(root, "Projects", "Project A");
|
||||
var projectFile = Path.Combine(projectRoot, "notes.md");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(glossaryPath)!);
|
||||
Directory.CreateDirectory(projectRoot);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
projects:
|
||||
- Project A
|
||||
---
|
||||
|
||||
note one
|
||||
note two
|
||||
note three
|
||||
""");
|
||||
await File.WriteAllTextAsync(artifacts.TranscriptPath, "transcript one\ntranscript two\ntranscript three");
|
||||
await File.WriteAllTextAsync(artifacts.AssistantContextPath, "context one\ncontext two\ncontext three");
|
||||
await File.WriteAllTextAsync(glossaryPath, "word one\nword two\nword three");
|
||||
await File.WriteAllTextAsync(projectFile, "project one\nproject two\nproject three");
|
||||
var tools = new MeetingSummaryTools(
|
||||
artifacts,
|
||||
new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
{
|
||||
DictationWordsPath = glossaryPath,
|
||||
ProjectsFolder = Path.Combine(root, "Projects")
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal("transcript two", await tools.ReadTranscript(from: 2, to: 2));
|
||||
Assert.Equal("context two\ncontext three", await tools.ReadContext(from: 2, to: 9));
|
||||
Assert.Equal("note two\nnote three", await tools.ReadUserNotes(from: 2, to: 99));
|
||||
Assert.Equal("word two", await tools.ReadGlossary(from: 2, to: 2));
|
||||
Assert.Equal("project two", await tools.ReadProjectFile("Project A", "notes.md", from: 2, to: 2));
|
||||
Assert.Equal("", await tools.ReadTranscript(from: 3, to: 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsWriteAssistantContextBodyWithLineModes()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
"""
|
||||
---
|
||||
meeting: "[[../Notes/meeting|Meeting Note]]"
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
state: summarizing
|
||||
---
|
||||
|
||||
line one
|
||||
line two
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
await tools.WriteContext("inserted", insert: 2);
|
||||
await tools.WriteContext("replacement", from: 1, to: 1);
|
||||
|
||||
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
|
||||
Assert.Contains("state: summarizing", context);
|
||||
Assert.Contains("replacement\ninserted\nline two", context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
#if WINDOWS
|
||||
public sealed class OutlookClassicMeetingMetadataProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractAgendaStopsBeforeTeamsJoinInformation()
|
||||
{
|
||||
var agenda = OutlookClassicMeetingMetadataProvider.ExtractAgenda(
|
||||
"""
|
||||
Review current prototype
|
||||
Decide next backend
|
||||
|
||||
________________________________________________________________________________
|
||||
Microsoft Teams Need help?
|
||||
Join the meeting now
|
||||
https://teams.microsoft.com/l/meetup-join/...
|
||||
""");
|
||||
|
||||
Assert.Equal("Review current prototype\nDecide next backend", agenda.Replace("\r\n", "\n", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,72 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class OutlookMeetingCandidateSelectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectIgnoresOverlappingMeetingThatEndsInLessThanFiveMinutes()
|
||||
{
|
||||
var now = new DateTime(2026, 5, 20, 10, 0, 0);
|
||||
var selected = OutlookMeetingCandidateSelector.Select(
|
||||
[
|
||||
new Candidate(now.AddMinutes(-25), now.AddMinutes(4))
|
||||
],
|
||||
now,
|
||||
candidate => candidate.Start,
|
||||
candidate => candidate.End);
|
||||
|
||||
Assert.Null(selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectPrefersSingleGoodOverlappingMeetingOverUpcomingMeeting()
|
||||
{
|
||||
var now = new DateTime(2026, 5, 20, 10, 0, 0);
|
||||
var overlap = new Candidate(now.AddMinutes(-10), now.AddMinutes(30));
|
||||
var upcoming = new Candidate(now.AddMinutes(3), now.AddMinutes(33));
|
||||
|
||||
var selected = OutlookMeetingCandidateSelector.Select(
|
||||
[upcoming, overlap],
|
||||
now,
|
||||
candidate => candidate.Start,
|
||||
candidate => candidate.End);
|
||||
|
||||
Assert.Same(overlap, selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectUsesSingleTeamsMeetingStartingWithinFiveMinutesWhenNoGoodOverlapExists()
|
||||
{
|
||||
var now = new DateTime(2026, 5, 20, 10, 0, 0);
|
||||
var endingOverlap = new Candidate(now.AddMinutes(-25), now.AddMinutes(2));
|
||||
var upcoming = new Candidate(now.AddMinutes(5), now.AddMinutes(35));
|
||||
|
||||
var selected = OutlookMeetingCandidateSelector.Select(
|
||||
[endingOverlap, upcoming],
|
||||
now,
|
||||
candidate => candidate.Start,
|
||||
candidate => candidate.End);
|
||||
|
||||
Assert.Same(upcoming, selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectRejectsAmbiguousUpcomingMeetings()
|
||||
{
|
||||
var now = new DateTime(2026, 5, 20, 10, 0, 0);
|
||||
|
||||
var selected = OutlookMeetingCandidateSelector.Select(
|
||||
[
|
||||
new Candidate(now.AddMinutes(2), now.AddMinutes(32)),
|
||||
new Candidate(now.AddMinutes(4), now.AddMinutes(34))
|
||||
],
|
||||
now,
|
||||
candidate => candidate.Start,
|
||||
candidate => candidate.End);
|
||||
|
||||
Assert.Null(selected);
|
||||
}
|
||||
|
||||
private sealed record Candidate(DateTime Start, DateTime End);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ProjectKnowledgeToolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ToolsOperateOnProjectsBoundInMeetingFrontmatter()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
var meetingAssistantRoot = Path.Combine(projectsRoot, "MeetingAssistant");
|
||||
var ignoredRoot = Path.Combine(projectsRoot, "IgnoredProject");
|
||||
Directory.CreateDirectory(Path.Combine(meetingAssistantRoot, "notes"));
|
||||
Directory.CreateDirectory(ignoredRoot);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(meetingAssistantRoot, "README.md"),
|
||||
"First line\nSecond alpha line\nThird beta line");
|
||||
await File.WriteAllTextAsync(Path.Combine(meetingAssistantRoot, "notes", "context.md"), "Project context");
|
||||
await File.WriteAllTextAsync(Path.Combine(ignoredRoot, "ignored.md"), "alpha should not be searched");
|
||||
|
||||
var artifacts = CreateArtifacts(root);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Project Meeting
|
||||
attendees: []
|
||||
projects:
|
||||
- MeetingAssistant
|
||||
- MissingProject
|
||||
transcript: ""
|
||||
assistant_context: ""
|
||||
summary: ""
|
||||
---
|
||||
|
||||
User notes.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(
|
||||
artifacts,
|
||||
new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
{
|
||||
ProjectsFolder = projectsRoot
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal("MeetingAssistant", await tools.ListProjects());
|
||||
Assert.Equal("README.md\nnotes/context.md", await tools.ListProjectFiles("MeetingAssistant"));
|
||||
Assert.Equal("Second alpha line\nThird beta line", await tools.ReadProjectFile("MeetingAssistant", "README.md", 2, 99));
|
||||
|
||||
var writeResult = await tools.WriteProjectFile("MeetingAssistant", "notes/summary.md", "# Project Update");
|
||||
|
||||
Assert.Equal("MeetingAssistant/notes/summary.md", writeResult);
|
||||
Assert.Equal("# Project Update", await File.ReadAllTextAsync(Path.Combine(meetingAssistantRoot, "notes", "summary.md")));
|
||||
Assert.Equal("IgnoredProject/ignored.md", await tools.WriteProjectFile("IgnoredProject", "ignored.md", "changed"));
|
||||
Assert.Equal("changed", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
|
||||
Assert.Equal(
|
||||
"README.md:2 Second alpha line",
|
||||
await tools.Search("alpha"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteProjectFileSupportsOverwriteReplaceInsertAndCreate()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
var projectRoot = Path.Combine(projectsRoot, "MeetingAssistant");
|
||||
Directory.CreateDirectory(projectRoot);
|
||||
var projectFile = Path.Combine(projectRoot, "notes.md");
|
||||
await File.WriteAllTextAsync(projectFile, "one\ntwo\nthree\nfour");
|
||||
var tools = new MeetingSummaryTools(
|
||||
CreateArtifacts(root),
|
||||
new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
{
|
||||
ProjectsFolder = projectsRoot
|
||||
}
|
||||
});
|
||||
|
||||
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "TWO\nTHREE", from: 2, to: 3);
|
||||
Assert.Equal("one\nTWO\nTHREE\nfour", await File.ReadAllTextAsync(projectFile));
|
||||
|
||||
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "inserted", insert: 2);
|
||||
Assert.Equal("one\ninserted\nTWO\nTHREE\nfour", await File.ReadAllTextAsync(projectFile));
|
||||
|
||||
await tools.WriteProjectFile("MeetingAssistant", "created/new.md", "created content");
|
||||
Assert.Equal("created content", await File.ReadAllTextAsync(Path.Combine(projectRoot, "created", "new.md")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteProjectFileRefusesMissingProjectsEscapingPathsAndAmbiguousLineArguments()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
Directory.CreateDirectory(Path.Combine(projectsRoot, "MeetingAssistant"));
|
||||
var tools = new MeetingSummaryTools(
|
||||
CreateArtifacts(root),
|
||||
new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
{
|
||||
ProjectsFolder = projectsRoot
|
||||
}
|
||||
});
|
||||
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MissingProject", "notes.md", "content"));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "../outside.md", "content"));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, insert: 1));
|
||||
}
|
||||
|
||||
private static MeetingSessionArtifacts CreateArtifacts(string root)
|
||||
{
|
||||
return new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class PyannoteTranscriptFinalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FinalizerAssignsPyannoteSpeakersToLiveWhisperSegmentsByOverlap()
|
||||
{
|
||||
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
|
||||
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
|
||||
var commandRunner = new CapturingCommandRunner(
|
||||
"""
|
||||
install noise
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
|
||||
[{"start":0.0,"end":1.5,"speaker":"SPEAKER_00"},{"start":1.5,"end":3.0,"speaker":"SPEAKER_01"}]
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
|
||||
""");
|
||||
var finalizer = CreateFinalizer(
|
||||
commandRunner,
|
||||
token: "hf_test",
|
||||
alignmentMode: PyannoteAlignmentMode.BestOverlap);
|
||||
var liveSegments = new[]
|
||||
{
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(0.2), TimeSpan.FromSeconds(1.2), "Unknown", "hello"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(1.7), TimeSpan.FromSeconds(2.4), "Unknown", "there")
|
||||
};
|
||||
|
||||
var segments = await finalizer.FinalizeAsync(
|
||||
audioPath,
|
||||
liveSegments,
|
||||
SpeechRecognitionPipelineOptions.Default,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("inspect"));
|
||||
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("--format"));
|
||||
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("build"));
|
||||
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote:local"));
|
||||
Assert.Equal("docker", commandRunner.Commands.Last().FileName);
|
||||
Assert.Contains("run", commandRunner.Commands.Last().Arguments);
|
||||
Assert.Contains("meeting-assistant-pyannote:local", commandRunner.Commands.Last().Arguments);
|
||||
Assert.Contains("HF_TOKEN", commandRunner.Commands.Last().Arguments);
|
||||
Assert.DoesNotContain("HF_TOKEN=hf_test", commandRunner.Commands.Last().Arguments);
|
||||
Assert.Equal("hf_test", commandRunner.Environment["HF_TOKEN"]);
|
||||
Assert.Contains("sh", commandRunner.Commands.Last().Arguments);
|
||||
Assert.DoesNotContain("bash", commandRunner.Commands.Last().Arguments);
|
||||
Assert.DoesNotContain(commandRunner.Commands.Last().Arguments, argument => argument.Contains("pip install", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(commandRunner.Commands.Last().Arguments, argument => argument.Contains("apt-get install", StringComparison.Ordinal));
|
||||
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("PIP_CACHE_DIR=/workspace/cache/pip", StringComparison.Ordinal));
|
||||
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("HF_HOME=/workspace/cache/huggingface", StringComparison.Ordinal));
|
||||
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("TORCH_HOME=/workspace/cache/torch", StringComparison.Ordinal));
|
||||
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("speaker_diarization", StringComparison.Ordinal));
|
||||
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("token=os.environ.get('HF_TOKEN')", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(commandRunner.Commands.Last().Arguments, argument => argument.Contains("use_auth_token", StringComparison.Ordinal));
|
||||
Assert.Collection(
|
||||
segments,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("SPEAKER_00", first.Speaker);
|
||||
Assert.Equal("hello", first.Text);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("SPEAKER_01", second.Speaker);
|
||||
Assert.Equal("there", second.Text);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalizerCanBuildTranscriptSegmentsFromPyannoteTurns()
|
||||
{
|
||||
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
|
||||
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
|
||||
var commandRunner = new CapturingCommandRunner(
|
||||
"""
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
|
||||
[{"start":0.0,"end":1.0,"speaker":"SPEAKER_00"},{"start":1.0,"end":2.0,"speaker":"SPEAKER_01"}]
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
|
||||
""");
|
||||
var finalizer = CreateFinalizer(
|
||||
commandRunner,
|
||||
token: "hf_test",
|
||||
annotationSource: PyannoteAnnotationSource.ExclusiveSpeakerDiarization,
|
||||
alignmentMode: PyannoteAlignmentMode.PyannoteTurns);
|
||||
var liveSegments = new[]
|
||||
{
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(2), "Unknown", "alpha beta gamma delta")
|
||||
};
|
||||
|
||||
var segments = await finalizer.FinalizeAsync(
|
||||
audioPath,
|
||||
liveSegments,
|
||||
SpeechRecognitionPipelineOptions.Default,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("exclusive_speaker_diarization", StringComparison.Ordinal));
|
||||
Assert.Collection(
|
||||
segments,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal(TimeSpan.Zero, first.Start);
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), first.End);
|
||||
Assert.Equal("SPEAKER_00", first.Speaker);
|
||||
Assert.Equal("alpha beta", first.Text);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), second.Start);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), second.End);
|
||||
Assert.Equal("SPEAKER_01", second.Speaker);
|
||||
Assert.Equal("gamma delta", second.Text);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalizerPassesConfiguredNumSpeakersToPyannote()
|
||||
{
|
||||
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
|
||||
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
|
||||
var commandRunner = new CapturingCommandRunner(
|
||||
"""
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
|
||||
[{"start":0.0,"end":1.0,"speaker":"SPEAKER_00"}]
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
|
||||
""");
|
||||
var finalizer = CreateFinalizer(commandRunner, token: "hf_test");
|
||||
|
||||
await finalizer.FinalizeAsync(
|
||||
audioPath,
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello")],
|
||||
new SpeechRecognitionPipelineOptions(5),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("num_speakers=5", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalizerCanUseExplicitDiarizationOptions()
|
||||
{
|
||||
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
|
||||
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
|
||||
var commandRunner = new CapturingCommandRunner(
|
||||
"""
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
|
||||
[{"start":0.0,"end":1.0,"speaker":"SPEAKER_00"}]
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
|
||||
""");
|
||||
var finalizer = new PyannoteTranscriptFinalizer(
|
||||
commandRunner,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||
var explicitDiarization = new PyannoteDiarizationOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DockerCommand = "docker",
|
||||
BaseImage = "python:3.11-slim",
|
||||
Image = "meeting-assistant-pyannote-azure:local",
|
||||
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models"),
|
||||
Model = "custom/diarization",
|
||||
Token = "hf_azure",
|
||||
TokenEnv = "",
|
||||
CommandTimeout = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
|
||||
await finalizer.FinalizeAsync(
|
||||
audioPath,
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello")],
|
||||
explicitDiarization,
|
||||
SpeechRecognitionPipelineOptions.Default,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-azure:local"));
|
||||
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("custom/diarization", StringComparison.Ordinal));
|
||||
Assert.Equal("hf_azure", commandRunner.Environment["HF_TOKEN"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalizerSkipsPyannoteWhenTokenIsMissing()
|
||||
{
|
||||
var commandRunner = new CapturingCommandRunner("");
|
||||
var finalizer = CreateFinalizer(commandRunner, token: null);
|
||||
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
|
||||
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
|
||||
|
||||
var segments = await finalizer.FinalizeAsync(
|
||||
audioPath,
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello")],
|
||||
SpeechRecognitionPipelineOptions.Default,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Empty(segments);
|
||||
Assert.Empty(commandRunner.Commands);
|
||||
}
|
||||
|
||||
private static PyannoteTranscriptFinalizer CreateFinalizer(
|
||||
CapturingCommandRunner commandRunner,
|
||||
string? token,
|
||||
PyannoteAnnotationSource annotationSource = PyannoteAnnotationSource.SpeakerDiarization,
|
||||
PyannoteAlignmentMode alignmentMode = PyannoteAlignmentMode.PyannoteTurns)
|
||||
{
|
||||
return new PyannoteTranscriptFinalizer(
|
||||
commandRunner,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
WhisperLocal = new WhisperLocalOptions
|
||||
{
|
||||
Diarization = new PyannoteDiarizationOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DockerCommand = "docker",
|
||||
BaseImage = "python:3.11-slim",
|
||||
Image = "meeting-assistant-pyannote:local",
|
||||
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models"),
|
||||
AnnotationSource = annotationSource,
|
||||
AlignmentMode = alignmentMode,
|
||||
Token = token,
|
||||
TokenEnv = "",
|
||||
CommandTimeout = TimeSpan.FromMinutes(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||
}
|
||||
|
||||
private sealed class CapturingCommandRunner : ICommandRunner
|
||||
{
|
||||
private readonly string output;
|
||||
|
||||
public CapturingCommandRunner(string output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
public IReadOnlyList<CapturedCommand> Commands { get; private set; } = [];
|
||||
|
||||
public IReadOnlyDictionary<string, string> Environment { get; private set; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
public Task<CommandResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> arguments,
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyDictionary<string, string>? environment = null)
|
||||
{
|
||||
Commands = Commands.Append(new CapturedCommand(fileName, arguments)).ToList();
|
||||
Environment = environment ?? new Dictionary<string, string>();
|
||||
if (arguments.Contains("inspect"))
|
||||
{
|
||||
return Task.FromResult(new CommandResult(1, "", "image missing"));
|
||||
}
|
||||
|
||||
return Task.FromResult(new CommandResult(0, output, ""));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CapturedCommand(string FileName, IReadOnlyList<string> Arguments);
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class RecordingCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var provider = new EchoStreamingTranscriptionProvider();
|
||||
var store = new InMemoryTranscriptStore();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var noteOpener = new CapturingMeetingNoteOpener();
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var audioArchive = new InMemoryRecordedAudioStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
store,
|
||||
noteStore,
|
||||
noteOpener,
|
||||
artifactStore,
|
||||
audioArchive,
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
var started = await coordinator.ToggleAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk(new byte[] { 1, 0 }, 16000, 1), CancellationToken.None);
|
||||
|
||||
await store.WaitForTextAsync("chunk:2");
|
||||
var stopped = await coordinator.ToggleAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(started.IsRecording);
|
||||
Assert.Equal(noteStore.SavedNote?.Path, noteOpener.OpenedPath);
|
||||
Assert.Equal(started.MeetingNotePath, noteOpener.OpenedPath);
|
||||
Assert.Equal(started.MeetingNotePath, artifactStore.CreatedArtifacts?.MeetingNotePath);
|
||||
Assert.True(provider.FirstChunkWasObservedBeforeSourceCompleted);
|
||||
Assert.False(stopped.IsRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md");
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md");
|
||||
var noteOpener = new CapturingMeetingNoteOpener();
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var audioArchive = new InMemoryRecordedAudioStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
noteOpener,
|
||||
artifactStore,
|
||||
audioArchive,
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
AssistantContextFolder = "C:\\Vault\\Meetings\\Assistant Context",
|
||||
SummariesFolder = "C:\\Vault\\Meetings\\Summaries"
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
var status = await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(status.IsRecording);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript);
|
||||
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
|
||||
Assert.EndsWith("-assistant-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
|
||||
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
|
||||
Assert.EndsWith("-summary.md", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath);
|
||||
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);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable()
|
||||
{
|
||||
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,
|
||||
new FixedMeetingMetadataProvider(new MeetingMetadata(
|
||||
"Architecture Sync",
|
||||
["Ada <ada@example.com>", "Grace"],
|
||||
"Review API shape",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"))));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(["Ada <ada@example.com>", "Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
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 StopCompletesAudioCaptureAndDrainsFinalTranscriptionWindow()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0 }, 16000, 1));
|
||||
var provider = new FinalSegmentOnAudioCompletionProvider();
|
||||
var store = new InMemoryTranscriptStore();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var noteOpener = new CapturingMeetingNoteOpener();
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var audioArchive = new InMemoryRecordedAudioStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
store,
|
||||
noteStore,
|
||||
noteOpener,
|
||||
artifactStore,
|
||||
audioArchive,
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
var stopped = await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(stopped.IsRecording);
|
||||
await store.WaitForTextAsync("final:2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureStartsEvenWhenTranscriptionProviderIsStillWarmingUp()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var provider = new BlockingBeforeTranscriptionProvider();
|
||||
var store = new InMemoryTranscriptStore();
|
||||
var audioArchive = new InMemoryRecordedAudioStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
store,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
audioArchive,
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await provider.WaitUntilWaitingForBackendAsync();
|
||||
await audioSource.WriteAsync(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1), CancellationToken.None);
|
||||
|
||||
await audioArchive.WaitForAppendAsync();
|
||||
Assert.Equal([4], audioArchive.AppendedChunkSizes);
|
||||
|
||||
provider.MarkBackendReady();
|
||||
await store.WaitForTextAsync("chunk:4");
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopRewritesTranscriptWithFinalDiarizedSegmentsWhenAvailable()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Speaker 0", "hello"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), "Speaker 1", "there")
|
||||
]);
|
||||
var audioArchive = new InMemoryRecordedAudioStore("memory-recording.wav");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
audioArchive,
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("memory-recording.wav", finalizer.AudioPath);
|
||||
Assert.Collection(
|
||||
finalizer.LiveSegments,
|
||||
segment => Assert.Equal("final:4", segment.Text));
|
||||
Assert.Equal([4], audioArchive.AppendedChunkSizes);
|
||||
Assert.True(audioArchive.Completed);
|
||||
Assert.True(audioArchive.Deleted);
|
||||
Assert.Collection(
|
||||
transcriptStore.ReplacedSegments,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("Speaker 0", first.Speaker);
|
||||
Assert.Equal("hello", first.Text);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("Speaker 1", second.Speaker);
|
||||
Assert.Equal("there", second.Text);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, null)]
|
||||
[InlineData(1, null)]
|
||||
[InlineData(2, 2)]
|
||||
[InlineData(5, 5)]
|
||||
public async Task StopUsesMeetingNoteAttendeeCountAsSpeakerHintWhenThereAreMultipleAttendees(
|
||||
int attendeeCount,
|
||||
int? expectedNumSpeakers)
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Speaker 0", "hello")
|
||||
]);
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
noteStore.UpdateAttendees(Enumerable.Range(1, attendeeCount).Select(index => $"Person {index}"));
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(expectedNumSpeakers, finalizer.Options?.NumSpeakers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAddsMeetingEndTimeAndRunsSummaryAfterFinishedTranscript()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
var startTime = noteStore.SavedNote?.Frontmatter.StartTime;
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(startTime);
|
||||
Assert.NotNull(noteStore.SavedNote?.Frontmatter.EndTime);
|
||||
Assert.True(noteStore.SavedNote?.Frontmatter.EndTime >= startTime);
|
||||
Assert.Equal(artifactStore.CreatedArtifacts, summaryPipeline.Artifacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopUpdatesTranscriptMetadataWithMeetingEndTime()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
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.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopUpdatesAssistantContextStateThroughSummaryLifecycle()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(CreateOptionsWithoutFinalizer()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
[AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopMarksAssistantContextErrorWhenSummaryFails()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(succeeded: false),
|
||||
Options.Create(CreateOptionsWithoutFinalizer()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
[AssistantContextState.Summarizing, AssistantContextState.Error],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopMarksSpeakerRecognitionBeforeSummaryWhenFinalizerIsConfigured()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions { TranscriptionProvider = "whisper-local" },
|
||||
WhisperLocal = new WhisperLocalOptions
|
||||
{
|
||||
Diarization = new PyannoteDiarizationOptions { Enabled = true }
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
[AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VaultTranscriptStoreCreatesConfiguredFolderAndAppendsSegments()
|
||||
{
|
||||
var vaultFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var store = new VaultTranscriptStore(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions { TranscriptsFolder = vaultFolder }
|
||||
}),
|
||||
NullLogger<VaultTranscriptStore>.Instance);
|
||||
|
||||
var session = await store.CreateSessionAsync(CancellationToken.None);
|
||||
await store.AppendAsync(
|
||||
session,
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello vault"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(Directory.Exists(vaultFolder));
|
||||
Assert.EndsWith(".md", session.TranscriptPath, StringComparison.Ordinal);
|
||||
Assert.Contains("hello vault", await File.ReadAllTextAsync(session.TranscriptPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemporaryRecordedAudioStoreCreatesConfiguredFolderAndWritesPcmWav()
|
||||
{
|
||||
var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var store = new TemporaryRecordedAudioStore(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions
|
||||
{
|
||||
SampleRate = 16000,
|
||||
Channels = 1,
|
||||
TemporaryRecordingsFolder = recordingFolder
|
||||
}
|
||||
}),
|
||||
NullLogger<TemporaryRecordedAudioStore>.Instance);
|
||||
|
||||
await using var session = await store.CreateSessionAsync(CancellationToken.None);
|
||||
await session.AppendAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
|
||||
await session.CompleteAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(Directory.Exists(recordingFolder));
|
||||
Assert.EndsWith(".wav", session.AudioPath, StringComparison.Ordinal);
|
||||
Assert.True(new FileInfo(session.AudioPath).Length > 44);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemporaryRecordedAudioStoreDeletesStaleRecordingsOnStartup()
|
||||
{
|
||||
var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(recordingFolder);
|
||||
var staleRecording = Path.Combine(recordingFolder, "stale.wav");
|
||||
var unrelatedFile = Path.Combine(recordingFolder, "keep.txt");
|
||||
await File.WriteAllTextAsync(staleRecording, "stale");
|
||||
await File.WriteAllTextAsync(unrelatedFile, "keep");
|
||||
var store = new TemporaryRecordedAudioStore(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions { TemporaryRecordingsFolder = recordingFolder }
|
||||
}),
|
||||
NullLogger<TemporaryRecordedAudioStore>.Instance);
|
||||
|
||||
await store.DeleteStaleRecordingsAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(File.Exists(staleRecording));
|
||||
Assert.True(File.Exists(unrelatedFile));
|
||||
}
|
||||
|
||||
private static MeetingAssistantOptions CreateOptionsWithoutFinalizer()
|
||||
{
|
||||
return new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions { TranscriptionProvider = "whisper-local" },
|
||||
WhisperLocal = new WhisperLocalOptions
|
||||
{
|
||||
Diarization = new PyannoteDiarizationOptions { Enabled = false }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class InMemoryTranscriptStore : ITranscriptStore
|
||||
{
|
||||
private readonly List<TranscriptionSegment> segments = [];
|
||||
private readonly TaskCompletionSource segmentWritten = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly string transcriptPath;
|
||||
|
||||
public InMemoryTranscriptStore(string transcriptPath = "memory-transcript.md")
|
||||
{
|
||||
this.transcriptPath = transcriptPath;
|
||||
}
|
||||
|
||||
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new TranscriptSession(transcriptPath));
|
||||
}
|
||||
|
||||
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
|
||||
{
|
||||
segments.Add(segment);
|
||||
segmentWritten.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task WaitForTextAsync(string text)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (segments.Any(segment => segment.Text.Contains(text, StringComparison.Ordinal)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await segmentWritten.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Segment containing '{text}' was not written.");
|
||||
}
|
||||
|
||||
public IReadOnlyList<TranscriptionSegment> ReplacedSegments { get; private set; } = [];
|
||||
|
||||
public MeetingNote? MetadataMeetingNote { get; private set; }
|
||||
|
||||
public Task ReplaceAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<TranscriptionSegment> replacementSegments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ReplacedSegments = replacementSegments;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateMetadataAsync(
|
||||
TranscriptSession session,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MetadataMeetingNote = meetingNote;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryMeetingNoteStore : IMeetingNoteStore
|
||||
{
|
||||
private readonly string notePath;
|
||||
|
||||
public InMemoryMeetingNoteStore(string notePath = "memory-meeting.md")
|
||||
{
|
||||
this.notePath = notePath;
|
||||
}
|
||||
|
||||
public MeetingNote? SavedNote { get; private set; }
|
||||
|
||||
public Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
|
||||
{
|
||||
SavedNote = note with { Path = notePath };
|
||||
return Task.FromResult(SavedNote);
|
||||
}
|
||||
|
||||
public Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(SavedNote ?? throw new FileNotFoundException(path));
|
||||
}
|
||||
|
||||
public void UpdateAttendees(IEnumerable<string> attendees)
|
||||
{
|
||||
if (SavedNote is null)
|
||||
{
|
||||
throw new InvalidOperationException("No meeting note has been saved.");
|
||||
}
|
||||
|
||||
SavedNote.Frontmatter.Attendees = attendees.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener
|
||||
{
|
||||
public string? OpenedPath { get; private set; }
|
||||
|
||||
public Task OpenAsync(string notePath, CancellationToken cancellationToken)
|
||||
{
|
||||
OpenedPath = notePath;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
|
||||
|
||||
public List<AssistantContextState> States { get; } = [];
|
||||
|
||||
public string? Agenda { get; private set; }
|
||||
|
||||
public DateTimeOffset? ScheduledEnd { get; private set; }
|
||||
|
||||
public Task CreateAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CreatedArtifacts = artifacts;
|
||||
Agenda = agenda;
|
||||
ScheduledEnd = scheduledEnd;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextStateAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
States.Add(state);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
private readonly MeetingMetadata? metadata;
|
||||
|
||||
public FixedMeetingMetadataProvider(MeetingMetadata? metadata)
|
||||
{
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly bool succeeded;
|
||||
|
||||
public CapturingMeetingSummaryPipeline(bool succeeded = true)
|
||||
{
|
||||
this.succeeded = succeeded;
|
||||
}
|
||||
|
||||
public MeetingSessionArtifacts? Artifacts { get; private set; }
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Artifacts = artifacts;
|
||||
return Task.FromResult(new MeetingSummaryRunResult(
|
||||
artifacts.SummaryPath,
|
||||
succeeded ? "summary ok" : "summary failed",
|
||||
succeeded,
|
||||
succeeded ? null : "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRecordedAudioStore : IRecordedAudioStore
|
||||
{
|
||||
private readonly string audioPath;
|
||||
|
||||
public InMemoryRecordedAudioStore(string audioPath = "memory-recording.wav")
|
||||
{
|
||||
this.audioPath = audioPath;
|
||||
}
|
||||
|
||||
public List<int> AppendedChunkSizes { get; } = [];
|
||||
|
||||
public bool Completed { get; private set; }
|
||||
|
||||
public bool Deleted { get; private set; }
|
||||
|
||||
private TaskCompletionSource AppendObserved { get; set; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task WaitForAppendAsync()
|
||||
{
|
||||
return AppendObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IRecordedAudioSink>(new Sink(this, audioPath));
|
||||
}
|
||||
|
||||
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class Sink : IRecordedAudioSink
|
||||
{
|
||||
private readonly InMemoryRecordedAudioStore store;
|
||||
|
||||
public Sink(InMemoryRecordedAudioStore store, string audioPath)
|
||||
{
|
||||
this.store = store;
|
||||
AudioPath = audioPath;
|
||||
}
|
||||
|
||||
public string AudioPath { get; }
|
||||
|
||||
public Task AppendAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||
{
|
||||
store.AppendedChunkSizes.Add(chunk.Pcm.Length);
|
||||
store.AppendObserved.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
store.Completed = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
store.Deleted = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly IStreamingTranscriptionProvider provider;
|
||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||
|
||||
public TestSpeechRecognitionPipelineFactory(
|
||||
IStreamingTranscriptionProvider provider,
|
||||
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>>? finalize = null)
|
||||
{
|
||||
this.provider = provider;
|
||||
this.finalize = finalize ?? ((_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return new TestSpeechRecognitionPipeline(provider, finalize);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||
{
|
||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||
|
||||
public TestSpeechRecognitionPipeline(
|
||||
IStreamingTranscriptionProvider provider,
|
||||
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
|
||||
: base(provider)
|
||||
{
|
||||
this.finalize = finalize;
|
||||
}
|
||||
|
||||
protected override Task<IReadOnlyList<TranscriptionSegment>> BuildFinishedTranscriptAsync(
|
||||
string audioPath,
|
||||
IReadOnlyList<TranscriptionSegment> liveSegments,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return finalize(audioPath, liveSegments, options, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingTranscriptFinalizer
|
||||
{
|
||||
private readonly IReadOnlyList<TranscriptionSegment> segments;
|
||||
|
||||
public CapturingTranscriptFinalizer(IReadOnlyList<TranscriptionSegment> segments)
|
||||
{
|
||||
this.segments = segments;
|
||||
}
|
||||
|
||||
public string? AudioPath { get; private set; }
|
||||
|
||||
public IReadOnlyList<TranscriptionSegment> LiveSegments { get; private set; } = [];
|
||||
|
||||
public SpeechRecognitionPipelineOptions? Options { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<TranscriptionSegment>> FinalizeAsync(
|
||||
string audioPath,
|
||||
IReadOnlyList<TranscriptionSegment> liveSegments,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AudioPath = audioPath;
|
||||
LiveSegments = liveSegments;
|
||||
Options = options;
|
||||
return Task.FromResult(segments);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControlledAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly Channel<AudioChunk> chunks = Channel.CreateUnbounded<AudioChunk>();
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return chunks.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask WriteAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||
{
|
||||
return chunks.Writer.WriteAsync(chunk, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturedChunkThenCancelAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly AudioChunk chunk;
|
||||
private readonly TaskCompletionSource captured = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public CapturedChunkThenCancelAudioSource(AudioChunk chunk)
|
||||
{
|
||||
this.chunk = chunk;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield return chunk;
|
||||
captured.TrySetResult();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
captured.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitUntilCapturedAsync()
|
||||
{
|
||||
return captured.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EchoStreamingTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public bool FirstChunkWasObservedBeforeSourceCompleted { get; private set; }
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
FirstChunkWasObservedBeforeSourceCompleted = true;
|
||||
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"chunk:{chunk.Pcm.Length}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FinalSegmentOnAudioCompletionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var byteCount = 0;
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
byteCount += chunk.Pcm.Length;
|
||||
}
|
||||
|
||||
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"final:{byteCount}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingBeforeTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
private readonly TaskCompletionSource waitingForBackend =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource backendReady =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task WaitUntilWaitingForBackendAsync()
|
||||
{
|
||||
return waitingForBackend.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void MarkBackendReady()
|
||||
{
|
||||
backendReady.TrySetResult();
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
waitingForBackend.TrySetResult();
|
||||
await backendReady.Task.WaitAsync(cancellationToken);
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"chunk:{chunk.Pcm.Length}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class SampleWavFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SampleWavFixtureIsSmallMonoPcmAudio()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
|
||||
var bytes = await File.ReadAllBytesAsync(path);
|
||||
|
||||
Assert.True(bytes.Length < 16_000);
|
||||
Assert.Equal("RIFF", ReadAscii(bytes, 0, 4));
|
||||
Assert.Equal("WAVE", ReadAscii(bytes, 8, 4));
|
||||
Assert.Equal("fmt ", ReadAscii(bytes, 12, 4));
|
||||
Assert.Equal((short)1, BitConverter.ToInt16(bytes, 20));
|
||||
Assert.Equal((short)1, BitConverter.ToInt16(bytes, 22));
|
||||
Assert.Equal(16000, BitConverter.ToInt32(bytes, 24));
|
||||
Assert.Equal((short)16, BitConverter.ToInt16(bytes, 34));
|
||||
}
|
||||
|
||||
private static string ReadAscii(byte[] bytes, int start, int length)
|
||||
{
|
||||
return System.Text.Encoding.ASCII.GetString(bytes, start, length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class SpeechRecognitionPipelineHostedServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HostedServiceInitializesAndWarmsConfiguredPipelineWithoutBlockingStartup()
|
||||
{
|
||||
var pipeline = new CapturingSpeechRecognitionPipeline { BlockReadinessUntilCancelled = true };
|
||||
var service = new SpeechRecognitionPipelineHostedService(
|
||||
new CapturingSpeechRecognitionPipelineFactory(pipeline),
|
||||
NullLogger<SpeechRecognitionPipelineHostedService>.Instance);
|
||||
|
||||
var startTask = service.StartAsync(CancellationToken.None);
|
||||
|
||||
await startTask.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
await pipeline.WaitForInitializeAsync();
|
||||
await pipeline.WaitForReadinessAsync();
|
||||
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, pipeline.InitializeCount);
|
||||
Assert.Equal(1, pipeline.ReadinessCount);
|
||||
Assert.True(pipeline.ReadinessCancellationWasObserved);
|
||||
Assert.True(pipeline.Disposed);
|
||||
}
|
||||
|
||||
private sealed class CapturingSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly ISpeechRecognitionPipeline pipeline;
|
||||
|
||||
public CapturingSpeechRecognitionPipelineFactory(ISpeechRecognitionPipeline pipeline)
|
||||
{
|
||||
this.pipeline = pipeline;
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return pipeline;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingSpeechRecognitionPipeline : ISpeechRecognitionPipeline
|
||||
{
|
||||
private readonly TaskCompletionSource initialized = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource readinessChecked = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int InitializeCount { get; private set; }
|
||||
|
||||
public int ReadinessCount { get; private set; }
|
||||
|
||||
public bool BlockReadinessUntilCancelled { get; init; }
|
||||
|
||||
public bool ReadinessCancellationWasObserved { get; private set; }
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public Task WaitForInitializeAsync()
|
||||
{
|
||||
return initialized.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public Task WaitForReadinessAsync()
|
||||
{
|
||||
return readinessChecked.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
InitializeCount++;
|
||||
initialized.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task WaitUntilReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReadinessCount++;
|
||||
readinessChecked.TrySetResult();
|
||||
if (!BlockReadinessUntilCancelled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ReadinessCancellationWasObserved = true;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask WriteAsync(MeetingAssistant.Recording.AudioChunk chunk, CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> ReadLiveTranscriptAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TranscriptionSegment>> ReadFinishedTranscriptAsync(
|
||||
string audioPath,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Disposed = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user