Public Access
Make meeting lifecycle stateful
This commit is contained in:
@@ -30,6 +30,37 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
Assert.StartsWith("endpoint-bytes:", segment.Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NamedProfileEndpointPassesProfileToConfiguredProvider()
|
||||
{
|
||||
var pipelineFactory = new CapturingProfileSpeechRecognitionPipelineFactory();
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
|
||||
});
|
||||
});
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<ISpeechRecognitionPipelineFactory>();
|
||||
services.AddSingleton<ISpeechRecognitionPipelineFactory>(pipelineFactory);
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
|
||||
|
||||
using var response = await client.PostAsJsonAsync("/profiles/english/asr/transcribe-file", new { path = wavPath });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("english", pipelineFactory.LastProfileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndpointReportsProviderFailuresAsBadGateway()
|
||||
{
|
||||
@@ -179,6 +210,24 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingProfileSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
public string? LastProfileName { get; private set; }
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return Create(null);
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||
{
|
||||
LastProfileName = launchProfileName;
|
||||
return new TestSpeechRecognitionPipeline(
|
||||
new EndpointFakeTranscriptionProvider(),
|
||||
(_, liveSegments, _, _) => Task.FromResult(liveSegments));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||
{
|
||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||
|
||||
@@ -100,7 +100,27 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
||||
Assert.Null(match);
|
||||
}
|
||||
|
||||
private static AzureSpeechSpeakerIdentityMatcher CreateMatcher(ISpeakerIdentityDiarizationClient diarizationClient)
|
||||
[Fact]
|
||||
public async Task MatcherReturnsNullWhenDiarizationTimesOut()
|
||||
{
|
||||
var matcher = CreateMatcher(
|
||||
new HangingSpeakerIdentityDiarizationClient(),
|
||||
TimeSpan.FromMilliseconds(20));
|
||||
var snippet = CreateWavSnippet();
|
||||
|
||||
var match = await matcher.MatchAsync(
|
||||
new SpeakerIdentityMatchRequest(
|
||||
"Guest03",
|
||||
snippet,
|
||||
[new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet])]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(match);
|
||||
}
|
||||
|
||||
private static AzureSpeechSpeakerIdentityMatcher CreateMatcher(
|
||||
ISpeakerIdentityDiarizationClient diarizationClient,
|
||||
TimeSpan? matchTimeout = null)
|
||||
{
|
||||
return new AzureSpeechSpeakerIdentityMatcher(
|
||||
diarizationClient,
|
||||
@@ -108,7 +128,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
SilenceBetweenSnippetsSeconds = 1
|
||||
SilenceBetweenSnippetsSeconds = 1,
|
||||
MatchTimeout = matchTimeout ?? TimeSpan.FromMinutes(1)
|
||||
}
|
||||
}),
|
||||
NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance);
|
||||
@@ -145,4 +166,15 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
||||
return Task.FromResult(segments);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HangingSpeakerIdentityDiarizationClient : ISpeakerIdentityDiarizationClient
|
||||
{
|
||||
public async Task<IReadOnlyList<TranscriptionSegment>> DiarizeAsync(
|
||||
string wavPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.CognitiveServices.Speech;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -62,6 +63,50 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
||||
]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSpeechConfigSkipsConfiguredPostProcessingOptionForConversationTranscriber()
|
||||
{
|
||||
var provider = new AzureSpeechStreamingTranscriptionProvider(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
AzureSpeech = new AzureSpeechOptions
|
||||
{
|
||||
Endpoint = "wss://westeurope.stt.speech.microsoft.com/speech/universal/v2",
|
||||
Language = "de-DE",
|
||||
Key = "test-key",
|
||||
PostProcessingOption = "PostRefinement"
|
||||
}
|
||||
}),
|
||||
NullLogger<AzureSpeechStreamingTranscriptionProvider>.Instance);
|
||||
|
||||
var speechConfig = provider.CreateSpeechConfig();
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(
|
||||
speechConfig.GetProperty(PropertyId.SpeechServiceResponse_PostProcessingOption)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSpeechConfigDoesNotApplyBlankPostProcessingOption()
|
||||
{
|
||||
var provider = new AzureSpeechStreamingTranscriptionProvider(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
AzureSpeech = new AzureSpeechOptions
|
||||
{
|
||||
Endpoint = "wss://westeurope.stt.speech.microsoft.com/speech/universal/v2",
|
||||
Language = "de-DE",
|
||||
Key = "test-key",
|
||||
PostProcessingOption = " "
|
||||
}
|
||||
}),
|
||||
NullLogger<AzureSpeechStreamingTranscriptionProvider>.Instance);
|
||||
|
||||
var speechConfig = provider.CreateSpeechConfig();
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(
|
||||
speechConfig.GetProperty(PropertyId.SpeechServiceResponse_PostProcessingOption)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveKeyFallsBackToUserEnvironment()
|
||||
{
|
||||
@@ -82,15 +127,17 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointDefaultsToUniversalV2SpeechEndpointForRegion()
|
||||
public void CreateSpeechConfigUsesSubscriptionWhenEndpointIsBlank()
|
||||
{
|
||||
Assert.Equal(
|
||||
new Uri("wss://germanywestcentral.stt.speech.microsoft.com/speech/universal/v2"),
|
||||
AzureSpeechStreamingTranscriptionProvider.GetEndpoint(new AzureSpeechOptions
|
||||
var speechConfig = AzureSpeechStreamingTranscriptionProvider.CreateSpeechConfig(
|
||||
new AzureSpeechOptions
|
||||
{
|
||||
Endpoint = "",
|
||||
Region = "germanywestcentral"
|
||||
}));
|
||||
Region = "westeurope"
|
||||
},
|
||||
"test-key");
|
||||
|
||||
Assert.NotNull(speechConfig);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -24,11 +27,101 @@ public sealed class ConfiguredSpeechRecognitionPipelineFactoryTests
|
||||
services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
var factory = new ConfiguredSpeechRecognitionPipelineFactory(
|
||||
provider,
|
||||
[new AzureSpeechRecognitionPipelineBuilder(provider)],
|
||||
provider.GetRequiredService<IOptions<MeetingAssistantOptions>>());
|
||||
|
||||
await using var pipeline = factory.Create();
|
||||
|
||||
Assert.IsType<AzureSpeechRecognitionPipeline>(pipeline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FactoryPassesResolvedLaunchProfileOptionsToSelectedBuilder()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var builder = new CapturingPipelineBuilder();
|
||||
var launchProfiles = new ConfigurationLaunchProfileOptionsProvider(
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Recording:TranscriptionProvider"] = "capturing",
|
||||
["MeetingAssistant:Agent:Model"] = "default-model",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:Recording:TranscriptionProvider"] = "capturing",
|
||||
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-model"
|
||||
})
|
||||
.Build());
|
||||
var factory = new ConfiguredSpeechRecognitionPipelineFactory(
|
||||
[builder],
|
||||
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
|
||||
launchProfiles);
|
||||
|
||||
await using var pipeline = factory.Create("english");
|
||||
|
||||
Assert.IsType<CapturingPipeline>(pipeline);
|
||||
Assert.Equal("english-model", builder.Options?.Agent.Model);
|
||||
}
|
||||
|
||||
private sealed class CapturingPipelineBuilder : ISpeechRecognitionPipelineBuilder
|
||||
{
|
||||
public string ProviderName => "capturing";
|
||||
|
||||
public MeetingAssistantOptions? Options { get; private set; }
|
||||
|
||||
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
|
||||
{
|
||||
Options = options;
|
||||
return new CapturingPipeline();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingPipeline : ISpeechRecognitionPipeline
|
||||
{
|
||||
public Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InitializeAsync(
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WaitUntilReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask WriteAsync(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()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class LaunchProfileOptionsProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultProfileUsesRootMeetingAssistantConfiguration()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Recording:TranscriptionProvider"] = "azure-speech",
|
||||
["MeetingAssistant:AzureSpeech:Language"] = "de-DE"
|
||||
});
|
||||
|
||||
var profile = provider.GetRequiredProfile(null);
|
||||
|
||||
Assert.Equal("default", profile.Name);
|
||||
Assert.Equal("Ctrl+Alt+M", profile.Options.Hotkey.Toggle);
|
||||
Assert.Equal("azure-speech", profile.Options.Recording.TranscriptionProvider);
|
||||
Assert.Equal("de-DE", profile.Options.AzureSpeech.Language);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamedProfileMergesOverrideOntoDefaultConfiguration()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Recording:TranscriptionProvider"] = "azure-speech",
|
||||
["MeetingAssistant:AzureSpeech:Region"] = "germanywestcentral",
|
||||
["MeetingAssistant:AzureSpeech:Language"] = "de-DE",
|
||||
["MeetingAssistant:AzureSpeech:AutoDetectLanguages:0"] = "de-DE",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:AutoDetectLanguages:0"] = "en-US"
|
||||
});
|
||||
|
||||
var profile = provider.GetRequiredProfile("english");
|
||||
|
||||
Assert.Equal("english", profile.Name);
|
||||
Assert.Equal("Ctrl+Alt+E", profile.Options.Hotkey.Toggle);
|
||||
Assert.Equal("azure-speech", profile.Options.Recording.TranscriptionProvider);
|
||||
Assert.Equal("germanywestcentral", profile.Options.AzureSpeech.Region);
|
||||
Assert.Equal("en-US", profile.Options.AzureSpeech.Language);
|
||||
Assert.Equal(["en-US"], profile.Options.AzureSpeech.AutoDetectLanguages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateProfileHotkeysAreRejected()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+M"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => provider.GetHotkeys());
|
||||
Assert.Contains("Ctrl+Alt+M", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ConfigurationLaunchProfileOptionsProvider CreateProvider(
|
||||
IReadOnlyDictionary<string, string?> values)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
return new ConfigurationLaunchProfileOptionsProvider(configuration);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,13 @@ public sealed class MeetingArtifactStoreTests
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
var meetingNote = MeetingNoteTemplate.Create(
|
||||
"Planning Sync",
|
||||
DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"));
|
||||
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
"Review previous decisions\nAgree next steps",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
@@ -26,7 +30,9 @@ public sealed class MeetingArtifactStoreTests
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("# Assistant Context", content);
|
||||
Assert.StartsWith("---", content, StringComparison.Ordinal);
|
||||
Assert.Contains("state: transcribing", content);
|
||||
Assert.Contains("title: Planning Sync", content);
|
||||
Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("state: collecting metadata", content);
|
||||
Assert.Contains("agenda: |-", content);
|
||||
Assert.Contains(" Review previous decisions", content);
|
||||
Assert.Contains(" Agree next steps", content);
|
||||
@@ -34,6 +40,7 @@ public sealed class MeetingArtifactStoreTests
|
||||
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.DoesNotContain("assistant_context:", content);
|
||||
Assert.Contains("[[../Notes/20260519-meeting|Meeting Note]]", content);
|
||||
Assert.Contains("[[../Transcripts/20260519-transcript|Transcript]]", content);
|
||||
Assert.Contains("[[../Summaries/20260519-summary|Summary]]", content);
|
||||
@@ -50,9 +57,13 @@ public sealed class MeetingArtifactStoreTests
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
var meetingNote = MeetingNoteTemplate.Create(
|
||||
"Planning Sync",
|
||||
DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"));
|
||||
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
"Initial agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
@@ -65,6 +76,8 @@ public sealed class MeetingArtifactStoreTests
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("state: summarizing", content);
|
||||
Assert.Contains("title: Planning Sync", content);
|
||||
Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("agenda: |-", content);
|
||||
Assert.Contains(" Initial agenda", content);
|
||||
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
|
||||
@@ -82,10 +95,82 @@ public sealed class MeetingArtifactStoreTests
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
var meetingNote = MeetingNoteTemplate.Create(
|
||||
"Planning Sync",
|
||||
DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"));
|
||||
|
||||
await artifactStore.CreateAssistantContextAsync(artifacts, "", null, CancellationToken.None);
|
||||
await artifactStore.CreateAssistantContextAsync(artifacts, meetingNote, "", null, CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.DoesNotContain("scheduled_end:", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreUpdatesAssistantContextMetadataAndMeetingFrontmatter()
|
||||
{
|
||||
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,
|
||||
MeetingNoteTemplate.Create("Meeting 2026-05-19 10:00", DateTimeOffset.Parse("2026-05-19T10:00:00+02:00")),
|
||||
"",
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
await artifactStore.UpdateAssistantContextMetadataAsync(
|
||||
artifacts,
|
||||
MeetingNoteTemplate.Create("Architecture Sync", DateTimeOffset.Parse("2026-05-19T10:00:00+02:00")),
|
||||
"Calendar agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("title: Architecture Sync", content);
|
||||
Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("state: collecting metadata", content);
|
||||
Assert.Contains("agenda: |-", content);
|
||||
Assert.Contains(" Calendar agenda", content);
|
||||
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreUpdatesAssistantContextMeetingEndAndPreservesCalendarMetadata()
|
||||
{
|
||||
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,
|
||||
MeetingNoteTemplate.Create("Architecture Sync", DateTimeOffset.Parse("2026-05-19T10:00:00+02:00")),
|
||||
"Calendar agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
|
||||
await artifactStore.UpdateAssistantContextMeetingAsync(
|
||||
artifacts,
|
||||
MeetingNoteTemplate.Create(
|
||||
"Architecture Sync",
|
||||
DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-19T10:30:00+02:00")),
|
||||
CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("title: Architecture Sync", 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("agenda: |-", content);
|
||||
Assert.Contains(" Calendar agenda", content);
|
||||
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,4 +73,56 @@ public sealed class MeetingSummaryArtifactResolverTests
|
||||
|
||||
Assert.Null(artifacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolverUsesSuppliedProfileOptionsForSummaryLookup()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var defaultRoot = Path.Combine(root, "default");
|
||||
var englishRoot = Path.Combine(root, "english");
|
||||
var englishNotes = Path.Combine(englishRoot, "Notes");
|
||||
var englishSummaries = Path.Combine(englishRoot, "Summaries");
|
||||
Directory.CreateDirectory(englishNotes);
|
||||
Directory.CreateDirectory(englishSummaries);
|
||||
var notePath = Path.Combine(englishNotes, "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 defaultOptions = Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
MeetingNotesFolder = Path.Combine(defaultRoot, "Notes"),
|
||||
SummariesFolder = Path.Combine(defaultRoot, "Summaries")
|
||||
}
|
||||
});
|
||||
var profileOptions = new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
MeetingNotesFolder = englishNotes,
|
||||
SummariesFolder = englishSummaries
|
||||
}
|
||||
};
|
||||
var resolver = new MeetingSummaryArtifactResolver(
|
||||
defaultOptions,
|
||||
new MarkdownMeetingNoteStore(defaultOptions, NullLogger<MarkdownMeetingNoteStore>.Instance));
|
||||
|
||||
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", profileOptions, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(artifacts);
|
||||
Assert.Equal(notePath, artifacts.MeetingNotePath);
|
||||
Assert.Equal(Path.Combine(englishSummaries, "summary.md"), artifacts.SummaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ public sealed class MeetingSummaryFailureWriterTests
|
||||
Assert.Contains("Summary Generation Failed", content);
|
||||
Assert.Contains("title: Failure Meeting", content);
|
||||
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", content);
|
||||
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", content);
|
||||
Assert.Contains("assistant_context: \"[[../Assistant Context/context|Assistant Context]]\"", content);
|
||||
Assert.DoesNotContain("summary:", content);
|
||||
Assert.Contains(
|
||||
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary.md)",
|
||||
content);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
@@ -55,6 +57,7 @@ public sealed class MeetingSummaryToolTests
|
||||
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.DoesNotContain("summary:", summary);
|
||||
Assert.Contains("# Summary\n\n- Done.", summary);
|
||||
}
|
||||
|
||||
@@ -142,4 +145,63 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.Contains("state: summarizing", context);
|
||||
Assert.Contains("replacement\ninserted\nline two", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExternalAgentWritesAreAppendedToAssistantContextAsDiffs()
|
||||
{
|
||||
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");
|
||||
var projectFile = Path.Combine(projectRoot, "notes.md");
|
||||
var dictionaryPath = Path.Combine(root, "Meetings", "Dictionary.md");
|
||||
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(projectRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
await File.WriteAllTextAsync(projectFile, "one\ntwo\nthree");
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
projects:
|
||||
- MeetingAssistant
|
||||
---
|
||||
""");
|
||||
await File.WriteAllTextAsync(artifacts.AssistantContextPath, "Context before.");
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
{
|
||||
DictationWordsPath = dictionaryPath,
|
||||
ProjectsFolder = projectsRoot
|
||||
}
|
||||
};
|
||||
var audit = new SummaryAgentWriteAudit(artifacts);
|
||||
var tools = new MeetingSummaryTools(
|
||||
artifacts,
|
||||
options,
|
||||
new MarkdownDictationWordStore(Options.Create(options)),
|
||||
audit);
|
||||
|
||||
await tools.WriteContext("owned context");
|
||||
await tools.WriteSummary("owned summary");
|
||||
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "TWO", from: 2, to: 2);
|
||||
await tools.AddDictationWord("PBI");
|
||||
await audit.AppendToAssistantContextAsync(CancellationToken.None);
|
||||
|
||||
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
|
||||
Assert.Contains("## Summary Agent External File Changes", context);
|
||||
Assert.Contains(projectFile, context);
|
||||
Assert.Contains("- two", context);
|
||||
Assert.Contains("+ TWO", context);
|
||||
Assert.Contains(dictionaryPath, context);
|
||||
Assert.Contains("+ - PBI", context);
|
||||
Assert.DoesNotContain("owned summary", context);
|
||||
Assert.DoesNotContain("- Context before.", context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Threading.Channels;
|
||||
@@ -105,6 +107,8 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
|
||||
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", artifactStore.CreatedArtifacts?.AssistantContextPath, StringComparison.Ordinal);
|
||||
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", artifactStore.CreatedArtifacts?.SummaryPath, StringComparison.Ordinal);
|
||||
Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title);
|
||||
Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
@@ -137,12 +141,47 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(["Ada <ada@example.com>", "Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal("Architecture Sync", artifactStore.ContextMeetingNote?.Frontmatter.Title);
|
||||
Assert.Equal("Review API shape", artifactStore.Agenda);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartCanonicalizesOutlookMeetingAttendeesBeforeWritingNote()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: new FixedMeetingMetadataProvider(new MeetingMetadata(
|
||||
"Architecture Sync",
|
||||
["Karl Berger <karl.work@example.com>", "Berger, Karl <karl.private@example.com>", "Ada"],
|
||||
"",
|
||||
null)),
|
||||
attendeeCanonicalizer: new FixedAttendeeCanonicalizer(["Karl Berger", "Ada"]));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await WaitUntilAsync(() =>
|
||||
noteStore.SavedNote?.Frontmatter.Title == "Architecture Sync" &&
|
||||
noteStore.SavedNote.Frontmatter.Attendees.Count > 0);
|
||||
|
||||
Assert.Equal(["Karl Berger", "Ada"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater()
|
||||
{
|
||||
@@ -182,6 +221,61 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartCanCreateNewRunWhilePreviousRunIsStillFinalizing()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new SequencedTranscriptStore();
|
||||
var noteStore = new SequencedMeetingNoteStore();
|
||||
var artifactStore = new CapturingArtifactStore();
|
||||
var summaryPipeline = new RecordingSummaryPipeline();
|
||||
var finalizer = new BlockingFirstFinalizer();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(
|
||||
new EchoStreamingTranscriptionProvider(),
|
||||
finalizer.FinalizeAsync),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
var firstStarted = await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForAppendCountAsync(1);
|
||||
|
||||
var stopFirst = coordinator.StopAsync(CancellationToken.None);
|
||||
await finalizer.WaitUntilFirstFinalizerIsBlockedAsync();
|
||||
var secondStarted = await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([2, 0, 3, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForAppendCountAsync(2);
|
||||
finalizer.ReleaseFirstFinalizer();
|
||||
await stopFirst.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(secondStarted.IsRecording);
|
||||
Assert.NotEqual(firstStarted.TranscriptPath, secondStarted.TranscriptPath);
|
||||
Assert.NotEqual(firstStarted.MeetingNotePath, secondStarted.MeetingNotePath);
|
||||
Assert.Contains(summaryPipeline.ArtifactHistory, artifacts => artifacts.MeetingNotePath == firstStarted.MeetingNotePath);
|
||||
Assert.Contains(summaryPipeline.ArtifactHistory, artifacts => artifacts.MeetingNotePath == secondStarted.MeetingNotePath);
|
||||
Assert.Contains(transcriptStore.ReplacementHistory, entry =>
|
||||
entry.Session.TranscriptPath == firstStarted.TranscriptPath &&
|
||||
entry.Segments.Single().Text == "final run 1");
|
||||
Assert.Contains(transcriptStore.ReplacementHistory, entry =>
|
||||
entry.Session.TranscriptPath == secondStarted.TranscriptPath &&
|
||||
entry.Segments.Single().Text == "final run 2");
|
||||
Assert.All(
|
||||
transcriptStore.MetadataHistory.Where(entry => entry.Session.TranscriptPath == firstStarted.TranscriptPath),
|
||||
entry => Assert.Equal(firstStarted.MeetingNotePath, entry.MeetingNote.Path));
|
||||
Assert.All(
|
||||
transcriptStore.MetadataHistory.Where(entry => entry.Session.TranscriptPath == secondStarted.TranscriptPath),
|
||||
entry => Assert.Equal(secondStarted.MeetingNotePath, entry.MeetingNote.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartCreatesMeetingNoteWhenMetadataProviderFails()
|
||||
{
|
||||
@@ -300,6 +394,63 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartPassesLaunchProfileToSpeechPipelineFactory()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var pipelineFactory = new CapturingProfileSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider());
|
||||
var launchProfiles = CreateLaunchProfiles(
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"));
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
pipelineFactory,
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
launchProfiles: launchProfiles);
|
||||
|
||||
await coordinator.StartAsync("english", CancellationToken.None);
|
||||
|
||||
Assert.Equal("english", pipelineFactory.LastProfileName);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartUsesLaunchProfileStorageAndSummaryOptions()
|
||||
{
|
||||
var defaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default");
|
||||
var englishRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english");
|
||||
var launchProfiles = CreateLaunchProfiles(defaultRoot, englishRoot);
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0], 16000, 1)),
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new ProfileAwareTranscriptStore(),
|
||||
new ProfileAwareMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new ProfileAwareRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
launchProfiles: launchProfiles);
|
||||
|
||||
var started = await coordinator.StartAsync("english", CancellationToken.None);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.StartsWith(Path.Combine(englishRoot, "Transcripts"), started.TranscriptPath, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.StartsWith(Path.Combine(englishRoot, "Notes"), started.MeetingNotePath, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.StartsWith(Path.Combine(englishRoot, "Context"), started.AssistantContextPath, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.StartsWith(Path.Combine(englishRoot, "Summaries"), started.SummaryPath, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("english-summary-model", summaryPipeline.Options?.Agent.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartPassesDictationWordsToSpeechPipeline()
|
||||
{
|
||||
@@ -512,9 +663,9 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal)));
|
||||
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => segment.Text?.Contains("first", StringComparison.Ordinal) == true));
|
||||
await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal)));
|
||||
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Text?.Contains("first", StringComparison.Ordinal) == true));
|
||||
Assert.Equal("Chris", transcriptStore.ReplacedSegments.Single().Speaker);
|
||||
await Task.Delay(50);
|
||||
await audioSource.WriteAsync(new AudioChunk(Samples(8, 9, 10, 11, 12, 13, 14, 15), 4, 1), CancellationToken.None);
|
||||
@@ -528,7 +679,7 @@ public sealed class RecordingCoordinatorTests
|
||||
second => Assert.Equal("Chris", second.Speaker));
|
||||
Assert.Contains(
|
||||
transcriptStore.ReplacedSegments,
|
||||
segment => segment.Text.Contains("first", StringComparison.Ordinal) && segment.Speaker == "Chris");
|
||||
segment => segment.Text?.Contains("first", StringComparison.Ordinal) == true && segment.Speaker == "Chris");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -714,13 +865,14 @@ public sealed class RecordingCoordinatorTests
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
@@ -732,6 +884,7 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime);
|
||||
Assert.Equal(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime, artifactStore.ContextMeetingNote?.Frontmatter.EndTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -757,7 +910,7 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
[AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||
[AssistantContextState.Transcribing, AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
@@ -784,7 +937,7 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
[AssistantContextState.Summarizing, AssistantContextState.Error],
|
||||
[AssistantContextState.Transcribing, AssistantContextState.Summarizing, AssistantContextState.Error],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
@@ -818,7 +971,7 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
[AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||
[AssistantContextState.Transcribing, AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
@@ -842,6 +995,23 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.True(Directory.Exists(vaultFolder));
|
||||
Assert.EndsWith(".md", session.TranscriptPath, StringComparison.Ordinal);
|
||||
Assert.Contains("hello vault", await File.ReadAllTextAsync(session.TranscriptPath));
|
||||
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(vaultFolder, "meeting.md"),
|
||||
TranscriptPath: session.TranscriptPath,
|
||||
AssistantContextPath: Path.Combine(vaultFolder, "context.md"),
|
||||
SummaryPath: Path.Combine(vaultFolder, "summary.md"));
|
||||
await store.UpdateMetadataAsync(
|
||||
session,
|
||||
artifacts,
|
||||
MeetingNoteTemplate.Create("Transcript Metadata", transcriptPath: session.TranscriptPath),
|
||||
CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(session.TranscriptPath);
|
||||
Assert.Contains("meeting: \"[[meeting|Meeting Note]]\"", content);
|
||||
Assert.Contains("assistant_context: \"[[context|Assistant Context]]\"", content);
|
||||
Assert.Contains("summary: \"[[summary|Summary]]\"", content);
|
||||
Assert.DoesNotContain("transcript:", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -903,6 +1073,29 @@ public sealed class RecordingCoordinatorTests
|
||||
};
|
||||
}
|
||||
|
||||
private static ILaunchProfileOptionsProvider CreateLaunchProfiles(
|
||||
string defaultRoot,
|
||||
string englishRoot)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Vault:BaseFolder"] = defaultRoot,
|
||||
["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts",
|
||||
["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes",
|
||||
["MeetingAssistant:Vault:AssistantContextFolder"] = "Context",
|
||||
["MeetingAssistant:Vault:SummariesFolder"] = "Summaries",
|
||||
["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(defaultRoot, "Recordings"),
|
||||
["MeetingAssistant:Agent:Model"] = "default-summary-model",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:Vault:BaseFolder"] = englishRoot,
|
||||
["MeetingAssistant:LaunchProfiles:english:Recording:TemporaryRecordingsFolder"] = Path.Combine(englishRoot, "Recordings"),
|
||||
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-summary-model"
|
||||
})
|
||||
.Build();
|
||||
return new ConfigurationLaunchProfileOptionsProvider(configuration);
|
||||
}
|
||||
|
||||
private sealed class InMemoryTranscriptStore : ITranscriptStore
|
||||
{
|
||||
private readonly List<TranscriptionSegment> segments = [];
|
||||
@@ -931,7 +1124,7 @@ public sealed class RecordingCoordinatorTests
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (segments.Any(segment => segment.Text.Contains(text, StringComparison.Ordinal)))
|
||||
if (segments.Any(segment => segment.Text?.Contains(text, StringComparison.Ordinal) == true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -968,6 +1161,82 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SequencedTranscriptStore : ITranscriptStore
|
||||
{
|
||||
private int created;
|
||||
private int appendCount;
|
||||
private TaskCompletionSource appendObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public List<TranscriptWrite> AppendHistory { get; } = [];
|
||||
|
||||
public List<TranscriptReplacement> ReplacementHistory { get; } = [];
|
||||
|
||||
public List<TranscriptMetadataUpdate> MetadataHistory { get; } = [];
|
||||
|
||||
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var index = Interlocked.Increment(ref created);
|
||||
return Task.FromResult(new TranscriptSession($"memory-transcript-{index}.md"));
|
||||
}
|
||||
|
||||
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
|
||||
{
|
||||
AppendHistory.Add(new TranscriptWrite(session, segment));
|
||||
Interlocked.Increment(ref appendCount);
|
||||
appendObserved.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task WaitForAppendCountAsync(int expectedCount)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (Volatile.Read(ref appendCount) >= expectedCount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var observed = appendObserved;
|
||||
await observed.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
|
||||
if (ReferenceEquals(observed, appendObserved))
|
||||
{
|
||||
appendObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Expected {expectedCount} transcript append(s).");
|
||||
}
|
||||
|
||||
public Task ReplaceAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<TranscriptionSegment> replacementSegments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ReplacementHistory.Add(new TranscriptReplacement(session, replacementSegments));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateMetadataAsync(
|
||||
TranscriptSession session,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MetadataHistory.Add(new TranscriptMetadataUpdate(session, artifacts, meetingNote));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed record TranscriptWrite(TranscriptSession Session, TranscriptionSegment Segment);
|
||||
|
||||
public sealed record TranscriptReplacement(TranscriptSession Session, IReadOnlyList<TranscriptionSegment> Segments);
|
||||
|
||||
public sealed record TranscriptMetadataUpdate(
|
||||
TranscriptSession Session,
|
||||
MeetingSessionArtifacts Artifacts,
|
||||
MeetingNote MeetingNote);
|
||||
}
|
||||
|
||||
private sealed class InMemoryMeetingNoteStore : IMeetingNoteStore
|
||||
{
|
||||
private readonly string notePath;
|
||||
@@ -1006,6 +1275,29 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SequencedMeetingNoteStore : IMeetingNoteStore
|
||||
{
|
||||
private int saved;
|
||||
private readonly Dictionary<string, MeetingNote> notes = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = string.IsNullOrWhiteSpace(note.Path)
|
||||
? $"memory-meeting-{Interlocked.Increment(ref saved)}.md"
|
||||
: note.Path;
|
||||
var savedNote = note with { Path = path };
|
||||
notes[path] = savedNote;
|
||||
return Task.FromResult(savedNote);
|
||||
}
|
||||
|
||||
public Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(notes.TryGetValue(path, out var note)
|
||||
? note
|
||||
: throw new FileNotFoundException(path));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener
|
||||
{
|
||||
public string? OpenedPath { get; private set; }
|
||||
@@ -1027,13 +1319,17 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public DateTimeOffset? ScheduledEnd { get; private set; }
|
||||
|
||||
public MeetingNote? ContextMeetingNote { get; private set; }
|
||||
|
||||
public Task CreateAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CreatedArtifacts = artifacts;
|
||||
ContextMeetingNote = meetingNote;
|
||||
Agenda = agenda;
|
||||
ScheduledEnd = scheduledEnd;
|
||||
return Task.CompletedTask;
|
||||
@@ -1050,14 +1346,70 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public Task UpdateAssistantContextMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContextMeetingNote = meetingNote;
|
||||
Agenda = agenda;
|
||||
ScheduledEnd = scheduledEnd;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextMeetingAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContextMeetingNote = meetingNote;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
public List<MeetingSessionArtifacts> CreatedArtifacts { get; } = [];
|
||||
|
||||
public List<(MeetingSessionArtifacts Artifacts, AssistantContextState State)> States { get; } = [];
|
||||
|
||||
public Task CreateAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CreatedArtifacts.Add(artifacts);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextMeetingAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextStateAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
States.Add((artifacts, state));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
@@ -1130,6 +1482,8 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public MeetingSessionArtifacts? Artifacts { get; private set; }
|
||||
|
||||
public MeetingAssistantOptions? Options { get; private set; }
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -1141,6 +1495,192 @@ public sealed class RecordingCoordinatorTests
|
||||
succeeded,
|
||||
succeeded ? null : "error"));
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Options = options;
|
||||
return RunAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
public List<MeetingSessionArtifacts> ArtifactHistory { get; } = [];
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArtifactHistory.Add(artifacts);
|
||||
return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok"));
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return RunAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingFirstFinalizer
|
||||
{
|
||||
private readonly TaskCompletionSource firstFinalizerBlocked =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource releaseFirstFinalizer =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int calls;
|
||||
|
||||
public Task WaitUntilFirstFinalizerIsBlockedAsync()
|
||||
{
|
||||
return firstFinalizerBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void ReleaseFirstFinalizer()
|
||||
{
|
||||
releaseFirstFinalizer.TrySetResult();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TranscriptionSegment>> FinalizeAsync(
|
||||
string audioPath,
|
||||
IReadOnlyList<TranscriptionSegment> liveSegments,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var call = Interlocked.Increment(ref calls);
|
||||
if (call == 1)
|
||||
{
|
||||
firstFinalizerBlocked.TrySetResult();
|
||||
await releaseFirstFinalizer.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(1),
|
||||
"Unknown",
|
||||
$"final run {call}")
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProfileAwareTranscriptStore : ITranscriptStore
|
||||
{
|
||||
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("Profile options were not supplied.");
|
||||
}
|
||||
|
||||
public Task<TranscriptSession> CreateSessionAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
|
||||
return Task.FromResult(new TranscriptSession(Path.Combine(folder, "transcript.md")));
|
||||
}
|
||||
|
||||
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReplaceAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<TranscriptionSegment> replacementSegments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateMetadataAsync(
|
||||
TranscriptSession session,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProfileAwareMeetingNoteStore : IMeetingNoteStore
|
||||
{
|
||||
private MeetingNote? savedNote;
|
||||
|
||||
public Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("Profile options were not supplied.");
|
||||
}
|
||||
|
||||
public Task<MeetingNote> SaveAsync(
|
||||
MeetingNote note,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
|
||||
savedNote = note with { Path = string.IsNullOrWhiteSpace(note.Path) ? Path.Combine(folder, "meeting.md") : note.Path };
|
||||
return Task.FromResult(savedNote);
|
||||
}
|
||||
|
||||
public Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(savedNote ?? throw new FileNotFoundException(path));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProfileAwareRecordedAudioStore : IRecordedAudioStore
|
||||
{
|
||||
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("Profile options were not supplied.");
|
||||
}
|
||||
|
||||
public Task<IRecordedAudioSink> CreateSessionAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder);
|
||||
return Task.FromResult<IRecordedAudioSink>(new Sink(Path.Combine(folder, "recording.wav")));
|
||||
}
|
||||
|
||||
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class Sink : IRecordedAudioSink
|
||||
{
|
||||
public Sink(string audioPath)
|
||||
{
|
||||
AudioPath = audioPath;
|
||||
}
|
||||
|
||||
public string AudioPath { get; }
|
||||
|
||||
public Task AppendAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRecordedAudioStore : IRecordedAudioStore
|
||||
@@ -1233,6 +1773,31 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingProfileSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly IStreamingTranscriptionProvider provider;
|
||||
|
||||
public CapturingProfileSpeechRecognitionPipelineFactory(IStreamingTranscriptionProvider provider)
|
||||
{
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
public string? LastProfileName { get; private set; }
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return Create(null);
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||
{
|
||||
LastProfileName = launchProfileName;
|
||||
return new TestSpeechRecognitionPipeline(
|
||||
provider,
|
||||
(_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||
{
|
||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||
@@ -1397,6 +1962,23 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
|
||||
{
|
||||
private readonly IReadOnlyList<string> attendees;
|
||||
|
||||
public FixedAttendeeCanonicalizer(IReadOnlyList<string> attendees)
|
||||
{
|
||||
this.attendees = attendees;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(this.attendees);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingPipelineOptionsProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public TaskCompletionSource OptionsObserved { get; } =
|
||||
|
||||
@@ -17,13 +17,13 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
aliases: ["M. Schweigert"],
|
||||
snippets: [[1], [2], [3]],
|
||||
createdAt: DateTimeOffset.UtcNow.AddMonths(-2),
|
||||
transcriptionCount: 5);
|
||||
referenceCount: 5);
|
||||
var recent = await fixture.AddIdentityAsync(
|
||||
canonicalName: "Guest Manuel",
|
||||
aliases: [],
|
||||
snippets: [[4], [5]],
|
||||
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||
transcriptionCount: 2);
|
||||
referenceCount: 2);
|
||||
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
||||
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
||||
var service = fixture.CreateService();
|
||||
@@ -36,7 +36,9 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
Assert.Equal([5], fixture.Matcher.Requests[1].UnknownSnippet);
|
||||
var savedOlder = await fixture.LoadIdentityAsync(older.Id);
|
||||
Assert.Equal("Manuel", savedOlder.CanonicalName);
|
||||
Assert.Equal(7, savedOlder.TranscriptionCount);
|
||||
Assert.Equal(7, savedOlder.References.Count);
|
||||
Assert.All(savedOlder.References, reference =>
|
||||
Assert.Contains("Manuel and Guest Manuel were merged", File.ReadAllText(reference.TranscriptPath)));
|
||||
Assert.True(savedOlder.UpdatedAt > older.UpdatedAt);
|
||||
Assert.Equal(["Guest Manuel", "M. Schweigert"], savedOlder.Aliases.Select(alias => alias.Name).Order());
|
||||
Assert.Equal(3, savedOlder.Snippets.Count);
|
||||
@@ -74,10 +76,12 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
|
||||
private sealed class SpeakerIdentityMergeFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string tempDirectory;
|
||||
private readonly string dbPath;
|
||||
|
||||
private SpeakerIdentityMergeFixture(string dbPath, SpeakerIdentityDbContext context)
|
||||
private SpeakerIdentityMergeFixture(string tempDirectory, string dbPath, SpeakerIdentityDbContext context)
|
||||
{
|
||||
this.tempDirectory = tempDirectory;
|
||||
this.dbPath = dbPath;
|
||||
Context = context;
|
||||
}
|
||||
@@ -88,13 +92,14 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
|
||||
public static async Task<SpeakerIdentityMergeFixture> CreateAsync()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDirectory);
|
||||
var dbPath = Path.Combine(tempDirectory, "speaker-identities.db");
|
||||
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
return new SpeakerIdentityMergeFixture(dbPath, context);
|
||||
return new SpeakerIdentityMergeFixture(tempDirectory, dbPath, context);
|
||||
}
|
||||
|
||||
public SpeakerIdentityMergeService CreateService()
|
||||
@@ -118,14 +123,13 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
IReadOnlyList<string> aliases,
|
||||
IReadOnlyList<byte[]> snippets,
|
||||
DateTimeOffset createdAt,
|
||||
int transcriptionCount)
|
||||
int referenceCount)
|
||||
{
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = createdAt,
|
||||
TranscriptionCount = transcriptionCount,
|
||||
Aliases = aliases.Select(alias => new SpeakerAlias { Name = alias }).ToList(),
|
||||
Snippets = snippets
|
||||
.Select((snippet, index) => new SpeakerSnippet
|
||||
@@ -133,6 +137,19 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
WavBytes = snippet,
|
||||
CreatedAt = createdAt.AddMinutes(index)
|
||||
})
|
||||
.ToList(),
|
||||
References = Enumerable.Range(0, referenceCount)
|
||||
.Select(index =>
|
||||
{
|
||||
var transcriptPath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md");
|
||||
File.WriteAllText(transcriptPath, "Transcript");
|
||||
return new SpeakerIdentityReference
|
||||
{
|
||||
MeetingNotePath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md"),
|
||||
TranscriptPath = transcriptPath,
|
||||
CreatedAt = createdAt.AddMinutes(index)
|
||||
};
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
Context.SpeakerIdentities.Add(identity);
|
||||
@@ -146,15 +163,16 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
return Context.SpeakerIdentities
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.References)
|
||||
.SingleAsync(identity => identity.Id == id);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Context.DisposeAsync();
|
||||
if (File.Exists(dbPath))
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
File.Delete(dbPath);
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task MatchedUnnamedIdentityEliminatesCandidatesAndPromotesCanonicalName()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3]);
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], referenceCount: 2);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -27,7 +27,9 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal("John", saved.CanonicalName);
|
||||
Assert.Equal(["John"], saved.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Equal(1, saved.TranscriptionCount);
|
||||
Assert.Equal(3, saved.References.Count);
|
||||
Assert.All(saved.References, reference =>
|
||||
Assert.Contains("Guest01 was identified as John", File.ReadAllText(reference.TranscriptPath)));
|
||||
Assert.Equal("John", result.Segments.Single().Speaker);
|
||||
Assert.Equal("John", result.SpeakerMappings["Guest01"]);
|
||||
}
|
||||
@@ -41,7 +43,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
[],
|
||||
[1, 2, 3],
|
||||
"Chris",
|
||||
transcriptionCount: 5,
|
||||
referenceCount: 5,
|
||||
updatedAt: oldTimestamp);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
@@ -54,6 +56,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.True(saved.UpdatedAt > oldTimestamp);
|
||||
Assert.Equal(6, saved.References.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -73,6 +76,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Null(saved.CanonicalName);
|
||||
Assert.Equal(["Chris", "Jane"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
Assert.Single(saved.References);
|
||||
Assert.Equal([7, 8, 9], saved.Snippets.Single().WavBytes);
|
||||
}
|
||||
|
||||
@@ -93,13 +97,28 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal("Michael", saved.CanonicalName);
|
||||
Assert.Equal(["Michael"], saved.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Contains("Guest01 was identified as Michael", File.ReadAllText(saved.References.Single().TranscriptPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttendeeCanonicalizerDeduplicatesExactIdentityAliases()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
await fixture.AddIdentityAsync([], [1, 2, 3], "Karl Berger", aliases: ["Berger, Karl"]);
|
||||
var canonicalizer = fixture.CreateAttendeeCanonicalizer();
|
||||
|
||||
var attendees = await canonicalizer.CanonicalizeAsync(
|
||||
["Karl Berger <karl.work@example.com>", "Berger, Karl <karl.private@example.com>", "Ada"],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(["Karl Berger", "Ada"], attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmatchedDiarizedSpeakersCreateCandidateIdentitiesFromUnmatchedAttendees()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", transcriptionCount: 5);
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", referenceCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = chris.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -115,6 +134,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.References)
|
||||
.Where(identity => identity.CanonicalName == null)
|
||||
.OrderBy(identity => identity.Id)
|
||||
.ToListAsync();
|
||||
@@ -124,6 +144,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
{
|
||||
Assert.NotEqual(default, identity.CreatedAt);
|
||||
Assert.NotEqual(default, identity.UpdatedAt);
|
||||
Assert.Single(identity.References);
|
||||
Assert.Equal(["John", "Mike"], identity.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
});
|
||||
}
|
||||
@@ -132,7 +153,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var michael = await fixture.AddIdentityAsync([], [1, 2, 3], "Michael", transcriptionCount: 5, aliases: ["Mike"]);
|
||||
var michael = await fixture.AddIdentityAsync([], [1, 2, 3], "Michael", referenceCount: 5, aliases: ["Mike"]);
|
||||
fixture.Matcher.MatchIdentityId = michael.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -147,16 +168,42 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Where(identity => identity.CanonicalName == null)
|
||||
.Include(identity => identity.References)
|
||||
.Where(identity => identity.CanonicalName == "Jane")
|
||||
.SingleAsync();
|
||||
Assert.Equal("Jane", learned.CanonicalName);
|
||||
Assert.Equal(["Jane"], learned.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Single(learned.References);
|
||||
Assert.Contains("Guest01 was identified as Jane", File.ReadAllText(learned.References.Single().TranscriptPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmatchedSpeakerWithSingleRemainingCandidateIsPromotedOnInsert()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Manuel"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.References)
|
||||
.SingleAsync();
|
||||
Assert.Equal("Manuel", learned.CanonicalName);
|
||||
Assert.Equal(["Manuel"], learned.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Single(learned.References);
|
||||
Assert.Contains("Guest01 was identified as Manuel", File.ReadAllText(learned.References.Single().TranscriptPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", transcriptionCount: 5);
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", referenceCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = chris.Id;
|
||||
var service = fixture.CreateService();
|
||||
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest03", "hello from the live buffer");
|
||||
@@ -177,7 +224,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], transcriptionCount: 5);
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], referenceCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -190,7 +237,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Null(saved.CanonicalName);
|
||||
Assert.Equal(["John", "Mike"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
Assert.Equal(5, saved.TranscriptionCount);
|
||||
Assert.Equal(5, saved.References.Count);
|
||||
Assert.Single(saved.Snippets);
|
||||
Assert.Equal("Guest01", result.Segments.Single().Speaker);
|
||||
Assert.Empty(result.SpeakerMappings);
|
||||
@@ -200,9 +247,9 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task SpeakerMatchingPrioritizesAttendeeDisplayNamesAndAliases()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var unrelated = await fixture.AddIdentityAsync([], [1], "Frequent", transcriptionCount: 99);
|
||||
var aliasMatch = await fixture.AddIdentityAsync([], [2], "Michael", transcriptionCount: 1, aliases: ["Mike"]);
|
||||
var displayNameMatch = await fixture.AddIdentityAsync([], [3], "Jane", transcriptionCount: 2);
|
||||
var unrelated = await fixture.AddIdentityAsync([], [1], "Frequent", referenceCount: 99);
|
||||
var aliasMatch = await fixture.AddIdentityAsync([], [2], "Michael", referenceCount: 1, aliases: ["Mike"]);
|
||||
var displayNameMatch = await fixture.AddIdentityAsync([], [3], "Jane", referenceCount: 2);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.IdentifyKnownSpeakersAsync(
|
||||
@@ -223,10 +270,10 @@ public sealed class SpeakerIdentityServiceTests
|
||||
options.MaxMatchCandidates = 2;
|
||||
options.MatchIdentityActiveAge = TimeSpan.FromDays(365);
|
||||
});
|
||||
var activeHigh = await fixture.AddIdentityAsync([], [1], "Active High", transcriptionCount: 50);
|
||||
await fixture.AddIdentityAsync([], [2], "Stale High", transcriptionCount: 100, updatedAt: DateTimeOffset.UtcNow.AddYears(-2));
|
||||
var activeLow = await fixture.AddIdentityAsync([], [3], "Active Low", transcriptionCount: 5);
|
||||
var staleAttendee = await fixture.AddIdentityAsync([], [4], "Former Employee", transcriptionCount: 1, updatedAt: DateTimeOffset.UtcNow.AddYears(-2), aliases: ["Former"]);
|
||||
var activeHigh = await fixture.AddIdentityAsync([], [1], "Active High", referenceCount: 50);
|
||||
await fixture.AddIdentityAsync([], [2], "Stale High", referenceCount: 100, updatedAt: DateTimeOffset.UtcNow.AddYears(-2));
|
||||
var activeLow = await fixture.AddIdentityAsync([], [3], "Active Low", referenceCount: 5);
|
||||
var staleAttendee = await fixture.AddIdentityAsync([], [4], "Former Employee", referenceCount: 1, updatedAt: DateTimeOffset.UtcNow.AddYears(-2), aliases: ["Former"]);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.IdentifyKnownSpeakersAsync(
|
||||
@@ -240,6 +287,68 @@ public sealed class SpeakerIdentityServiceTests
|
||||
Assert.DoesNotContain(activeLow.Id, requestedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownSpeakerMappingsExcludeMappedSpeakersAndIdentitiesFromMatching()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var manuel = await fixture.AddIdentityAsync([], [1], "Manuel", referenceCount: 99);
|
||||
var daniel = await fixture.AddIdentityAsync([], [2], "Daniel", referenceCount: 5);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.IdentifyKnownSpeakersAsync(
|
||||
fixture.CreateRequest(
|
||||
["Manuel", "Daniel"],
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest-2", "already identified"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest-1", "unresolved")
|
||||
],
|
||||
[
|
||||
new SpeakerAudioSample(
|
||||
"Guest-2",
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest-2", "already identified"),
|
||||
[4],
|
||||
90),
|
||||
new SpeakerAudioSample(
|
||||
"Guest-1",
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest-1", "unresolved"),
|
||||
[5],
|
||||
90)
|
||||
],
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Guest-2"] = "Manuel"
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
var request = fixture.Matcher.Requests.Single();
|
||||
Assert.Equal("Guest-1", request.DiarizedSpeaker);
|
||||
Assert.Equal([daniel.Id], request.Candidates.Select(candidate => candidate.IdentityId));
|
||||
Assert.DoesNotContain(manuel.Id, request.Candidates.Select(candidate => candidate.IdentityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NamedSpeakersExcludeTheirIdentityFromFurtherMatching()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var manuel = await fixture.AddIdentityAsync([], [1], "Manuel", referenceCount: 99);
|
||||
var daniel = await fixture.AddIdentityAsync([], [2], "Daniel", referenceCount: 5);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Manuel", "Daniel"],
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Manuel", "already relabeled"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest-1", "unresolved")
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
var request = fixture.Matcher.Requests.Single();
|
||||
Assert.Equal("Guest-1", request.DiarizedSpeaker);
|
||||
Assert.Equal([daniel.Id], request.Candidates.Select(candidate => candidate.IdentityId));
|
||||
Assert.DoesNotContain(manuel.Id, request.Candidates.Select(candidate => candidate.IdentityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SchemaUpgradeAddsLastModifiedColumnInitializedFromCreatedAt()
|
||||
{
|
||||
@@ -275,14 +384,17 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
private sealed class SpeakerIdentityFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string tempDirectory;
|
||||
private readonly string dbPath;
|
||||
private readonly SpeakerIdentificationOptions options;
|
||||
|
||||
private SpeakerIdentityFixture(
|
||||
string tempDirectory,
|
||||
string dbPath,
|
||||
SpeakerIdentityDbContext context,
|
||||
SpeakerIdentificationOptions options)
|
||||
{
|
||||
this.tempDirectory = tempDirectory;
|
||||
this.dbPath = dbPath;
|
||||
Context = context;
|
||||
this.options = options;
|
||||
@@ -297,8 +409,9 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public static async Task<SpeakerIdentityFixture> CreateAsync(
|
||||
Action<SpeakerIdentificationOptions>? configureOptions = null)
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDirectory);
|
||||
var dbPath = Path.Combine(tempDirectory, "speaker-identities.db");
|
||||
var options = new SpeakerIdentificationOptions
|
||||
{
|
||||
DatabasePath = dbPath,
|
||||
@@ -310,7 +423,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
return new SpeakerIdentityFixture(dbPath, context, options);
|
||||
return new SpeakerIdentityFixture(tempDirectory, dbPath, context, options);
|
||||
}
|
||||
|
||||
public SpeakerIdentityService CreateService()
|
||||
@@ -323,29 +436,44 @@ public sealed class SpeakerIdentityServiceTests
|
||||
NullLogger<SpeakerIdentityService>.Instance);
|
||||
}
|
||||
|
||||
public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer()
|
||||
{
|
||||
return new SpeakerIdentityAttendeeCanonicalizer(new TestSpeakerIdentityDbContextFactory(dbPath));
|
||||
}
|
||||
|
||||
public SpeakerIdentificationRequest CreateRequest(
|
||||
IReadOnlyList<string> attendees,
|
||||
IReadOnlyList<TranscriptionSegment> segments,
|
||||
IReadOnlyList<SpeakerAudioSample>? samples = null)
|
||||
IReadOnlyList<SpeakerAudioSample>? samples = null,
|
||||
IReadOnlyDictionary<string, string>? knownSpeakerMappings = null)
|
||||
{
|
||||
var meetingNotePath = Path.Combine(tempDirectory, "meeting.md");
|
||||
var transcriptPath = Path.Combine(tempDirectory, "transcript.md");
|
||||
File.WriteAllText(transcriptPath, "Transcript");
|
||||
return new SpeakerIdentificationRequest(
|
||||
"test.wav",
|
||||
MeetingNoteTemplate.Create(
|
||||
"Test Meeting",
|
||||
DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
|
||||
attendees: attendees,
|
||||
transcriptPath: "transcript.md",
|
||||
assistantContextPath: "context.md",
|
||||
summaryPath: "summary.md"),
|
||||
new MeetingNote(
|
||||
meetingNotePath,
|
||||
new MeetingNoteFrontmatter
|
||||
{
|
||||
Title = "Test Meeting",
|
||||
StartTime = DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
|
||||
Attendees = attendees.ToList(),
|
||||
Transcript = transcriptPath,
|
||||
AssistantContext = Path.Combine(tempDirectory, "context.md"),
|
||||
Summary = Path.Combine(tempDirectory, "summary.md")
|
||||
},
|
||||
""),
|
||||
segments,
|
||||
samples);
|
||||
samples,
|
||||
knownSpeakerMappings);
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> AddIdentityAsync(
|
||||
IReadOnlyList<string> candidates,
|
||||
byte[] snippet,
|
||||
string? canonicalName = null,
|
||||
int transcriptionCount = 0,
|
||||
int referenceCount = 0,
|
||||
IReadOnlyList<string>? aliases = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
@@ -353,7 +481,6 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
TranscriptionCount = transcriptionCount,
|
||||
CreatedAt = timestamp,
|
||||
UpdatedAt = timestamp,
|
||||
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
|
||||
@@ -365,7 +492,20 @@ public sealed class SpeakerIdentityServiceTests
|
||||
WavBytes = snippet,
|
||||
CreatedAt = timestamp.AddMinutes(-10)
|
||||
}
|
||||
]
|
||||
],
|
||||
References = Enumerable.Range(0, referenceCount)
|
||||
.Select(index =>
|
||||
{
|
||||
var transcriptPath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md");
|
||||
File.WriteAllText(transcriptPath, "Transcript");
|
||||
return new SpeakerIdentityReference
|
||||
{
|
||||
MeetingNotePath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md"),
|
||||
TranscriptPath = transcriptPath,
|
||||
CreatedAt = timestamp.AddMinutes(index)
|
||||
};
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
Context.SpeakerIdentities.Add(identity);
|
||||
await Context.SaveChangesAsync();
|
||||
@@ -379,15 +519,16 @@ public sealed class SpeakerIdentityServiceTests
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.References)
|
||||
.SingleAsync(identity => identity.Id == id);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Context.DisposeAsync();
|
||||
if (File.Exists(dbPath))
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
File.Delete(dbPath);
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user