Public Access
Add meeting assistant speech and summary automation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
@@ -68,6 +69,36 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
Assert.StartsWith("diarized:endpoint-bytes:", segment.Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpeakerIdentityMergeDiagnosticEndpointUsesRecentDaysParameter()
|
||||
{
|
||||
var mergeService = new CapturingSpeakerIdentityMergeService();
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
|
||||
});
|
||||
});
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<ISpeakerIdentityMergeService>();
|
||||
services.AddSingleton<ISpeakerIdentityMergeService>(mergeService);
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"/diagnostics/speaker-identities/merge",
|
||||
new { recentDays = 7 });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(TimeSpan.FromDays(7), mergeService.RecentIdentityAge);
|
||||
}
|
||||
|
||||
private static WebApplicationFactory<Program> CreateFactory<TProvider>()
|
||||
where TProvider : class, IStreamingTranscriptionProvider
|
||||
{
|
||||
@@ -101,6 +132,7 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var bytes = 0;
|
||||
@@ -117,6 +149,7 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
@@ -171,4 +204,18 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
private sealed record AsrDiagnosticResponse(string Path, IReadOnlyList<AsrDiagnosticSegment> Segments);
|
||||
|
||||
private sealed record AsrDiagnosticSegment(string Speaker, string Text);
|
||||
|
||||
private sealed class CapturingSpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
{
|
||||
public TimeSpan? RecentIdentityAge { get; private set; }
|
||||
|
||||
public Task<SpeakerIdentityMergeResult> MergeRecentIdentitiesAsync(
|
||||
TimeSpan? recentIdentityAge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RecentIdentityAge = recentIdentityAge;
|
||||
return Task.FromResult(new SpeakerIdentityMergeResult(0, 0, 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class AsrDiagnosticTests
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
@@ -77,3 +78,4 @@ public sealed class AsrDiagnosticTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ public sealed class AzureSpeechRecognitionPipelineTests
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var _ in audio.WithCancellation(cancellationToken))
|
||||
@@ -60,3 +61,4 @@ public sealed class AzureSpeechRecognitionPipelineTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public void IdentityDiarizationLanguageUsesFirstAutoDetectLanguageWhenConfiguredLanguageIsAuto()
|
||||
{
|
||||
Assert.Equal(
|
||||
"de-DE",
|
||||
AzureSpeechSpeakerIdentityDiarizationClient.ResolveRecognitionLanguage(new AzureSpeechOptions
|
||||
{
|
||||
Language = "auto",
|
||||
AutoDetectLanguages = ["de-DE", "en-US"]
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityDiarizationLanguageUsesConfiguredConcreteLanguage()
|
||||
{
|
||||
Assert.Equal(
|
||||
"en-US",
|
||||
AzureSpeechSpeakerIdentityDiarizationClient.ResolveRecognitionLanguage(new AzureSpeechOptions
|
||||
{
|
||||
Language = "en-US",
|
||||
AutoDetectLanguages = ["de-DE"]
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatcherReturnsIdentityWhenAzureDiarizationAssignsUnknownSnippetToKnownSpeaker()
|
||||
{
|
||||
var diarizationClient = new FakeSpeakerIdentityDiarizationClient(
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "known"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), "Guest-1", "unknown")
|
||||
]);
|
||||
var matcher = CreateMatcher(diarizationClient);
|
||||
var snippet = CreateWavSnippet();
|
||||
|
||||
var match = await matcher.MatchAsync(
|
||||
new SpeakerIdentityMatchRequest(
|
||||
"Guest03",
|
||||
snippet,
|
||||
[new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet])]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(match);
|
||||
Assert.Equal(42, match.IdentityId);
|
||||
Assert.NotNull(diarizationClient.WavPath);
|
||||
Assert.False(File.Exists(diarizationClient.WavPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatcherRequiresTwoMatchingKnownSnippetsWhenIdentityHasMultipleSnippets()
|
||||
{
|
||||
var diarizationClient = new FakeSpeakerIdentityDiarizationClient(
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "known one"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), "Guest-2", "known two"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(5), "Guest-1", "unknown")
|
||||
]);
|
||||
var matcher = CreateMatcher(diarizationClient);
|
||||
var snippet = CreateWavSnippet();
|
||||
|
||||
var match = await matcher.MatchAsync(
|
||||
new SpeakerIdentityMatchRequest(
|
||||
"Guest03",
|
||||
snippet,
|
||||
[new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet, snippet])]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(match);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatcherReturnsNullWhenAzureDiarizationAssignsDifferentSpeakers()
|
||||
{
|
||||
var diarizationClient = new FakeSpeakerIdentityDiarizationClient(
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "known"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), "Guest-2", "unknown")
|
||||
]);
|
||||
var matcher = CreateMatcher(diarizationClient);
|
||||
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)
|
||||
{
|
||||
return new AzureSpeechSpeakerIdentityMatcher(
|
||||
diarizationClient,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
SilenceBetweenSnippetsSeconds = 1
|
||||
}
|
||||
}),
|
||||
NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance);
|
||||
}
|
||||
|
||||
private static byte[] CreateWavSnippet()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new WaveFileWriter(stream, new WaveFormat(16000, 16, 1)))
|
||||
{
|
||||
writer.Write(new byte[16000 * 2], 0, 16000 * 2);
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private sealed class FakeSpeakerIdentityDiarizationClient : ISpeakerIdentityDiarizationClient
|
||||
{
|
||||
private readonly IReadOnlyList<TranscriptionSegment> segments;
|
||||
|
||||
public FakeSpeakerIdentityDiarizationClient(IReadOnlyList<TranscriptionSegment> segments)
|
||||
{
|
||||
this.segments = segments;
|
||||
}
|
||||
|
||||
public string? WavPath { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<TranscriptionSegment>> DiarizeAsync(
|
||||
string wavPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
WavPath = wavPath;
|
||||
Assert.True(File.Exists(wavPath));
|
||||
return Task.FromResult(segments);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in provider.TranscribeAsync(ReadSingleChunk(), CancellationToken.None))
|
||||
await foreach (var _ in provider.TranscribeAsync(ReadSingleChunk(), SpeechRecognitionPipelineOptions.Default, CancellationToken.None))
|
||||
{
|
||||
}
|
||||
});
|
||||
@@ -31,6 +31,78 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
||||
Assert.Contains("Azure Speech key is not configured", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoDetectLanguagesAreUsedOnlyWhenLanguageIsAuto()
|
||||
{
|
||||
Assert.Equal(
|
||||
["de-DE", "en-US"],
|
||||
AzureSpeechStreamingTranscriptionProvider.GetAutoDetectLanguages(new AzureSpeechOptions
|
||||
{
|
||||
Language = "auto",
|
||||
AutoDetectLanguages = ["de-DE", "en-US", "de-DE", " "]
|
||||
}));
|
||||
|
||||
Assert.Empty(AzureSpeechStreamingTranscriptionProvider.GetAutoDetectLanguages(new AzureSpeechOptions
|
||||
{
|
||||
Language = "de-DE",
|
||||
AutoDetectLanguages = ["de-DE", "en-US"]
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeDictationWordsTrimsAndDeduplicatesPhrases()
|
||||
{
|
||||
Assert.Equal(
|
||||
["PBI", "Product Backlog Item"],
|
||||
AzureSpeechStreamingTranscriptionProvider.NormalizeDictationWords([
|
||||
" PBI ",
|
||||
"pbi",
|
||||
"",
|
||||
"Product Backlog Item"
|
||||
]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveKeyFallsBackToUserEnvironment()
|
||||
{
|
||||
var keyEnv = $"MEETING_ASSISTANT_AZURE_KEY_{Guid.NewGuid():N}";
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(keyEnv, "user-key", EnvironmentVariableTarget.User);
|
||||
|
||||
Assert.Equal("user-key", AzureSpeechStreamingTranscriptionProvider.ResolveKey(new AzureSpeechOptions
|
||||
{
|
||||
KeyEnv = keyEnv
|
||||
}));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(keyEnv, null, EnvironmentVariableTarget.User);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointDefaultsToUniversalV2SpeechEndpointForRegion()
|
||||
{
|
||||
Assert.Equal(
|
||||
new Uri("wss://germanywestcentral.stt.speech.microsoft.com/speech/universal/v2"),
|
||||
AzureSpeechStreamingTranscriptionProvider.GetEndpoint(new AzureSpeechOptions
|
||||
{
|
||||
Endpoint = "",
|
||||
Region = "germanywestcentral"
|
||||
}));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Continuous")]
|
||||
[InlineData("", "Continuous")]
|
||||
[InlineData("continuous", "Continuous")]
|
||||
[InlineData("AtStart", "AtStart")]
|
||||
public void LanguageIdModeDefaultsToContinuous(string? configured, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AzureSpeechStreamingTranscriptionProvider.NormalizeLanguageIdMode(configured));
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AudioChunk> ReadSingleChunk()
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using MeetingAssistant.Transcription;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class DictationWordStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizeParsesMarkdownBulletsAndDeduplicatesWords()
|
||||
{
|
||||
var words = MarkdownDictationWordStore.Normalize([
|
||||
"- PBI",
|
||||
"* Product Backlog Item",
|
||||
"pbi",
|
||||
"`Velocity Vanguards`",
|
||||
"",
|
||||
" \"SEW\" "
|
||||
]);
|
||||
|
||||
Assert.Equal([
|
||||
"PBI",
|
||||
"Product Backlog Item",
|
||||
"SEW",
|
||||
"Velocity Vanguards"
|
||||
], words);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public sealed class FunAsrStreamingTranscriptionProviderTests
|
||||
new AudioChunk([3, 0, 4, 0], 16000, 1)
|
||||
};
|
||||
|
||||
var segments = await CollectAsync(provider.TranscribeAsync(ToAsyncEnumerable(chunks), CancellationToken.None));
|
||||
var segments = await CollectAsync(provider.TranscribeAsync(ToAsyncEnumerable(chunks), SpeechRecognitionPipelineOptions.Default, CancellationToken.None));
|
||||
|
||||
Assert.Equal(new Uri("ws://localhost:10095"), connection.ConnectedEndpoint);
|
||||
Assert.Equal(2, connection.BinaryMessages.Count);
|
||||
@@ -75,6 +75,7 @@ public sealed class FunAsrStreamingTranscriptionProviderTests
|
||||
|
||||
var segments = await CollectAsync(provider.TranscribeAsync(
|
||||
ToAsyncEnumerable([new AudioChunk([1, 0], 16000, 1)]),
|
||||
SpeechRecognitionPipelineOptions.Default,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Collection(
|
||||
@@ -95,6 +96,22 @@ public sealed class FunAsrStreamingTranscriptionProviderTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHotwordsSerializesDictationWordsWithDeduplication()
|
||||
{
|
||||
var hotwords = FunAsrStreamingTranscriptionProvider.BuildHotwords([
|
||||
"PBI",
|
||||
"pbi",
|
||||
"Product Backlog Item",
|
||||
" "
|
||||
]);
|
||||
|
||||
using var document = JsonDocument.Parse(hotwords);
|
||||
Assert.Equal(20, document.RootElement.GetProperty("PBI").GetInt32());
|
||||
Assert.Equal(20, document.RootElement.GetProperty("Product Backlog Item").GetInt32());
|
||||
Assert.Equal(2, document.RootElement.EnumerateObject().Count());
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AudioChunk> ToAsyncEnumerable(IEnumerable<AudioChunk> chunks)
|
||||
{
|
||||
foreach (var chunk in chunks)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingSummaryInstructionBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuilderUsesDefaultPromptWhenConfiguredPromptIsBlank()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = CreateArtifacts(root);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, []);
|
||||
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Agent = new AgentOptions { InitialPrompt = " " },
|
||||
Vault = new VaultOptions { ProjectsFolder = Path.Combine(root, "Projects") }
|
||||
}));
|
||||
|
||||
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
|
||||
|
||||
Assert.Contains("You are the Meeting Assistant summary agent.", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuilderUsesConfiguredPrompt()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = CreateArtifacts(root);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, []);
|
||||
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Agent = new AgentOptions { InitialPrompt = "Custom summarizer instructions." },
|
||||
Vault = new VaultOptions { ProjectsFolder = Path.Combine(root, "Projects") }
|
||||
}));
|
||||
|
||||
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Custom summarizer instructions.", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuilderAppendsAgentsFilesForBoundProjects()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
var artifacts = CreateArtifacts(root);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, ["Beta", "Alpha", "Missing"]);
|
||||
await WriteProjectAgentsAsync(projectsRoot, "Alpha", "Alpha instructions.");
|
||||
await WriteProjectAgentsAsync(projectsRoot, "Beta", "Beta instructions.");
|
||||
Directory.CreateDirectory(Path.Combine(projectsRoot, "Missing"));
|
||||
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Agent = new AgentOptions { InitialPrompt = "Base." },
|
||||
Vault = new VaultOptions { ProjectsFolder = projectsRoot }
|
||||
}));
|
||||
|
||||
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
"""
|
||||
Base.
|
||||
|
||||
---
|
||||
projects:
|
||||
|
||||
# Alpha
|
||||
|
||||
Alpha instructions.
|
||||
|
||||
# Beta
|
||||
|
||||
Beta instructions.
|
||||
""",
|
||||
instructions);
|
||||
}
|
||||
|
||||
private static MeetingSessionArtifacts CreateArtifacts(string root)
|
||||
{
|
||||
return new MeetingSessionArtifacts(
|
||||
Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
}
|
||||
|
||||
private static async Task WriteMeetingNoteAsync(string path, IReadOnlyList<string> projects)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var projectLines = projects.Count == 0
|
||||
? "projects: []"
|
||||
: "projects:\n" + string.Join('\n', projects.Select(project => $"- {project}"));
|
||||
await File.WriteAllTextAsync(
|
||||
path,
|
||||
$"""
|
||||
---
|
||||
title: Meeting
|
||||
{projectLines}
|
||||
---
|
||||
""");
|
||||
}
|
||||
|
||||
private static async Task WriteProjectAgentsAsync(string projectsRoot, string project, string content)
|
||||
{
|
||||
var folder = Path.Combine(projectsRoot, project);
|
||||
Directory.CreateDirectory(folder);
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "AGENTS.md"), content);
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,21 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
|
||||
|
||||
Assert.Equal("Review current prototype\nDecide next backend", agenda.Replace("\r\n", "\n", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeAttendeesDeduplicatesOrganizerAndRecipientEmail()
|
||||
{
|
||||
var attendees = OutlookClassicMeetingMetadataProvider.NormalizeAttendees([
|
||||
"Marcus.Altmann@sew-eurodrive.de",
|
||||
"Marcus.Altmann@sew-eurodrive.de <Marcus.Altmann@sew-eurodrive.de>",
|
||||
"Schweigert, Manuel",
|
||||
" "
|
||||
]);
|
||||
|
||||
Assert.Equal([
|
||||
"Marcus.Altmann@sew-eurodrive.de",
|
||||
"Schweigert, Manuel"
|
||||
], attendees);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
@@ -10,6 +11,22 @@ namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class RecordingCoordinatorTests
|
||||
{
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition was not met.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
|
||||
{
|
||||
@@ -116,6 +133,7 @@ public sealed class RecordingCoordinatorTests
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"))));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await WaitUntilAsync(() => artifactStore.Agenda == "Review API shape");
|
||||
|
||||
Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(["Ada <ada@example.com>", "Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
@@ -125,6 +143,101 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var provider = new BlockingMeetingMetadataProvider(new MeetingMetadata(
|
||||
"Late Calendar Match",
|
||||
["Ada"],
|
||||
"Late agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T12:00:00+02:00")));
|
||||
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,
|
||||
provider);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.StartsWith("Meeting ", noteStore.SavedNote?.Frontmatter.Title, StringComparison.Ordinal);
|
||||
provider.Release();
|
||||
await WaitUntilAsync(() =>
|
||||
noteStore.SavedNote?.Frontmatter.Title == "Late Calendar Match" &&
|
||||
artifactStore.Agenda == "Late agenda");
|
||||
|
||||
Assert.Equal(["Ada"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal("Late agenda", artifactStore.Agenda);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-19T12:00:00+02:00"), artifactStore.ScheduledEnd);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartCreatesMeetingNoteWhenMetadataProviderFails()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\fallback-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\fallback-transcript.md"),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
new ThrowingMeetingMetadataProvider());
|
||||
|
||||
var status = await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(status.IsRecording);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\fallback-meeting.md", status.MeetingNotePath);
|
||||
Assert.NotNull(artifactStore.CreatedArtifacts);
|
||||
Assert.StartsWith("Meeting ", noteStore.SavedNote?.Frontmatter.Title, StringComparison.Ordinal);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartContinuesWhenObsidianOpenFails()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\open-fails.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\open-fails-transcript.md"),
|
||||
noteStore,
|
||||
new ThrowingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
var status = await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(status.IsRecording);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\open-fails.md", status.MeetingNotePath);
|
||||
Assert.NotNull(artifactStore.CreatedArtifacts);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopCompletesAudioCaptureAndDrainsFinalTranscriptionWindow()
|
||||
{
|
||||
@@ -187,6 +300,33 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartPassesDictationWordsToSpeechPipeline()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var provider = new CapturingPipelineOptionsProvider();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
dictationWordStore: new FixedDictationWordStore(["PBI", "Product Backlog Item"]));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await provider.OptionsObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(["PBI", "Product Backlog Item"], provider.Options?.DictationWords);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopRewritesTranscriptWithFinalDiarizedSegmentsWhenAvailable()
|
||||
{
|
||||
@@ -236,6 +376,272 @@ public sealed class RecordingCoordinatorTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopRelabelsFinishedTranscriptWithLearnedSpeakerIdentity()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
noteStore.UpdateSavedNote(MeetingNoteTemplate.Create(
|
||||
"Identity Test",
|
||||
DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
|
||||
attendees: ["Chris"],
|
||||
transcriptPath: "memory-transcript.md",
|
||||
assistantContextPath: "memory-context.md",
|
||||
summaryPath: "memory-summary.md"));
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")
|
||||
]);
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: new FixedSpeakerIdentificationService("Guest03", "Chris"));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("Chris", transcriptStore.ReplacedSegments.Single().Speaker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAddsIdentifiedSpeakerToMeetingAttendees()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")
|
||||
]);
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: new FixedSpeakerIdentificationService(
|
||||
"Guest03",
|
||||
"Chris",
|
||||
["Chris", "Christopher"]));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
var attendees = Assert.IsType<List<string>>(noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Contains("Chris", attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopDoesNotDuplicateIdentifiedSpeakerWhenAliasIsAlreadyAttendee()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")
|
||||
]);
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: new FixedSpeakerIdentificationService(
|
||||
"Guest03",
|
||||
"Christopher",
|
||||
["Christopher", "Chris"]));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
noteStore.UpdateAttendees(["Chris <chris@example.com>"]);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
var attendees = Assert.IsType<List<string>>(noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal(["Chris <chris@example.com>"], attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveSpeakerIdentityMatchRelabelsCurrentAndFutureTranscriptSegments()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var speakerIdentification = new BlockingSpeakerIdentificationService("Guest03", "Chris");
|
||||
var provider = new OrderedChunkProvider();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.Zero,
|
||||
Interval = TimeSpan.FromMilliseconds(10)
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
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 speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal)));
|
||||
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);
|
||||
|
||||
await transcriptStore.WaitForTextAsync("second");
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Collection(
|
||||
transcriptStore.Segments,
|
||||
first => Assert.Equal("Guest03", first.Speaker),
|
||||
second => Assert.Equal("Chris", second.Speaker));
|
||||
Assert.Contains(
|
||||
transcriptStore.ReplacedSegments,
|
||||
segment => segment.Text.Contains("first", StringComparison.Ordinal) && segment.Speaker == "Chris");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveSpeakerIdentityMatchAddsIdentifiedSpeakerToMeetingAttendees()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var speakerIdentification = new BlockingSpeakerIdentificationService("Guest03", "Chris");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.Zero,
|
||||
Interval = TimeSpan.FromMilliseconds(10)
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
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(() => noteStore.SavedNote?.Frontmatter.Attendees.Contains("Chris") == true);
|
||||
var attendees = Assert.IsType<List<string>>(noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Contains("Chris", attendees);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveSpeakerIdentificationRetriesWhenAttendeesChange()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var speakerIdentification = new CountingSpeakerIdentificationService();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.Zero,
|
||||
Interval = TimeSpan.FromMilliseconds(20)
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
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(() => speakerIdentification.Requests.Count == 1);
|
||||
|
||||
noteStore.UpdateAttendees(["Chris"]);
|
||||
|
||||
await WaitUntilAsync(() => speakerIdentification.Requests.Count == 2);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(["Chris"], speakerIdentification.Requests.Last().MeetingNote.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveSpeakerIdentificationRetriesWhenNewUnmappedSpeakerAppears()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var speakerIdentification = new CountingSpeakerIdentificationService();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new ChangingSpeakerOrderedChunkProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.Zero,
|
||||
Interval = TimeSpan.FromMilliseconds(20)
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
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(() => speakerIdentification.Requests.Count == 1);
|
||||
await audioSource.WriteAsync(new AudioChunk(Samples(8, 9, 10, 11, 12, 13, 14, 15), 4, 1), CancellationToken.None);
|
||||
|
||||
await WaitUntilAsync(() => speakerIdentification.Requests.Count == 2);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
var samples = Assert.IsAssignableFrom<IReadOnlyList<SpeakerAudioSample>>(
|
||||
speakerIdentification.Requests.Last().Samples);
|
||||
Assert.Contains(samples, sample => sample.Speaker == "Guest04");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, null)]
|
||||
[InlineData(1, null)]
|
||||
@@ -538,6 +944,8 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public IReadOnlyList<TranscriptionSegment> ReplacedSegments { get; private set; } = [];
|
||||
|
||||
public IReadOnlyList<TranscriptionSegment> Segments => segments;
|
||||
|
||||
public MeetingNote? MetadataMeetingNote { get; private set; }
|
||||
|
||||
public Task ReplaceAsync(
|
||||
@@ -591,6 +999,11 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
SavedNote.Frontmatter.Attendees = attendees.ToList();
|
||||
}
|
||||
|
||||
public void UpdateSavedNote(MeetingNote note)
|
||||
{
|
||||
SavedNote = note with { Path = notePath };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener
|
||||
@@ -634,6 +1047,17 @@ public sealed class RecordingCoordinatorTests
|
||||
States.Add(state);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Agenda = agenda;
|
||||
ScheduledEnd = scheduledEnd;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
@@ -653,6 +1077,48 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("metadata unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
private readonly MeetingMetadata metadata;
|
||||
private readonly TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public BlockingMeetingMetadataProvider(MeetingMetadata metadata)
|
||||
{
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public void Release()
|
||||
{
|
||||
release.TrySetResult();
|
||||
}
|
||||
|
||||
public async Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await release.Task.WaitAsync(cancellationToken);
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingMeetingNoteOpener : IMeetingNoteOpener
|
||||
{
|
||||
public Task OpenAsync(string notePath, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("obsidian unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly bool succeeded;
|
||||
@@ -817,6 +1283,181 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
private readonly string sourceSpeaker;
|
||||
private readonly string targetSpeaker;
|
||||
private readonly IReadOnlyList<string> acceptedNames;
|
||||
|
||||
public FixedSpeakerIdentificationService(
|
||||
string sourceSpeaker,
|
||||
string targetSpeaker,
|
||||
IReadOnlyList<string>? acceptedNames = null)
|
||||
{
|
||||
this.sourceSpeaker = sourceSpeaker;
|
||||
this.targetSpeaker = targetSpeaker;
|
||||
this.acceptedNames = acceptedNames ?? [targetSpeaker];
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(
|
||||
request.Segments.Select(segment => segment.Speaker == sourceSpeaker
|
||||
? segment with { Speaker = targetSpeaker }
|
||||
: segment).ToList(),
|
||||
new Dictionary<string, string> { [sourceSpeaker] = targetSpeaker },
|
||||
[new SpeakerIdentityAttendeeMatch(targetSpeaker, acceptedNames)]));
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ProcessFinishedTranscriptAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
private readonly string sourceSpeaker;
|
||||
private readonly string targetSpeaker;
|
||||
private readonly IReadOnlyList<string> acceptedNames;
|
||||
|
||||
public BlockingSpeakerIdentificationService(
|
||||
string sourceSpeaker,
|
||||
string targetSpeaker,
|
||||
IReadOnlyList<string>? acceptedNames = null)
|
||||
{
|
||||
this.sourceSpeaker = sourceSpeaker;
|
||||
this.targetSpeaker = targetSpeaker;
|
||||
this.acceptedNames = acceptedNames ?? [targetSpeaker];
|
||||
}
|
||||
|
||||
public TaskCompletionSource IdentificationObserved { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IdentificationObserved.TrySetResult();
|
||||
return Task.FromResult(new SpeakerIdentificationResult(
|
||||
request.Segments,
|
||||
new Dictionary<string, string> { [sourceSpeaker] = targetSpeaker },
|
||||
[new SpeakerIdentityAttendeeMatch(targetSpeaker, acceptedNames)]));
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
public List<SpeakerIdentificationRequest> Requests { get; } = [];
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedDictationWordStore : IDictationWordStore
|
||||
{
|
||||
private readonly IReadOnlyList<string> words;
|
||||
|
||||
public FixedDictationWordStore(IReadOnlyList<string> words)
|
||||
{
|
||||
this.words = words;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(words);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(words);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingPipelineOptionsProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public TaskCompletionSource OptionsObserved { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public SpeechRecognitionPipelineOptions? Options { get; private set; }
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
Options = options;
|
||||
OptionsObserved.TrySetResult();
|
||||
await foreach (var _ in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", "ok");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OrderedChunkProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var index = 0;
|
||||
await foreach (var _ in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
var text = index++ == 0 ? "first" : "second";
|
||||
yield return new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(index - 1),
|
||||
TimeSpan.FromSeconds(index + 1),
|
||||
"Guest03",
|
||||
$"{text} has enough words for identification.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ChangingSpeakerOrderedChunkProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var index = 0;
|
||||
await foreach (var _ in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
var speaker = index++ == 0 ? "Guest03" : "Guest04";
|
||||
yield return new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(index - 1),
|
||||
TimeSpan.FromSeconds(index + 1),
|
||||
speaker,
|
||||
"This segment has enough words for identification.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControlledAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly Channel<AudioChunk> chunks = Channel.CreateUnbounded<AudioChunk>();
|
||||
@@ -870,6 +1511,7 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
@@ -884,6 +1526,7 @@ public sealed class RecordingCoordinatorTests
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var byteCount = 0;
|
||||
@@ -915,6 +1558,7 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
waitingForBackend.TrySetResult();
|
||||
@@ -925,6 +1569,14 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Samples(params short[] samples)
|
||||
{
|
||||
var bytes = new byte[samples.Length * sizeof(short)];
|
||||
Buffer.BlockCopy(samples, 0, bytes, 0, bytes.Length);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class RollingAudioBufferTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryExtractWavReturnsRequestedRangeFromBufferedPcm()
|
||||
{
|
||||
var buffer = new RollingAudioBuffer(TimeSpan.FromSeconds(10));
|
||||
buffer.Append(new AudioChunk(Samples(0, 1, 2, 3), SampleRate: 2, Channels: 1));
|
||||
buffer.Append(new AudioChunk(Samples(4, 5, 6, 7), SampleRate: 2, Channels: 1));
|
||||
|
||||
var wav = buffer.TryExtractWav(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3));
|
||||
|
||||
using var reader = new WaveFileReader(new MemoryStream(wav));
|
||||
var bytes = new byte[reader.Length];
|
||||
var read = reader.Read(bytes, 0, bytes.Length);
|
||||
Assert.Equal(bytes.Length, read);
|
||||
Assert.Equal(Samples(2, 3, 4, 5), bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryExtractWavDropsAudioOutsideRetentionWindow()
|
||||
{
|
||||
var buffer = new RollingAudioBuffer(TimeSpan.FromSeconds(1));
|
||||
buffer.Append(new AudioChunk(Samples(0, 1), SampleRate: 1, Channels: 1));
|
||||
buffer.Append(new AudioChunk(Samples(2, 3), SampleRate: 1, Channels: 1));
|
||||
|
||||
var wav = buffer.TryExtractWav(TimeSpan.Zero, TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.Empty(wav);
|
||||
}
|
||||
|
||||
private static byte[] Samples(params short[] samples)
|
||||
{
|
||||
var bytes = new byte[samples.Length * sizeof(short)];
|
||||
Buffer.BlockCopy(samples, 0, bytes, 0, bytes.Length);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class SpeakerIdentityMergeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MergeRecentIdentitiesConfirmsMatchTwiceAndMergesAliasesAndSamples()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityMergeFixture.CreateAsync();
|
||||
var older = await fixture.AddIdentityAsync(
|
||||
canonicalName: "Manuel",
|
||||
aliases: ["M. Schweigert"],
|
||||
snippets: [[1], [2], [3]],
|
||||
createdAt: DateTimeOffset.UtcNow.AddMonths(-2),
|
||||
transcriptionCount: 5);
|
||||
var recent = await fixture.AddIdentityAsync(
|
||||
canonicalName: "Guest Manuel",
|
||||
aliases: [],
|
||||
snippets: [[4], [5]],
|
||||
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||
transcriptionCount: 2);
|
||||
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
||||
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.MergedPairs);
|
||||
Assert.Equal(2, fixture.Matcher.Requests.Count);
|
||||
Assert.Equal([4], fixture.Matcher.Requests[0].UnknownSnippet);
|
||||
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.True(savedOlder.UpdatedAt > older.UpdatedAt);
|
||||
Assert.Equal(["Guest Manuel", "M. Schweigert"], savedOlder.Aliases.Select(alias => alias.Name).Order());
|
||||
Assert.Equal(3, savedOlder.Snippets.Count);
|
||||
Assert.False(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == recent.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeRecentIdentitiesDoesNotMergeWithoutSecondValidation()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityMergeFixture.CreateAsync();
|
||||
var older = await fixture.AddIdentityAsync("Manuel", [], [[1]], DateTimeOffset.UtcNow.AddMonths(-2), 5);
|
||||
var recent = await fixture.AddIdentityAsync("Guest Manuel", [], [[4], [5]], DateTimeOffset.UtcNow.AddDays(-1), 2);
|
||||
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.MergedPairs);
|
||||
Assert.True(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == recent.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeRecentIdentitiesIgnoresOldSourceIdentities()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityMergeFixture.CreateAsync();
|
||||
await fixture.AddIdentityAsync("Older A", [], [[1], [2]], DateTimeOffset.UtcNow.AddMonths(-2), 5);
|
||||
await fixture.AddIdentityAsync("Older B", [], [[4], [5]], DateTimeOffset.UtcNow.AddMonths(-1), 2);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.MergedPairs);
|
||||
Assert.Empty(fixture.Matcher.Requests);
|
||||
}
|
||||
|
||||
private sealed class SpeakerIdentityMergeFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string dbPath;
|
||||
|
||||
private SpeakerIdentityMergeFixture(string dbPath, SpeakerIdentityDbContext context)
|
||||
{
|
||||
this.dbPath = dbPath;
|
||||
Context = context;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext Context { get; }
|
||||
|
||||
public QueueingSpeakerIdentityMatcher Matcher { get; } = new();
|
||||
|
||||
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 context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
return new SpeakerIdentityMergeFixture(dbPath, context);
|
||||
}
|
||||
|
||||
public SpeakerIdentityMergeService CreateService()
|
||||
{
|
||||
return new SpeakerIdentityMergeService(
|
||||
new TestSpeakerIdentityDbContextFactory(dbPath),
|
||||
Matcher,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
MatchBatchSize = 6,
|
||||
MaxSnippetsPerSpeaker = 3
|
||||
}
|
||||
}),
|
||||
NullLogger<SpeakerIdentityMergeService>.Instance);
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> AddIdentityAsync(
|
||||
string? canonicalName,
|
||||
IReadOnlyList<string> aliases,
|
||||
IReadOnlyList<byte[]> snippets,
|
||||
DateTimeOffset createdAt,
|
||||
int transcriptionCount)
|
||||
{
|
||||
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
|
||||
{
|
||||
WavBytes = snippet,
|
||||
CreatedAt = createdAt.AddMinutes(index)
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
Context.SpeakerIdentities.Add(identity);
|
||||
await Context.SaveChangesAsync();
|
||||
return identity;
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentity> LoadIdentityAsync(int id)
|
||||
{
|
||||
Context.ChangeTracker.Clear();
|
||||
return Context.SpeakerIdentities
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.SingleAsync(identity => identity.Id == id);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Context.DisposeAsync();
|
||||
if (File.Exists(dbPath))
|
||||
{
|
||||
File.Delete(dbPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory<SpeakerIdentityDbContext>
|
||||
{
|
||||
private readonly string dbPath;
|
||||
|
||||
public TestSpeakerIdentityDbContextFactory(string dbPath)
|
||||
{
|
||||
this.dbPath = dbPath;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext CreateDbContext()
|
||||
{
|
||||
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QueueingSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
{
|
||||
public Queue<int> MatchIdentityIds { get; } = new();
|
||||
|
||||
public List<SpeakerIdentityMatchRequest> Requests { get; } = [];
|
||||
|
||||
public Task<SpeakerIdentityMatch?> MatchAsync(
|
||||
SpeakerIdentityMatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
if (!MatchIdentityIds.TryDequeue(out var identityId))
|
||||
{
|
||||
return Task.FromResult<SpeakerIdentityMatch?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(identityId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class SpeakerIdentityServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MatchedUnnamedIdentityEliminatesCandidatesAndPromotesCanonicalName()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3]);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var result = await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Jane", "John", "Chris"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
|
||||
CancellationToken.None);
|
||||
|
||||
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("John", result.Segments.Single().Speaker);
|
||||
Assert.Equal("John", result.SpeakerMappings["Guest01"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchedIdentityUpdatesLastModifiedTimestamp()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var oldTimestamp = DateTimeOffset.UtcNow.AddDays(-30);
|
||||
var identity = await fixture.AddIdentityAsync(
|
||||
[],
|
||||
[1, 2, 3],
|
||||
"Chris",
|
||||
transcriptionCount: 5,
|
||||
updatedAt: oldTimestamp);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Chris"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.True(saved.UpdatedAt > oldTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchedIdentityWithEmptyCandidateIntersectionResetsCandidatesAndReplacesOldestSnippet()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync(options => options.MaxSnippetsPerSpeaker = 1);
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [9, 9, 9]);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Jane", "Chris"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Null(saved.CanonicalName);
|
||||
Assert.Equal(["Chris", "Jane"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
Assert.Equal([7, 8, 9], saved.Snippets.Single().WavBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchedUnnamedIdentityTreatsAliasesAsCandidateMatches()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(["Michael"], [1, 2, 3], aliases: ["Mike"]);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Mike", "Jane"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal("Michael", saved.CanonicalName);
|
||||
Assert.Equal(["Michael"], saved.CandidateNames.Select(candidate => candidate.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmatchedDiarizedSpeakersCreateCandidateIdentitiesFromUnmatchedAttendees()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", transcriptionCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = chris.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["John", "Mike", "Chris"],
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest03", "known"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest01", "unknown one"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(12), "Guest02", "unknown two")
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Where(identity => identity.CanonicalName == null)
|
||||
.OrderBy(identity => identity.Id)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Equal(2, learned.Count);
|
||||
Assert.All(learned, identity =>
|
||||
{
|
||||
Assert.NotEqual(default, identity.CreatedAt);
|
||||
Assert.NotEqual(default, identity.UpdatedAt);
|
||||
Assert.Equal(["John", "Mike"], identity.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var michael = await fixture.AddIdentityAsync([], [1, 2, 3], "Michael", transcriptionCount: 5, aliases: ["Mike"]);
|
||||
fixture.Matcher.MatchIdentityId = michael.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Mike", "Jane"],
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest03", "known"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest01", "unknown")
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Where(identity => identity.CanonicalName == null)
|
||||
.SingleAsync();
|
||||
Assert.Equal(["Jane"], learned.CandidateNames.Select(candidate => candidate.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", transcriptionCount: 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");
|
||||
|
||||
var result = await service.IdentifyKnownSpeakersAsync(
|
||||
fixture.CreateRequest(
|
||||
["Chris"],
|
||||
[segment],
|
||||
[new SpeakerAudioSample("Guest03", segment, [4, 5, 6], 90)]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Chris", result.Segments.Single().Speaker);
|
||||
Assert.Equal([4, 5, 6], fixture.Matcher.LastUnknownSnippet);
|
||||
Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], transcriptionCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var result = await service.IdentifyKnownSpeakersAsync(
|
||||
fixture.CreateRequest(
|
||||
["Jane", "John", "Chris"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
|
||||
CancellationToken.None);
|
||||
|
||||
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.Single(saved.Snippets);
|
||||
Assert.Equal("Guest01", result.Segments.Single().Speaker);
|
||||
Assert.Empty(result.SpeakerMappings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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 service = fixture.CreateService();
|
||||
|
||||
await service.IdentifyKnownSpeakersAsync(
|
||||
fixture.CreateRequest(
|
||||
["Jane", "Mike"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var requestedIds = fixture.Matcher.Requests.Single().Candidates.Select(candidate => candidate.IdentityId).ToList();
|
||||
Assert.Equal([displayNameMatch.Id, aliasMatch.Id, unrelated.Id], requestedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpeakerMatchingFiltersInactiveNonAttendeeIdentitiesAndLimitsCandidates()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync(options =>
|
||||
{
|
||||
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 service = fixture.CreateService();
|
||||
|
||||
await service.IdentifyKnownSpeakersAsync(
|
||||
fixture.CreateRequest(
|
||||
["Former"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var requestedIds = fixture.Matcher.Requests.Single().Candidates.Select(candidate => candidate.IdentityId).ToList();
|
||||
Assert.Equal([staleAttendee.Id, activeHigh.Id], requestedIds);
|
||||
Assert.DoesNotContain(activeLow.Id, requestedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SchemaUpgradeAddsLastModifiedColumnInitializedFromCreatedAt()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
var createdAt = DateTimeOffset.Parse("2026-05-01T10:00:00+00:00");
|
||||
await using var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE TABLE "SpeakerIdentities" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerIdentities" PRIMARY KEY AUTOINCREMENT,
|
||||
"CanonicalName" TEXT NULL,
|
||||
"TranscriptionCount" INTEGER NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
await context.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"""
|
||||
INSERT INTO "SpeakerIdentities" ("CanonicalName", "TranscriptionCount", "CreatedAt")
|
||||
VALUES ('Legacy Speaker', 3, {createdAt});
|
||||
""");
|
||||
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
|
||||
context.ChangeTracker.Clear();
|
||||
var saved = await context.SpeakerIdentities.SingleAsync();
|
||||
Assert.Equal(createdAt, saved.UpdatedAt);
|
||||
await context.DisposeAsync();
|
||||
File.Delete(dbPath);
|
||||
}
|
||||
|
||||
private sealed class SpeakerIdentityFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string dbPath;
|
||||
private readonly SpeakerIdentificationOptions options;
|
||||
|
||||
private SpeakerIdentityFixture(
|
||||
string dbPath,
|
||||
SpeakerIdentityDbContext context,
|
||||
SpeakerIdentificationOptions options)
|
||||
{
|
||||
this.dbPath = dbPath;
|
||||
Context = context;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext Context { get; }
|
||||
|
||||
public FakeSpeakerIdentityMatcher Matcher { get; } = new();
|
||||
|
||||
public FakeSpeakerSnippetExtractor SnippetExtractor { get; } = new();
|
||||
|
||||
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 options = new SpeakerIdentificationOptions
|
||||
{
|
||||
DatabasePath = dbPath,
|
||||
MaxSnippetsPerSpeaker = 3,
|
||||
MatchBatchSize = 6
|
||||
};
|
||||
configureOptions?.Invoke(options);
|
||||
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
return new SpeakerIdentityFixture(dbPath, context, options);
|
||||
}
|
||||
|
||||
public SpeakerIdentityService CreateService()
|
||||
{
|
||||
return new SpeakerIdentityService(
|
||||
new TestSpeakerIdentityDbContextFactory(dbPath),
|
||||
SnippetExtractor,
|
||||
Matcher,
|
||||
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
|
||||
NullLogger<SpeakerIdentityService>.Instance);
|
||||
}
|
||||
|
||||
public SpeakerIdentificationRequest CreateRequest(
|
||||
IReadOnlyList<string> attendees,
|
||||
IReadOnlyList<TranscriptionSegment> segments,
|
||||
IReadOnlyList<SpeakerAudioSample>? samples = null)
|
||||
{
|
||||
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"),
|
||||
segments,
|
||||
samples);
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> AddIdentityAsync(
|
||||
IReadOnlyList<string> candidates,
|
||||
byte[] snippet,
|
||||
string? canonicalName = null,
|
||||
int transcriptionCount = 0,
|
||||
IReadOnlyList<string>? aliases = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
var timestamp = updatedAt ?? DateTimeOffset.UtcNow;
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
TranscriptionCount = transcriptionCount,
|
||||
CreatedAt = timestamp,
|
||||
UpdatedAt = timestamp,
|
||||
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
|
||||
CandidateNames = candidates.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList(),
|
||||
Snippets =
|
||||
[
|
||||
new SpeakerSnippet
|
||||
{
|
||||
WavBytes = snippet,
|
||||
CreatedAt = timestamp.AddMinutes(-10)
|
||||
}
|
||||
]
|
||||
};
|
||||
Context.SpeakerIdentities.Add(identity);
|
||||
await Context.SaveChangesAsync();
|
||||
return identity;
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentity> LoadIdentityAsync(int id)
|
||||
{
|
||||
Context.ChangeTracker.Clear();
|
||||
return Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.SingleAsync(identity => identity.Id == id);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Context.DisposeAsync();
|
||||
if (File.Exists(dbPath))
|
||||
{
|
||||
File.Delete(dbPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory<SpeakerIdentityDbContext>
|
||||
{
|
||||
private readonly string dbPath;
|
||||
|
||||
public TestSpeakerIdentityDbContextFactory(string dbPath)
|
||||
{
|
||||
this.dbPath = dbPath;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext CreateDbContext()
|
||||
{
|
||||
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSpeakerSnippetExtractor : ISpeakerSnippetExtractor
|
||||
{
|
||||
public int ExtractCalls { get; private set; }
|
||||
|
||||
public Task<byte[]> ExtractSnippetAsync(
|
||||
string audioPath,
|
||||
IReadOnlyList<TranscriptionSegment> speakerSegments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ExtractCalls++;
|
||||
return Task.FromResult<byte[]>([7, 8, 9]);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
{
|
||||
private bool hasMatched;
|
||||
|
||||
public int? MatchIdentityId { get; set; }
|
||||
|
||||
public byte[]? LastUnknownSnippet { get; private set; }
|
||||
|
||||
public List<SpeakerIdentityMatchRequest> Requests { get; } = [];
|
||||
|
||||
public Task<SpeakerIdentityMatch?> MatchAsync(
|
||||
SpeakerIdentityMatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastUnknownSnippet = request.UnknownSnippet;
|
||||
Requests.Add(request);
|
||||
if (hasMatched || MatchIdentityId is null)
|
||||
{
|
||||
return Task.FromResult<SpeakerIdentityMatch?>(null);
|
||||
}
|
||||
|
||||
hasMatched = true;
|
||||
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(MatchIdentityId.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,13 @@ public sealed class SpeechRecognitionPipelineHostedServiceTests
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return InitializeAsync(SpeechRecognitionPipelineOptions.Default, cancellationToken);
|
||||
}
|
||||
|
||||
public Task InitializeAsync(
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
InitializeCount++;
|
||||
initialized.TrySetResult();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using MeetingAssistant;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class VaultPathTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveUsesVaultBaseFolderForRelativePaths()
|
||||
{
|
||||
var vault = new VaultOptions
|
||||
{
|
||||
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
|
||||
};
|
||||
|
||||
var resolved = VaultPath.Resolve(vault, @"Meetings\Transcripts");
|
||||
|
||||
Assert.Equal(
|
||||
@"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta\Meetings\Transcripts",
|
||||
resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveKeepsAbsolutePathsIndependentFromVaultBaseFolder()
|
||||
{
|
||||
var vault = new VaultOptions
|
||||
{
|
||||
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
|
||||
};
|
||||
|
||||
var resolved = VaultPath.Resolve(vault, @"C:\Other\Path");
|
||||
|
||||
Assert.Equal(@"C:\Other\Path", resolved);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user